Compare commits
9 Commits
777f239c7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1534c84d45 | |||
| c85f2aaea0 | |||
| 6a65087449 | |||
| 0553d4ef74 | |||
| 465e7cf092 | |||
| 5e98423e7a | |||
| 9f9a3666c1 | |||
| c585979340 | |||
| 6f8010514d |
@@ -3,7 +3,8 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
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
|
||||
SEED_DEV_DATA=false
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,7 +4,9 @@
|
||||
/storage/*.key
|
||||
/storage/app/.app_key
|
||||
/storage/app/.db_seeded
|
||||
/storage/app/public/territori-pdf/*.pdf
|
||||
/storage/app/public/territori-pdf/*.*
|
||||
/storage/app/public/territori-thumbnails/*.*
|
||||
/storage/app/livewire-tmp/*.*
|
||||
/storage/logs/
|
||||
/storage/framework/
|
||||
/bootstrap/cache/
|
||||
|
||||
504
README.md
504
README.md
@@ -9,16 +9,17 @@ Applicazione web per la **gestione dell'assegnazione e rientro di territori** (c
|
||||
- [Panoramica](#panoramica)
|
||||
- [Stack tecnologico](#stack-tecnologico)
|
||||
- [Requisiti di sistema](#requisiti-di-sistema)
|
||||
- [Installazione rapida](#installazione-rapida)
|
||||
- [Installazione da zero](#installazione-da-zero)
|
||||
- [Configurazione](#configurazione)
|
||||
- [Migrazione su nuovo server](#migrazione-su-nuovo-server)
|
||||
- [Comandi utili](#comandi-utili)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Struttura del progetto](#struttura-del-progetto)
|
||||
- [Funzionalità principali](#funzionalità-principali)
|
||||
- [Ruoli e permessi (RBAC)](#ruoli-e-permessi-rbac)
|
||||
- [Sicurezza e GDPR](#sicurezza-e-gdpr)
|
||||
- [Modello dati](#modello-dati)
|
||||
- [Regole di business](#regole-di-business)
|
||||
- [Pagine dell'applicazione](#pagine-dellapplicazione)
|
||||
- [Comandi utili](#comandi-utili)
|
||||
- [Produzione](#produzione)
|
||||
- [Licenza](#licenza)
|
||||
|
||||
@@ -54,72 +55,142 @@ Applicazione web per la **gestione dell'assegnazione e rientro di territori** (c
|
||||
| **Asset Build** | Vite 6.0 + @tailwindcss/vite |
|
||||
| **Node.js** | 20 LTS (nel container PHP) |
|
||||
| **Mail (dev)** | Mailpit |
|
||||
| **Container** | Docker Compose (5 servizi) |
|
||||
| **Container** | Docker Compose (6 servizi) |
|
||||
|
||||
---
|
||||
|
||||
## Requisiti di sistema
|
||||
|
||||
- **Docker** >= 24.0 e **Docker Compose** >= 2.20
|
||||
- Oppure, senza Docker:
|
||||
- PHP >= 8.3 con estensioni: pdo_mysql, mbstring, gd, intl, zip, bcmath, redis, opcache
|
||||
- Composer >= 2.7
|
||||
- Node.js >= 20 LTS + npm
|
||||
- MariaDB >= 11 (o MySQL 8)
|
||||
- Redis >= 7
|
||||
- **Git** per clonare il repository
|
||||
- **Porte libere** (configurabili nel `.env`):
|
||||
|
||||
| Porta | Servizio | Variabile `.env` |
|
||||
|---------|-------------------------|-------------------|
|
||||
| `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
|
||||
|
||||
- **Docker** e **Docker Compose** (v2) installati
|
||||
- Porte libere: `8080` (app), `3306` (MariaDB), `6379` (Redis), `8025` (Mailpit)
|
||||
|
||||
### Procedura
|
||||
### Passo 1 — Clona il repository
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url> termanager2
|
||||
cd termanager2
|
||||
|
||||
# 2. Configura l'ambiente
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Imposta i permessi dei file (UID 1000 = appuser nel container)
|
||||
chown -R 1000:1000 .
|
||||
mkdir -p storage/app/public storage/framework/{cache/data,sessions,views} storage/logs bootstrap/cache
|
||||
chmod -R 775 storage bootstrap/cache
|
||||
|
||||
# 4. Avvia i container (build al primo avvio)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Il codice applicativo e montato direttamente dal filesystem host, quindi ogni modifica locale e immediatamente visibile nel container (senza rebuild ad ogni edit).
|
||||
|
||||
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):
|
||||
### Passo 2 — Crea il file `.env`
|
||||
|
||||
```bash
|
||||
docker compose down -v --remove-orphans
|
||||
docker compose up -d --build
|
||||
# Ripetere i passaggi dal punto 4 in poi
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -128,36 +199,125 @@ docker compose up -d --build
|
||||
|
||||
| Variabile | Default | Descrizione |
|
||||
|-----------------------|----------------------|------------------------------------------|
|
||||
| `APP_KEY` | (generata) | Chiave AES-256 per cifratura. **Mai condividere** |
|
||||
| `APP_PORT` | `8080` | Porta host per l'applicazione |
|
||||
| `SEED_DEV_DATA` | `false` | Se `true`, `php artisan db:seed` include anche i dati demo |
|
||||
| `RUN_DB_SEED_ON_FIRST_START` | `true` | Se `true`, esegue il seed automatico solo al primo avvio container |
|
||||
| `ENSURE_INITIAL_ADMIN_ON_EMPTY_DB` | `true` | Se `true`, richiede/crea admin iniziale quando non esistono utenti |
|
||||
| `INITIAL_ADMIN_NAME` | vuoto | Nome admin creato al primo avvio |
|
||||
| `INITIAL_ADMIN_EMAIL` | vuoto | Email admin creata al primo avvio |
|
||||
| `INITIAL_ADMIN_PASSWORD` | vuoto | Password admin creata al primo avvio (min 8) |
|
||||
| `APP_KEY` | (auto-generata) | Chiave AES-256 per cifratura dati. **Mai condividere.** Viene generata automaticamente al primo avvio |
|
||||
| `APP_URL` | da `.env.example` | URL completo dell'applicazione (es. `https://miodominio.it`) |
|
||||
| `ASSET_URL` | da `.env.example` | URL base per gli asset CSS/JS (normalmente uguale a `APP_URL`) |
|
||||
| `APP_PORT` | `8080` | Porta host su cui Nginx espone l'app |
|
||||
| `APP_ENV` | `local` | Ambiente: `local` (sviluppo) o `production` |
|
||||
| `APP_DEBUG` | `true` | Mostra errori dettagliati. **Impostare `false` in produzione** |
|
||||
| `SEED_DEV_DATA` | `false` | Se `true`, il seed include dati demo di test |
|
||||
| `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_USERNAME` | `termanager2` | Utente database |
|
||||
| `DB_PASSWORD` | `secret` | Password database |
|
||||
| `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB |
|
||||
| `REDIS_PASSWORD` | `redissecret` | Password Redis |
|
||||
| `MAIL_PORT` | `1025` | Porta SMTP Mailpit |
|
||||
| `MAILPIT_UI_PORT` | `8025` | UI Mailpit per debug email |
|
||||
| `USER_ID` / `GROUP_ID`| `1000` | UID/GID container (match con host) |
|
||||
| `DB_PASSWORD` | `secret` | Password database — **cambiare in produzione** |
|
||||
| `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB — **cambiare in produzione** |
|
||||
| `REDIS_PASSWORD` | `redissecret` | Password Redis — **cambiare in produzione** |
|
||||
| `MAIL_PORT` | `1025` | Porta SMTP (Mailpit in dev, SMTP reale in prod) |
|
||||
| `MAILPIT_UI_PORT` | `8025` | Porta UI Mailpit per debug email |
|
||||
|
||||
### 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
|
||||
|
||||
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_EMAIL`
|
||||
- `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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -377,27 +537,57 @@ Il menu sidebar mostra solo le voci per cui l'utente ha permesso.
|
||||
|
||||
## Comandi utili
|
||||
|
||||
### Gestione container
|
||||
|
||||
```bash
|
||||
# Avviare i container
|
||||
# Avviare i container (dopo il primo build)
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
docker compose exec app php artisan migrate
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Seed dati di sviluppo
|
||||
docker compose exec app php artisan db:seed
|
||||
|
||||
# Compilare asset (dev con hot reload)
|
||||
docker compose exec app npm run dev
|
||||
|
||||
# Compilare asset (produzione)
|
||||
docker compose exec app npm run build
|
||||
# Seed del database
|
||||
docker compose exec app php artisan db:seed --force
|
||||
|
||||
# Pulizia manuale audit log
|
||||
docker compose exec app php artisan audit:cleanup
|
||||
@@ -407,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 view:cache
|
||||
|
||||
# Svuotare cache
|
||||
# Svuotare TUTTA la cache
|
||||
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
|
||||
|
||||
Per il deploy in produzione:
|
||||
### Checklist deploy in produzione
|
||||
|
||||
1. **Variabili d'ambiente**:
|
||||
- `APP_ENV=production`
|
||||
- `APP_DEBUG=false`
|
||||
- Password sicure per DB e Redis (non i default)
|
||||
- `APP_KEY` generata e conservata come secret
|
||||
1. **Variabili d'ambiente** — modifica nel `.env`:
|
||||
```dotenv
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
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**:
|
||||
- `php artisan config:cache && route:cache && view:cache`
|
||||
- OPcache abilitato (già configurato in `php.ini`)
|
||||
- Redis per cache, sessioni e code
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
79
app/Http/Controllers/AssignmentPdfController.php
Normal file
79
app/Http/Controllers/AssignmentPdfController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/ShortPdfLinkController.php
Normal file
34
app/Http/Controllers/ShortPdfLinkController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
240
app/Jobs/ImportTerritoryPdfFolder.php
Normal file
240
app/Jobs/ImportTerritoryPdfFolder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,13 @@ class Assegna extends Component
|
||||
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
|
||||
{
|
||||
@@ -116,9 +123,19 @@ class Assegna extends Component
|
||||
->get()
|
||||
->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', [
|
||||
'territoriDisponibili' => $territoriDisponibili,
|
||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
||||
'assegnazioniAperte' => $assegnazioniAperte,
|
||||
'linkTtlMonths' => $linkTtlMonths,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ class CampagnaShow extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// All assignments with returned_at in campaign range that were counted
|
||||
$conteggiate = Assegnazione::where('campagna_id', $this->campagna->id)
|
||||
// Assignments counted for this campaign:
|
||||
// - 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('assigned_at', '>=', $this->campagna->start_date)
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->orderBy('returned_at')
|
||||
->get();
|
||||
|
||||
@@ -9,12 +9,88 @@ use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Setting;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
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()
|
||||
{
|
||||
$settings = Setting::instance();
|
||||
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$campagnaAttiva = Campagna::attiva();
|
||||
|
||||
@@ -32,12 +108,24 @@ class Home extends Component
|
||||
->count('territorio_id');
|
||||
}
|
||||
|
||||
// Monthly average
|
||||
// Monthly average (territories/month this year)
|
||||
$mediaPercorrenzaMensile = 0;
|
||||
if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) {
|
||||
$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
|
||||
$campagnaStats = null;
|
||||
if ($campagnaAttiva) {
|
||||
@@ -49,19 +137,49 @@ class Home extends Component
|
||||
}
|
||||
|
||||
// Quick lists
|
||||
$daAssegnare = Territorio::daAssegnare()
|
||||
$territoriDaAssegnare = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->take(10)
|
||||
->get();
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
|
||||
$prioritari = Territorio::prioritari()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->take(10)
|
||||
->get();
|
||||
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) {
|
||||
$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()
|
||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||
->take(10)
|
||||
->take($homeLimit)
|
||||
->get();
|
||||
|
||||
return view('livewire.home', [
|
||||
@@ -72,9 +190,10 @@ class Home extends Component
|
||||
'totInReparto' => $totInReparto,
|
||||
'territoriPercorsi' => $territoriPercorsi,
|
||||
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
|
||||
'mediaDurataPercorrenzaMesi' => $mediaDurataPercorrenzaMesi,
|
||||
'campagnaStats' => $campagnaStats,
|
||||
'daAssegnare' => $daAssegnare,
|
||||
'prioritari' => $prioritari,
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
'daRientrare' => $daRientrare,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ class Registro extends Component
|
||||
public string $filtroZona = '';
|
||||
public string $filtroTipologia = '';
|
||||
public string $filtroStato = ''; // aperte, chiuse
|
||||
public string $sortField = 'assigned_at';
|
||||
public string $sortDirection = 'desc';
|
||||
public string $sortField = 'territorio_numero';
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
// ─── Modal create/edit ──────────────────────────────────────
|
||||
public bool $showModal = false;
|
||||
@@ -149,9 +149,17 @@ class Registro extends Component
|
||||
$this->showDeleteConfirm = false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if ($this->filtroAnno === '') {
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$this->filtroAnno = (string) $annoCorrente->id;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = Assegnazione::with(['territorio.zona', 'proclamatore', 'annoTeocratico', 'campagna']);
|
||||
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
|
||||
|
||||
if ($this->filtroAnno) {
|
||||
$query->where('anno_teocratico_id', $this->filtroAnno);
|
||||
@@ -171,7 +179,14 @@ class Registro extends Component
|
||||
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
|
||||
}
|
||||
|
||||
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
|
||||
if ($this->search !== '') {
|
||||
|
||||
@@ -12,6 +12,8 @@ class SettingsEdit extends Component
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
public int $giorni_per_smarrito = 120;
|
||||
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 function mount()
|
||||
@@ -22,6 +24,8 @@ class SettingsEdit extends Component
|
||||
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
|
||||
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
|
||||
'giorni_per_smarrito' => 'required|integer|min:30|max:365',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
@@ -48,6 +54,8 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
|
||||
'giorni_per_smarrito' => $this->giorni_per_smarrito,
|
||||
'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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
@@ -11,9 +12,12 @@ 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;
|
||||
|
||||
@@ -21,51 +25,154 @@ class XmlExchange extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public $sqlDump;
|
||||
public $xmlImport;
|
||||
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 convertLegacySqlToXml()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->currentPdfImportId = request()->query('pdf-import');
|
||||
|
||||
if ($this->currentPdfImportId) {
|
||||
$this->refreshPdfImportStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public function importTerritoryPdfFolder(): void
|
||||
{
|
||||
$this->validate([
|
||||
'sqlDump' => ['required', 'file', 'mimes:sql,txt'],
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
|
||||
$content = file_get_contents($this->sqlDump->getRealPath());
|
||||
$dataset = $this->legacySqlToDataset($content ?: '');
|
||||
$xml = $this->datasetToXml($dataset, 'legacy-sql-conversion');
|
||||
$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-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function importXmlIntoApp(): void
|
||||
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 = [];
|
||||
|
||||
$this->validate([
|
||||
'xmlImport' => ['required', 'file', 'mimes:xml,txt'],
|
||||
]);
|
||||
|
||||
$content = file_get_contents($this->xmlImport->getRealPath());
|
||||
if (! $content) {
|
||||
$this->addError('xmlImport', 'File XML non valido.');
|
||||
return;
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_string($content);
|
||||
if (! $xml) {
|
||||
$this->addError('xmlImport', 'Impossibile leggere il file XML.');
|
||||
return;
|
||||
return ['error' => 'Impossibile leggere il file XML.'];
|
||||
}
|
||||
|
||||
$actorId = auth()->id() ?? User::query()->value('id');
|
||||
if (! $actorId) {
|
||||
$this->addError('xmlImport', 'Serve almeno un utente nel sistema per importare i dati.');
|
||||
return;
|
||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||
}
|
||||
|
||||
$stats = [
|
||||
@@ -98,6 +205,7 @@ class XmlExchange extends Component
|
||||
'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,
|
||||
]);
|
||||
@@ -247,49 +355,10 @@ class XmlExchange extends Component
|
||||
}
|
||||
});
|
||||
|
||||
$this->importStats = $stats;
|
||||
|
||||
$message = 'Import XML completato con successo.';
|
||||
if ($stats['duplicate_territori'] > 0 || $stats['assegnazioni_saltate'] > 0) {
|
||||
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
||||
}
|
||||
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return view('livewire.settings.xml-exchange');
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'issues' => $this->importIssues,
|
||||
];
|
||||
}
|
||||
|
||||
private function currentDataset(): array
|
||||
@@ -303,6 +372,7 @@ class XmlExchange extends Component
|
||||
'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(),
|
||||
@@ -328,6 +398,7 @@ class XmlExchange extends Component
|
||||
'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,
|
||||
];
|
||||
|
||||
@@ -425,7 +496,7 @@ class XmlExchange extends Component
|
||||
private function extractInsertRows(string $sql): array
|
||||
{
|
||||
$result = [];
|
||||
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+?);/s', $sql, $matches, PREG_SET_ORDER);
|
||||
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$table = $match[1];
|
||||
@@ -458,7 +529,7 @@ class XmlExchange extends Component
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\\\') {
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
@@ -516,7 +587,7 @@ class XmlExchange extends Component
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\\\') {
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
@@ -558,7 +629,8 @@ class XmlExchange extends Component
|
||||
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
|
||||
$v = substr($raw, 1, -1);
|
||||
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
|
||||
return html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return $this->normalizeUnicodeQuotes($v);
|
||||
}
|
||||
|
||||
if (is_numeric($raw)) {
|
||||
@@ -568,6 +640,15 @@ class XmlExchange extends Component
|
||||
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)) {
|
||||
@@ -622,7 +703,7 @@ class XmlExchange extends Component
|
||||
|
||||
$settings = $xml->addChild('settings');
|
||||
foreach ($dataset['settings'] as $key => $value) {
|
||||
$settings->addChild($key, htmlspecialchars((string) $value, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($settings, $key, (string) $value);
|
||||
}
|
||||
|
||||
$zonesNode = $xml->addChild('zones');
|
||||
@@ -631,7 +712,7 @@ class XmlExchange extends Component
|
||||
if (isset($zone['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
|
||||
}
|
||||
$node->addChild('nome', htmlspecialchars((string) ($zone['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
@@ -641,7 +722,7 @@ class XmlExchange extends Component
|
||||
if (isset($tipologia['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
|
||||
}
|
||||
$node->addChild('nome', htmlspecialchars((string) ($tipologia['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
@@ -651,8 +732,8 @@ class XmlExchange extends Component
|
||||
if (isset($proclamatore['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
|
||||
}
|
||||
$node->addChild('nome', htmlspecialchars((string) ($proclamatore['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$node->addChild('cognome', htmlspecialchars((string) ($proclamatore['cognome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
@@ -662,11 +743,11 @@ class XmlExchange extends Component
|
||||
if (isset($territorio['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
|
||||
}
|
||||
$node->addChild('numero', htmlspecialchars((string) ($territorio['numero'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$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'] ?? '')));
|
||||
$node->addChild('confini', htmlspecialchars((string) ($territorio['confini'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$node->addChild('note', htmlspecialchars((string) ($territorio['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$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)));
|
||||
}
|
||||
@@ -677,7 +758,7 @@ class XmlExchange extends Component
|
||||
if (isset($anno['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
|
||||
}
|
||||
$node->addChild('label', htmlspecialchars((string) ($anno['label'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
|
||||
}
|
||||
@@ -688,7 +769,7 @@ class XmlExchange extends Component
|
||||
if (isset($campagna['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
|
||||
}
|
||||
$node->addChild('descrizione', htmlspecialchars((string) ($campagna['descrizione'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
|
||||
}
|
||||
@@ -706,9 +787,18 @@ class XmlExchange extends Component
|
||||
$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)));
|
||||
$node->addChild('note', htmlspecialchars((string) ($assegnazione['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -16,6 +17,9 @@ class TerritorioIndex extends Component
|
||||
public string $filterZona = '';
|
||||
public string $filterTipologia = '';
|
||||
public string $filterStato = '';
|
||||
public string $filterPriorita = '';
|
||||
public string $filterPdf = '';
|
||||
public string $filterContenuti = '';
|
||||
public string $sortField = 'numero';
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
@@ -24,6 +28,9 @@ class TerritorioIndex extends Component
|
||||
'filterZona' => ['except' => ''],
|
||||
'filterTipologia' => ['except' => ''],
|
||||
'filterStato' => ['except' => ''],
|
||||
'filterPriorita' => ['except' => ''],
|
||||
'filterPdf' => ['except' => ''],
|
||||
'filterContenuti' => ['except' => ''],
|
||||
];
|
||||
|
||||
public function updatingSearch()
|
||||
@@ -31,6 +38,36 @@ class TerritorioIndex extends Component
|
||||
$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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
$territorio->update(['attivo' => !$territorio->attivo]);
|
||||
@@ -73,7 +126,14 @@ class TerritorioIndex extends Component
|
||||
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
|
||||
|
||||
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) {
|
||||
@@ -90,16 +150,104 @@ class TerritorioIndex extends Component
|
||||
'assegnato' => $query->assegnato(),
|
||||
'da_rientrare' => $query->daRientrare(),
|
||||
'inattivo' => $query->where('attivo', false),
|
||||
'prioritari' => $query->inReparto(),
|
||||
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', [
|
||||
'territori' => $query->paginate(20),
|
||||
'territori' => $paginatedTerritori,
|
||||
'zone' => Zona::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 !== ''
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Livewire\Territori;
|
||||
use Livewire\Component;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Setting;
|
||||
|
||||
class TerritorioShow extends Component
|
||||
{
|
||||
@@ -13,7 +13,7 @@ class TerritorioShow extends Component
|
||||
|
||||
public function mount(Territorio $territorio)
|
||||
{
|
||||
$this->territorio = $territorio->load(['zona', 'tipologia']);
|
||||
$this->territorio = $territorio->load(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'ultimaAssegnazione']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -25,6 +25,8 @@ class TerritorioShow extends Component
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
|
||||
return view('livewire.territori.territorio-show', [
|
||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||
'assignmentLinkTtlMonths' => (int) Setting::getValue('assignment_link_ttl_hours', 1),
|
||||
'assegnazioniPerAnno' => $assegnazioni,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Assegnazione extends Model
|
||||
{
|
||||
@@ -17,6 +18,8 @@ class Assegnazione extends Model
|
||||
'returned_at',
|
||||
'counted_in_campaign',
|
||||
'campaign_id',
|
||||
'pdf_access_code',
|
||||
'link_sent',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
@@ -28,6 +31,7 @@ class Assegnazione extends Model
|
||||
'assigned_at' => 'date',
|
||||
'returned_at' => 'date',
|
||||
'counted_in_campaign' => 'boolean',
|
||||
'link_sent' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +83,47 @@ class Assegnazione extends Model
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
public function scopeAperte($query)
|
||||
|
||||
@@ -72,19 +72,19 @@ class Campagna extends Model
|
||||
/**
|
||||
* Campaign coverage percentage.
|
||||
* 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
|
||||
{
|
||||
$totaleNelRange = $this->assegnazioniNelRange()->count();
|
||||
$totaleAttivi = Territorio::where('attivo', true)->count();
|
||||
|
||||
if ($totaleNelRange === 0) {
|
||||
if ($totaleAttivi === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$conteggiate = $this->assegnazioniConteggiate()->count();
|
||||
|
||||
return round(($conteggiate / $totaleNelRange) * 100, 1);
|
||||
return round(($conteggiate / $totaleAttivi) * 100, 1);
|
||||
}
|
||||
|
||||
public function scopeCompletate($query)
|
||||
|
||||
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected static ?self $cachedInstance = null;
|
||||
|
||||
protected $fillable = [
|
||||
'congregazione_nome',
|
||||
'public_base_url',
|
||||
'logo_path',
|
||||
'giorni_giacenza_da_assegnare',
|
||||
'giorni_giacenza_prioritari',
|
||||
'giorni_per_smarrito',
|
||||
'home_limit_list',
|
||||
'assignment_link_ttl_hours',
|
||||
'pdf_viewer_show_download',
|
||||
'audit_retention_days',
|
||||
'setup_completed',
|
||||
];
|
||||
@@ -21,26 +26,46 @@ class Setting extends Model
|
||||
{
|
||||
return [
|
||||
'setup_completed' => 'boolean',
|
||||
'pdf_viewer_show_download' => 'boolean',
|
||||
'giorni_giacenza_da_assegnare' => 'integer',
|
||||
'giorni_giacenza_prioritari' => 'integer',
|
||||
'giorni_per_smarrito' => 'integer',
|
||||
'home_limit_list' => 'integer',
|
||||
'assignment_link_ttl_hours' => '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).
|
||||
*/
|
||||
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_prioritari' => 180,
|
||||
'giorni_per_smarrito' => 120,
|
||||
'home_limit_list' => 10,
|
||||
'assignment_link_ttl_hours' => 1,
|
||||
'audit_retention_days' => 730,
|
||||
]);
|
||||
|
||||
return static::$cachedInstance;
|
||||
}
|
||||
|
||||
public static function isSetupComplete(): bool
|
||||
|
||||
@@ -107,11 +107,11 @@ class Territorio extends Model
|
||||
$ultima = $this->ultimaAssegnazione;
|
||||
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
return Carbon::parse($ultima->returned_at)->diffInDays(now());
|
||||
return Carbon::parse($ultima->returned_at)->startOfDay()->diffInDays(today());
|
||||
}
|
||||
|
||||
if (!$ultima) {
|
||||
return $this->created_at->diffInDays(now());
|
||||
return $this->created_at->startOfDay()->diffInDays(today());
|
||||
}
|
||||
|
||||
// Currently assigned, no giacenza concept
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -15,6 +16,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
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
|
||||
if (empty(config('app.key'))) {
|
||||
Artisan::call('key:generate', ['--force' => true]);
|
||||
|
||||
94
app/Services/TerritorioPdfImportDispatcher.php
Normal file
94
app/Services/TerritorioPdfImportDispatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
122
app/Services/TerritorioPdfImportState.php
Normal file
122
app/Services/TerritorioPdfImportState.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -11,6 +12,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->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([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
|
||||
47
config/livewire.php
Normal file
47
config/livewire.php
Normal 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,
|
||||
];
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -47,6 +47,26 @@ services:
|
||||
app:
|
||||
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:
|
||||
image: mariadb:11
|
||||
container_name: termanager2_db
|
||||
@@ -58,8 +78,6 @@ services:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "${DB_PORT:-3306}:3306"
|
||||
networks:
|
||||
- termanager2
|
||||
healthcheck:
|
||||
@@ -75,8 +93,6 @@ services:
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
networks:
|
||||
- termanager2
|
||||
healthcheck:
|
||||
|
||||
@@ -21,6 +21,18 @@ server {
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
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).* {
|
||||
|
||||
@@ -216,7 +216,6 @@ if [ "$ENSURE_INITIAL_ADMIN_ON_EMPTY_DB" = "true" ]; then
|
||||
--password="$INITIAL_ADMIN_PASSWORD_VALUE" \
|
||||
--no-interaction; then
|
||||
warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[PHP]
|
||||
upload_max_filesize = 64M
|
||||
post_max_size = 64M
|
||||
upload_max_filesize = 256M
|
||||
post_max_size = 256M
|
||||
memory_limit = 256M
|
||||
max_execution_time = 120
|
||||
max_input_vars = 3000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"resources/css/app.css": {
|
||||
"file": "assets/app-DTUtvTBm.css",
|
||||
"file": "assets/app-DL92hBto.css",
|
||||
"src": "resources/css/app.css",
|
||||
"isEntry": true
|
||||
},
|
||||
|
||||
@@ -1 +1,103 @@
|
||||
@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; }
|
||||
}
|
||||
|
||||
80
resources/views/assignments/link-scaduto.blade.php
Normal file
80
resources/views/assignments/link-scaduto.blade.php
Normal 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 N° {{ $numero }}</span>
|
||||
@endif
|
||||
<p class="footer">TerManager2</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
352
resources/views/assignments/pdf-viewer.blade.php
Normal file
352
resources/views/assignments/pdf-viewer.blade.php
Normal 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">N° {{ $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>
|
||||
@@ -11,11 +11,11 @@
|
||||
<body class="h-full">
|
||||
<div class="min-h-full">
|
||||
{{-- 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="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2"
|
||||
<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')">
|
||||
<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"/>
|
||||
@@ -23,22 +23,26 @@
|
||||
</button>
|
||||
@php $settings = \App\Models\Setting::first(); @endphp
|
||||
@if($settings && $settings->logo_path)
|
||||
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-8 w-8 rounded">
|
||||
<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
|
||||
<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
|
||||
<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' }}
|
||||
</span>
|
||||
</div>
|
||||
<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)">
|
||||
<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-sm" style="color:rgba(255,255,255,0.9)">
|
||||
{{ auth()->user()->name }}
|
||||
<span class="text-indigo-300 text-xs">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
|
||||
<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') }}">
|
||||
@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
|
||||
</button>
|
||||
</form>
|
||||
@@ -59,16 +63,16 @@
|
||||
{{-- Sidebar --}}
|
||||
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
|
||||
<nav class="mt-4 px-3 space-y-1">
|
||||
<nav class="mt-4 px-3 space-y-0.5">
|
||||
<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>
|
||||
Home
|
||||
</a>
|
||||
|
||||
@can('territori.manage')
|
||||
<a href="{{ route('territori.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('territori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
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>
|
||||
Territori
|
||||
</a>
|
||||
@@ -76,7 +80,7 @@
|
||||
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('proclamatori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
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>
|
||||
Proclamatori
|
||||
</a>
|
||||
@@ -84,7 +88,7 @@
|
||||
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('assegnazioni.assegna') ? '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('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>
|
||||
@@ -92,7 +96,7 @@
|
||||
|
||||
@can('campagne.manage')
|
||||
<a href="{{ route('campagne.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('campagne.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
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>
|
||||
Campagne
|
||||
</a>
|
||||
@@ -100,7 +104,7 @@
|
||||
|
||||
@can('registro.view')
|
||||
<a href="{{ route('registro.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('registro.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
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>
|
||||
Registro
|
||||
</a>
|
||||
@@ -108,7 +112,7 @@
|
||||
|
||||
@can('audit.view')
|
||||
<a href="{{ route('audit.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('audit.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
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>
|
||||
Audit Log
|
||||
</a>
|
||||
@@ -140,7 +144,7 @@
|
||||
<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>
|
||||
XML Exchange
|
||||
Import
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
@@ -159,17 +163,30 @@
|
||||
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
|
||||
{{-- Flash messages --}}
|
||||
@if (session()->has('success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
|
||||
<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') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-700 border border-red-200">
|
||||
<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') }}
|
||||
<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>
|
||||
@endif
|
||||
|
||||
<div class="page-enter">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="h-full flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<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 px-4">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@livewireScripts
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
<div>
|
||||
<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 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>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna ai territori</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
{{-- 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)
|
||||
@@ -31,14 +48,16 @@
|
||||
|
||||
@if($territorio_id)
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-1">Anteprima territorio</p>
|
||||
<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="Anteprima territorio"
|
||||
class="rounded-lg border border-gray-200 shadow-sm max-h-64 w-auto">
|
||||
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">
|
||||
Nessun PDF disponibile per questo territorio.
|
||||
Nessuna thumbnail disponibile per questo territorio.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -62,10 +81,95 @@
|
||||
@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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioniAperte->count() }})</h3>
|
||||
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · link valido {{ $linkTtlMonths }} {{ $linkTtlMonths === 1 ? 'mese' : 'mesi' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($assegnazioniAperte as $a)
|
||||
@php
|
||||
$pdfUrl = $a->shortPdfUrl();
|
||||
$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
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna al territorio</a>
|
||||
</div>
|
||||
|
||||
{{-- Assignment summary --}}
|
||||
|
||||
@@ -26,19 +26,19 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data/Ora</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Utente</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Evento</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Soggetto</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dettagli</th>
|
||||
<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-semibold text-gray-600 uppercase">Utente</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-semibold text-gray-600 uppercase">Soggetto</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dettagli</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@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">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
|
||||
<td class="px-3 py-2">
|
||||
|
||||
@@ -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="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>
|
||||
<p class="text-gray-500 text-sm mt-1">Accedi per continuare</p>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition">
|
||||
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>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('campagne.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
|
||||
@can('campagne.manage')
|
||||
<a href="{{ route('campagne.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
|
||||
+ Nuova Campagna
|
||||
<a 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'">
|
||||
<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>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Descrizione</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Inizio</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fine</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">% Percorrenza</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<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-semibold text-gray-600 uppercase">Inizio</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-semibold text-gray-600 uppercase">Stato</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-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@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 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>
|
||||
@@ -44,12 +45,20 @@
|
||||
<span class="text-xs text-gray-600">{{ $campagna->percentuale_percorrenza }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<a href="{{ route('campagne.show', $campagna) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a href="{{ route('campagne.show', $campagna) }}" 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>
|
||||
@can('campagne.manage')
|
||||
<a href="{{ route('campagne.edit', $campagna) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
|
||||
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
<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>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<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>
|
||||
<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>
|
||||
<a href="{{ route('campagne.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
@can('campagne.manage')
|
||||
<a href="{{ route('campagne.edit', $campagna) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
|
||||
<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
|
||||
</div>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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="mt-1 text-sm font-medium text-gray-900">{{ $campagna->start_date->format('d/m/Y') }} — {{ $campagna->end_date->format('d/m/Y') }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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>
|
||||
@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>
|
||||
@@ -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>
|
||||
@endif
|
||||
</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="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 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>
|
||||
|
||||
@@ -3,138 +3,177 @@
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
@if($annoCorrente)
|
||||
<p class="text-sm text-gray-500">Anno Teocratico {{ $annoCorrente->label }} — {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Anno Teocratico {{ $annoCorrente->label }} — {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stats cards --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-indigo">
|
||||
<div class="flex items-center justify-between">
|
||||
<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="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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 class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-blue">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
|
||||
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $totAssegnati }}</p>
|
||||
<p class="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="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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 class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-green">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{{ $territoriPercorsi }}</p>
|
||||
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p>
|
||||
<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>
|
||||
@if($campagnaStats)
|
||||
<div class="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-4">
|
||||
<p class="text-xs font-medium text-amber-600 uppercase">Campagna</p>
|
||||
<p class="mt-1 text-lg font-bold text-amber-800">{{ $campagnaStats['descrizione'] }}</p>
|
||||
<div class="rounded-xl shadow-sm border p-4 card-hover" style="background:#fffbeb;border-color:#fde68a;border-left:4px solid #f59e0b">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<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="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>scade {{ $campagnaStats['fine'] }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-amber-200 rounded-full h-2">
|
||||
<div class="bg-amber-500 h-2 rounded-full transition-all" style="width: {{ min($campagnaStats['percentuale'], 100) }}%"></div>
|
||||
<div class="w-full rounded-full h-2" style="background:#fde68a">
|
||||
<div class="h-2 rounded-full transition-all" style="background:#f59e0b;width:{{ min($campagnaStats['percentuale'], 100) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@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">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 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 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-50 border-b border-green-100">
|
||||
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
|
||||
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
|
||||
<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>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($daAssegnare as $t)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
@forelse($territoriDaAssegnare as $t)
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<p class="text-xs text-gray-500">{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}</p>
|
||||
</div>
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna →</a>
|
||||
@endcan
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@if($daAssegnare->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
||||
@if($t->home_is_prioritario)
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Prioritari --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-amber-50 border-b border-amber-100">
|
||||
<h3 class="text-sm font-semibold text-amber-800">★ Prioritari</h3>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($prioritari as $t)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
<div>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $t->zona?->nome }}
|
||||
@if($t->ultimaAssegnazione?->returned_at)
|
||||
— ultimo rientro {{ $t->ultimaAssegnazione->returned_at->diffForHumans() }}
|
||||
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
||||
@if($t->home_giorni_giacenza > 0)
|
||||
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-amber-600">
|
||||
{{ $t->prioritario ? 'Man' : 'Auto' }}
|
||||
</span>
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna →</a>
|
||||
@endcan
|
||||
</li>
|
||||
@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
|
||||
</ul>
|
||||
@if($prioritari->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=prioritari" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
@if($territoriDaAssegnare->count() >= $homeLimit)
|
||||
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
|
||||
<a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Da rientrare --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-red-50 border-b border-red-100">
|
||||
<h3 class="text-sm font-semibold text-red-800">Da Rientrare</h3>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
|
||||
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
|
||||
<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>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($daRientrare as $t)
|
||||
@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>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
|
||||
— {{ $assegnazioneCorrente?->giorni }} giorni
|
||||
— <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
|
||||
</p>
|
||||
</div>
|
||||
@can('territori.return')
|
||||
@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
|
||||
@endcan
|
||||
</li>
|
||||
@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
|
||||
</ul>
|
||||
@if($daRientrare->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
@if($daRientrare->count() >= $homeLimit)
|
||||
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
|
||||
<a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome Completo</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<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-semibold text-gray-600 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@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 text-gray-600">{{ $proclamatore->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<button wire:click="restore({{ $proclamatore->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina definitivamente</button>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<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>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
|
||||
+ Nuovo Proclamatore
|
||||
<a 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'">
|
||||
<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>
|
||||
@endcan
|
||||
</div>
|
||||
@@ -28,23 +29,23 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
|
||||
Cognome @if($sortField==='cognome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th 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') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</th>
|
||||
<th wire:click="sortBy('nome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
|
||||
Nome @if($sortField==='nome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th 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') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Territori Assegnati</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<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-semibold text-gray-600 uppercase">Territori Assegnati</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@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 text-gray-700">{{ $proclamatore->nome }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
@@ -55,20 +56,37 @@
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a href="{{ route('proclamatori.show', $proclamatore) }}" 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>
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
|
||||
<button wire:click="toggleActive({{ $proclamatore->id }})" class="text-xs {{ $proclamatore->attivo ? 'text-gray-500 hover:text-gray-700' : 'text-green-600 hover:text-green-800' }}">
|
||||
{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" 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({{ $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>
|
||||
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<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>
|
||||
<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>
|
||||
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
|
||||
<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
|
||||
</div>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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>
|
||||
@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>
|
||||
@@ -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>
|
||||
@endif
|
||||
</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="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 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="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>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
|
||||
@can('settings.manage')
|
||||
<button wire:click="openCreate"
|
||||
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
||||
<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
|
||||
@@ -43,20 +42,22 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('territorio_id')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">Territorio</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Proclamatore</th>
|
||||
<th wire:click="sortBy('assigned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
|
||||
Assegnato @if($sortField==='assigned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th wire:click="sortBy('territorio_numero')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
|
||||
Territorio @if($sortField==='territorio_numero') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</th>
|
||||
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
|
||||
Rientrato @if($sortField==='returned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
|
||||
<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 class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Giorni</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anno</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campagna</th>
|
||||
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
|
||||
Rientrato @if($sortField==='returned_at') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</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
|
||||
@@ -64,10 +65,20 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@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">
|
||||
<div class="flex items-center gap-2">
|
||||
@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">N° {{ $a->territorio?->numero }}</a>
|
||||
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -89,20 +100,30 @@
|
||||
</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 }})"
|
||||
style="background:#e0e7ff;color:#4338ca;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;margin-right:4px;">
|
||||
Modifica
|
||||
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 }})"
|
||||
style="background:#fee2e2;color:#b91c1c;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">
|
||||
Elimina
|
||||
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>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
</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">Import / Export dati XML</p>
|
||||
<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-amber-600 rounded-lg hover:bg-amber-700 transition">
|
||||
Import XML
|
||||
</a>
|
||||
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition">
|
||||
Export XML
|
||||
<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">I pulsanti aprono la sezione XML Exchange con gli strumenti di conversione, import e export.</p>
|
||||
<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">
|
||||
@@ -59,6 +56,21 @@
|
||||
@error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -1,54 +1,291 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Import/Export XML</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Converti dump SQL legacy in XML, importa XML nell'app ed esporta i dati correnti in XML.</p>
|
||||
<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 wire:model="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">
|
||||
<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">
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
wire:click="convertLegacySqlToXml"
|
||||
type="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>
|
||||
|
||||
<div wire:loading wire:target="importXmlIntoApp" style="padding:10px 12px;border-radius:10px;background:#fffbeb;border:1px solid #f59e0b;color:#92400e;font-size:13px;">
|
||||
Importazione in corso... attendi il completamento.
|
||||
</div>
|
||||
|
||||
<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 wire:model="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">
|
||||
<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">
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
wire:click="importXmlIntoApp"
|
||||
type="button"
|
||||
onclick="if(!confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.')) event.stopImmediatePropagation();"
|
||||
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))
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">N°</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">N°</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-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@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 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">
|
||||
<button wire:click="restore({{ $territorio->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button>
|
||||
<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>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<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>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<form wire:submit="save" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 max-w-2xl">
|
||||
|
||||
@@ -5,15 +5,25 @@
|
||||
<p class="text-sm text-gray-500 mt-1">Gestione dei territori della congregazione</p>
|
||||
</div>
|
||||
<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.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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero..."
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<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">
|
||||
<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>
|
||||
@@ -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">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="in_reparto">In reparto</option>
|
||||
<option value="prioritari">Prioritari</option>
|
||||
<option value="assegnato">Assegnato</option>
|
||||
<option value="da_rientrare">Da rientrare</option>
|
||||
<option value="inattivo">Inattivo</option>
|
||||
</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>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<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">
|
||||
N° @if($sortField === 'numero') <span>{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif
|
||||
<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">
|
||||
N° @if($sortField === 'numero') <span style="color:#6366f1">{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif
|
||||
</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-medium text-gray-500 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-medium text-gray-500 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-left text-xs font-semibold text-gray-600 uppercase">Zona</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-semibold text-gray-600 uppercase">Stato</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-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@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">
|
||||
<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 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>
|
||||
@@ -74,27 +128,62 @@
|
||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
||||
</span>
|
||||
@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"
|
||||
title="{{ $territorio->prioritario ? 'Prioritario (manuale)' : 'Prioritario (giacenza)' }}">
|
||||
★ {{ $territorio->prioritario ? 'Man.' : 'Auto' }}
|
||||
<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">
|
||||
★ Prioritario
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ $territorio->assegnatario?->nome_completo ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-1">
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium">Dettaglio</a>
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="text-gray-600 hover:text-gray-800 text-xs font-medium">Modifica</a>
|
||||
<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' }}">
|
||||
{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center justify-end gap-1 flex-wrap">
|
||||
{{-- Primary actions: Assegna / Rientra --}}
|
||||
@can('territori.assign')
|
||||
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green">
|
||||
<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="text-red-600 hover:text-red-800 text-xs font-medium">Elimina</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>
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
<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>
|
||||
<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>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</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>
|
||||
|
||||
{{-- Info card --}}
|
||||
@@ -56,6 +80,48 @@
|
||||
@endif
|
||||
</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 --}}
|
||||
@if($territorio->pdf_path)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
@@ -72,7 +138,12 @@
|
||||
|
||||
{{-- Assignment history --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
|
||||
<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)
|
||||
<div class="mb-6">
|
||||
|
||||
102
resources/views/pdf/territori-lista.blade.php
Normal file
102
resources/views/pdf/territori-lista.blade.php
Normal 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 }} — {{ $data }}</div>
|
||||
</div>
|
||||
|
||||
@if($tipo === 'assegnare')
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:10%">N°</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%">N°</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>
|
||||
46
resources/views/vendor/pagination/bootstrap-4.blade.php
vendored
Normal file
46
resources/views/vendor/pagination/bootstrap-4.blade.php
vendored
Normal 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">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</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')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span class="page-link" aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
88
resources/views/vendor/pagination/bootstrap-5.blade.php
vendored
Normal file
88
resources/views/vendor/pagination/bootstrap-5.blade.php
vendored
Normal 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">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</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')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span class="page-link" aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
46
resources/views/vendor/pagination/default.blade.php
vendored
Normal file
46
resources/views/vendor/pagination/default.blade.php
vendored
Normal 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">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li>
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</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')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
36
resources/views/vendor/pagination/semantic-ui.blade.php
vendored
Normal file
36
resources/views/vendor/pagination/semantic-ui.blade.php
vendored
Normal 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
|
||||
27
resources/views/vendor/pagination/simple-bootstrap-4.blade.php
vendored
Normal file
27
resources/views/vendor/pagination/simple-bootstrap-4.blade.php
vendored
Normal 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
|
||||
29
resources/views/vendor/pagination/simple-bootstrap-5.blade.php
vendored
Normal file
29
resources/views/vendor/pagination/simple-bootstrap-5.blade.php
vendored
Normal 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
|
||||
19
resources/views/vendor/pagination/simple-default.blade.php
vendored
Normal file
19
resources/views/vendor/pagination/simple-default.blade.php
vendored
Normal 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
|
||||
25
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal file
25
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal 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
|
||||
131
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
131
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal 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
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
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\Livewire\Home;
|
||||
use App\Livewire\Territori\TerritorioIndex;
|
||||
@@ -46,6 +50,12 @@ Route::post('logout', function () {
|
||||
return redirect('/login');
|
||||
})->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
|
||||
@@ -107,6 +117,9 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('zone', ZoneIndex::class)->name('zone.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
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Reference in New Issue
Block a user