Compare commits

...

13 Commits

Author SHA1 Message Date
1534c84d45 ++ fix: add pdf download visibility setting 2026-04-16 10:37:05 +00:00
c85f2aaea0 ++ fix manifest.json and README.md 2026-04-16 10:05:15 +00:00
6a65087449 ++ fix prioritario display and add link sent toggle 2026-04-13 15:40:35 +00:00
0553d4ef74 ++ fix temporary PDF viewer URLs, which were causing issues with caching and expiring links. Instead, we now generate short-lived URLs that redirect to the PDF viewer route, ensuring that users can access the PDFs without running into expired links. This change affects the Assegnazione model, the ShortPdfLinkController, and the relevant Blade views for assignments and records. Additionally, I've updated the Home Livewire component to calculate and display the average duration of assignments in months, providing more insight into assignment durations on the dashboard. 2026-04-13 15:05:37 +00:00
465e7cf092 ++ add Gestione dietro reverse proxy 2026-04-13 12:30:33 +00:00
5e98423e7a ++ fix: aggiornamento dipendenze e correzione bug 2026-04-12 18:07:59 +00:00
9f9a3666c1 ++ fix: aggiornamento dipendenze e correzione bug 2026-04-12 17:38:43 +00:00
c585979340 ++ fix: use months for assignment PDF link TTL instead of hours 2026-04-08 15:22:37 +00:00
6f8010514d ++ fix: Rientro assegnazione e territorio 2026-04-08 11:55:14 +00:00
777f239c7a +++fix: add thumbnail support for territori 2026-04-08 09:32:07 +00:00
aac13522e5 ++ fix: Show causer name from activity properties if not available in relation 2026-04-07 15:09:49 +00:00
be1ac25047 ++ fix: update .env.example and .gitignore, add CreateInitialAdmin command, add UsersIndex Livewire component, update entrypoint.sh, update app layout, add users-index blade view, update web routes 2026-04-07 14:57:54 +00:00
7fd5b0c3a0 ++ fix: remove .app_key and .db_seeded files, update related code and documentation 2026-04-07 14:57:37 +00:00
89 changed files with 6194 additions and 430 deletions

View File

@@ -3,15 +3,16 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=Europe/Rome APP_TIMEZONE=Europe/Rome
APP_URL=http://localhost:8080 APP_URL=https://demo-termanager.pyconetwork.it
ASSET_URL=https://demo-termanager.pyconetwork.it
APP_PORT=8080 APP_PORT=8080
SEED_DEV_DATA=false SEED_DEV_DATA=false
RUN_DB_SEED_ON_FIRST_START=true RUN_DB_SEED_ON_FIRST_START=true
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true
INITIAL_ADMIN_NAME=Administrator INITIAL_ADMIN_NAME=
INITIAL_ADMIN_EMAIL=info@termanager.it INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=Password123! INITIAL_ADMIN_PASSWORD=
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mariadb DB_HOST=mariadb

6
.gitignore vendored
View File

@@ -2,6 +2,11 @@
/node_modules/ /node_modules/
/.env /.env
/storage/*.key /storage/*.key
/storage/app/.app_key
/storage/app/.db_seeded
/storage/app/public/territori-pdf/*.*
/storage/app/public/territori-thumbnails/*.*
/storage/app/livewire-tmp/*.*
/storage/logs/ /storage/logs/
/storage/framework/ /storage/framework/
/bootstrap/cache/ /bootstrap/cache/
@@ -13,6 +18,7 @@
/.vscode/ /.vscode/
*.swp *.swp
*.swo *.swo
*.sql
docker-compose.override.yml docker-compose.override.yml
db_data/ db_data/
redis_data/ redis_data/

509
README.md
View File

@@ -9,16 +9,17 @@ Applicazione web per la **gestione dell'assegnazione e rientro di territori** (c
- [Panoramica](#panoramica) - [Panoramica](#panoramica)
- [Stack tecnologico](#stack-tecnologico) - [Stack tecnologico](#stack-tecnologico)
- [Requisiti di sistema](#requisiti-di-sistema) - [Requisiti di sistema](#requisiti-di-sistema)
- [Installazione rapida](#installazione-rapida) - [Installazione da zero](#installazione-da-zero)
- [Configurazione](#configurazione) - [Configurazione](#configurazione)
- [Migrazione su nuovo server](#migrazione-su-nuovo-server)
- [Comandi utili](#comandi-utili)
- [Troubleshooting](#troubleshooting)
- [Struttura del progetto](#struttura-del-progetto) - [Struttura del progetto](#struttura-del-progetto)
- [Funzionalità principali](#funzionalità-principali) - [Funzionalità principali](#funzionalità-principali)
- [Ruoli e permessi (RBAC)](#ruoli-e-permessi-rbac) - [Ruoli e permessi (RBAC)](#ruoli-e-permessi-rbac)
- [Sicurezza e GDPR](#sicurezza-e-gdpr) - [Sicurezza e GDPR](#sicurezza-e-gdpr)
- [Modello dati](#modello-dati) - [Modello dati](#modello-dati)
- [Regole di business](#regole-di-business) - [Regole di business](#regole-di-business)
- [Pagine dell'applicazione](#pagine-dellapplicazione)
- [Comandi utili](#comandi-utili)
- [Produzione](#produzione) - [Produzione](#produzione)
- [Licenza](#licenza) - [Licenza](#licenza)
@@ -54,72 +55,142 @@ Applicazione web per la **gestione dell'assegnazione e rientro di territori** (c
| **Asset Build** | Vite 6.0 + @tailwindcss/vite | | **Asset Build** | Vite 6.0 + @tailwindcss/vite |
| **Node.js** | 20 LTS (nel container PHP) | | **Node.js** | 20 LTS (nel container PHP) |
| **Mail (dev)** | Mailpit | | **Mail (dev)** | Mailpit |
| **Container** | Docker Compose (5 servizi) | | **Container** | Docker Compose (6 servizi) |
--- ---
## Requisiti di sistema ## Requisiti di sistema
- **Docker** >= 24.0 e **Docker Compose** >= 2.20 - **Docker** >= 24.0 e **Docker Compose** >= 2.20
- Oppure, senza Docker: - **Git** per clonare il repository
- PHP >= 8.3 con estensioni: pdo_mysql, mbstring, gd, intl, zip, bcmath, redis, opcache - **Porte libere** (configurabili nel `.env`):
- Composer >= 2.7
- Node.js >= 20 LTS + npm | Porta | Servizio | Variabile `.env` |
- MariaDB >= 11 (o MySQL 8) |---------|-------------------------|-------------------|
- Redis >= 7 | `8080` | Applicazione web (Nginx)| `APP_PORT` |
| `1025` | SMTP Mailpit | `MAIL_PORT` |
| `8025` | UI Mailpit | `MAILPIT_UI_PORT` |
> MariaDB (`3306`) e Redis (`6379`) non espongono porte sull'host: sono accessibili solo dalla rete interna Docker.
--- ---
## Installazione ## Installazione da zero
### Prerequisiti ### Passo 1 — Clona il repository
- **Docker** e **Docker Compose** (v2) installati
- Porte libere: `8080` (app), `3306` (MariaDB), `6379` (Redis), `8025` (Mailpit)
### Procedura
```bash ```bash
# 1. Clona il repository
git clone <repository-url> termanager2 git clone <repository-url> termanager2
cd 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
``` ```
Il codice applicativo e montato direttamente dal filesystem host, quindi ogni modifica locale e immediatamente visibile nel container (senza rebuild ad ogni edit). ### Passo 2 — Crea il file `.env`
Al primo avvio del container `app` vengono eseguiti automaticamente:
- setup `.env` e `APP_KEY`
- install/build dipendenze
- `php artisan migrate --force`
- `php artisan db:seed --force` **solo al primo avvio** (marker persistente)
- creazione admin iniziale se il database non ha utenti
> **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 ```bash
docker compose down -v --remove-orphans cp .env.example .env
docker compose up -d --build
# Ripetere i passaggi dal punto 4 in poi
``` ```
### Passo 3 — Configura le variabili obbligatorie
Apri `.env` con un editor e imposta **almeno** queste variabili:
```bash
nano .env
```
```dotenv
# --- Credenziali admin iniziale (OBBLIGATORIE al primo avvio) ---
INITIAL_ADMIN_NAME="Mario Rossi"
INITIAL_ADMIN_EMAIL=admin@esempio.it
INITIAL_ADMIN_PASSWORD=UnaPasswordSicura123
# --- URL dell'applicazione (adatta al tuo dominio/IP) ---
APP_URL=http://localhost:8080
ASSET_URL=http://localhost:8080
# --- Password database (cambia i default in produzione!) ---
DB_PASSWORD=una_password_sicura
DB_ROOT_PASSWORD=una_root_password_sicura
REDIS_PASSWORD=una_redis_password_sicura
```
> **Importante**: `INITIAL_ADMIN_NAME`, `INITIAL_ADMIN_EMAIL` e `INITIAL_ADMIN_PASSWORD` sono obbligatorie.
> Se mancano e il database è vuoto, il container si avvia ma mostra un warning e non potrai accedere.
### Passo 4 — Imposta i permessi
Il container PHP gira con UID/GID `1000`. I file devono appartenere a questo utente:
```bash
# Crea le cartelle necessarie
mkdir -p storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
# Imposta proprietario e permessi
sudo chown -R 1000:1000 .
chmod -R 775 storage bootstrap/cache
```
### Passo 5 — Avvia i container (primo avvio)
```bash
docker compose up -d --build
```
Questo comando:
1. **Costruisce** le immagini Docker (PHP + Nginx)
2. **Avvia** 6 servizi: `app`, `nginx`, `queue-worker`, `mariadb`, `redis`, `mailpit`
3. L'entrypoint del container `app` esegue automaticamente:
- Copia `.env.example``.env` (se mancante)
- Installa le dipendenze Composer (`vendor/`)
- Genera `APP_KEY` (se vuota) e la salva in `storage/app/.app_key`
- Installa le dipendenze NPM (`node_modules/`)
- Compila gli asset frontend CSS/JS (`public/build/`)
- Crea il symlink `public/storage``storage/app/public`
- Esegue le migrazioni database
- Esegue il seed iniziale (ruoli e permessi)
- Crea l'account admin iniziale
- Mette in cache config, routes e views
### Passo 6 — Verifica che tutto funzioni
```bash
# Controlla che tutti i container siano "healthy"
docker compose ps
```
Output atteso — tutti i container devono essere `Up` (e `healthy` dove previsto):
```
NAME STATUS PORTS
termanager2_app Up (healthy) 9000/tcp
termanager2_nginx Up 0.0.0.0:8080->80/tcp
termanager2_queue Up 9000/tcp
termanager2_db Up (healthy) 3306/tcp
termanager2_redis Up (healthy) 6379/tcp
termanager2_mail Up 0.0.0.0:1025->1025/tcp, 0.0.0.0:8025->8025/tcp
```
Se un container non è healthy, controlla i log:
```bash
# Log del container app (il più importante)
docker compose logs app --tail=100
# Log di tutti i container
docker compose logs --tail=50
```
### Passo 7 — Accedi all'applicazione
Apri il browser su: **http://localhost:8080** (o la porta configurata in `APP_PORT`)
Accedi con le credenziali impostate in `INITIAL_ADMIN_EMAIL` e `INITIAL_ADMIN_PASSWORD`.
--- ---
## Configurazione ## Configurazione
@@ -128,36 +199,125 @@ docker compose up -d --build
| Variabile | Default | Descrizione | | Variabile | Default | Descrizione |
|-----------------------|----------------------|------------------------------------------| |-----------------------|----------------------|------------------------------------------|
| `APP_KEY` | (generata) | Chiave AES-256 per cifratura. **Mai condividere** | | `APP_KEY` | (auto-generata) | Chiave AES-256 per cifratura dati. **Mai condividere.** Viene generata automaticamente al primo avvio |
| `APP_PORT` | `8080` | Porta host per l'applicazione | | `APP_URL` | da `.env.example` | URL completo dell'applicazione (es. `https://miodominio.it`) |
| `SEED_DEV_DATA` | `false` | Se `true`, `php artisan db:seed` include anche i dati demo | | `ASSET_URL` | da `.env.example` | URL base per gli asset CSS/JS (normalmente uguale a `APP_URL`) |
| `RUN_DB_SEED_ON_FIRST_START` | `true` | Se `true`, esegue il seed automatico solo al primo avvio container | | `APP_PORT` | `8080` | Porta host su cui Nginx espone l'app |
| `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB` | `true` | Se `true`, richiede/crea admin iniziale quando non esistono utenti | | `APP_ENV` | `local` | Ambiente: `local` (sviluppo) o `production` |
| `INITIAL_ADMIN_NAME` | vuoto | Nome admin creato al primo avvio | | `APP_DEBUG` | `true` | Mostra errori dettagliati. **Impostare `false` in produzione** |
| `INITIAL_ADMIN_EMAIL` | vuoto | Email admin creata al primo avvio | | `SEED_DEV_DATA` | `false` | Se `true`, il seed include dati demo di test |
| `INITIAL_ADMIN_PASSWORD` | vuoto | Password admin creata al primo avvio (min 8) | | `RUN_DB_SEED_ON_FIRST_START` | `true` | Se `true`, esegue il seed automatico solo al primo avvio |
| `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB` | `true` | Se `true`, crea admin iniziale quando non esistono utenti |
| `INITIAL_ADMIN_NAME` | (vuoto) | Nome dell'admin iniziale — **obbligatorio al primo avvio** |
| `INITIAL_ADMIN_EMAIL` | (vuoto) | Email dell'admin iniziale — **obbligatorio al primo avvio** |
| `INITIAL_ADMIN_PASSWORD` | (vuoto) | Password dell'admin iniziale (min 8 caratteri) — **obbligatorio al primo avvio** |
| `DB_DATABASE` | `termanager2` | Nome database MariaDB | | `DB_DATABASE` | `termanager2` | Nome database MariaDB |
| `DB_USERNAME` | `termanager2` | Utente database | | `DB_USERNAME` | `termanager2` | Utente database |
| `DB_PASSWORD` | `secret` | Password database | | `DB_PASSWORD` | `secret` | Password database **cambiare in produzione** |
| `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB | | `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB **cambiare in produzione** |
| `REDIS_PASSWORD` | `redissecret` | Password Redis | | `REDIS_PASSWORD` | `redissecret` | Password Redis **cambiare in produzione** |
| `MAIL_PORT` | `1025` | Porta SMTP Mailpit | | `MAIL_PORT` | `1025` | Porta SMTP (Mailpit in dev, SMTP reale in prod) |
| `MAILPIT_UI_PORT` | `8025` | UI Mailpit per debug email | | `MAILPIT_UI_PORT` | `8025` | Porta UI Mailpit per debug email |
| `USER_ID` / `GROUP_ID`| `1000` | UID/GID container (match con host) |
### Configurazione iniziale ### Configurazione applicativa
La configurazione viene gestita dalla sezione **Impostazioni** (menu amministrazione), senza wizard iniziale. Dopo il primo accesso, la configurazione si gestisce dalla sezione **Impostazioni** nel menu (solo Amministratore):
- Nome congregazione
- Soglia mesi per priorità automatica territori
- Soglia giorni per "da rientrare"
- Retention giorni audit log
### Credenziali iniziali ### Credenziali iniziali
Le credenziali non sono hardcoded nel progetto. Al primo avvio devi impostare in `.env`: Le credenziali admin non sono hardcoded. Al primo avvio devi impostare nel `.env`:
- `INITIAL_ADMIN_NAME` - `INITIAL_ADMIN_NAME`
- `INITIAL_ADMIN_EMAIL` - `INITIAL_ADMIN_EMAIL`
- `INITIAL_ADMIN_PASSWORD` - `INITIAL_ADMIN_PASSWORD`
Se il database e vuoto e queste variabili non sono valorizzate, il container `app` interrompe l'avvio con errore esplicito. Se il database è vuoto e queste variabili non sono valorizzate, il container `app` mostra un warning e non verrà creato l'account admin.
---
## Migrazione su nuovo server
Quando sposti TerManager2 su un nuovo server, segui questa procedura:
### 1. Copia i file sul nuovo server
```bash
# Dal vecchio server: crea un archivio (escludi vendor, node_modules e volumi Docker)
tar czf termanager2-backup.tar.gz \
--exclude='vendor' \
--exclude='node_modules' \
--exclude='public/build' \
--exclude='.git' \
termanager2/
# Copia sul nuovo server
scp termanager2-backup.tar.gz utente@nuovo-server:/home/utente/Docker/
# Sul nuovo server: estrai
cd /home/utente/Docker
tar xzf termanager2-backup.tar.gz
cd termanager2
```
### 2. Verifica il file `.env`
```bash
# Controlla che .env esista e contenga i valori corretti
cat .env
# Adatta APP_URL e ASSET_URL al nuovo dominio/IP
nano .env
```
> **Critico**: se il nuovo server ha un URL/IP diverso, aggiorna `APP_URL` e `ASSET_URL`.
### 3. Imposta i permessi
```bash
sudo chown -R 1000:1000 .
chmod -R 775 storage bootstrap/cache
```
### 4. Ricostruisci e avvia
```bash
docker compose up -d --build
```
### 5. Forza la ricompilazione degli asset
Se la pagina appare senza stile (CSS mancante), ricompila gli asset:
```bash
# Ricompila CSS e JS dentro il container
docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build"
# Correggi i permessi dei file generati
docker compose exec -u root app chown -R 1000:1000 public/build node_modules
```
### 6. (Opzionale) Ripristina il database
Se hai un dump SQL dal vecchio server:
```bash
# Copia il dump nel container MariaDB
docker cp backup.sql termanager2_db:/tmp/backup.sql
# Importa il dump
docker compose exec mariadb mysql -u root -p"$(grep DB_ROOT_PASSWORD .env | cut -d= -f2)" termanager2 < /tmp/backup.sql
# Esegui le eventuali migrazioni mancanti
docker compose exec app php artisan migrate --force
# Pulisci la cache
docker compose exec app php artisan optimize:clear
```
--- ---
@@ -267,6 +427,11 @@ TerManager2/
- Soglia giorni per "da rientrare" - Soglia giorni per "da rientrare"
- Retention giorni audit log - Retention giorni audit log
### XML Exchange
- Conversione dump SQL legacy in XML compatibile con TerManager2
- Import XML nell'app (sostituzione dati gestionali: impostazioni, zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni)
- Export XML dei dati correnti
### Zone e Tipologie ### Zone e Tipologie
- CRUD inline (aggiungi, rinomina, attiva/disattiva, elimina) - CRUD inline (aggiungi, rinomina, attiva/disattiva, elimina)
- Protezione: non eliminabile se ha territori associati - Protezione: non eliminabile se ha territori associati
@@ -372,27 +537,57 @@ Il menu sidebar mostra solo le voci per cui l'utente ha permesso.
## Comandi utili ## Comandi utili
### Gestione container
```bash ```bash
# Avviare i container # Avviare i container (dopo il primo build)
docker compose up -d docker compose up -d
# Fermare i container # Avviare con ricostruzione immagini (dopo modifiche a Dockerfile o dipendenze)
docker compose up -d --build
# Fermare i container (i dati persistono nei volumi)
docker compose down docker compose down
# Shell nel container app # Fermare e CANCELLARE tutti i dati (database, redis, uploads)
docker compose down -v --remove-orphans
# Stato dei container
docker compose ps
# Log in tempo reale (tutti i container)
docker compose logs -f
# Log di un singolo container
docker compose logs app --tail=100
docker compose logs nginx --tail=100
docker compose logs mariadb --tail=100
# Riavviare un singolo container
docker compose restart app
```
### Shell interattiva
```bash
# Shell nel container app (come utente 1000)
docker compose exec app bash docker compose exec app bash
# Shell nel container app come root (per operazioni di sistema)
docker compose exec -u root app bash
# Shell nel container MariaDB
docker compose exec mariadb mysql -u root -p
```
### Laravel / Artisan
```bash
# Eseguire migrazioni # Eseguire migrazioni
docker compose exec app php artisan migrate docker compose exec app php artisan migrate --force
# Seed dati di sviluppo # Seed del database
docker compose exec app php artisan db:seed docker compose exec app php artisan db:seed --force
# 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 # Pulizia manuale audit log
docker compose exec app php artisan audit:cleanup docker compose exec app php artisan audit:cleanup
@@ -402,36 +597,160 @@ docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache docker compose exec app php artisan view:cache
# Svuotare cache # Svuotare TUTTA la cache
docker compose exec app php artisan optimize:clear docker compose exec app php artisan optimize:clear
``` ```
### Asset frontend (CSS/JS)
```bash
# Compilare asset per produzione
docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build"
docker compose exec -u root app chown -R 1000:1000 public/build node_modules
# Compilare asset per sviluppo (hot reload, richiede porta 5173 libera)
docker compose exec app npm run dev
```
### Backup e ripristino database
```bash
# Backup del database (sostituisci le credenziali)
docker compose exec mariadb mysqldump -u root -prootsecret termanager2 > backup_$(date +%Y%m%d).sql
# Ripristino da backup
docker compose exec -T mariadb mysql -u root -prootsecret termanager2 < backup_20250101.sql
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize:clear
```
---
## Troubleshooting
### La pagina appare senza stile (CSS mancante)
**Sintomo**: la pagina di login appare con testo non formattato, senza colori né layout.
**Causa**: il file CSS compilato manca da `public/build/assets/`. Succede tipicamente dopo una migrazione su nuovo server.
**Soluzione**:
```bash
# Ricompila gli asset come root (evita problemi di permessi)
docker compose exec -u root app bash -c "npm install --no-audit --no-fund && npm run build"
# Correggi i permessi
docker compose exec -u root app chown -R 1000:1000 public/build node_modules
```
### Il container `app` non diventa healthy
**Sintomo**: `docker compose ps` mostra `app` come `starting` o `unhealthy`.
**Cosa controllare**:
```bash
# Guarda i log per capire dove si blocca
docker compose logs app --tail=200
```
Cause comuni:
- **MariaDB non pronto**: l'entrypoint ripete il tentativo 10 volte. Attendi qualche secondo.
- **Credenziali admin mancanti**: se `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true` e le variabili `INITIAL_ADMIN_*` sono vuote, il container mostra un warning.
- **Errore di permessi**: esegui `sudo chown -R 1000:1000 .` sulla cartella del progetto.
### Errore "Permission denied" su storage o bootstrap/cache
```bash
sudo chown -R 1000:1000 storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
```
### La `APP_KEY` è cambiata e i dati cifrati non sono leggibili
La `APP_KEY` viene salvata in `storage/app/.app_key` per persistere tra i riavvii. Se perdi questo file:
- I **nomi e cognomi dei proclamatori** (cifrati con AES-256) diventano illeggibili
- Le **sessioni** vengono invalidate
**Prevenzione**: dopo il primo avvio, salva il valore di `APP_KEY` dal file `.env` in un luogo sicuro.
### Errore npm "EACCES" o "vite: not found"
```bash
# Installa le dipendenze npm come root
docker compose exec -u root app npm install --no-audit --no-fund
# Verifica che vite sia installato
docker compose exec app ls node_modules/.bin/vite
# Compila come root
docker compose exec -u root app npm run build
# Poi correggi i permessi
docker compose exec -u root app chown -R 1000:1000 public/build node_modules
```
### Reset completo (ripartire da zero)
> **Attenzione**: questo cancella il database, i file Redis e tutti i dati applicativi.
```bash
docker compose down -v --remove-orphans
sudo chown -R 1000:1000 .
rm -rf node_modules public/build vendor storage/app/.app_key storage/framework/.runtime_db_seeded
docker compose up -d --build
```
--- ---
## Produzione ## Produzione
Per il deploy in produzione: ### Checklist deploy in produzione
1. **Variabili d'ambiente**: 1. **Variabili d'ambiente** — modifica nel `.env`:
- `APP_ENV=production` ```dotenv
- `APP_DEBUG=false` APP_ENV=production
- Password sicure per DB e Redis (non i default) APP_DEBUG=false
- `APP_KEY` generata e conservata come secret APP_URL=https://tuodominio.it
ASSET_URL=https://tuodominio.it
```
2. **HTTPS**: configurare un reverse proxy (Traefik, Nginx) con certificato SSL/TLS e HSTS 2. **Password sicure** — cambia TUTTI i default:
```dotenv
DB_PASSWORD=<password-sicura-generata>
DB_ROOT_PASSWORD=<password-root-sicura-generata>
REDIS_PASSWORD=<password-redis-sicura-generata>
```
3. **Immagini Docker**: build senza volume codice montato, asset pre-compilati 3. **HTTPS** — configura un reverse proxy (Traefik, Nginx, Caddy) con certificato SSL/TLS davanti alla porta `APP_PORT`
4. **Backup**: backup cifrati del database con rotazione automatica 4. **Cache** — al primo avvio viene fatto automaticamente dall'entrypoint, ma dopo modifiche:
```bash
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
```
5. **Segreti**: gestire `APP_KEY`, password DB/Redis con Docker secrets o secret manager 5. **Backup** — programma backup regolari del database:
```bash
# Cron job giornaliero (esempio)
0 2 * * * cd /path/to/termanager2 && docker compose exec -T mariadb mysqldump -u root -prootsecret termanager2 | gzip > /backups/termanager2_$(date +\%Y\%m\%d).sql.gz
```
6. **Performance**: 6. **APP_KEY** — salva il valore di `APP_KEY` dal `.env` in un luogo sicuro (password manager, vault). Senza di essa i dati cifrati dei proclamatori sono irrecuperabili.
- `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 7. **Mailpit** — in produzione rimuovi il servizio `mailpit` dal `docker-compose.yml` e configura un server SMTP reale:
```dotenv
MAIL_MAILER=smtp
MAIL_HOST=smtp.tuoprovider.it
MAIL_PORT=587
MAIL_USERNAME=utente@tuodominio.it
MAIL_PASSWORD=password-smtp
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@tuodominio.it
```
--- ---

View File

@@ -2,8 +2,11 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Database\Seeders\RolesAndPermissionsSeeder;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -19,10 +22,8 @@ class CreateInitialAdmin extends Command
public function handle(): int public function handle(): int
{ {
if (User::count() > 0) { // Always ensure roles/permissions are present before assigning roles.
$this->info('Users already exist. Skipping initial admin creation.'); Artisan::call('db:seed', ['--class' => RolesAndPermissionsSeeder::class, '--force' => true]);
return self::SUCCESS;
}
$name = (string) ($this->option('name') ?? ''); $name = (string) ($this->option('name') ?? '');
$email = (string) ($this->option('email') ?? ''); $email = (string) ($this->option('email') ?? '');
@@ -45,6 +46,30 @@ class CreateInitialAdmin extends Command
$password = $password !== '' ? $password : (string) $this->secret('Password amministratore (min 8 caratteri)'); $password = $password !== '' ? $password : (string) $this->secret('Password amministratore (min 8 caratteri)');
} }
if (User::count() > 0) {
$existingAdmin = User::role('amministratore')->first();
if ($existingAdmin) {
$this->info('An administrator already exists. Skipping initial admin creation.');
return self::SUCCESS;
}
if ($email !== '') {
$existingUser = User::where('email', $email)->first();
if ($existingUser) {
$existingUser->assignRole('amministratore');
$this->info("Granted admin role to existing user: {$existingUser->email}");
return self::SUCCESS;
}
}
$firstUser = User::query()->oldest('id')->first();
if ($firstUser) {
$firstUser->assignRole('amministratore');
$this->warn("No admin role found. Granted admin role to first existing user: {$firstUser->email}");
return self::SUCCESS;
}
}
$validator = Validator::make([ $validator = Validator::make([
'name' => $name, 'name' => $name,
'email' => $email, 'email' => $email,
@@ -63,13 +88,17 @@ class CreateInitialAdmin extends Command
return self::FAILURE; return self::FAILURE;
} }
$admin = User::create([ $admin = DB::transaction(function () use ($name, $email, $password) {
'name' => $name, $user = User::create([
'email' => $email, 'name' => $name,
'password' => Hash::make($password), 'email' => $email,
]); 'password' => Hash::make($password),
]);
$admin->assignRole('amministratore'); $user->assignRole('amministratore');
return $user;
});
$this->info("Initial admin created: {$admin->email}"); $this->info("Initial admin created: {$admin->email}");

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Models\Assegnazione;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AssignmentPdfController extends Controller
{
public function viewer(Request $request, Assegnazione $assignment, string $code): View
{
$this->validateAccess($assignment, $code);
if ($expired = $this->linkScaduto($assignment)) {
return $expired;
}
$pdfUrl = route('assignments.pdf.file', [
'assignment' => $assignment->id,
'code' => $code,
]);
return view('assignments.pdf-viewer', [
'assignment' => $assignment,
'pdfUrl' => $pdfUrl,
'showDownload' => (bool) \App\Models\Setting::getValue('pdf_viewer_show_download', true),
]);
}
public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse|View
{
$this->validateAccess($assignment, $code);
if ($expired = $this->linkScaduto($assignment)) {
return $expired;
}
$pdfPath = $assignment->territorio?->pdf_path;
abort_unless($pdfPath && Storage::disk('public')->exists($pdfPath), 404);
return Storage::disk('public')->response(
$pdfPath,
'territorio-' . $assignment->territorio?->numero . '.pdf',
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="territorio-' . $assignment->territorio?->numero . '.pdf"',
]
);
}
protected function validateAccess(Assegnazione $assignment, string $code): void
{
abort_unless($assignment->pdf_access_code && hash_equals($assignment->pdf_access_code, $code), 404);
abort_unless($assignment->is_aperta, 403);
abort_unless($assignment->territorio?->pdf_path, 404);
}
protected function linkScaduto(Assegnazione $assignment): ?View
{
if (auth()->check()) {
return null;
}
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
return view('assignments.link-scaduto', [
'numero' => $assignment->territorio?->numero,
]);
}
return null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Services\TerritorioPdfImportDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
class TerritoryPdfImportController extends Controller
{
public function storeZip(Request $request, TerritorioPdfImportDispatcher $dispatcher): JsonResponse|RedirectResponse
{
$request->validate([
'pdfZip' => ['required', 'file', 'mimes:zip', 'max:256000'],
]);
try {
$importId = $dispatcher->dispatchUploadedZip($request->file('pdfZip'), auth()->id());
} catch (RuntimeException $exception) {
if ($request->expectsJson()) {
return response()->json([
'message' => $exception->getMessage(),
'errors' => ['pdfZip' => [$exception->getMessage()]],
], 422);
}
return back()->withErrors(['pdfZip' => $exception->getMessage()]);
}
$redirectUrl = route('xml.exchange', ['pdf-import' => $importId]);
if ($request->expectsJson()) {
return response()->json([
'message' => 'Import PDF avviato in background.',
'import_id' => $importId,
'redirect_url' => $redirectUrl,
]);
}
return redirect($redirectUrl)
->with('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange;
use Illuminate\Http\Request;
class XmlExchangeUploadController extends Controller
{
public function convertSqlToXml(Request $request)
{
$request->validate([
'sqlDump' => ['required', 'file', 'max:256000'],
]);
$file = $request->file('sqlDump');
$ext = strtolower($file->getClientOriginalExtension());
if (! in_array($ext, ['sql', 'txt'])) {
return back()->withErrors(['sqlDump' => 'Il file deve essere .sql o .txt']);
}
$content = file_get_contents($file->getRealPath());
if (! $content) {
return back()->withErrors(['sqlDump' => 'File vuoto o non leggibile.']);
}
$exchange = app(XmlExchange::class);
$dataset = $exchange->legacySqlToDatasetPublic($content);
$xml = $exchange->datasetToXmlPublic($dataset, 'legacy-sql-conversion');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function importXml(Request $request)
{
$request->validate([
'xmlImport' => ['required', 'file', 'max:256000'],
]);
$file = $request->file('xmlImport');
$ext = strtolower($file->getClientOriginalExtension());
if (! in_array($ext, ['xml', 'txt'])) {
return back()->withErrors(['xmlImport' => 'Il file deve essere .xml o .txt']);
}
$content = file_get_contents($file->getRealPath());
if (! $content) {
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
}
$exchange = new XmlExchange();
$result = $exchange->importXmlFromContent($content);
if (isset($result['error'])) {
return back()->withErrors(['xmlImport' => $result['error']]);
}
$stats = $result['stats'];
$issues = $result['issues'];
$message = 'Import XML completato con successo.';
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
}
return redirect()->route('xml.exchange')
->with('success', $message)
->with('importStats', $stats)
->with('importIssues', $issues);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Assegnazione;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
class ShortPdfLinkController extends Controller
{
public function __invoke(string $code): RedirectResponse|View
{
$assignment = Assegnazione::where('pdf_access_code', $code)->firstOrFail();
abort_unless($assignment->is_aperta, 403);
abort_unless($assignment->territorio?->pdf_path, 404);
// Unauthenticated users (proclamatori) are subject to link TTL
if (! auth()->check()) {
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
return view('assignments.link-scaduto', [
'numero' => $assignment->territorio?->numero,
]);
}
}
return redirect()->route('assignments.pdf.viewer', [
'assignment' => $assignment->id,
'code' => $code,
]);
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Jobs;
use App\Models\Territorio;
use App\Models\User;
use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportTerritoryPdfFolder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 0;
public function __construct(
public string $importId,
public array $files,
public int $actorId,
) {
}
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
{
$stateService->markRunning($this->importId);
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
$territoriMap = [];
foreach (Territorio::withTrashed()->get() as $territorio) {
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
}
$actor = User::find($this->actorId);
$seenNumbers = [];
try {
foreach ($this->files as $file) {
$originalName = $file['original_name'] ?? 'file-sconosciuto.pdf';
$stateService->increment($this->importId, 'processed');
$territoryMatch = $this->resolveTerritoryFromFilename($originalName, $territoriMap);
if ($territoryMatch === null) {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nessun numero territorio riconosciuto nel nome file.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'no-match',
'message' => 'Nessun numero territorio riconosciuto nel nome file.',
]);
continue;
}
if (($territoryMatch['type'] ?? 'single') === 'ambiguous') {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nome ambiguo, possibili territori: ' . implode(', ', $territoryMatch['matched_numbers']) . '.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'ambiguous',
'message' => 'Nome ambiguo.',
'matched_numbers' => $territoryMatch['matched_numbers'],
]);
continue;
}
$normalizedNumber = $territoryMatch['normalized_number'];
$matchedNumber = $territoryMatch['matched_number'];
$territorio = $territoryMatch['territorio'];
if (isset($seenNumbers[$normalizedNumber])) {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - territorio ' . $matchedNumber . ' presente piu volte nella stessa importazione.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'duplicate-in-batch',
'message' => 'Territorio presente piu volte nella stessa importazione.',
'matched_numbers' => [$matchedNumber],
]);
continue;
}
$seenNumbers[$normalizedNumber] = true;
$sourcePath = Storage::disk('local')->path($file['stored_path']);
if (! is_file($sourcePath)) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - file temporaneo non trovato.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'missing-temp-file',
'message' => 'File temporaneo non trovato.',
'matched_numbers' => [$matchedNumber],
]);
continue;
}
try {
if ($territorio->pdf_path) {
Storage::disk('public')->delete($territorio->pdf_path);
}
if ($territorio->thumbnail_path) {
$thumbnailService->delete($territorio->thumbnail_path);
}
$storedFilename = Str::slug('territorio-' . $territorio->numero) . '.pdf';
$publicPath = 'territori-pdf/' . $storedFilename;
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
$thumbnailPath = $thumbnailService->generate($publicPath);
$territorio->update([
'pdf_path' => $publicPath,
'thumbnail_path' => $thumbnailPath,
]);
if ($actor) {
activity()->causedBy($actor)
->performedOn($territorio)
->withProperties([
'numero' => $territorio->numero,
'pdf' => $originalName,
'bulk_import' => true,
])
->log('bulk_uploaded_pdf');
}
$stateService->increment($this->importId, 'updated');
$stateService->appendLog(
$this->importId,
'[OK] ' . $originalName . ' - aggiornato territorio ' . $territorio->numero . ($thumbnailPath ? ' con thumbnail generata.' : ' ma la thumbnail non e stata generata.')
);
} catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - ' . $exception->getMessage());
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'processing-error',
'message' => $exception->getMessage(),
'matched_numbers' => [$matchedNumber],
]);
}
}
$stateService->appendLog($this->importId, 'Import completato.');
$stateService->markCompleted($this->importId);
} catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
$stateService->markFailed($this->importId);
throw $exception;
} finally {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
}
}
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{
if ($territoriMap === []) {
return null;
}
$basename = pathinfo($filename, PATHINFO_FILENAME);
$normalizedBasename = mb_strtoupper($basename);
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
$territoryKeys = array_keys($territoriMap);
usort($territoryKeys, function (string $left, string $right) {
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
if ($lengthComparison !== 0) {
return $lengthComparison;
}
return strnatcasecmp($left, $right);
});
$matches = [];
foreach ($territoryKeys as $normalizedNumber) {
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
continue;
}
$matches[] = [
'normalized_number' => $normalizedNumber,
'matched_number' => $territoriMap[$normalizedNumber]->numero,
'territorio' => $territoriMap[$normalizedNumber],
];
}
if ($matches === []) {
return null;
}
if (count($matches) > 1) {
return [
'type' => 'ambiguous',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
];
}
return $matches[0];
}
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
{
$escapedNumber = preg_quote($normalizedNumber, '/');
if (preg_match('/^\d+$/', $normalizedNumber)) {
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
}
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
}
protected function normalizeTerritoryNumber(string $number): string
{
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
}
}

View File

@@ -3,16 +3,19 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use Illuminate\Support\Facades\Storage;
class Assegna extends Component class Assegna extends Component
{ {
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
public string $territorioSearch = '';
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
@@ -80,10 +83,39 @@ class Assegna extends Component
return $this->redirect(route('territori.show', $territorio), navigate: true); return $this->redirect(route('territori.show', $territorio), navigate: true);
} }
public function toggleLinkSent(int $assegnazioneId): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
}
#[Computed]
public function selectedThumbnailUrl(): ?string
{
if (!$this->territorio_id) {
return null;
}
$t = Territorio::find($this->territorio_id);
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
}
public function render() public function render()
{ {
$territoriDisponibili = Territorio::where('attivo', true) $territoriQuery = Territorio::where('attivo', true)
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte()) ->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
->with(['zona', 'tipologia']);
if (trim($this->territorioSearch) !== '') {
$term = trim($this->territorioSearch);
$territoriQuery->where(function ($q) use ($term) {
$q->where('numero', 'like', "%{$term}%")
->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%"))
->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%"));
});
}
$territoriDisponibili = $territoriQuery
->orderBy('numero') ->orderBy('numero')
->get(); ->get();
@@ -91,9 +123,19 @@ class Assegna extends Component
->get() ->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome)); ->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
// All currently assigned territories with links
$assegnazioniAperte = Assegnazione::aperte()
->with(['territorio.zona', 'proclamatore'])
->get()
->sortBy(fn($a) => (int) $a->territorio?->numero);
$linkTtlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'territoriDisponibili' => $territoriDisponibili, 'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi, 'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte,
'linkTtlMonths' => $linkTtlMonths,
]); ]);
} }
} }

View File

@@ -18,9 +18,12 @@ class CampagnaShow extends Component
public function render() public function render()
{ {
// All assignments with returned_at in campaign range that were counted // Assignments counted for this campaign:
$conteggiate = Assegnazione::where('campagna_id', $this->campagna->id) // - assigned on or after campaign start
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
->where('counted_in_campaign', true) ->where('counted_in_campaign', true)
->where('assigned_at', '>=', $this->campagna->start_date)
->with(['territorio', 'proclamatore']) ->with(['territorio', 'proclamatore'])
->orderBy('returned_at') ->orderBy('returned_at')
->get(); ->get();

View File

@@ -9,12 +9,88 @@ use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Setting; use App\Models\Setting;
use Barryvdh\DomPDF\Facade\Pdf;
class Home extends Component class Home extends Component
{ {
public function downloadPdfDaAssegnare()
{
$this->authorize('territori.assign');
$settings = Setting::instance();
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$territori = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->get()
->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($p !== 0) return $p;
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($g !== 0) return $g;
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->values();
$pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Assegnare',
'congregazione' => $settings->congregazione_nome ?? 'TerManager2',
'data' => now()->format('d/m/Y'),
'territori' => $territori,
'tipo' => 'assegnare',
]);
return response()->streamDownload(
fn () => print($pdf->output()),
'territori-da-assegnare-' . now()->format('Y-m-d') . '.pdf'
);
}
public function downloadPdfDaRientrare()
{
$this->authorize('territori.return');
$settings = Setting::instance();
$territori = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore'])
->get();
$pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Rientrare',
'congregazione' => $settings->congregazione_nome ?? 'TerManager2',
'data' => now()->format('d/m/Y'),
'territori' => $territori,
'tipo' => 'rientrare',
]);
return response()->streamDownload(
fn () => print($pdf->output()),
'territori-da-rientrare-' . now()->format('Y-m-d') . '.pdf'
);
}
public function render() public function render()
{ {
$settings = Setting::instance(); $settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente(); $annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva(); $campagnaAttiva = Campagna::attiva();
@@ -32,12 +108,24 @@ class Home extends Component
->count('territorio_id'); ->count('territorio_id');
} }
// Monthly average // Monthly average (territories/month this year)
$mediaPercorrenzaMensile = 0; $mediaPercorrenzaMensile = 0;
if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) { if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) {
$mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1); $mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1);
} }
// Average assignment duration in months (current year only, matches old app "Media Percorrenza Congregazione")
$avgGiorni = null;
if ($annoCorrente) {
$avgGiorni = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
->whereNotNull('returned_at')
->whereRaw('YEAR(assigned_at) >= 1900')
->whereRaw('DATEDIFF(returned_at, assigned_at) > 0')
->selectRaw('AVG(DATEDIFF(returned_at, assigned_at)) as media_giorni')
->value('media_giorni');
}
$mediaDurataPercorrenzaMesi = $avgGiorni ? round($avgGiorni / 30.44, 1) : 0;
// Campaign stats // Campaign stats
$campagnaStats = null; $campagnaStats = null;
if ($campagnaAttiva) { if ($campagnaAttiva) {
@@ -49,19 +137,49 @@ class Home extends Component
} }
// Quick lists // Quick lists
$daAssegnare = Territorio::daAssegnare() $territoriDaAssegnare = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione') ->with('zona', 'tipologia', 'ultimaAssegnazione')
->take(10) ->get()
->get(); ->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
$prioritari = Territorio::prioritari() if ($ultima && $ultima->returned_at) {
->with('zona', 'tipologia', 'ultimaAssegnazione') $giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
->take(10) } elseif (! $ultima) {
->get(); $giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->take($homeLimit)
->values();
$daRientrare = Territorio::daRientrare() $daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore']) ->with(['zona', 'assegnazioneCorrente.proclamatore'])
->take(10) ->take($homeLimit)
->get(); ->get();
return view('livewire.home', [ return view('livewire.home', [
@@ -72,9 +190,10 @@ class Home extends Component
'totInReparto' => $totInReparto, 'totInReparto' => $totInReparto,
'territoriPercorsi' => $territoriPercorsi, 'territoriPercorsi' => $territoriPercorsi,
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile, 'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
'mediaDurataPercorrenzaMesi' => $mediaDurataPercorrenzaMesi,
'campagnaStats' => $campagnaStats, 'campagnaStats' => $campagnaStats,
'daAssegnare' => $daAssegnare, 'homeLimit' => $homeLimit,
'prioritari' => $prioritari, 'territoriDaAssegnare' => $territoriDaAssegnare,
'daRientrare' => $daRientrare, 'daRientrare' => $daRientrare,
]); ]);
} }

View File

@@ -6,6 +6,9 @@ use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\Zona; use App\Models\Zona;
use App\Models\Tipologia; use App\Models\Tipologia;
@@ -18,8 +21,25 @@ class Registro extends Component
public string $filtroZona = ''; public string $filtroZona = '';
public string $filtroTipologia = ''; public string $filtroTipologia = '';
public string $filtroStato = ''; // aperte, chiuse public string $filtroStato = ''; // aperte, chiuse
public string $sortField = 'assigned_at'; public string $sortField = 'territorio_numero';
public string $sortDirection = 'desc'; public string $sortDirection = 'asc';
// ─── Modal create/edit ──────────────────────────────────────
public bool $showModal = false;
public ?int $editingId = null;
public string $form_territorio_id = '';
public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = '';
public string $form_returned_at = '';
public bool $form_counted_in_campaign = false;
public string $form_campaign_id = '';
public string $form_note = '';
// ─── Delete confirm ─────────────────────────────────────────
public bool $showDeleteConfirm = false;
public ?int $deleteId = null;
protected $queryString = [ protected $queryString = [
'search' => ['except' => ''], 'search' => ['except' => ''],
@@ -44,9 +64,102 @@ class Registro extends Component
} }
} }
// ─── Admin CRUD ─────────────────────────────────────────────
public function openCreate(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->editingId = null;
$this->form_territorio_id = '';
$this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = '';
$this->form_counted_in_campaign = false;
$this->form_campaign_id = '';
$this->form_note = '';
$this->resetValidation();
$this->showModal = true;
}
public function openEdit(int $id): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$a = Assegnazione::findOrFail($id);
$this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_id;
$this->form_anno_id = (string) $a->anno_teocratico_id;
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
$this->form_campaign_id = (string) ($a->campaign_id ?? '');
$this->form_note = $a->note ?? '';
$this->resetValidation();
$this->showModal = true;
}
public function save(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->validate([
'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id',
'form_assigned_at' => 'required|date',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_campaign_id' => 'nullable|exists:campagne,id',
]);
$data = [
'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id,
'assigned_at' => $this->form_assigned_at,
'returned_at' => $this->form_returned_at ?: null,
'counted_in_campaign' => $this->form_counted_in_campaign,
'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null,
];
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
$data['created_by'] = auth()->id();
Assegnazione::create($data);
}
$this->showModal = false;
}
public function askDelete(int $id): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->deleteId = $id;
$this->showDeleteConfirm = true;
}
public function deleteConfirmed(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
if ($this->deleteId) {
Assegnazione::findOrFail($this->deleteId)->delete();
}
$this->deleteId = null;
$this->showDeleteConfirm = false;
}
public function mount(): void
{
if ($this->filtroAnno === '') {
$annoCorrente = AnnoTeocratico::corrente();
$this->filtroAnno = (string) $annoCorrente->id;
}
}
public function render() public function render()
{ {
$query = Assegnazione::with(['territorio.zona', 'proclamatore', 'annoTeocratico', 'campagna']); $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) { if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno); $query->where('anno_teocratico_id', $this->filtroAnno);
@@ -66,7 +179,14 @@ class Registro extends Component
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia)); $query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
} }
$query->orderBy($this->sortField, $this->sortDirection); if ($this->sortField === 'territorio_numero') {
$dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
$query->orderByRaw(
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
);
} else {
$query->orderBy($this->sortField, $this->sortDirection);
}
// In-memory search for encrypted proclamatore fields / territorio numero // In-memory search for encrypted proclamatore fields / territorio numero
if ($this->search !== '') { if ($this->search !== '') {
@@ -96,6 +216,9 @@ class Registro extends Component
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(), 'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
'zone' => Zona::attive()->orderBy('nome')->get(), 'zone' => Zona::attive()->orderBy('nome')->get(),
'tipologie' => Tipologia::orderBy('nome')->get(), 'tipologie' => Tipologia::orderBy('nome')->get(),
'territori' => Territorio::attivi()->orderBy('numero')->get(),
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
'campagne' => Campagna::orderByDesc('created_at')->get(),
]); ]);
} }
} }

View File

@@ -12,6 +12,8 @@ class SettingsEdit extends Component
public int $giorni_giacenza_prioritari = 180; public int $giorni_giacenza_prioritari = 180;
public int $giorni_per_smarrito = 120; public int $giorni_per_smarrito = 120;
public int $home_limit_list = 10; public int $home_limit_list = 10;
public int $assignment_link_ttl_months = 1;
public bool $pdf_viewer_show_download = true;
public int $audit_retention_days = 365; public int $audit_retention_days = 365;
public function mount() public function mount()
@@ -22,6 +24,8 @@ class SettingsEdit extends Component
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180; $this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120; $this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
$this->home_limit_list = $settings->home_limit_list ?? 10; $this->home_limit_list = $settings->home_limit_list ?? 10;
$this->assignment_link_ttl_months = $settings->assignment_link_ttl_hours ?? 1;
$this->pdf_viewer_show_download = $settings->pdf_viewer_show_download ?? true;
$this->audit_retention_days = $settings->audit_retention_days ?? 365; $this->audit_retention_days = $settings->audit_retention_days ?? 365;
} }
@@ -33,6 +37,8 @@ class SettingsEdit extends Component
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730', 'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
'giorni_per_smarrito' => 'required|integer|min:30|max:365', 'giorni_per_smarrito' => 'required|integer|min:30|max:365',
'home_limit_list' => 'required|integer|min:1|max:100', 'home_limit_list' => 'required|integer|min:1|max:100',
'assignment_link_ttl_months' => 'required|integer|min:1|max:24',
'pdf_viewer_show_download' => 'required|boolean',
'audit_retention_days' => 'required|integer|min:30|max:3650', 'audit_retention_days' => 'required|integer|min:30|max:3650',
]; ];
} }
@@ -48,6 +54,8 @@ class SettingsEdit extends Component
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari, 'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
'giorni_per_smarrito' => $this->giorni_per_smarrito, 'giorni_per_smarrito' => $this->giorni_per_smarrito,
'home_limit_list' => $this->home_limit_list, 'home_limit_list' => $this->home_limit_list,
'assignment_link_ttl_hours' => $this->assignment_link_ttl_months,
'pdf_viewer_show_download' => $this->pdf_viewer_show_download,
'audit_retention_days' => $this->audit_retention_days, 'audit_retention_days' => $this->audit_retention_days,
]); ]);

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Livewire\Settings;
use App\Models\Assegnazione;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
class UsersIndex extends Component
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public string $selectedRole = '';
public array $availableRoles = [];
public ?int $editingUserId = null;
public string $editName = '';
public string $editEmail = '';
public string $editPassword = '';
public string $editPassword_confirmation = '';
public string $editSelectedRole = '';
public function mount(): void
{
$this->availableRoles = Role::query()
->orderBy('name')
->pluck('name')
->all();
if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0];
}
}
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'selectedRole' => ['required', 'string', Rule::in($this->availableRoles)],
];
}
protected function editRules(): array
{
return [
'editName' => ['required', 'string', 'max:255'],
'editEmail' => [
'required',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($this->editingUserId),
],
'editPassword' => ['nullable', 'string', 'min:8', 'confirmed'],
'editSelectedRole' => ['required', 'string', Rule::in($this->availableRoles)],
];
}
public function createUser(): void
{
$validated = $this->validate();
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
$user->syncRoles([$validated['selectedRole']]);
$this->reset(['name', 'email', 'password', 'password_confirmation']);
if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0];
}
session()->flash('success', 'Utente creato con successo.');
}
public function startEdit(int $userId): void
{
$user = User::query()->with('roles')->findOrFail($userId);
$this->editingUserId = $user->id;
$this->editName = $user->name;
$this->editEmail = $user->email;
$this->editPassword = '';
$this->editPassword_confirmation = '';
$this->editSelectedRole = $user->roles->first()?->name ?? ($this->availableRoles[0] ?? '');
}
public function cancelEdit(): void
{
$this->reset([
'editingUserId',
'editName',
'editEmail',
'editPassword',
'editPassword_confirmation',
'editSelectedRole',
]);
}
public function updateUser(): void
{
if (! $this->editingUserId) {
return;
}
$validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId);
$user->name = $validated['editName'];
$user->email = $validated['editEmail'];
if (! empty($validated['editPassword'])) {
$user->password = $validated['editPassword'];
}
$user->save();
$user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit();
session()->flash('success', 'Utente aggiornato con successo.');
}
public function deleteUser(int $userId): void
{
$currentUser = auth()->user();
$user = User::query()->with('roles')->findOrFail($userId);
if (! $currentUser || $currentUser->id === $user->id) {
session()->flash('error', 'Non puoi cancellare il tuo utente.');
return;
}
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
return;
}
DB::transaction(function () use ($user, $currentUser) {
$causerName = $user->name;
$causerEmail = $user->email;
$deletedAt = now()->toDateTimeString();
Activity::query()
->where('causer_type', User::class)
->where('causer_id', $user->id)
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
foreach ($activities as $activity) {
$properties = $activity->properties?->toArray() ?? [];
$properties['causer_name'] = $causerName;
$properties['causer_email'] = $causerEmail;
$properties['causer_deleted_at'] = $deletedAt;
$activity->properties = $properties;
$activity->save();
}
});
Assegnazione::query()
->where('created_by', $user->id)
->update(['created_by' => $currentUser->id]);
Assegnazione::query()
->where('returned_by', $user->id)
->update(['returned_by' => $currentUser->id]);
$user->syncRoles([]);
$user->delete();
});
if ($this->editingUserId === $userId) {
$this->cancelEdit();
}
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
}
public function render()
{
return view('livewire.settings.users-index', [
'users' => User::query()->with('roles')->orderBy('name')->get(),
]);
}
}

View File

@@ -0,0 +1,804 @@
<?php
namespace App\Livewire\Settings;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\WithFileUploads;
class XmlExchange extends Component
{
use WithFileUploads;
public array $importStats = [];
public array $importIssues = [];
public array $pdfFolder = [];
public array $pdfImportLogs = [];
public array $pdfImportStats = [];
public array $pdfImportIssues = [];
public ?string $currentPdfImportId = null;
public string $pdfImportStatus = 'idle';
public string $pdfImportLogText = '';
public function mount(): void
{
$this->currentPdfImportId = request()->query('pdf-import');
if ($this->currentPdfImportId) {
$this->refreshPdfImportStatus();
}
}
public function importTerritoryPdfFolder(): void
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$importId = (string) Str::uuid();
$storedFiles = [];
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
$this->pdfFolder = [];
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$dataset = $this->currentDataset();
$xml = $this->datasetToXml($dataset, 'current-app-export');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function render()
{
if (session()->has('importStats')) {
$this->importStats = session('importStats');
}
if (session()->has('importIssues')) {
$this->importIssues = session('importIssues');
}
return view('livewire.settings.xml-exchange');
}
public function legacySqlToDatasetPublic(string $sql): array
{
return $this->legacySqlToDataset($sql);
}
public function datasetToXmlPublic(array $dataset, string $source): string
{
return $this->datasetToXml($dataset, $source);
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
$this->importIssues = [];
$xml = @simplexml_load_string($content);
if (! $xml) {
return ['error' => 'Impossibile leggere il file XML.'];
}
$actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
}
$stats = [
'zone_importate' => 0,
'tipologie_importate' => 0,
'proclamatori_importati' => 0,
'anni_importati' => 0,
'campagne_importate' => 0,
'territori_importati' => 0,
'assegnazioni_importate' => 0,
'duplicate_territori' => 0,
'assegnazioni_saltate' => 0,
];
DB::transaction(function () use ($xml, $actorId, &$stats) {
Assegnazione::query()->delete();
Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete();
Proclamatore::query()->withTrashed()->forceDelete();
Tipologia::query()->delete();
Zona::query()->delete();
AnnoTeocratico::query()->delete();
Setting::query()->delete();
$settingsNode = $xml->settings;
if ($settingsNode) {
Setting::instance()->update([
'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10),
'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1),
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
'setup_completed' => true,
]);
}
$zoneMap = [];
foreach (($xml->zones->zone ?? []) as $zoneNode) {
$z = Zona::create([
'nome' => (string) $zoneNode->nome,
'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1,
]);
$stats['zone_importate']++;
$legacyId = (string) ($zoneNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$zoneMap[$legacyId] = $z->id;
}
}
$tipologiaMap = [];
foreach (($xml->tipologie->tipologia ?? []) as $tipologiaNode) {
$t = Tipologia::create([
'nome' => (string) $tipologiaNode->nome,
'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1,
]);
$stats['tipologie_importate']++;
$legacyId = (string) ($tipologiaNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$tipologiaMap[$legacyId] = $t->id;
}
}
$proclamatoreMap = [];
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
$p = Proclamatore::create([
'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
]);
$stats['proclamatori_importati']++;
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$proclamatoreMap[$legacyId] = $p->id;
}
}
$annoMap = [];
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
$label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([
'label' => $label,
'start_date' => (string) $annoNode->start_date,
'end_date' => (string) $annoNode->end_date,
]);
$stats['anni_importati']++;
$legacyId = (string) ($annoNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$annoMap[$legacyId] = $anno->id;
}
}
$campagnaMap = [];
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => (string) $campagnaNode->start_date,
'end_date' => (string) $campagnaNode->end_date,
]);
$stats['campagne_importate']++;
$legacyId = (string) ($campagnaNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$campagnaMap[$legacyId] = $campagna->id;
}
}
$territorioMap = [];
$territoriNumeroVisti = [];
foreach (($xml->territori->territorio ?? []) as $territorioNode) {
$legacyZonaId = (string) ($territorioNode->legacy_zona_id ?? '');
$legacyTipologiaId = (string) ($territorioNode->legacy_tipologia_id ?? '');
$numero = trim((string) $territorioNode->numero);
$legacyId = (string) ($territorioNode['legacy_id'] ?? '');
if ($numero === '' || isset($territoriNumeroVisti[$numero])) {
$stats['duplicate_territori']++;
$this->pushImportIssue('territorio', $legacyId, 'Territorio duplicato o numero vuoto (numero=' . ($numero !== '' ? $numero : 'vuoto') . ')');
continue;
}
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
continue;
}
$territorio = Territorio::create([
'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId],
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
]);
$territoriNumeroVisti[$numero] = true;
$stats['territori_importati']++;
if ($legacyId !== '') {
$territorioMap[$legacyId] = $territorio->id;
}
}
foreach (($xml->assegnazioni->assegnazione ?? []) as $assegnazioneNode) {
$legacyTerritorio = (string) ($assegnazioneNode->legacy_territorio_id ?? '');
$legacyProclamatore = (string) ($assegnazioneNode->legacy_proclamatore_id ?? '');
$legacyAnno = (string) ($assegnazioneNode->legacy_anno_id ?? '');
$legacyId = (string) ($assegnazioneNode['legacy_id'] ?? '');
$assignedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->assigned_at ?? ''), false);
$returnedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->returned_at ?? ''), true);
if (!isset($territorioMap[$legacyTerritorio], $proclamatoreMap[$legacyProclamatore], $annoMap[$legacyAnno])) {
$stats['assegnazioni_saltate']++;
$this->pushImportIssue('assegnazione', $legacyId, 'Riferimenti mancanti (territorio=' . $legacyTerritorio . ', proclamatore=' . $legacyProclamatore . ', anno=' . $legacyAnno . ')');
continue;
}
if ($assignedAt === null) {
$stats['assegnazioni_saltate']++;
$this->pushImportIssue('assegnazione', $legacyId, 'Data assigned_at non valida o vuota');
continue;
}
$legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? '');
Assegnazione::create([
'territorio_id' => $territorioMap[$legacyTerritorio],
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore],
'anno_teocratico_id' => $annoMap[$legacyAnno],
'assigned_at' => $assignedAt,
'returned_at' => $returnedAt,
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $actorId,
'returned_by' => $returnedAt !== null ? $actorId : null,
]);
$stats['assegnazioni_importate']++;
}
});
return [
'stats' => $stats,
'issues' => $this->importIssues,
];
}
private function currentDataset(): array
{
$settings = Setting::instance();
return [
'settings' => [
'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
],
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo'])->toArray(),
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario'])->toArray(),
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date'])->toArray(),
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date'])->toArray(),
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note'])->toArray(),
];
}
private function legacySqlToDataset(string $sql): array
{
$rows = $this->extractInsertRows($sql);
$congregazione = $rows['congregazione'][0][1] ?? 'Congregazione';
$impostazioni = $rows['impostazioni'][0] ?? [1, 120, 10, 120];
$settings = [
'congregazione_nome' => (string) $congregazione,
'giorni_giacenza_da_assegnare' => (int) ($impostazioni[3] ?? 120),
'giorni_giacenza_prioritari' => (int) ($impostazioni[1] ?? 180),
'giorni_per_smarrito' => (int) ($impostazioni[3] ?? 120),
'home_limit_list' => (int) ($impostazioni[2] ?? 10),
'assignment_link_ttl_months' => 1,
'audit_retention_days' => 730,
];
$zones = [];
foreach (($rows['zona'] ?? []) as $r) {
$zones[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'nome' => (string) ($r[1] ?? ''),
'attivo' => 1,
];
}
$tipologie = [];
foreach (($rows['tipologia'] ?? []) as $r) {
$tipologie[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'nome' => (string) ($r[1] ?? ''),
'attivo' => 1,
];
}
$proclamatori = [];
foreach (($rows['proclamatore'] ?? []) as $r) {
$proclamatori[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'cognome' => (string) ($r[1] ?? ''),
'nome' => (string) ($r[2] ?? ''),
'attivo' => ((int) ($r[3] ?? 1)) === 1 ? 1 : 0,
];
}
$territori = [];
foreach (($rows['territorio'] ?? []) as $r) {
$territori[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'numero' => (string) ($r[1] ?? ''),
'legacy_tipologia_id' => (string) ($r[2] ?? ''),
'legacy_zona_id' => (string) ($r[3] ?? ''),
'confini' => (string) ($r[4] ?? ''),
'note' => (string) ($r[5] ?? ''),
'attivo' => ((int) ($r[8] ?? 1)) === 1 ? 1 : 0,
'prioritario' => ((int) ($r[11] ?? 0)) > 0 ? 1 : 0,
];
}
$anni = [];
foreach (($rows['annoServizio'] ?? []) as $r) {
$label = (string) ($r[1] ?? '');
[$startDate, $endDate] = $this->datesFromLegacyAnnoLabel($label);
$anni[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'label' => str_replace(' - ', '-', $label),
'start_date' => $startDate,
'end_date' => $endDate,
];
}
$campagne = [];
foreach (($rows['campagna'] ?? []) as $r) {
$campagne[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'descrizione' => (string) ($r[1] ?? ''),
'start_date' => $this->toDateOnly((string) ($r[2] ?? '')),
'end_date' => $this->toDateOnly((string) ($r[3] ?? '')),
];
}
$assegnazioni = [];
foreach (($rows['assegnazione'] ?? []) as $r) {
$assegnazioni[] = [
'legacy_id' => (string) ($r[0] ?? ''),
'legacy_territorio_id' => (string) ($r[1] ?? ''),
'legacy_proclamatore_id' => (string) ($r[2] ?? ''),
'legacy_anno_id' => (string) ($r[9] ?? ''),
'legacy_campagna_id' => $r[8] === null ? '' : (string) $r[8],
'assigned_at' => $this->toDateOnly((string) ($r[4] ?? '')),
'returned_at' => $this->toDateOnly((string) ($r[5] ?? '')),
'counted_in_campaign' => ((int) ($r[7] ?? 0)) === 1 ? 1 : 0,
'note' => '',
];
}
return [
'settings' => $settings,
'zones' => $zones,
'tipologie' => $tipologie,
'proclamatori' => $proclamatori,
'territori' => $territori,
'anni_teocratici' => $anni,
'campagne' => $campagne,
'assegnazioni' => $assegnazioni,
];
}
private function extractInsertRows(string $sql): array
{
$result = [];
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$table = $match[1];
$valuesPayload = $match[2];
$tuples = $this->splitSqlTuples($valuesPayload);
foreach ($tuples as $tuple) {
$result[$table][] = $this->parseSqlTuple($tuple);
}
}
return $result;
}
private function splitSqlTuples(string $payload): array
{
$tuples = [];
$inString = false;
$escape = false;
$depth = 0;
$current = '';
$len = strlen($payload);
for ($i = 0; $i < $len; $i++) {
$ch = $payload[$i];
if ($inString) {
$current .= $ch;
if ($escape) {
$escape = false;
continue;
}
if ($ch === '\\') {
$escape = true;
continue;
}
if ($ch === "'") {
$inString = false;
}
continue;
}
if ($ch === "'") {
$inString = true;
$current .= $ch;
continue;
}
if ($ch === '(') {
if ($depth === 0) {
$current = '';
}
$depth++;
continue;
}
if ($ch === ')') {
$depth--;
if ($depth === 0) {
$tuples[] = $current;
$current = '';
continue;
}
}
if ($depth > 0) {
$current .= $ch;
}
}
return $tuples;
}
private function parseSqlTuple(string $tuple): array
{
$values = [];
$inString = false;
$escape = false;
$current = '';
$len = strlen($tuple);
for ($i = 0; $i < $len; $i++) {
$ch = $tuple[$i];
if ($inString) {
$current .= $ch;
if ($escape) {
$escape = false;
continue;
}
if ($ch === '\\') {
$escape = true;
continue;
}
if ($ch === "'") {
$inString = false;
}
continue;
}
if ($ch === "'") {
$inString = true;
$current .= $ch;
continue;
}
if ($ch === ',') {
$values[] = $this->normalizeSqlValue($current);
$current = '';
continue;
}
$current .= $ch;
}
if ($current !== '') {
$values[] = $this->normalizeSqlValue($current);
}
return $values;
}
private function normalizeSqlValue(string $raw)
{
$raw = trim($raw);
if (strcasecmp($raw, 'NULL') === 0) {
return null;
}
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
$v = substr($raw, 1, -1);
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
$v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $this->normalizeUnicodeQuotes($v);
}
if (is_numeric($raw)) {
return str_contains($raw, '.') ? (float) $raw : (int) $raw;
}
return $raw;
}
private function normalizeUnicodeQuotes(string $value): string
{
return str_replace(
["\u{2018}", "\u{2019}", "\u{2032}", "\u{2035}", "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}", "\u{2036}"],
["'", "'", "'", "'", '"', '"', '"', '"', '"'],
$value
);
}
private function datesFromLegacyAnnoLabel(string $label): array
{
if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) {
return [$m[1] . '-09-01', $m[2] . '-08-31'];
}
$now = Carbon::now();
return [$now->copy()->startOfYear()->toDateString(), $now->copy()->endOfYear()->toDateString()];
}
private function toDateOnly(string $dateTime): string
{
if ($dateTime === '') {
return '';
}
return substr($dateTime, 0, 10);
}
private function normalizeDateForDb(string $raw, bool $nullable): ?string
{
$raw = trim($raw);
if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') {
return $nullable ? null : null;
}
try {
return Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return $nullable ? null : null;
}
}
private function pushImportIssue(string $entity, string $legacyId, string $reason): void
{
if (count($this->importIssues) >= 300) {
return;
}
$this->importIssues[] = [
'entity' => $entity,
'legacy_id' => $legacyId !== '' ? $legacyId : '-',
'reason' => $reason,
];
}
private function datasetToXml(array $dataset, string $source): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><termanager2/>');
$xml->addAttribute('version', '1.0');
$xml->addAttribute('source', $source);
$xml->addAttribute('generated_at', now()->toIso8601String());
$settings = $xml->addChild('settings');
foreach ($dataset['settings'] as $key => $value) {
$this->addXmlText($settings, $key, (string) $value);
}
$zonesNode = $xml->addChild('zones');
foreach ($dataset['zones'] as $zone) {
$node = $zonesNode->addChild('zone');
if (isset($zone['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
}
$tipologieNode = $xml->addChild('tipologie');
foreach ($dataset['tipologie'] as $tipologia) {
$node = $tipologieNode->addChild('tipologia');
if (isset($tipologia['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
}
$proclamatoriNode = $xml->addChild('proclamatori');
foreach ($dataset['proclamatori'] as $proclamatore) {
$node = $proclamatoriNode->addChild('proclamatore');
if (isset($proclamatore['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
}
$territoriNode = $xml->addChild('territori');
foreach ($dataset['territori'] as $territorio) {
$node = $territoriNode->addChild('territorio');
if (isset($territorio['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
}
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
$this->addXmlText($node, 'confini', (string) ($territorio['confini'] ?? ''));
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
}
$anniNode = $xml->addChild('anni_teocratici');
foreach ($dataset['anni_teocratici'] as $anno) {
$node = $anniNode->addChild('anno');
if (isset($anno['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
}
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
}
$campagneNode = $xml->addChild('campagne');
foreach ($dataset['campagne'] as $campagna) {
$node = $campagneNode->addChild('campagna');
if (isset($campagna['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
}
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
}
$assegnazioniNode = $xml->addChild('assegnazioni');
foreach ($dataset['assegnazioni'] as $assegnazione) {
$node = $assegnazioniNode->addChild('assegnazione');
if (isset($assegnazione['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $assegnazione['legacy_id']);
}
$node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? '')));
$node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? '')));
$node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? '')));
$node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? '')));
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
}
return $xml->asXML() ?: '';
}
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
return $child;
}
}

View File

@@ -7,6 +7,7 @@ use Livewire\WithFileUploads;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Zona; use App\Models\Zona;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Services\TerritorioThumbnailService;
class TerritorioCreate extends Component class TerritorioCreate extends Component
{ {
@@ -48,7 +49,9 @@ class TerritorioCreate extends Component
]; ];
if ($this->pdf) { if ($this->pdf) {
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public'); $pdfPath = $this->pdf->store('territori-pdf', 'public');
$data['pdf_path'] = $pdfPath;
$data['thumbnail_path'] = app(TerritorioThumbnailService::class)->generate($pdfPath);
} }
$territorio = Territorio::create($data); $territorio = Territorio::create($data);

View File

@@ -7,6 +7,7 @@ use Livewire\WithFileUploads;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Zona; use App\Models\Zona;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Services\TerritorioThumbnailService;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class TerritorioEdit extends Component class TerritorioEdit extends Component
@@ -60,11 +61,17 @@ class TerritorioEdit extends Component
]; ];
if ($this->pdf) { if ($this->pdf) {
// Remove old PDF $thumbService = app(TerritorioThumbnailService::class);
// Remove old PDF and thumbnail
if ($this->territorio->pdf_path) { if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path); Storage::disk('public')->delete($this->territorio->pdf_path);
} }
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public'); if ($this->territorio->thumbnail_path) {
$thumbService->delete($this->territorio->thumbnail_path);
}
$pdfPath = $this->pdf->store('territori-pdf', 'public');
$data['pdf_path'] = $pdfPath;
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
} }
$this->territorio->update($data); $this->territorio->update($data);
@@ -77,7 +84,10 @@ class TerritorioEdit extends Component
{ {
if ($this->territorio->pdf_path) { if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path); Storage::disk('public')->delete($this->territorio->pdf_path);
$this->territorio->update(['pdf_path' => null]); if ($this->territorio->thumbnail_path) {
app(TerritorioThumbnailService::class)->delete($this->territorio->thumbnail_path);
}
$this->territorio->update(['pdf_path' => null, 'thumbnail_path' => null]);
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->performedOn($this->territorio) ->performedOn($this->territorio)
->log('removed_pdf'); ->log('removed_pdf');

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
@@ -16,6 +17,9 @@ class TerritorioIndex extends Component
public string $filterZona = ''; public string $filterZona = '';
public string $filterTipologia = ''; public string $filterTipologia = '';
public string $filterStato = ''; public string $filterStato = '';
public string $filterPriorita = '';
public string $filterPdf = '';
public string $filterContenuti = '';
public string $sortField = 'numero'; public string $sortField = 'numero';
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
@@ -24,6 +28,9 @@ class TerritorioIndex extends Component
'filterZona' => ['except' => ''], 'filterZona' => ['except' => ''],
'filterTipologia' => ['except' => ''], 'filterTipologia' => ['except' => ''],
'filterStato' => ['except' => ''], 'filterStato' => ['except' => ''],
'filterPriorita' => ['except' => ''],
'filterPdf' => ['except' => ''],
'filterContenuti' => ['except' => ''],
]; ];
public function updatingSearch() public function updatingSearch()
@@ -31,6 +38,36 @@ class TerritorioIndex extends Component
$this->resetPage(); $this->resetPage();
} }
public function updatingFilterZona()
{
$this->resetPage();
}
public function updatingFilterTipologia()
{
$this->resetPage();
}
public function updatingFilterStato()
{
$this->resetPage();
}
public function updatingFilterPriorita()
{
$this->resetPage();
}
public function updatingFilterPdf()
{
$this->resetPage();
}
public function updatingFilterContenuti()
{
$this->resetPage();
}
public function sortBy(string $field) public function sortBy(string $field)
{ {
if ($this->sortField === $field) { if ($this->sortField === $field) {
@@ -41,6 +78,22 @@ class TerritorioIndex extends Component
} }
} }
public function clearFilters(): void
{
$this->reset([
'search',
'filterZona',
'filterTipologia',
'filterStato',
'filterPriorita',
'filterPdf',
'filterContenuti',
]);
$this->sortField = 'numero';
$this->sortDirection = 'asc';
$this->resetPage();
}
public function toggleActive(Territorio $territorio) public function toggleActive(Territorio $territorio)
{ {
$territorio->update(['attivo' => !$territorio->attivo]); $territorio->update(['attivo' => !$territorio->attivo]);
@@ -73,7 +126,14 @@ class TerritorioIndex extends Component
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']); $query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
if ($this->search) { if ($this->search) {
$query->where('numero', 'like', "%{$this->search}%"); $search = $this->search;
$query->where(function ($subQuery) use ($search) {
$subQuery->where('numero', 'like', "%{$search}%")
->orWhere('note', 'like', "%{$search}%")
->orWhere('confini', 'like', "%{$search}%")
->orWhereHas('zona', fn($zonaQuery) => $zonaQuery->where('nome', 'like', "%{$search}%"))
->orWhereHas('tipologia', fn($tipologiaQuery) => $tipologiaQuery->where('nome', 'like', "%{$search}%"));
});
} }
if ($this->filterZona) { if ($this->filterZona) {
@@ -90,16 +150,104 @@ class TerritorioIndex extends Component
'assegnato' => $query->assegnato(), 'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(), 'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false), 'inattivo' => $query->where('attivo', false),
'prioritari' => $query->inReparto(),
default => null, default => null,
}; };
} }
$query->orderBy($this->sortField, $this->sortDirection); if ($this->filterPdf) {
match ($this->filterPdf) {
'con_pdf' => $query->whereNotNull('pdf_path'),
'senza_pdf' => $query->whereNull('pdf_path'),
'con_thumbnail' => $query->whereNotNull('thumbnail_path'),
'senza_thumbnail' => $query->whereNull('thumbnail_path'),
default => null,
};
}
if ($this->filterContenuti) {
match ($this->filterContenuti) {
'con_note' => $query->whereNotNull('note')->where('note', '!=', ''),
'senza_note' => $query->where(function ($subQuery) {
$subQuery->whereNull('note')->orWhere('note', '');
}),
'con_confini' => $query->whereNotNull('confini')->where('confini', '!=', ''),
'senza_confini' => $query->where(function ($subQuery) {
$subQuery->whereNull('confini')->orWhere('confini', '');
}),
default => null,
};
}
$territori = $query->get();
if ($this->filterStato === 'prioritari') {
$territori = $territori->filter(fn(Territorio $territorio) => $territorio->is_prioritario)->values();
}
if ($this->filterPriorita) {
$territori = $territori->filter(function (Territorio $territorio) {
return match ($this->filterPriorita) {
'prioritari' => $territorio->is_prioritario,
'manuali' => $territorio->prioritario,
'automatici' => $territorio->is_prioritario && !$territorio->prioritario,
'non_prioritari' => !$territorio->is_prioritario,
default => true,
};
})->values();
}
if ($this->usesPriorityOrdering()) {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->is_prioritario <=> (int) $left->is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->giorni_giacenza <=> $left->giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})->values();
} else {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$result = strnatcasecmp((string) data_get($left, $this->sortField), (string) data_get($right, $this->sortField));
return $this->sortDirection === 'asc' ? $result : -$result;
})->values();
}
$perPage = 20;
$page = $this->getPage();
$items = $territori->slice(($page - 1) * $perPage, $perPage)->values();
$paginatedTerritori = new LengthAwarePaginator(
$items,
$territori->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
return view('livewire.territori.territorio-index', [ return view('livewire.territori.territorio-index', [
'territori' => $query->paginate(20), 'territori' => $paginatedTerritori,
'zone' => Zona::attive()->get(), 'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(), 'tipologie' => Tipologia::attive()->get(),
'usesPriorityOrdering' => $this->usesPriorityOrdering(),
]); ]);
} }
protected function usesPriorityOrdering(): bool
{
return $this->sortField === 'numero'
&& $this->sortDirection === 'asc'
&& (
in_array($this->filterStato, ['in_reparto', 'prioritari'], true)
|| $this->filterPriorita !== ''
);
}
} }

View File

@@ -5,7 +5,7 @@ namespace App\Livewire\Territori;
use Livewire\Component; use Livewire\Component;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\Setting;
class TerritorioShow extends Component class TerritorioShow extends Component
{ {
@@ -13,7 +13,7 @@ class TerritorioShow extends Component
public function mount(Territorio $territorio) public function mount(Territorio $territorio)
{ {
$this->territorio = $territorio->load(['zona', 'tipologia']); $this->territorio = $territorio->load(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'ultimaAssegnazione']);
} }
public function render() public function render()
@@ -25,6 +25,8 @@ class TerritorioShow extends Component
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label);
return view('livewire.territori.territorio-show', [ return view('livewire.territori.territorio-show', [
'activeAssignment' => $this->territorio->assegnazioneCorrente,
'assignmentLinkTtlMonths' => (int) Setting::getValue('assignment_link_ttl_hours', 1),
'assegnazioniPerAnno' => $assegnazioni, 'assegnazioniPerAnno' => $assegnazioni,
]); ]);
} }

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Assegnazione extends Model class Assegnazione extends Model
{ {
@@ -17,6 +18,8 @@ class Assegnazione extends Model
'returned_at', 'returned_at',
'counted_in_campaign', 'counted_in_campaign',
'campaign_id', 'campaign_id',
'pdf_access_code',
'link_sent',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
@@ -28,6 +31,7 @@ class Assegnazione extends Model
'assigned_at' => 'date', 'assigned_at' => 'date',
'returned_at' => 'date', 'returned_at' => 'date',
'counted_in_campaign' => 'boolean', 'counted_in_campaign' => 'boolean',
'link_sent' => 'boolean',
]; ];
} }
@@ -79,6 +83,47 @@ class Assegnazione extends Model
return is_null($this->returned_at); return is_null($this->returned_at);
} }
public function ensurePdfAccessCode(): string
{
if ($this->pdf_access_code) {
return $this->pdf_access_code;
}
do {
$code = strtoupper(Str::random(12));
} while (static::query()->where('pdf_access_code', $code)->exists());
$this->forceFill(['pdf_access_code' => $code])->saveQuietly();
return $code;
}
public function temporaryPdfViewerUrl(): ?string
{
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
return null;
}
return route('assignments.pdf.viewer', [
'assignment' => $this->id,
'code' => $this->ensurePdfAccessCode(),
]);
}
public function shortPdfUrl(): ?string
{
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
return null;
}
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
}
public function markLinkSent(): void
{
$this->forceFill(['link_sent' => true])->saveQuietly();
}
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
public function scopeAperte($query) public function scopeAperte($query)

View File

@@ -72,19 +72,19 @@ class Campagna extends Model
/** /**
* Campaign coverage percentage. * Campaign coverage percentage.
* Numerator: assignments counted for campaign * Numerator: assignments counted for campaign
* Denominator: ALL assignments with assigned_at in campaign range (returned or not) * Denominator: total active territories
*/ */
public function getPercentualePercorrenzaAttribute(): float public function getPercentualePercorrenzaAttribute(): float
{ {
$totaleNelRange = $this->assegnazioniNelRange()->count(); $totaleAttivi = Territorio::where('attivo', true)->count();
if ($totaleNelRange === 0) { if ($totaleAttivi === 0) {
return 0.0; return 0.0;
} }
$conteggiate = $this->assegnazioniConteggiate()->count(); $conteggiate = $this->assegnazioniConteggiate()->count();
return round(($conteggiate / $totaleNelRange) * 100, 1); return round(($conteggiate / $totaleAttivi) * 100, 1);
} }
public function scopeCompletate($query) public function scopeCompletate($query)

View File

@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
class Setting extends Model class Setting extends Model
{ {
protected static ?self $cachedInstance = null;
protected $fillable = [ protected $fillable = [
'congregazione_nome', 'congregazione_nome',
'public_base_url',
'logo_path', 'logo_path',
'giorni_giacenza_da_assegnare', 'giorni_giacenza_da_assegnare',
'giorni_giacenza_prioritari', 'giorni_giacenza_prioritari',
'giorni_per_smarrito', 'giorni_per_smarrito',
'home_limit_list', 'home_limit_list',
'assignment_link_ttl_hours',
'pdf_viewer_show_download',
'audit_retention_days', 'audit_retention_days',
'setup_completed', 'setup_completed',
]; ];
@@ -21,26 +26,46 @@ class Setting extends Model
{ {
return [ return [
'setup_completed' => 'boolean', 'setup_completed' => 'boolean',
'pdf_viewer_show_download' => 'boolean',
'giorni_giacenza_da_assegnare' => 'integer', 'giorni_giacenza_da_assegnare' => 'integer',
'giorni_giacenza_prioritari' => 'integer', 'giorni_giacenza_prioritari' => 'integer',
'giorni_per_smarrito' => 'integer', 'giorni_per_smarrito' => 'integer',
'home_limit_list' => 'integer', 'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer', 'audit_retention_days' => 'integer',
]; ];
} }
protected static function booted(): void
{
static::saved(function (): void {
static::$cachedInstance = null;
});
static::deleted(function (): void {
static::$cachedInstance = null;
});
}
/** /**
* Get the singleton settings instance (first row). * Get the singleton settings instance (first row).
*/ */
public static function instance(): static public static function instance(): static
{ {
return static::firstOrCreate([], [ if (static::$cachedInstance instanceof static) {
return static::$cachedInstance;
}
static::$cachedInstance = static::firstOrCreate([], [
'giorni_giacenza_da_assegnare' => 120, 'giorni_giacenza_da_assegnare' => 120,
'giorni_giacenza_prioritari' => 180, 'giorni_giacenza_prioritari' => 180,
'giorni_per_smarrito' => 120, 'giorni_per_smarrito' => 120,
'home_limit_list' => 10, 'home_limit_list' => 10,
'assignment_link_ttl_hours' => 1,
'audit_retention_days' => 730, 'audit_retention_days' => 730,
]); ]);
return static::$cachedInstance;
} }
public static function isSetupComplete(): bool public static function isSetupComplete(): bool

View File

@@ -21,6 +21,7 @@ class Territorio extends Model
'note', 'note',
'confini', 'confini',
'pdf_path', 'pdf_path',
'thumbnail_path',
'attivo', 'attivo',
'prioritario', 'prioritario',
]; ];
@@ -106,11 +107,11 @@ class Territorio extends Model
$ultima = $this->ultimaAssegnazione; $ultima = $this->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) { if ($ultima && $ultima->returned_at) {
return Carbon::parse($ultima->returned_at)->diffInDays(now()); return Carbon::parse($ultima->returned_at)->startOfDay()->diffInDays(today());
} }
if (!$ultima) { if (!$ultima) {
return $this->created_at->diffInDays(now()); return $this->created_at->startOfDay()->diffInDays(today());
} }
// Currently assigned, no giacenza concept // Currently assigned, no giacenza concept

View File

@@ -5,6 +5,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\URL;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -15,6 +16,11 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
// Force HTTPS when behind a reverse proxy
if (str_starts_with(config('app.url'), 'https://')) {
URL::forceScheme('https');
}
// Auto-generate APP_KEY if missing // Auto-generate APP_KEY if missing
if (empty(config('app.key'))) { if (empty(config('app.key'))) {
Artisan::call('key:generate', ['--force' => true]); Artisan::call('key:generate', ['--force' => true]);

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Services;
use App\Jobs\ImportTerritoryPdfFolder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
class TerritorioPdfImportDispatcher
{
public function __construct(
protected TerritorioPdfImportState $stateService,
) {
}
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
{
$state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog);
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
return $this->stateService->get($importId) ?? $state;
}
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{
$importId = (string) Str::uuid();
$zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip',
'local'
);
$zipAbsolutePath = storage_path('app/' . $zipStoredPath);
$zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) {
throw new RuntimeException('Impossibile aprire il file ZIP.');
}
$storedFiles = [];
$entryIndex = 0;
try {
for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index);
if (! $entryName || str_ends_with($entryName, '/')) {
continue;
}
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
continue;
}
$content = $zip->getFromIndex($index);
if ($content === false) {
continue;
}
$originalName = basename($entryName);
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = 'bulk-territori-imports/' . $importId . '/zip-' . str_pad((string) $entryIndex, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf';
file_put_contents(storage_path('app/' . $storedPath), $content);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
$entryIndex++;
}
} finally {
$zip->close();
}
if ($storedFiles === []) {
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
}
$this->dispatchStoredFiles(
$importId,
$storedFiles,
$actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
);
return $importId;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class TerritorioPdfImportState
{
protected int $ttlSeconds = 86400;
public function initialize(string $importId, int $totalFiles): array
{
$state = [
'id' => $importId,
'status' => 'queued',
'stats' => [
'total' => $totalFiles,
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => 0,
],
'logs' => [
'Import creato. In attesa del worker di coda.',
],
'issues' => [],
'started_at' => null,
'finished_at' => null,
];
$this->put($importId, $state);
return $state;
}
public function get(string $importId): ?array
{
return Cache::get($this->key($importId));
}
public function put(string $importId, array $state): void
{
Cache::put($this->key($importId), $state, $this->ttlSeconds);
}
public function update(string $importId, callable $callback): ?array
{
$state = $this->get($importId);
if (! $state) {
return null;
}
$updatedState = $callback($state) ?? $state;
$this->put($importId, $updatedState);
return $updatedState;
}
public function appendLog(string $importId, string $message): void
{
$this->update($importId, function (array $state) use ($message) {
$timestamp = now()->format('H:i:s');
$state['logs'][] = '[' . $timestamp . '] ' . $message;
return $state;
});
}
public function increment(string $importId, string $metric, int $amount = 1): void
{
$this->update($importId, function (array $state) use ($metric, $amount) {
$state['stats'][$metric] = ($state['stats'][$metric] ?? 0) + $amount;
return $state;
});
}
public function addIssue(string $importId, array $issue): void
{
$this->update($importId, function (array $state) use ($issue) {
$state['issues'][] = $issue;
return $state;
});
}
public function markRunning(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'running';
$state['started_at'] = now()->toDateTimeString();
return $state;
});
}
public function markCompleted(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'completed';
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
}
public function markFailed(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'failed';
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
}
protected function key(string $importId): string
{
return 'territori-pdf-import:' . $importId;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
class TerritorioThumbnailService
{
/**
* Generate a PNG thumbnail of the first page of a PDF stored on the public disk.
*
* @param string $pdfStoragePath Relative path within the public disk (e.g. "territori-pdf/abc.pdf")
* @return string|null Relative path of the saved thumbnail, or null on failure
*/
public function generate(string $pdfStoragePath): ?string
{
$pdfAbsPath = Storage::disk('public')->path($pdfStoragePath);
if (!file_exists($pdfAbsPath)) {
return null;
}
$tempPrefix = sys_get_temp_dir() . '/terr_thumb_' . uniqid();
exec(
'pdftoppm -r 72 -png -l 1 ' . escapeshellarg($pdfAbsPath) . ' ' . escapeshellarg($tempPrefix),
$output,
$exitCode
);
// pdftoppm may produce -1.png, -01.png or even -001.png depending on page count
$generated = null;
foreach (['-1.png', '-01.png', '-001.png'] as $suffix) {
$candidate = $tempPrefix . $suffix;
if (file_exists($candidate)) {
$generated = $candidate;
break;
}
}
if (!$generated) {
return null;
}
$thumbRelPath = 'territori-thumbnails/' . basename($pdfStoragePath, '.pdf') . '.png';
Storage::disk('public')->put($thumbRelPath, file_get_contents($generated));
unlink($generated);
return $thumbRelPath;
}
/**
* Delete an existing thumbnail from the public disk.
*/
public function delete(string $thumbnailPath): void
{
if ($thumbnailPath) {
Storage::disk('public')->delete($thumbnailPath);
}
}
}

View File

@@ -3,6 +3,7 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -11,6 +12,14 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
);
$middleware->alias([ $middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,

View File

@@ -6,11 +6,12 @@
"license": "proprietary", "license": "proprietary",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"livewire/livewire": "^3.5", "livewire/livewire": "^3.5",
"spatie/laravel-permission": "^6.4", "spatie/laravel-activitylog": "^4.8",
"spatie/laravel-activitylog": "^4.8" "spatie/laravel-permission": "^6.4"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

524
composer.lock generated
View File

@@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f5778263babc2e758e3225284a64e9b3", "content-hash": "2501abb81bccf7d5db1e152bde41bd5b",
"packages": [ "packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -377,6 +454,161 @@
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2024-02-05T11:56:58+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
@@ -2089,6 +2321,73 @@
], ],
"time": "2026-04-03T13:08:41+00:00" "time": "2026-04-03T13:08:41+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3364,6 +3663,86 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.3.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.4.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
},
"time": "2026-03-03T17:31:43+00:00"
},
{ {
"name": "spatie/laravel-activitylog", "name": "spatie/laravel-activitylog",
"version": "4.12.3", "version": "4.12.3",
@@ -6021,6 +6400,149 @@
], ],
"time": "2026-03-30T13:44:50+00:00" "time": "2026-03-30T13:44:50+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",

47
config/livewire.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
return [
'class_namespace' => 'App\\Livewire',
'view_path' => resource_path('views/livewire'),
'layout' => 'components.layouts.app',
'lazy_placeholder' => null,
'temporary_file_upload' => [
'disk' => null,
'rules' => ['required', 'file', 'max:256000'],
'directory' => null,
'middleware' => null,
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 15,
'cleanup' => true,
],
'render_on_redirect' => false,
'legacy_model_binding' => false,
'inject_assets' => true,
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
'inject_morph_markers' => true,
'smart_wire_keys' => false,
'pagination_theme' => 'tailwind',
'release_token' => env('APP_VERSION'),
'inject_assets_after_styles' => false,
];

View File

@@ -0,0 +1,28 @@
<?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
{
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});
}
};

View File

@@ -0,0 +1,32 @@
<?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
{
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});
}
}
};

View File

@@ -0,0 +1,32 @@
<?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
{
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('pdf_path');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('territori', 'thumbnail_path')) {
Schema::table('territori', function (Blueprint $table) {
$table->dropColumn('thumbnail_path');
});
}
}
};

View File

@@ -0,0 +1,22 @@
<?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::table('settings', function (Blueprint $table) {
$table->unsignedInteger('assignment_link_ttl_hours')->default(24)->after('home_limit_list');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('assignment_link_ttl_hours');
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->string('pdf_access_code', 32)->nullable()->unique()->after('campaign_id');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropUnique(['pdf_access_code']);
$table->dropColumn('pdf_access_code');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->update([
'assignment_link_ttl_hours' => DB::raw('GREATEST(1, CEIL(assignment_link_ttl_hours / 720))'),
]);
}
public function down(): void
{
DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->update([
'assignment_link_ttl_hours' => DB::raw('assignment_link_ttl_hours * 720'),
]);
}
};

View File

@@ -0,0 +1,22 @@
<?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::table('settings', function (Blueprint $table) {
$table->string('public_base_url')->nullable()->after('congregazione_nome');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('public_base_url');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('link_sent')->default(false)->after('pdf_access_code');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('link_sent');
});
}
};

View File

@@ -0,0 +1,22 @@
<?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::table('settings', function (Blueprint $table) {
$table->boolean('pdf_viewer_show_download')->default(true)->after('assignment_link_ttl_hours');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('pdf_viewer_show_download');
});
}
};

View File

@@ -5,6 +5,7 @@ services:
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
container_name: termanager2_app container_name: termanager2_app
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
working_dir: /var/www/html working_dir: /var/www/html
volumes: volumes:
- ./:/var/www/html - ./:/var/www/html
@@ -46,6 +47,26 @@ services:
app: app:
condition: service_healthy condition: service_healthy
queue-worker:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: termanager2_queue
restart: unless-stopped
user: "1000:1000"
working_dir: /var/www/html
volumes:
- ./:/var/www/html
networks:
- termanager2
depends_on:
app:
condition: service_healthy
entrypoint: ["php"]
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
mariadb: mariadb:
image: mariadb:11 image: mariadb:11
container_name: termanager2_db container_name: termanager2_db
@@ -57,8 +78,6 @@ services:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret} MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
ports:
- "${DB_PORT:-3306}:3306"
networks: networks:
- termanager2 - termanager2
healthcheck: healthcheck:
@@ -74,8 +93,6 @@ services:
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret} command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret}
volumes: volumes:
- redis_data:/data - redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
networks: networks:
- termanager2 - termanager2
healthcheck: healthcheck:

View File

@@ -21,6 +21,18 @@ server {
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params; include fastcgi_params;
fastcgi_hide_header X-Powered-By; fastcgi_hide_header X-Powered-By;
# Forward proxy headers so Laravel can validate signed URLs behind reverse proxy
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_HOST $http_host;
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
# Trust external proxy proto, or default to https for signed URL validation
set $fwd_proto $http_x_forwarded_proto;
if ($fwd_proto = "") {
set $fwd_proto "https";
}
fastcgi_param HTTP_X_FORWARDED_PROTO $fwd_proto;
} }
location ~ /\.(?!well-known).* { location ~ /\.(?!well-known).* {

View File

@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
zip \ zip \
unzip \ unzip \
supervisor \ supervisor \
poppler-utils \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \ && docker-php-ext-install \
pdo_mysql \ pdo_mysql \

View File

@@ -70,7 +70,9 @@ mkdir -p storage/framework/{cache,sessions,views}
mkdir -p storage/logs mkdir -p storage/logs
mkdir -p storage/app mkdir -p storage/app
mkdir -p bootstrap/cache mkdir -p bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache if [ "$(id -u)" = "0" ]; then
chown -R www-data:www-data storage bootstrap/cache
fi
# ----------------------------------------------- # -----------------------------------------------
# 1. .env file (must exist before composer/artisan) # 1. .env file (must exist before composer/artisan)
@@ -181,7 +183,7 @@ retry 10 3 php artisan migrate --force
# ----------------------------------------------- # -----------------------------------------------
# 7b. Seed database on first container startup only # 7b. Seed database on first container startup only
# ----------------------------------------------- # -----------------------------------------------
SEED_MARKER_FILE="/var/www/html/storage/app/.db_seeded" SEED_MARKER_FILE="/var/www/html/storage/framework/.runtime_db_seeded"
RUN_DB_SEED_ON_FIRST_START="${RUN_DB_SEED_ON_FIRST_START:-true}" RUN_DB_SEED_ON_FIRST_START="${RUN_DB_SEED_ON_FIRST_START:-true}"
if [ "$RUN_DB_SEED_ON_FIRST_START" = "true" ]; then if [ "$RUN_DB_SEED_ON_FIRST_START" = "true" ]; then
@@ -214,7 +216,6 @@ if [ "$ENSURE_INITIAL_ADMIN_ON_EMPTY_DB" = "true" ]; then
--password="$INITIAL_ADMIN_PASSWORD_VALUE" \ --password="$INITIAL_ADMIN_PASSWORD_VALUE" \
--no-interaction; then --no-interaction; then
warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD." warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD."
exit 1
fi fi
else else
echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check." echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check."

View File

@@ -1,6 +1,6 @@
[PHP] [PHP]
upload_max_filesize = 64M upload_max_filesize = 256M
post_max_size = 64M post_max_size = 256M
memory_limit = 256M memory_limit = 256M
max_execution_time = 120 max_execution_time = 120
max_input_vars = 3000 max_input_vars = 3000

View File

@@ -1,6 +1,6 @@
{ {
"resources/css/app.css": { "resources/css/app.css": {
"file": "assets/app-DTUtvTBm.css", "file": "assets/app-DL92hBto.css",
"src": "resources/css/app.css", "src": "resources/css/app.css",
"isEntry": true "isEntry": true
}, },

View File

@@ -1 +1,103 @@
@import "tailwindcss"; @import "tailwindcss";
/* ─── TerManager2 Custom UI ─── */
/* Card hover lift effect */
.card-hover {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Colored left accent border for stat cards */
.card-accent-indigo { border-left: 4px solid #6366f1; }
.card-accent-green { border-left: 4px solid #22c55e; }
.card-accent-blue { border-left: 4px solid #3b82f6; }
.card-accent-red { border-left: 4px solid #ef4444; }
.card-accent-amber { border-left: 4px solid #f59e0b; }
/* Table zebra striping */
.table-striped tbody tr:nth-child(even) {
background-color: #f9fafb;
}
/* Action buttons */
.btn-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
transition: all 0.15s ease;
border: none;
cursor: pointer;
text-decoration: none;
}
.btn-action-green { background: #dcfce7; color: #166534; }
.btn-action-green:hover { background: #bbf7d0; }
.btn-action-red { background: #fee2e2; color: #991b1b; }
.btn-action-red:hover { background: #fecaca; }
.btn-action-blue { background: #dbeafe; color: #1e40af; }
.btn-action-blue:hover { background: #bfdbfe; }
.btn-action-amber { background: #fef3c7; color: #92400e; }
.btn-action-amber:hover { background: #fde68a; }
.btn-action-gray { background: #f3f4f6; color: #374151; }
.btn-action-gray:hover { background: #e5e7eb; }
.btn-action-indigo { background: #e0e7ff; color: #4338ca; }
.btn-action-indigo:hover { background: #c7d2fe; }
/* Primary action buttons (filled) */
.btn-primary-green { background: #16a34a; color: #fff; }
.btn-primary-green:hover { background: #15803d; }
.btn-primary-red { background: #dc2626; color: #fff; }
.btn-primary-red:hover { background: #b91c1c; }
/* Gradient header */
.header-gradient {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 50%, #818cf8 100%);
}
/* Sidebar active state */
.sidebar-active {
background: linear-gradient(90deg, #eef2ff, #e0e7ff);
border-right: 3px solid #6366f1;
color: #4338ca;
font-weight: 600;
}
/* Smooth page transitions */
.page-enter {
animation: fadeSlideIn 0.25s ease-out;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Empty state styling */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 2rem 1rem;
color: #9ca3af;
}
.empty-state svg {
width: 48px;
height: 48px;
opacity: 0.4;
}
/* Badge pulse for active items */
.badge-pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link scaduto TerManager2</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f1f5f9;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,.08);
padding: 40px 32px;
max-width: 420px;
width: 100%;
text-align: center;
}
.icon {
width: 56px;
height: 56px;
background: #fef2f2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h1 {
font-size: 20px;
font-weight: 700;
color: #111827;
margin-bottom: 10px;
}
p {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
}
.territory {
display: inline-block;
margin-top: 16px;
background: #f3f4f6;
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
color: #374151;
font-weight: 600;
}
.footer {
margin-top: 28px;
font-size: 12px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="card">
<div class="icon">
<svg width="26" height="26" fill="none" viewBox="0 0 24 24" stroke="#ef4444" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1>Link scaduto</h1>
<p>Il link per questo territorio non è più valido.<br>Contatta il responsabile dei territori per ricevere un nuovo link.</p>
@if($numero)
<span class="territory">Territorio {{ $numero }}</span>
@endif
<p class="footer">TerManager2</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
<title>Territorio {{ $assignment->territorio?->numero }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f1f5f9;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 10px 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
z-index: 10;
}
.logo {
font-size: 15px;
font-weight: 700;
color: #4f46e5;
letter-spacing: -.3px;
flex: none;
}
.info {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
flex: 1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 3px 8px;
font-size: 12px;
color: #374151;
}
.chip .label {
font-size: 10px;
color: #94a3b8;
font-weight: 500;
text-transform: uppercase;
letter-spacing: .4px;
}
.chip .value {
font-weight: 600;
color: #1e293b;
}
.open-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: #4f46e5;
color: #fff;
text-decoration: none;
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
flex: none;
transition: background .15s;
}
.open-btn:hover { background: #4338ca; }
/* Toolbar */
.pdf-toolbar {
background: #1e293b;
color: #fff;
padding: 6px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 13px;
flex: none;
z-index: 10;
}
.pdf-toolbar button {
background: rgba(255,255,255,.1);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .15s;
font-size: 16px;
}
.pdf-toolbar button:hover { background: rgba(255,255,255,.2); }
.pdf-toolbar button:disabled { opacity: .3; cursor: default; }
.page-info {
font-variant-numeric: tabular-nums;
min-width: 80px;
text-align: center;
}
/* PDF viewport */
.pdf-viewport {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: #64748b;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 12px;
}
.pdf-viewport canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 16px rgba(0,0,0,.25);
background: #fff;
}
/* Loading spinner */
.pdf-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: #fff;
font-size: 14px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pdf-error {
display: none;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px;
text-align: center;
color: #fff;
}
.pdf-error a {
background: #4f46e5;
color: #fff;
text-decoration: none;
font-weight: 600;
font-size: 14px;
padding: 10px 20px;
border-radius: 8px;
}
@media (max-width: 480px) {
header { padding: 8px 10px; }
.chip { font-size: 11px; padding: 2px 6px; }
.info { gap: 3px 8px; }
.pdf-toolbar { padding: 4px 8px; gap: 8px; }
.pdf-toolbar button { width: 28px; height: 28px; }
}
</style>
</head>
<body>
<header>
<span class="logo">TerManager2</span>
<div class="info">
<div class="chip">
<span class="label">Territorio</span>
<span class="value"> {{ $assignment->territorio?->numero }}</span>
</div>
<div class="chip">
<span class="label">Assegnato a</span>
<span class="value">{{ $assignment->proclamatore?->nome_completo ?? '—' }}</span>
</div>
<div class="chip">
<span class="label">Data</span>
<span class="value">{{ $assignment->assigned_at->format('d/m/Y') }}</span>
</div>
</div>
@if($showDownload)
<a href="{{ $pdfUrl }}" download class="open-btn">
<svg width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"/></svg>
Scarica PDF
</a>
@endif
</header>
<div class="pdf-toolbar">
<button id="prevPage" title="Pagina precedente" disabled></button>
<span class="page-info" id="pageInfo"></span>
<button id="nextPage" title="Pagina successiva" disabled></button>
<button id="zoomOut" title="Riduci"></button>
<button id="zoomIn" title="Ingrandisci">+</button>
</div>
<div class="pdf-viewport" id="pdfViewport">
<div class="pdf-loading" id="pdfLoading">
<div class="spinner"></div>
Caricamento PDF…
</div>
<div class="pdf-error" id="pdfError">
<svg width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<p>Impossibile caricare il PDF.</p>
@if($showDownload)
<a href="{{ $pdfUrl }}" download>Scarica PDF</a>
@endif
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs" type="module"></script>
<script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs';
const url = @json($pdfUrl);
const viewport = document.getElementById('pdfViewport');
const loading = document.getElementById('pdfLoading');
const error = document.getElementById('pdfError');
const pageInfo = document.getElementById('pageInfo');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const zoomIn = document.getElementById('zoomIn');
const zoomOut = document.getElementById('zoomOut');
let pdfDoc = null;
let scale = 1.5;
let rendering = false;
const canvases = [];
// Determine initial scale based on screen width
if (window.innerWidth <= 480) {
scale = 1.0;
} else if (window.innerWidth <= 768) {
scale = 1.2;
}
async function renderPage(pageNum) {
const page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale });
// Reuse or create canvas
let canvas = canvases[pageNum - 1];
if (!canvas) {
canvas = document.createElement('canvas');
canvases[pageNum - 1] = canvas;
}
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(vp.width * dpr);
canvas.height = Math.floor(vp.height * dpr);
canvas.style.width = Math.floor(vp.width) + 'px';
canvas.style.height = Math.floor(vp.height) + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
await page.render({ canvasContext: ctx, viewport: vp }).promise;
return canvas;
}
async function renderAllPages() {
if (rendering) return;
rendering = true;
// Clear viewport
viewport.querySelectorAll('canvas').forEach(c => c.remove());
for (let i = 1; i <= pdfDoc.numPages; i++) {
const canvas = await renderPage(i);
viewport.appendChild(canvas);
}
pageInfo.textContent = pdfDoc.numPages + (pdfDoc.numPages === 1 ? ' pagina' : ' pagine');
rendering = false;
}
async function init() {
try {
pdfDoc = await pdfjsLib.getDocument({ url, withCredentials: false }).promise;
loading.style.display = 'none';
prevBtn.disabled = true;
nextBtn.disabled = true;
// Render all pages as continuous scroll
await renderAllPages();
zoomIn.disabled = false;
zoomOut.disabled = false;
} catch (err) {
console.error('PDF load error:', err);
loading.style.display = 'none';
error.style.display = 'flex';
}
}
zoomIn.addEventListener('click', () => {
scale = Math.min(scale + 0.25, 4);
canvases.length = 0;
renderAllPages();
});
zoomOut.addEventListener('click', () => {
scale = Math.max(scale - 0.25, 0.5);
canvases.length = 0;
renderAllPages();
});
init();
</script>
</body>
</html>

View File

@@ -11,11 +11,11 @@
<body class="h-full"> <body class="h-full">
<div class="min-h-full"> <div class="min-h-full">
{{-- Header --}} {{-- Header --}}
<nav class="bg-indigo-700 shadow-lg"> <nav class="header-gradient shadow-lg">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <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 h-16 items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2" <button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2 rounded-lg" style="background:rgba(255,255,255,0.1)"
x-data x-on:click="$dispatch('toggle-sidebar')"> x-data x-on:click="$dispatch('toggle-sidebar')">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
@@ -23,22 +23,26 @@
</button> </button>
@php $settings = \App\Models\Setting::first(); @endphp @php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path) @if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-8 w-8 rounded"> <img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@else @else
<div class="h-8 w-8 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-sm">T2</div> <div class="h-9 w-9 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm" style="background:rgba(255,255,255,0.2);backdrop-filter:blur(4px)">T2</div>
@endif @endif
<span class="text-white font-semibold text-lg hidden sm:block"> <span class="text-white font-semibold text-lg hidden sm:block" style="text-shadow:0 1px 2px rgba(0,0,0,0.1)">
{{ $settings->congregazione_nome ?? 'TerManager2' }} {{ $settings->congregazione_nome ?? 'TerManager2' }}
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="text-indigo-200 text-sm hidden sm:block"> <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg" style="background:rgba(255,255,255,0.1)">
{{ auth()->user()->name }} <svg class="h-4 w-4" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="text-indigo-300 text-xs">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span> <span class="text-sm" style="color:rgba(255,255,255,0.9)">
</span> {{ auth()->user()->name }}
<span class="text-xs" style="color:rgba(255,255,255,0.6)">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
</span>
</div>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" class="text-indigo-200 hover:text-white text-sm font-medium transition"> <button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Esci Esci
</button> </button>
</form> </form>
@@ -59,16 +63,16 @@
{{-- Sidebar --}} {{-- Sidebar --}}
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" <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"> 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"> <nav class="mt-4 px-3 space-y-0.5">
<a href="{{ route('dashboard') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Home
</a> </a>
@can('territori.manage') @can('territori.manage')
<a href="{{ route('territori.index') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('territori.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Territori
</a> </a>
@@ -76,15 +80,23 @@
@can('proclamatori.manage') @can('proclamatori.manage')
<a href="{{ route('proclamatori.index') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('proclamatori.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Proclamatori
</a> </a>
@endcan @endcan
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.assegna') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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 4v16m8-8H4"/></svg>
Assegnazioni
</a>
@endcan
@can('campagne.manage') @can('campagne.manage')
<a href="{{ route('campagne.index') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('campagne.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Campagne
</a> </a>
@@ -92,7 +104,7 @@
@can('registro.view') @can('registro.view')
<a href="{{ route('registro.index') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('registro.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Registro
</a> </a>
@@ -100,7 +112,7 @@
@can('audit.view') @can('audit.view')
<a href="{{ route('audit.index') }}" <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' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('audit.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<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> <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 Audit Log
</a> </a>
@@ -114,6 +126,11 @@
<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> <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 Impostazioni
</a> </a>
<a href="{{ route('users.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('users.*') ? '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>
Utenti
</a>
<a href="{{ route('zone.index') }}" <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' }}"> 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> <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>
@@ -124,6 +141,11 @@
<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> <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 Tipologie
</a> </a>
<a href="{{ route('xml.exchange') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*') ? '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 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>
Import
</a>
</div> </div>
@endcan @endcan
@@ -141,17 +163,30 @@
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen"> <main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
{{-- Flash messages --}} {{-- Flash messages --}}
@if (session()->has('success')) @if (session()->has('success'))
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200"> <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#f0fdf4;border-color:#bbf7d0;color:#166534">
<svg class="h-5 w-5 flex-shrink-0" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('success') }} {{ session('success') }}
</div> </div>
@endif @endif
@if (session()->has('error')) @if (session()->has('error'))
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-700 border border-red-200"> <div x-data="{ show: true }" x-show="show"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#fef2f2;border-color:#fecaca;color:#991b1b">
<svg class="h-5 w-5 flex-shrink-0" style="color:#ef4444" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('error') }} {{ session('error') }}
<button @click="show = false" class="ml-auto" style="color:#991b1b;opacity:0.5" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div> </div>
@endif @endif
{{ $slot }} <div class="page-enter">
{{ $slot }}
</div>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -8,8 +8,8 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="h-full flex items-center justify-center"> <body class="h-full flex items-center justify-center" style="background:linear-gradient(135deg,#eef2ff 0%,#e0e7ff 50%,#c7d2fe 100%)">
<div class="w-full max-w-md"> <div class="w-full max-w-md px-4">
{{ $slot }} {{ $slot }}
</div> </div>
@livewireScripts @livewireScripts

View File

@@ -1,46 +1,175 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Assegna Territorio</h1> <a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna ai territori</a> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna ai territori
</a>
<h1 class="text-2xl font-bold text-gray-900">Assegnazioni</h1>
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div x-data="{ formOpen: true }">
<form wire:submit="save" class="space-y-4">
{{-- Toggle form --}}
<button type="button"
x-on:click="formOpen = !formOpen"
class="mb-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800 transition">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path x-show="formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
<path x-show="!formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<span x-text="formOpen ? 'Nascondi form' : 'Nuova assegnazione'"></span>
</button>
{{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
<form wire:submit="save" class="space-y-4">
<div>
@if(!$preselectedTerritorioId)
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
<input wire:model.live.debounce.300ms="territorioSearch"
type="text"
id="territorio_search"
placeholder="Cerca per numero, zona o tipologia"
class="mt-1 mb-2 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@endif
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
<select wire:model.live="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
@if($territorio_id)
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
@if($this->selectedThumbnailUrl)
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm">
<img src="{{ $this->selectedThumbnailUrl }}"
alt="Thumbnail territorio selezionato"
class="block w-full h-auto max-h-[70vh] object-contain bg-white">
</div>
@else
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
Nessuna thumbnail disponibile per questo territorio.
</div>
@endif
</div>
@endif
</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 flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<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>
<button type="button" x-on:click="formOpen = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</button>
</div>
</form>
</div>
</div>
{{-- Elenco territori attualmente assegnati --}}
@if($assegnazioniAperte->count())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div> <div>
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label> <h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioniAperte->count() }})</h3>
<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> <p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · link valido {{ $linkTtlMonths }} {{ $linkTtlMonths === 1 ? 'mese' : 'mesi' }}</p>
<option value="">Seleziona un territorio</option> </div>
@foreach($territoriDisponibili as $t) </div>
<option value="{{ $t->id }}"> {{ $t->numero }} {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option> <div class="divide-y divide-gray-100">
@endforeach @foreach($assegnazioniAperte as $a)
</select> @php
@if($preselectedTerritorioId) $pdfUrl = $a->shortPdfUrl();
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}"> $linkScaduto = ! auth()->check() && $a->assigned_at->copy()->addMonths($linkTtlMonths)->isPast();
@endphp
<div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors">
{{-- Testata riga --}}
<div class="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div class="flex items-center gap-2">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-bold text-sm">N° {{ $a->territorio?->numero }}</a>
@if($a->territorio?->zona?->nome)
<span class="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">{{ $a->territorio?->zona?->nome }}</span>
@endif
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<span class="font-medium text-gray-800">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</span>
<span class="text-gray-300">·</span>
<span class="text-gray-500 text-xs">{{ $a->assigned_at->format('d/m/Y') }}</span>
<span class="text-gray-300">·</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full
{{ $a->giorni > 120 ? 'bg-red-50 text-red-600' : ($a->giorni > 90 ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-500') }}">
{{ $a->giorni }}g
</span>
</div>
</div>
{{-- Avviso link scaduto (solo per non loggati) --}}
@if($linkScaduto)
<div class="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold bg-red-50 border border-red-200 text-red-700">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Link scaduto — rigenerare dal dettaglio territorio
</div>
{{-- Link attivo --}}
@elseif($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<button type="button"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
class="flex-none btn-action btn-action-indigo" title="Copia link" style="padding:6px 10px">
<svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>
{{-- Flag Link Inviato --}}
<button type="button"
wire:click="toggleLinkSent({{ $a->id }})"
title="{{ $a->link_sent ? 'Link inviato clicca per annullare' : 'Segna come inviato' }}"
class="flex-none inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold border transition
{{ $a->link_sent ? 'bg-green-50 border-green-200 text-green-700 hover:bg-green-100' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100' }}">
@if($a->link_sent)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Link inviato
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
Non inviato
@endif
</button>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>
@else
<p class="mt-2 text-xs text-gray-300">Nessun PDF disponibile</p>
@endif @endif
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@endforeach
<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>
@endif
</div> </div>

View File

@@ -1,7 +1,10 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna al territorio
</a>
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1> <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> </div>
{{-- Assignment summary --}} {{-- Assignment summary --}}

View File

@@ -26,21 +26,21 @@
{{-- Table --}} {{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm"> <table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <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-semibold text-gray-600 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-semibold text-gray-600 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-semibold text-gray-600 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-semibold text-gray-600 uppercase">Soggetto</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dettagli</th> <th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dettagli</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($activities as $activity) @forelse($activities as $activity)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<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 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 text-xs">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full
{{ match($activity->description) { {{ match($activity->description) {

View File

@@ -1,6 +1,6 @@
<div class="bg-white shadow-xl rounded-2xl p-8"> <div class="bg-white shadow-xl rounded-2xl p-8" style="border-top:4px solid #6366f1">
<div class="text-center mb-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> <div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
<h2 class="text-2xl font-bold text-gray-900">TerManager2</h2> <h2 class="text-2xl font-bold text-gray-900">TerManager2</h2>
<p class="text-gray-500 text-sm mt-1">Accedi per continuare</p> <p class="text-gray-500 text-sm mt-1">Accedi per continuare</p>
</div> </div>
@@ -28,7 +28,7 @@
</div> </div>
<button type="submit" <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"> class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition" style="background:linear-gradient(135deg,#4338ca,#6366f1)" onmouseover="this.style.background='linear-gradient(135deg,#3730a3,#4f46e5)'" onmouseout="this.style.background='linear-gradient(135deg,#4338ca,#6366f1)'">
<span>Accedi</span> <span>Accedi</span>
</button> </button>
</form> </form>

View File

@@ -1,7 +1,10 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1> <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>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">

View File

@@ -2,28 +2,29 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <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> <h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
@can('campagne.manage') @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"> <a href="{{ route('campagne.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
+ Nuova Campagna <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova Campagna
</a> </a>
@endcan @endcan
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <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-semibold text-gray-600 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-semibold text-gray-600 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-semibold text-gray-600 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-semibold text-gray-600 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-left text-xs font-semibold text-gray-600 uppercase">% Percorrenza</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th> <th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-100">
@forelse($campagne as $campagna) @forelse($campagne as $campagna)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $campagna->descrizione }}</td> <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->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 text-gray-600">{{ $campagna->end_date->format('d/m/Y') }}</td>
@@ -44,12 +45,20 @@
<span class="text-xs text-gray-600">{{ $campagna->percentuale_percorrenza }}%</span> <span class="text-xs text-gray-600">{{ $campagna->percentuale_percorrenza }}%</span>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-sm text-right space-x-2"> <td class="px-4 py-3 text-sm text-right">
<a href="{{ route('campagne.show', $campagna) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a> <div class="flex items-center justify-end gap-1">
@can('campagne.manage') <a href="{{ route('campagne.show', $campagna) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<a href="{{ route('campagne.edit', $campagna) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<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> </a>
@endcan @can('campagne.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@endcan
</div>
</td> </td>
</tr> </tr>
@empty @empty

View File

@@ -1,21 +1,27 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1> <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> </div>
@can('campagne.manage') @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> <a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
@endcan @endcan
</div> </div>
{{-- Stats --}} {{-- Stats --}}
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6"> <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"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-blue">
<p class="text-xs font-medium text-gray-500 uppercase">Periodo</p> <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> <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>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-green">
<p class="text-xs font-medium text-gray-500 uppercase">Stato</p> <p class="text-xs font-medium text-gray-500 uppercase">Stato</p>
@if($campagna->is_attiva) @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> <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>
@@ -25,9 +31,9 @@
<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> <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 @endif
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-indigo">
<p class="text-xs font-medium text-gray-500 uppercase">Conteggiati / Assegnati</p> <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> <p class="mt-1 text-2xl font-bold" style="color:#6366f1">{{ $conteggiate->count() }} / {{ $assegnateNelRange }}</p>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <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="text-xs font-medium text-gray-500 uppercase">Percentuale</p>

View File

@@ -3,138 +3,177 @@
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
@if($annoCorrente) @if($annoCorrente)
<p class="text-sm text-gray-500">Anno Teocratico {{ $annoCorrente->label }} {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p> <p class="text-sm text-gray-500 mt-1">Anno Teocratico {{ $annoCorrente->label }} {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
@endif @endif
</div> </div>
{{-- Stats cards --}} {{-- Stats cards --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <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"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-indigo">
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attivi</p> <div class="flex items-center justify-between">
<p class="mt-1 text-3xl font-bold text-gray-900">{{ $totTerritoriAttivi }}</p> <div>
<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="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#eef2ff">
<svg class="h-5 w-5" style="color:#6366f1" 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>
</div>
</div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-blue">
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p> <div class="flex items-center justify-between">
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $totAssegnati }}</p> <div>
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p> <p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
<p class="mt-1 text-3xl font-bold" style="color:#3b82f6">{{ $totAssegnati }}</p>
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dbeafe">
<svg class="h-5 w-5" style="color:#3b82f6" 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>
</div>
</div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-green">
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p> <div class="flex items-center justify-between">
<p class="mt-1 text-3xl font-bold text-green-600">{{ $territoriPercorsi }}</p> <div>
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p> <p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
<p class="mt-1 text-3xl font-bold" style="color:#22c55e">{{ $territoriPercorsi }}</p>
<p class="text-xs text-gray-500">durata media {{ $mediaDurataPercorrenzaMesi }} mesi</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dcfce7">
<svg class="h-5 w-5" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
</div>
</div> </div>
@if($campagnaStats) @if($campagnaStats)
<div class="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-4"> <div class="rounded-xl shadow-sm border p-4 card-hover" style="background:#fffbeb;border-color:#fde68a;border-left:4px solid #f59e0b">
<p class="text-xs font-medium text-amber-600 uppercase">Campagna</p> <div class="flex items-center justify-between mb-1">
<p class="mt-1 text-lg font-bold text-amber-800">{{ $campagnaStats['descrizione'] }}</p> <p class="text-xs font-medium uppercase" style="color:#d97706">Campagna</p>
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#fef3c7">
<svg class="h-4 w-4" style="color:#f59e0b" 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>
</div>
</div>
<p class="text-sm font-bold" style="color:#92400e">{{ $campagnaStats['descrizione'] }}</p>
<div class="mt-2"> <div class="mt-2">
<div class="flex justify-between text-xs text-amber-700 mb-1"> <div class="flex justify-between text-xs mb-1" style="color:#b45309">
<span>{{ $campagnaStats['percentuale'] }}%</span> <span>{{ $campagnaStats['percentuale'] }}%</span>
<span>scade {{ $campagnaStats['fine'] }}</span> <span>scade {{ $campagnaStats['fine'] }}</span>
</div> </div>
<div class="w-full bg-amber-200 rounded-full h-2"> <div class="w-full rounded-full h-2" style="background:#fde68a">
<div class="bg-amber-500 h-2 rounded-full transition-all" style="width: {{ min($campagnaStats['percentuale'], 100) }}%"></div> <div class="h-2 rounded-full transition-all" style="background:#f59e0b;width:{{ min($campagnaStats['percentuale'], 100) }}%"></div>
</div> </div>
</div> </div>
</div> </div>
@else @else
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-amber">
<p class="text-xs font-medium text-gray-500 uppercase">Campagna</p> <div class="flex items-center justify-between">
<p class="mt-1 text-sm text-gray-400">Nessuna campagna attiva</p> <div>
<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>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#fef3c7">
<svg class="h-5 w-5" style="color:#d97706" 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>
</div>
</div>
</div> </div>
@endif @endif
</div> </div>
{{-- Quick lists --}} {{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-4 py-3 bg-green-50 border-b border-green-100"> <div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3>
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaAssegnare" title="Scarica PDF" class="ml-auto btn-action btn-action-green" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
@endhasanyrole
</div> </div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($daAssegnare as $t) @forelse($territoriDaAssegnare as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50"> <li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div> <div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a> <div class="flex items-center gap-2">
<p class="text-xs text-gray-500">{{ $t->zona?->nome }} {{ $t->tipologia?->nome }}</p> <a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors"> {{ $t->numero }}</a>
</div> @if($t->home_is_prioritario)
@can('territori.assign') <span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e"> Prioritario</span>
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna </a> @endif
@endcan </div>
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
@endforelse
</ul>
@if($daAssegnare->count() >= 10)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div>
@endif
</div>
{{-- Prioritari --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-amber-50 border-b border-amber-100">
<h3 class="text-sm font-semibold text-amber-800"> Prioritari</h3>
</div>
<ul class="divide-y divide-gray-100">
@forelse($prioritari as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ $t->zona?->nome }} {{ $t->zona?->nome }} {{ $t->tipologia?->nome }}
@if($t->ultimaAssegnazione?->returned_at) @if($t->home_giorni_giacenza > 0)
ultimo rientro {{ $t->ultimaAssegnazione->returned_at->diffForHumans() }} <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
@endif @endif
</p> </p>
</div> </div>
<span class="text-xs font-medium text-amber-600"> @can('territori.assign')
{{ $t->prioritario ? 'Man' : 'Auto' }} <a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna </a>
</span> @endcan
</li> </li>
@empty @empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio prioritario</li> <li class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-sm">Tutti i territori sono assegnati</span>
</li>
@endforelse @endforelse
</ul> </ul>
@if($prioritari->count() >= 10) @if($territoriDaAssegnare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center"> <div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filtroStato=prioritari" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a> <a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti </a>
</div> </div>
@endif @endif
</div> </div>
{{-- Da rientrare --}} {{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-4 py-3 bg-red-50 border-b border-red-100"> <div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
<h3 class="text-sm font-semibold text-red-800">Da Rientrare</h3> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3>
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaRientrare" title="Scarica PDF" class="ml-auto btn-action btn-action-red" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
@endhasanyrole
</div> </div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($daRientrare as $t) @forelse($daRientrare as $t)
@php($assegnazioneCorrente = $t->assegnazioneCorrente) @php($assegnazioneCorrente = $t->assegnazioneCorrente)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50"> <li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div> <div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a> <a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }} {{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
{{ $assegnazioneCorrente?->giorni }} giorni <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
</p> </p>
</div> </div>
@can('territori.return') @can('territori.return')
@if($assegnazioneCorrente) @if($assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="text-xs font-medium text-red-600 hover:text-red-800">Rientra </a> <a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">Rientra </a>
@endif @endif
@endcan @endcan
</li> </li>
@empty @empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li> <li class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-sm">Nessun territorio da rientrare</span>
</li>
@endforelse @endforelse
</ul> </ul>
@if($daRientrare->count() >= 10) @if($daRientrare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center"> <div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a> <a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti </a>
</div> </div>
@endif @endif
</div> </div>

View File

@@ -1,26 +1,31 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">Cestino Proclamatori</h1> <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>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <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-semibold text-gray-600 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-left text-xs font-semibold text-gray-600 uppercase">Eliminato il</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th> <th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
@forelse($proclamatori as $proclamatore) @forelse($proclamatori as $proclamatore)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<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 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-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"> <td class="px-4 py-3 text-sm text-right">
<button wire:click="restore({{ $proclamatore->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button> <div class="flex items-center justify-end gap-1">
<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> <button wire:click="restore({{ $proclamatore->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
</div>
</td> </td>
</tr> </tr>
@empty @empty

View File

@@ -1,7 +1,10 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1> <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>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">

View File

@@ -2,8 +2,9 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <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> <h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
@can('proclamatori.manage') @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"> <a href="{{ route('proclamatori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
+ Nuovo Proclamatore <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo Proclamatore
</a> </a>
@endcan @endcan
</div> </div>
@@ -28,23 +29,23 @@
{{-- Table --}} {{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <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"> <th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
Cognome @if($sortField==='cognome') {{ $sortDirection==='asc'?'':'' }} @endif Cognome @if($sortField==='cognome') <span style="color:#6366f1">{{ $sortDirection==='asc'?'':'' }}</span> @endif
</th> </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"> <th wire:click="sortBy('nome')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
Nome @if($sortField==='nome') {{ $sortDirection==='asc'?'':'' }} @endif Nome @if($sortField==='nome') <span style="color:#6366f1">{{ $sortDirection==='asc'?'':'' }}</span> @endif
</th> </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-semibold text-gray-600 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-left text-xs font-semibold text-gray-600 uppercase">Territori Assegnati</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th> <th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-100">
@forelse($proclamatori as $proclamatore) @forelse($proclamatori as $proclamatore)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td> <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 text-gray-700">{{ $proclamatore->nome }}</td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
@@ -55,20 +56,37 @@
@endif @endif
</td> </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-gray-600">{{ $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right space-x-2"> <td class="px-4 py-3 text-sm text-right">
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a> <div class="flex items-center justify-end gap-1">
@can('proclamatori.manage') <a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<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' }}"> </a>
{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }} @can('proclamatori.manage')
</button> <a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-gray" title="Modifica">
<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> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
@endcan </a>
<button wire:click="toggleActive({{ $proclamatore->id }})" class="btn-action btn-action-amber" title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}">
@if($proclamatore->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@endcan
</div>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500 text-sm">Nessun proclamatore trovato.</td> <td colspan="5">
<div class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<span class="text-sm font-medium">Nessun proclamatore trovato</span>
</div>
</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>

View File

@@ -1,17 +1,23 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1> <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> </div>
@can('proclamatori.manage') @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> <a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
@endcan @endcan
</div> </div>
{{-- Stats --}} {{-- Stats --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6"> <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"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-green">
<p class="text-xs font-medium text-gray-500 uppercase">Stato</p> <p class="text-xs font-medium text-gray-500 uppercase">Stato</p>
@if($proclamatore->attivo) @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> <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>
@@ -19,11 +25,11 @@
<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> <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 @endif
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-indigo">
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attualmente</p> <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> <p class="mt-1 text-2xl font-bold" style="color:#6366f1">{{ $stats['attualmente_assegnati'] }}</p>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-blue">
<p class="text-xs font-medium text-gray-500 uppercase">Media Giorni Trattenuta</p> <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="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> <p class="text-xs text-gray-500">su {{ $stats['totale_assegnazioni'] }} assegnazioni totali</p>

View File

@@ -1,6 +1,12 @@
<div> <div>
<div class="mb-6"> <div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1> <h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
@can('settings.manage')
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova voce
</button>
@endcan
</div> </div>
{{-- Filters --}} {{-- Filters --}}
@@ -36,28 +42,43 @@
{{-- Table --}} {{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm"> <table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <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 wire:click="sortBy('territorio_numero')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Proclamatore</th> Territorio @if($sortField==='territorio_numero') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
<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>
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer"> <th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
Rientrato @if($sortField==='returned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif <th wire:click="sortBy('assigned_at')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
Assegnato @if($sortField==='assigned_at') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Giorni</th> <th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anno</th> Rientrato @if($sortField==='returned_at') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campagna</th> </th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Giorni</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Anno</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Campagna</th>
@can('settings.manage')
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Azioni</th>
@endcan
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($assegnazioni as $a) @forelse($assegnazioni as $a)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
@php($temporaryPdfUrl = !$a->returned_at ? $a->shortPdfUrl() : null)
<td class="px-3 py-2"> <td class="px-3 py-2">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium"> {{ $a->territorio?->numero }}</a> <div class="flex items-center gap-2">
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span> @if($a->territorio?->thumbnail_path)
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
alt="Thumbnail territorio {{ $a->territorio?->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif
<div class="min-w-0">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium"> {{ $a->territorio?->numero }}</a>
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
</div>
</div>
</td> </td>
<td class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</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">{{ $a->assigned_at->format('d/m/Y') }}</td>
@@ -77,10 +98,32 @@
<span class="text-gray-300">-</span> <span class="text-gray-300">-</span>
@endif @endif
</td> </td>
@can('settings.manage')
<td class="px-3 py-2 whitespace-nowrap">
<div class="flex items-center gap-1">
@if($temporaryPdfUrl)
<a href="{{ $temporaryPdfUrl }}"
target="_blank"
rel="noopener noreferrer"
class="btn-action btn-action-indigo" title="PDF">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</a>
@endif
<button wire:click="openEdit({{ $a->id }})"
class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="askDelete({{ $a->id }})"
class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
@endcan
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td> <td colspan="8" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -90,4 +133,124 @@
{{ $assegnazioni->links() }} {{ $assegnazioni->links() }}
</div> </div>
</div> </div>
{{-- ─── Modal Crea / Modifica (solo admin) ────────────────── --}}
@can('settings.manage')
@if($showModal)
<div style="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:12px;width:100%;max-width:560px;max-height:90vh;overflow-y:auto;padding:24px;">
<h2 style="font-size:18px;font-weight:700;color:#111827;margin-bottom:20px;">
{{ $editingId ? 'Modifica voce' : 'Nuova voce' }}
</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
{{-- Territorio --}}
<div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
<select wire:model="form_territorio_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($territori as $t)
<option value="{{ $t->id }}"> {{ $t->numero }} {{ $t->zona?->nome }}</option>
@endforeach
</select>
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Proclamatore --}}
<div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
<select wire:model="form_proclamatore_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($proclamatori as $p)
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Anno teocratico --}}
<div>
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Anno teocratico *</label>
<select wire:model="form_anno_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($anni as $anno)
<option value="{{ $anno->id }}">{{ $anno->label }}</option>
@endforeach
</select>
@error('form_anno_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Campagna --}}
<div>
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label>
<select wire:model="form_campaign_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">Nessuna</option>
@foreach($campagne as $c)
<option value="{{ $c->id }}">{{ $c->descrizione }}</option>
@endforeach
</select>
@error('form_campaign_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Data assegnazione --}}
<div>
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Data assegnazione *</label>
<input type="date" wire:model="form_assigned_at" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
@error('form_assigned_at') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Data rientro --}}
<div>
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Data rientro</label>
<input type="date" wire:model="form_returned_at" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
@error('form_returned_at') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Contata in campagna --}}
<div style="grid-column:1/-1;display:flex;align-items:center;gap:8px;">
<input type="checkbox" wire:model="form_counted_in_campaign" id="ccb" style="width:16px;height:16px;cursor:pointer;">
<label for="ccb" style="font-size:14px;color:#374151;cursor:pointer;">Contata in campagna</label>
</div>
{{-- Note --}}
<div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Note</label>
<textarea wire:model="form_note" rows="2" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;resize:vertical;"></textarea>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button wire:click="$set('showModal', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="save"
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}
</button>
</div>
</div>
</div>
@endif
{{-- ─── Conferma eliminazione (solo admin) ─────────────────── --}}
@if($showDeleteConfirm)
<div style="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:12px;width:100%;max-width:360px;padding:24px;">
<h2 style="font-size:17px;font-weight:700;color:#111827;margin-bottom:10px;">Elimina voce</h2>
<p style="font-size:14px;color:#6b7280;margin-bottom:20px;">Sei sicuro di voler eliminare questa assegnazione? L'operazione non può essere annullata.</p>
<div style="display:flex;justify-content:flex-end;gap:10px;">
<button wire:click="$set('showDeleteConfirm', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="deleteConfirmed"
style="background:#dc2626;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Elimina
</button>
</div>
</div>
</div>
@endif
@endcan
</div> </div>

View File

@@ -3,6 +3,16 @@
<h1 class="text-2xl font-bold text-gray-900">Impostazioni</h1> <h1 class="text-2xl font-bold text-gray-900">Impostazioni</h1>
</div> </div>
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
<p class="text-sm font-medium text-gray-700 mb-3">Sezione Import</p>
<div class="flex flex-wrap gap-2">
<a href="{{ route('xml.exchange') }}" 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">
Apri strumenti di import
</a>
</div>
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
@if (session()->has('success')) @if (session()->has('success'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms
@@ -46,6 +56,21 @@
@error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="assignment_link_ttl_months" class="block text-sm font-medium text-gray-700">Validità link PDF assegnazione (mesi)</label>
<p class="text-xs text-gray-500 mb-1">Durata del link temporaneo condivisibile per il PDF dell'assegnazione attiva.</p>
<input wire:model="assignment_link_ttl_months" type="number" min="1" max="24" id="assignment_link_ttl_months" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('assignment_link_ttl_months') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input wire:model="pdf_viewer_show_download" type="checkbox" value="1" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Mostra pulsante "Scarica PDF" nel viewer link temporaneo
</label>
<p class="text-xs text-gray-500 mt-1">Se disattivato, il proclamatore potrà solo visualizzare il PDF senza scaricarlo.</p>
</div>
<div> <div>
<label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label> <label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label>
<p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p> <p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p>

View File

@@ -0,0 +1,149 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Utenti</h1>
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p>
</div>
@if (session()->has('success'))
<div class="rounded-lg bg-green-50 p-3 text-sm text-green-700 border border-green-200">{{ session('success') }}</div>
@endif
@if (session()->has('error'))
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">{{ session('error') }}</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2>
<form wire:submit="createUser" class="space-y-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nome *</label>
<input wire:model="name" id="name" type="text" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email *</label>
<input wire:model="email" id="email" type="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password *</label>
<input wire:model="password" id="password" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Conferma Password *</label>
<input wire:model="password_confirmation" id="password_confirmation" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
</div>
<div>
<label for="selectedRole" class="block text-sm font-medium text-gray-700">Ruolo *</label>
<select wire:model="selectedRole" id="selectedRole" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@foreach($availableRoles as $role)
<option value="{{ $role }}">{{ ucfirst($role) }}</option>
@endforeach
</select>
@error('selectedRole') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Crea Utente</button>
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
<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 font-medium text-gray-600">Nome</th>
<th class="px-4 py-2 text-left font-medium text-gray-600">Email</th>
<th class="px-4 py-2 text-left font-medium text-gray-600">Ruoli</th>
<th class="px-4 py-2 text-left font-medium text-gray-600">Azioni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($users as $user)
<tr>
@if($editingUserId === $user->id)
<td class="px-4 py-2 align-top" colspan="4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600">Nome</label>
<input wire:model="editName" type="text" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('editName') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Email</label>
<input wire:model="editEmail" type="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('editEmail') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Nuova password (opzionale)</label>
<input wire:model="editPassword" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('editPassword') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Conferma password</label>
<input wire:model="editPassword_confirmation" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Ruolo</label>
<select wire:model="editSelectedRole" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@foreach($availableRoles as $role)
<option value="{{ $role }}">{{ ucfirst($role) }}</option>
@endforeach
</select>
@error('editSelectedRole') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="mt-3 flex gap-2">
<button wire:click="updateUser" type="button" class="px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">Salva</button>
<button wire:click="cancelEdit" type="button" class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">Annulla</button>
</div>
</td>
@else
<td class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
<td class="px-4 py-2 text-gray-700">{{ $user->email }}</td>
<td class="px-4 py-2">
<div class="flex flex-wrap gap-1">
@forelse($user->roles as $role)
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">{{ $role->name }}</span>
@empty
<span class="text-xs text-gray-400">-</span>
@endforelse
</div>
</td>
<td class="px-4 py-2">
<div class="flex gap-2">
<button wire:click="startEdit({{ $user->id }})" type="button" class="px-2 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 rounded hover:bg-indigo-100">Modifica</button>
<button
wire:click="deleteUser({{ $user->id }})"
onclick="if(!confirm('Confermi la cancellazione dell\'utente? I log verranno preservati.')) event.stopImmediatePropagation();"
type="button"
class="px-2 py-1 text-xs font-medium text-red-700 bg-red-50 rounded hover:bg-red-100"
>
Cancella
</button>
</div>
</td>
@endif
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-400">Nessun utente trovato</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,358 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Import</h1>
<p class="text-sm text-gray-500 mt-1">Centro importazioni: PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
x-data="{
uploadLog: '',
uploadProgress: 0,
uploading: false,
zipUploading: false,
zipProgress: 0,
selectedFiles: 0,
selectedZip: '',
append(message) {
this.uploadLog = this.uploadLog ? this.uploadLog + '\n' + message : message;
},
submitZip(event) {
const form = event.target;
const formData = new FormData(form);
this.uploadLog = '';
this.zipUploading = true;
this.zipProgress = 0;
this.append('Upload ZIP diretto al server avviato...');
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (uploadEvent) => {
if (!uploadEvent.lengthComputable) {
return;
}
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
});
xhr.addEventListener('load', () => {
this.zipUploading = false;
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (error) {
payload = {};
}
if (xhr.status >= 200 && xhr.status < 300) {
this.zipProgress = 100;
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
window.location = payload.redirect_url || window.location.href;
return;
}
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
this.append(message);
});
xhr.addEventListener('error', () => {
this.zipUploading = false;
this.append('Errore di rete durante il caricamento dello ZIP.');
});
xhr.send(formData);
}
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { uploading = true; uploadProgress = 0; append('Upload cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { uploadProgress = $event.detail.progress; append('Upload Livewire in corso: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { uploading = false; uploadProgress = 100; append('Upload completato. Avvio preparazione import lato server...'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { uploading = false; append('Errore durante l\'upload temporaneo dei file.'); }">
<h2 class="text-lg font-semibold text-gray-900">Import PDF territori</h2>
<p class="text-xs text-gray-500">Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.</p>
<form wire:submit.prevent="importTerritoryPdfFolder" class="space-y-4">
<div>
<input wire:model="pdfFolder"
x-on:change="selectedFiles = $event.target.files.length; uploadLog = ''; if (selectedFiles > 0) { append('Cartella selezionata: ' + selectedFiles + ' file.'); append('In attesa dell\'upload temporaneo Livewire...'); }"
type="file"
multiple
webkitdirectory
directory
accept=".pdf,application/pdf"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('pdfFolder') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="selectedFiles > 0 || uploading" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploading ? 'Upload file in corso...' : 'Upload file pronto'"></span>
<span x-text="selectedFiles > 0 ? selectedFiles + ' file selezionati' : ''"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width:' + uploadProgress + '%' "></div>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
wire:loading.attr="disabled"
@disabled(empty($pdfFolder))
>
Importa PDF territori
</button>
<div wire:loading wire:target="importTerritoryPdfFolder" class="text-sm text-indigo-700">Preparazione import in corso...</div>
</div>
</form>
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-semibold text-gray-900">Import da archivio ZIP</h3>
<p class="mt-1 text-xs text-gray-500">Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.</p>
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
@csrf
<div>
<input name="pdfZip"
x-on:change="selectedZip = $event.target.files[0] ? $event.target.files[0].name : ''; uploadLog = ''; zipProgress = 0; if (selectedZip) { append('Archivio ZIP selezionato: ' + selectedZip); append('Pronto per upload diretto al server.'); }"
type="file"
accept=".zip,application/zip"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
@error('pdfZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="selectedZip || zipUploading" x-cloak class="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-emerald-800">
<span x-text="zipUploading ? 'Upload ZIP in corso...' : 'ZIP pronto per l\'invio' "></span>
<span x-text="selectedZip"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-emerald-100">
<div class="h-full rounded-full bg-emerald-600 transition-all duration-300" :style="'width:' + zipProgress + '%' "></div>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
x-bind:disabled="!selectedZip || zipUploading"
>
Importa ZIP PDF
</button>
<div x-show="zipUploading" x-cloak class="text-sm text-emerald-700">Caricamento ZIP diretto in corso...</div>
</div>
</form>
</div>
<div x-show="uploadLog || uploading || zipUploading || selectedZip" x-cloak>
<div class="mb-2 text-sm font-medium text-gray-800">Console upload</div>
<textarea readonly rows="8" x-bind:value="uploadLog" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
</div>
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs)))
<div class="rounded-xl border border-indigo-200 bg-indigo-50/40 p-4" @if(in_array($pdfImportStatus, ['queued', 'running'], true)) wire:poll.1000ms="refreshPdfImportStatus" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-800">Stato import PDF</div>
<div class="mt-1 text-xs text-gray-500">ID import: {{ $currentPdfImportId }}</div>
</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
'running' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ match($pdfImportStatus) {
'queued' => 'In coda',
'running' => 'In esecuzione',
'completed' => 'Completato',
'failed' => 'Fallito',
default => 'Inattivo',
} }}
</span>
</div>
@if(!empty($pdfImportStats))
<div class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
<div class="text-xs text-gray-500">Processati</div>
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
<div class="text-xs text-green-700">Aggiornati</div>
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
<div class="text-xs text-amber-700">Saltati</div>
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
<div class="text-xs text-red-700">Errori</div>
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
</div>
</div>
@endif
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-800">Log import PDF</div>
<button type="button" wire:click="refreshPdfImportStatus" class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-white transition">
Aggiorna log
</button>
</div>
<textarea readonly rows="12" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 sm:rows-14">{{ $pdfImportLogText }}</textarea>
</div>
@if(!empty($pdfImportIssues))
<div class="mt-4">
<div class="mb-2 text-sm font-medium text-gray-800">Riepilogo file non associati o problematici</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<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 uppercase text-gray-500">File</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($pdfImportIssues as $issue)
<tr>
<td class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
<td class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
<td class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
@csrf
<div>
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2 mt-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 border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
>
Converti in XML
</button>
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
@csrf
<div>
<input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
>
Importa XML
</button>
</div>
</form>
</div>
@if(!empty($importStats))
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
</div>
<div class="mt-3">
<button
wire:click="downloadImportLogPdf"
type="button"
style="display:inline-flex;align-items:center;gap:6px;background:#1d4ed8;color:#fff;border:1px solid #1e3a8a;border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;"
>
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica log PDF
</button>
</div>
@if(!empty($importIssues))
<div class="mt-2" style="max-height:260px;overflow:auto;border:1px solid #e5e7eb;border-radius:10px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead style="background:#f9fafb;position:sticky;top:0;">
<tr>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Entità</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Legacy ID</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Motivo</th>
</tr>
</thead>
<tbody>
@foreach($importIssues as $issue)
<tr>
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Export XML</h2>
<p class="text-xs text-gray-500">Esporta i dati correnti dell'app in XML.</p>
<div class="flex gap-2">
<button
wire:click="exportCurrentAsXml"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
>
Esporta XML
</button>
</div>
</div>
</div>

View File

@@ -1,26 +1,31 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">Cestino Territori</h1> <h1 class="text-2xl font-bold text-gray-900">Cestino Territori</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th> <th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase"></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-left text-xs font-semibold text-gray-600 uppercase">Eliminato il</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th> <th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
@forelse($territori as $territorio) @forelse($territori as $territorio)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $territorio->numero }}</td> <td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $territorio->numero }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td> <td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
<td class="px-4 py-3 text-sm text-right space-x-2"> <td class="px-4 py-3 text-sm text-right">
<button wire:click="restore({{ $territorio->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button> <div class="flex items-center justify-end gap-1">
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina definitivamente</button> <button wire:click="restore({{ $territorio->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
</div>
</td> </td>
</tr> </tr>
@empty @empty

View File

@@ -1,7 +1,10 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div> </div>
<form wire:submit="save" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 max-w-2xl"> <form wire:submit="save" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 max-w-2xl">

View File

@@ -5,15 +5,25 @@
<p class="text-sm text-gray-500 mt-1">Gestione dei territori della congregazione</p> <p class="text-sm text-gray-500 mt-1">Gestione dei territori della congregazione</p>
</div> </div>
<div class="mt-4 sm:mt-0 flex gap-2"> <div class="mt-4 sm:mt-0 flex gap-2">
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cestino</a> <a href="{{ route('territori.cestino') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<a href="{{ route('territori.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 territorio</a> <svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</a>
<a href="{{ route('territori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo territorio
</a>
</div> </div>
</div> </div>
{{-- Filters --}} {{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4"> <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-4"> <div class="flex items-center gap-2 mb-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero..." <svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg>
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Filtri</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero, zona, tipologia, note..."
class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<select wire:model.live="filterZona" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <select wire:model.live="filterZona" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutte le zone</option> <option value="">Tutte le zone</option>
@@ -30,34 +40,78 @@
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutti gli stati</option> <option value="">Tutti gli stati</option>
<option value="in_reparto">In reparto</option> <option value="in_reparto">In reparto</option>
<option value="prioritari">Prioritari</option>
<option value="assegnato">Assegnato</option> <option value="assegnato">Assegnato</option>
<option value="da_rientrare">Da rientrare</option> <option value="da_rientrare">Da rientrare</option>
<option value="inattivo">Inattivo</option> <option value="inattivo">Inattivo</option>
</select> </select>
<select wire:model.live="filterPriorita" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutte le priorita</option>
<option value="prioritari">Solo prioritari</option>
<option value="manuali">Prioritari manuali</option>
<option value="automatici">Prioritari automatici</option>
<option value="non_prioritari">Solo non prioritari</option>
</select>
<select wire:model.live="filterPdf" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">PDF e thumbnail</option>
<option value="con_pdf">Con PDF</option>
<option value="senza_pdf">Senza PDF</option>
<option value="con_thumbnail">Con thumbnail</option>
<option value="senza_thumbnail">Senza thumbnail</option>
</select>
<select wire:model.live="filterContenuti" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Note e confini</option>
<option value="con_note">Con note</option>
<option value="senza_note">Senza note</option>
<option value="con_confini">Con confini</option>
<option value="senza_confini">Senza confini</option>
</select>
</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-gray-500">
@if($usesPriorityOrdering)
<span style="color:#6366f1"></span> Ordinamento attivo: prioritari prima, poi territori con piu tempo in reparto.
@else
Ordinamento predefinito: numero territorio dal piu piccolo al piu grande.
@endif
</p>
<button wire:click="clearFilters"
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
Azzera filtri
</button>
</div> </div>
</div> </div>
{{-- Table --}} {{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 table-striped">
<thead class="bg-gray-50"> <thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr> <tr>
<th wire:click="sortBy('numero')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700"> <th wire:click="sortBy('numero')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
@if($sortField === 'numero') <span>{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif @if($sortField === 'numero') <span style="color:#6366f1">{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zona</th> <th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Zona</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tipologia</th> <th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Tipologia</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-semibold text-gray-600 uppercase">Stato</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Assegnatario</th> <th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Assegnatario</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th> <th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-100">
@forelse($territori as $territorio) @forelse($territori as $territorio)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-indigo-50/30 transition-colors">
<td class="px-4 py-3 text-sm font-semibold text-gray-900"> <td class="px-4 py-3 text-sm font-semibold text-gray-900">
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600">{{ $territorio->numero }}</a> <div class="flex items-center gap-2">
@if($territorio->thumbnail_path)
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
alt="Thumbnail territorio {{ $territorio->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
@endif
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600 transition-colors">{{ $territorio->numero }}</a>
</div>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td> <td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td> <td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td>
@@ -74,27 +128,62 @@
{{ str_replace('_', ' ', ucfirst($stato)) }} {{ str_replace('_', ' ', ucfirst($stato)) }}
</span> </span>
@if($territorio->is_prioritario) @if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1" <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
title="{{ $territorio->prioritario ? 'Prioritario (manuale)' : 'Prioritario (giacenza)' }}"> Prioritario
{{ $territorio->prioritario ? 'Man.' : 'Auto' }}
</span> </span>
@endif @endif
</td> </td>
<td class="px-4 py-3 text-sm text-gray-600"> <td class="px-4 py-3 text-sm text-gray-600">
{{ $territorio->assegnatario?->nome_completo ?? '-' }} {{ $territorio->assegnatario?->nome_completo ?? '-' }}
</td> </td>
<td class="px-4 py-3 text-sm text-right space-x-1"> <td class="px-4 py-3 text-sm">
<a href="{{ route('territori.show', $territorio) }}" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium">Dettaglio</a> <div class="flex items-center justify-end gap-1 flex-wrap">
<a href="{{ route('territori.edit', $territorio) }}" class="text-gray-600 hover:text-gray-800 text-xs font-medium">Modifica</a> {{-- Primary actions: Assegna / Rientra --}}
<button wire:click="toggleActive({{ $territorio->id }})" class="text-xs font-medium {{ $territorio->attivo ? 'text-amber-600 hover:text-amber-800' : 'text-green-600 hover:text-green-800' }}"> @can('territori.assign')
{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }} @if(!$territorio->assegnazioneCorrente && $territorio->attivo)
</button> <a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green">
<button wire:click="deleteTerritorio({{ $territorio->id }})" wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?" class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina</button> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
@endif
@endcan
{{-- Secondary actions --}}
<a href="{{ route('territori.show', $territorio) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>
<button wire:click="toggleActive({{ $territorio->id }})" class="btn-action btn-action-amber" title="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}">
@if($territorio->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteTerritorio({{ $territorio->id }})" wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 text-sm">Nessun territorio trovato.</td> <td colspan="6">
<div class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<span class="text-sm font-medium">Nessun territorio trovato</span>
<span class="text-xs">Prova a modificare i filtri di ricerca</span>
</div>
</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>

View File

@@ -1,10 +1,34 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1> <h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div> </div>
<a href="{{ route('territori.edit', $territorio) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a> <div class="flex items-center gap-2 flex-wrap">
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
@endif
@endcan
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
</div>
</div> </div>
{{-- Info card --}} {{-- Info card --}}
@@ -56,6 +80,48 @@
@endif @endif
</div> </div>
@if($activeAssignment)
@php($temporaryPdfUrl = $activeAssignment->shortPdfUrl())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" x-data="{ copied: false }">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">Assegnazione attiva</h2>
<p class="mt-1 text-sm text-gray-600">
{{ $activeAssignment->proclamatore?->nome_completo ?? 'N/A' }}
assegnato il {{ $activeAssignment->assigned_at->format('d/m/Y') }}
{{ $activeAssignment->giorni }} giorni
</p>
</div>
<div class="flex flex-wrap gap-2">
@can('territori.return')
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id]) }}" class="inline-flex items-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">Rientra</a>
@endcan
</div>
</div>
@if($temporaryPdfUrl)
<div class="mt-4 rounded-xl border border-indigo-100 bg-indigo-50/70 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-indigo-900">Link PDF temporaneo</div>
<div class="mt-1 text-xs text-indigo-700">Valido per {{ $assignmentLinkTtlMonths }} {{ $assignmentLinkTtlMonths === 1 ? 'mese' : 'mesi' }} o fino al rientro del territorio.</div>
</div>
<a href="{{ $temporaryPdfUrl }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition">Apri viewer</a>
</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<input x-ref="assignmentPdfLink" type="text" readonly value="{{ $temporaryPdfUrl }}" class="block w-full rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
<button type="button" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">Copia link</button>
</div>
<p x-show="copied" x-cloak class="mt-2 text-xs font-medium text-green-700">Link copiato.</p>
</div>
@elseif($territorio->pdf_path)
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Il link PDF temporaneo è disponibile solo per assegnazioni attive con PDF associato.
</div>
@endif
</div>
@endif
{{-- PDF viewer --}} {{-- PDF viewer --}}
@if($territorio->pdf_path) @if($territorio->pdf_path)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
@@ -72,7 +138,12 @@
{{-- Assignment history --}} {{-- Assignment history --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <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> <div class="flex items-center gap-3 mb-4">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#eef2ff">
<svg class="h-4 w-4" style="color:#6366f1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
</div>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni) @forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<div class="mb-6"> <div class="mb-6">

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Log Import XML</title>
<style>
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111; margin: 20px; }
h1 { font-size: 17px; margin-bottom: 4px; }
.meta { font-size: 11px; color: #555; margin-bottom: 16px; }
.stats-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.stat { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; min-width: 180px; }
.stat-label { font-size: 11px; color: #6b7280; }
.stat-value { font-size: 14px; font-weight: bold; color: #1f2937; }
h2 { font-size: 14px; margin-top: 20px; margin-bottom: 8px; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
thead tr { background: #f9fafb; }
th { text-align: left; padding: 7px 8px; border-bottom: 1px solid #d1d5db; font-size: 11px; color: #374151; }
td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
tr:nth-child(even) td { background: #f9fafb; }
.badge-err { color: #b91c1c; font-weight: bold; }
.footer { margin-top: 30px; font-size: 10px; color: #9ca3af; text-align: right; }
</style>
</head>
<body>
<h1>Log Import XML TerManager2</h1>
<div class="meta">Generato il {{ $generatedAt }}</div>
<div class="stats-grid">
<div class="stat"><div class="stat-label">Zone importate</div><div class="stat-value">{{ $stats['zone_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Tipologie importate</div><div class="stat-value">{{ $stats['tipologie_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Proclamatori importati</div><div class="stat-value">{{ $stats['proclamatori_importati'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Territori importati</div><div class="stat-value">{{ $stats['territori_importati'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Anni teocratici</div><div class="stat-value">{{ $stats['anni_importati'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Campagne importate</div><div class="stat-value">{{ $stats['campagne_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Assegnazioni importate</div><div class="stat-value">{{ $stats['assegnazioni_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Territori duplicati saltati</div><div class="stat-value badge-err">{{ $stats['duplicate_territori'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Assegnazioni saltate</div><div class="stat-value badge-err">{{ $stats['assegnazioni_saltate'] ?? 0 }}</div></div>
</div>
@if(!empty($issues))
<h2>Dettaglio righe non importate</h2>
<table>
<thead>
<tr>
<th style="width:18%">Entità</th>
<th style="width:14%">Legacy ID</th>
<th>Motivo</th>
</tr>
</thead>
<tbody>
@foreach($issues as $issue)
<tr>
<td>{{ $issue['entity'] }}</td>
<td>{{ $issue['legacy_id'] }}</td>
<td>{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<p style="color:#16a34a;margin-top:12px;"> Nessun elemento saltato durante l'importazione.</p>
@endif
<div class="footer">TerManager2 Export generato automaticamente</div>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>{{ $titolo }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: DejaVu Sans, sans-serif; font-size: 11px; color: #1f2937; padding: 20px; }
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #6366f1; padding-bottom: 12px; }
.header h1 { font-size: 18px; color: #1e1b4b; margin-bottom: 4px; }
.header .sub { font-size: 10px; color: #6b7280; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th { background: #eef2ff; color: #4338ca; font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 6px; text-align: left; border-bottom: 2px solid #c7d2fe; }
td { padding: 7px 6px; border-bottom: 1px solid #e5e7eb; font-size: 10px; }
tr:nth-child(even) td { background: #f9fafb; }
.prioritario { background: #fef3c7; color: #92400e; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
.badge-green { background: #dcfce7; color: #166534; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
.badge-red { background: #fee2e2; color: #991b1b; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
.footer { margin-top: 20px; padding-top: 10px; border-top: 1px solid #e5e7eb; text-align: center; font-size: 9px; color: #9ca3af; }
.total { margin-top: 12px; text-align: right; font-size: 11px; font-weight: bold; color: #4338ca; }
</style>
</head>
<body>
<div class="header">
<h1>{{ $titolo }}</h1>
<div class="sub">{{ $congregazione }} &mdash; {{ $data }}</div>
</div>
@if($tipo === 'assegnare')
<table>
<thead>
<tr>
<th style="width:10%"></th>
<th style="width:20%">Zona</th>
<th style="width:20%">Tipologia</th>
<th style="width:20%">Giorni in reparto</th>
<th style="width:15%">Priorità</th>
<th style="width:15%">Note</th>
</tr>
</thead>
<tbody>
@forelse($territori as $t)
<tr>
<td style="font-weight:bold">{{ $t->numero }}</td>
<td>{{ $t->zona?->nome ?? '-' }}</td>
<td>{{ $t->tipologia?->nome ?? '-' }}</td>
<td>{{ $t->home_giorni_giacenza }} gg</td>
<td>
@if($t->home_is_prioritario)
<span class="prioritario"> Prioritario</span>
@else
-
@endif
</td>
<td style="font-size:9px;color:#6b7280">{{ \Illuminate\Support\Str::limit($t->note, 40) }}</td>
</tr>
@empty
<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:20px">Nessun territorio da assegnare</td></tr>
@endforelse
</tbody>
</table>
<div class="total">Totale: {{ $territori->count() }} territori</div>
@endif
@if($tipo === 'rientrare')
<table>
<thead>
<tr>
<th style="width:10%"></th>
<th style="width:20%">Zona</th>
<th style="width:25%">Assegnatario</th>
<th style="width:15%">Assegnato il</th>
<th style="width:15%">Giorni</th>
<th style="width:15%">Note</th>
</tr>
</thead>
<tbody>
@forelse($territori as $t)
@php($a = $t->assegnazioneCorrente)
<tr>
<td style="font-weight:bold">{{ $t->numero }}</td>
<td>{{ $t->zona?->nome ?? '-' }}</td>
<td>{{ $a?->proclamatore?->nome_completo ?? '-' }}</td>
<td>{{ $a?->assigned_at?->format('d/m/Y') }}</td>
<td>
<span class="badge-red">{{ $a?->giorni ?? '-' }} gg</span>
</td>
<td style="font-size:9px;color:#6b7280">{{ \Illuminate\Support\Str::limit($t->note, 40) }}</td>
</tr>
@empty
<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:20px">Nessun territorio da rientrare</td></tr>
@endforelse
</tbody>
</table>
<div class="total">Totale: {{ $territori->count() }} territori</div>
@endif
<div class="footer">
Generato da TerManager2 il {{ $data }}
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span class="page-link" aria-hidden="true">&lsaquo;</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
@else
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span class="page-link" aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,88 @@
@if ($paginator->hasPages())
<nav class="d-flex justify-items-center justify-content-between">
<div class="d-flex justify-content-between flex-fill d-sm-none">
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.next')</span>
</li>
@endif
</ul>
</div>
<div class="d-none flex-sm-fill d-sm-flex align-items-sm-center justify-content-sm-between">
<div>
<p class="small text-muted">
{!! __('Showing') !!}
<span class="fw-semibold">{{ $paginator->firstItem() }}</span>
{!! __('to') !!}
<span class="fw-semibold">{{ $paginator->lastItem() }}</span>
{!! __('of') !!}
<span class="fw-semibold">{{ $paginator->total() }}</span>
{!! __('results') !!}
</p>
</div>
<div>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span class="page-link" aria-hidden="true">&lsaquo;</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
@else
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span class="page-link" aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</div>
</div>
</nav>
@endif

View File

@@ -0,0 +1,46 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span aria-hidden="true">&lsaquo;</span>
</li>
@else
<li>
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="disabled" aria-disabled="true"><span>{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="active" aria-current="page"><span>{{ $page }}</span></li>
@else
<li><a href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li>
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,36 @@
@if ($paginator->hasPages())
<div class="ui pagination menu" role="navigation">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
@else
<a class="icon item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<a class="icon item disabled" aria-disabled="true">{{ $element }}</a>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<a class="item active" href="{{ $url }}" aria-current="page">{{ $page }}</a>
@else
<a class="item" href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a class="icon item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
@else
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
@endif
</div>
@endif

View File

@@ -0,0 +1,27 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.next')</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,29 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination Navigation">
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">{!! __('pagination.previous') !!}</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">
{!! __('pagination.previous') !!}
</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">{!! __('pagination.next') !!}</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">{!! __('pagination.next') !!}</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,19 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="disabled" aria-disabled="true"><span>@lang('pagination.previous')</span></li>
@else
<li><a href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a></li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li><a href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a></li>
@else
<li class="disabled" aria-disabled="true"><span>@lang('pagination.next')</span></li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,25 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.previous') !!}
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.previous') !!}
</a>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.next') !!}
</a>
@else
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.next') !!}
</span>
@endif
</nav>
@endif

View File

@@ -0,0 +1,131 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Navigazione pagine"
class="flex flex-col sm:flex-row items-center justify-between gap-4 py-2">
{{-- Info testo --}}
<p class="text-sm text-gray-500 order-2 sm:order-1 shrink-0">
@if ($paginator->firstItem())
Risultati <span class="font-semibold text-gray-700">{{ $paginator->firstItem() }}{{ $paginator->lastItem() }}</span>
di <span class="font-semibold text-gray-700">{{ $paginator->total() }}</span>
@else
{{ $paginator->count() }} risultati
@endif
</p>
{{-- Controlli --}}
<div class="flex items-center gap-1.5 order-1 sm:order-2">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Pagina precedente">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</a>
@endif
{{-- Numeri pagina (solo desktop) --}}
<div class="hidden sm:flex items-center gap-1.5">
@foreach ($elements as $element)
@if (is_string($element))
<span class="inline-flex items-center justify-center w-9 h-9 text-sm text-gray-400 select-none"></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-sm font-semibold text-white select-none"
style="background:#4f46e5">{{ $page }}</span>
@else
<a href="{{ $url }}"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Vai a pagina {{ $page }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Indicatore pagina corrente (solo mobile) --}}
<span class="sm:hidden inline-flex items-center justify-center px-4 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 select-none">
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
</span>
{{-- Next --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Pagina successiva">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</a>
@else
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</span>
@endif
</div>
</nav>
@endif
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('pagination.previous') }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</a>
@endif
{{-- Numeri pagina: nascosti su mobile, visibili da sm in su --}}
<div class="hidden sm:flex items-center gap-1">
@foreach ($elements as $element)
@if (is_string($element))
<span class="inline-flex items-center justify-center w-8 h-8 text-xs text-gray-400 select-none"></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-xs font-semibold text-white select-none"
style="background:#4f46e5">{{ $page }}</span>
@else
<a href="{{ $url }}"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Indicatore pagina mobile --}}
<span class="sm:hidden inline-flex items-center justify-center px-3 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 select-none">
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
</span>
{{-- Next --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('pagination.next') }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</a>
@else
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</span>
@endif
</div>
</nav>
@endif

View File

@@ -1,6 +1,10 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Settings\TerritoryPdfImportController;
use App\Http\Controllers\Settings\XmlExchangeUploadController;
use App\Http\Controllers\AssignmentPdfController;
use App\Http\Controllers\ShortPdfLinkController;
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Livewire\Home; use App\Livewire\Home;
use App\Livewire\Territori\TerritorioIndex; use App\Livewire\Territori\TerritorioIndex;
@@ -24,6 +28,8 @@ use App\Livewire\AuditLog;
use App\Livewire\Settings\SettingsEdit; use App\Livewire\Settings\SettingsEdit;
use App\Livewire\Settings\ZoneIndex; use App\Livewire\Settings\ZoneIndex;
use App\Livewire\Settings\TipologieIndex; use App\Livewire\Settings\TipologieIndex;
use App\Livewire\Settings\UsersIndex;
use App\Livewire\Settings\XmlExchange;
use App\Livewire\Privacy; use App\Livewire\Privacy;
/* /*
@@ -44,6 +50,12 @@ Route::post('logout', function () {
return redirect('/login'); return redirect('/login');
})->middleware('auth')->name('logout'); })->middleware('auth')->name('logout');
Route::get('p/{code}', ShortPdfLinkController::class)->name('assignments.pdf.short');
Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer'])
->name('assignments.pdf.viewer');
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
->name('assignments.pdf.file');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Authenticated Routes | Authenticated Routes
@@ -101,8 +113,13 @@ Route::middleware('auth')->group(function () {
// Settings (admin) // Settings (admin)
Route::middleware('permission:settings.manage')->group(function () { Route::middleware('permission:settings.manage')->group(function () {
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit'); Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
Route::get('utenti', UsersIndex::class)->name('users.index');
Route::get('zone', ZoneIndex::class)->name('zone.index'); Route::get('zone', ZoneIndex::class)->name('zone.index');
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index'); Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
}); });
// Privacy / Informativa GDPR // Privacy / Informativa GDPR

View File

@@ -1 +0,0 @@
base64:7ycOQwH6FjKdElpvJW9JU33pxtNAbOHxGhj6s930X+U=

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB