Compare commits
7 Commits
c585979340
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1534c84d45 | |||
| c85f2aaea0 | |||
| 6a65087449 | |||
| 0553d4ef74 | |||
| 465e7cf092 | |||
| 5e98423e7a | |||
| 9f9a3666c1 |
@@ -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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,35 +3,41 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AssignmentPdfController extends Controller
|
||||
{
|
||||
public function viewer(Request $request, Assegnazione $assignment, string $code): View
|
||||
{
|
||||
$this->validateAccess($request, $assignment, $code);
|
||||
$this->validateAccess($assignment, $code);
|
||||
|
||||
$expiresAt = Carbon::createFromTimestamp((int) $request->query('expires'));
|
||||
$pdfUrl = URL::temporarySignedRoute(
|
||||
'assignments.pdf.file',
|
||||
$expiresAt,
|
||||
['assignment' => $assignment->id, 'code' => $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,
|
||||
'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
|
||||
public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse|View
|
||||
{
|
||||
$this->validateAccess($request, $assignment, $code);
|
||||
$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);
|
||||
@@ -40,17 +46,34 @@ class AssignmentPdfController extends Controller
|
||||
$pdfPath,
|
||||
'territorio-' . $assignment->territorio?->numero . '.pdf',
|
||||
[
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="territorio-' . $assignment->territorio?->numero . '.pdf"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function validateAccess(Request $request, Assegnazione $assignment, string $code): void
|
||||
protected function validateAccess(Assegnazione $assignment, string $code): void
|
||||
{
|
||||
abort_unless($request->hasValidSignature(), 403);
|
||||
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,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +9,83 @@ 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();
|
||||
@@ -34,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) {
|
||||
@@ -104,6 +190,7 @@ class Home extends Component
|
||||
'totInReparto' => $totInReparto,
|
||||
'territoriPercorsi' => $territoriPercorsi,
|
||||
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
|
||||
'mediaDurataPercorrenzaMesi' => $mediaDurataPercorrenzaMesi,
|
||||
'campagnaStats' => $campagnaStats,
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
|
||||
@@ -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,6 +149,14 @@ 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', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
|
||||
@@ -171,7 +179,14 @@ class Registro extends Component
|
||||
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
|
||||
}
|
||||
|
||||
$query->orderBy($this->sortField, $this->sortDirection);
|
||||
if ($this->sortField === 'territorio_numero') {
|
||||
$dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
|
||||
$query->orderByRaw(
|
||||
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
|
||||
);
|
||||
} else {
|
||||
$query->orderBy($this->sortField, $this->sortDirection);
|
||||
}
|
||||
|
||||
// In-memory search for encrypted proclamatore fields / territorio numero
|
||||
if ($this->search !== '') {
|
||||
|
||||
@@ -13,6 +13,7 @@ class SettingsEdit extends Component
|
||||
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()
|
||||
@@ -24,6 +25,7 @@ class SettingsEdit extends Component
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ class SettingsEdit extends Component
|
||||
'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',
|
||||
];
|
||||
}
|
||||
@@ -52,6 +55,7 @@ class SettingsEdit extends Component
|
||||
'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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ class XmlExchange extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public $sqlDump;
|
||||
public $xmlImport;
|
||||
public array $importStats = [];
|
||||
public array $importIssues = [];
|
||||
public array $pdfFolder = [];
|
||||
@@ -46,46 +44,135 @@ class XmlExchange extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function convertLegacySqlToXml()
|
||||
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 = [
|
||||
@@ -268,113 +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 importTerritoryPdfFolder(): void
|
||||
{
|
||||
$this->validate([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$storedFiles = [];
|
||||
|
||||
foreach ($this->pdfFolder as $index => $file) {
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
||||
$storedPath = $file->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
|
||||
'local'
|
||||
);
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
}
|
||||
|
||||
$this->pdfFolder = [];
|
||||
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
{
|
||||
if (! $this->currentPdfImportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
|
||||
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdfImportStatus = $state['status'] ?? 'idle';
|
||||
$this->pdfImportStats = $state['stats'] ?? [];
|
||||
$this->pdfImportLogs = $state['logs'] ?? [];
|
||||
$this->pdfImportIssues = $state['issues'] ?? [];
|
||||
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
|
||||
}
|
||||
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
|
||||
{
|
||||
$state = app(TerritorioPdfImportDispatcher::class)
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
||||
|
||||
$this->currentPdfImportId = $importId;
|
||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||
$this->pdfImportStats = $state['stats'] ?? [];
|
||||
$this->pdfImportLogs = $state['logs'] ?? [];
|
||||
$this->pdfImportIssues = $state['issues'] ?? [];
|
||||
$this->refreshPdfImportStatus();
|
||||
|
||||
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
|
||||
}
|
||||
|
||||
public function downloadImportLogPdf()
|
||||
{
|
||||
if (empty($this->importStats)) {
|
||||
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
|
||||
return;
|
||||
}
|
||||
|
||||
$stats = $this->importStats;
|
||||
$issues = $this->importIssues;
|
||||
$generatedAt = now()->format('d/m/Y H:i:s');
|
||||
|
||||
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
|
||||
|
||||
return response()->streamDownload(function () use ($html) {
|
||||
echo Pdf::loadHTML($html)
|
||||
->setPaper('a4', 'portrait')
|
||||
->output();
|
||||
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
|
||||
}
|
||||
|
||||
public function exportCurrentAsXml()
|
||||
{
|
||||
$dataset = $this->currentDataset();
|
||||
$xml = $this->datasetToXml($dataset, 'current-app-export');
|
||||
|
||||
return response()->streamDownload(function () use ($xml) {
|
||||
echo $xml;
|
||||
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.xml-exchange');
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'issues' => $this->importIssues,
|
||||
];
|
||||
}
|
||||
|
||||
private function currentDataset(): array
|
||||
@@ -512,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];
|
||||
@@ -545,7 +529,7 @@ class XmlExchange extends Component
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\\\') {
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
@@ -603,7 +587,7 @@ class XmlExchange extends Component
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\\\') {
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
@@ -645,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)) {
|
||||
@@ -655,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)) {
|
||||
@@ -709,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');
|
||||
@@ -718,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)));
|
||||
}
|
||||
|
||||
@@ -728,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)));
|
||||
}
|
||||
|
||||
@@ -738,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)));
|
||||
}
|
||||
|
||||
@@ -749,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)));
|
||||
}
|
||||
@@ -764,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'] ?? ''));
|
||||
}
|
||||
@@ -775,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'] ?? ''));
|
||||
}
|
||||
@@ -793,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Assegnazione extends Model
|
||||
@@ -20,6 +19,7 @@ class Assegnazione extends Model
|
||||
'counted_in_campaign',
|
||||
'campaign_id',
|
||||
'pdf_access_code',
|
||||
'link_sent',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
@@ -31,6 +31,7 @@ class Assegnazione extends Model
|
||||
'assigned_at' => 'date',
|
||||
'returned_at' => 'date',
|
||||
'counted_in_campaign' => 'boolean',
|
||||
'link_sent' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -103,16 +104,24 @@ class Assegnazione extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
$months = max(1, (int) Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
return route('assignments.pdf.viewer', [
|
||||
'assignment' => $this->id,
|
||||
'code' => $this->ensurePdfAccessCode(),
|
||||
]);
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'assignments.pdf.viewer',
|
||||
now()->addMonths($months),
|
||||
[
|
||||
'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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,12 +10,14 @@ class Setting extends Model
|
||||
|
||||
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',
|
||||
];
|
||||
@@ -24,6 +26,7 @@ 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',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -60,19 +60,12 @@ services:
|
||||
networks:
|
||||
- termanager2
|
||||
depends_on:
|
||||
mariadb:
|
||||
app:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
|
||||
entrypoint: ["php"]
|
||||
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
|
||||
environment:
|
||||
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
|
||||
- SEED_DEV_DATA=${SEED_DEV_DATA:-false}
|
||||
- RUN_DB_SEED_ON_FIRST_START=${RUN_DB_SEED_ON_FIRST_START:-true}
|
||||
- ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}
|
||||
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-}
|
||||
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
|
||||
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
@@ -85,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:
|
||||
@@ -102,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 @@
|
||||
{
|
||||
"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>
|
||||
@@ -2,43 +2,351 @@
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF territorio {{ $assignment->territorio?->numero }}</title>
|
||||
<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 {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #111827;
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
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;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
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: 600 14px/1 system-ui, sans-serif;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
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>
|
||||
<iframe class="viewer" src="{{ $pdfUrl }}#toolbar=0&navpanes=0&scrollbar=0" title="PDF territorio {{ $assignment->territorio?->numero }}"></iframe>
|
||||
<a class="fallback" href="{{ $pdfUrl }}" target="_blank" rel="noopener noreferrer">Apri PDF</a>
|
||||
<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>
|
||||
</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">
|
||||
{{ auth()->user()->name }}
|
||||
<span class="text-indigo-300 text-xs">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
|
||||
</span>
|
||||
<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-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>
|
||||
@@ -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
|
||||
|
||||
{{ $slot }}
|
||||
<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,74 +1,175 @@
|
||||
<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-4 sm:p-6 max-w-2xl">
|
||||
<form wire:submit="save" class="space-y-4">
|
||||
<div>
|
||||
@if(!$preselectedTerritorioId)
|
||||
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
|
||||
<input wire:model.live.debounce.300ms="territorioSearch"
|
||||
type="text"
|
||||
id="territorio_search"
|
||||
placeholder="Cerca per numero, zona o tipologia"
|
||||
class="mt-1 mb-2 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@endif
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
|
||||
<select wire:model.live="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
|
||||
<option value="">Seleziona un territorio</option>
|
||||
@foreach($territoriDisponibili as $t)
|
||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if($preselectedTerritorioId)
|
||||
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
|
||||
@endif
|
||||
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
{{-- 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>
|
||||
|
||||
@if($territorio_id)
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
|
||||
@if($this->selectedThumbnailUrl)
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm">
|
||||
<img src="{{ $this->selectedThumbnailUrl }}"
|
||||
alt="Thumbnail territorio selezionato"
|
||||
class="block w-full h-auto max-h-[70vh] object-contain bg-white">
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Miniatura del territorio ottimizzata per consultazione rapida anche da mobile.</p>
|
||||
@else
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||
Nessuna thumbnail disponibile per questo territorio.
|
||||
{{-- Form --}}
|
||||
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
|
||||
<form wire:submit="save" class="space-y-4">
|
||||
<div>
|
||||
@if(!$preselectedTerritorioId)
|
||||
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
|
||||
<input wire:model.live.debounce.300ms="territorioSearch"
|
||||
type="text"
|
||||
id="territorio_search"
|
||||
placeholder="Cerca per numero, zona o tipologia"
|
||||
class="mt-1 mb-2 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@endif
|
||||
|
||||
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
|
||||
<select wire:model.live="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
|
||||
<option value="">Seleziona un territorio</option>
|
||||
@foreach($territoriDisponibili as $t)
|
||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if($preselectedTerritorioId)
|
||||
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
|
||||
@endif
|
||||
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
|
||||
@if($territorio_id)
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
|
||||
@if($this->selectedThumbnailUrl)
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm">
|
||||
<img src="{{ $this->selectedThumbnailUrl }}"
|
||||
alt="Thumbnail territorio selezionato"
|
||||
class="block w-full h-auto max-h-[70vh] object-contain bg-white">
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||
Nessuna thumbnail disponibile per questo territorio.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
|
||||
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<option value="">Seleziona un proclamatore</option>
|
||||
@foreach($proclamatoriAttivi as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assigned_at" class="block text-sm font-medium text-gray-700">Data Assegnazione *</label>
|
||||
<input wire:model="assigned_at" type="date" id="assigned_at" max="{{ now()->format('Y-m-d') }}" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
|
||||
<button type="button" x-on:click="formOpen = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Elenco territori attualmente assegnati --}}
|
||||
@if($assegnazioniAperte->count())
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2">
|
||||
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
|
||||
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
|
||||
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<option value="">Seleziona un proclamatore</option>
|
||||
@foreach($proclamatoriAttivi as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assigned_at" class="block text-sm font-medium text-gray-700">Data Assegnazione *</label>
|
||||
<input wire:model="assigned_at" type="date" id="assigned_at" max="{{ now()->format('Y-m-d') }}" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
|
||||
<a href="{{ route('territori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
|
||||
</div>
|
||||
</form>
|
||||
@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>
|
||||
@can('campagne.manage')
|
||||
<a href="{{ route('campagne.edit', $campagna) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
|
||||
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
@endcan
|
||||
<td 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="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,44 +3,77 @@
|
||||
<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">
|
||||
<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 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="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">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
|
||||
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $totAssegnati }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
|
||||
<div 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" style="color:#3b82f6">{{ $totAssegnati }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
|
||||
</div>
|
||||
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dbeafe">
|
||||
<svg class="h-5 w-5" style="color:#3b82f6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{{ $territoriPercorsi }}</p>
|
||||
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p>
|
||||
<div 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" 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">
|
||||
<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 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>
|
||||
@@ -48,72 +81,99 @@
|
||||
{{-- Quick lists --}}
|
||||
<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>
|
||||
<p class="mt-1 text-xs text-green-700">Prima i prioritari, poi i territori con piu tempo in reparto</p>
|
||||
<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($territoriDaAssegnare as $t)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
@if($t->home_is_prioritario)
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">Prioritario</span>
|
||||
<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>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
||||
@if($t->home_giorni_giacenza > 0)
|
||||
— in reparto da {{ $t->home_giorni_giacenza }} giorni
|
||||
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
|
||||
@endif
|
||||
</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>
|
||||
<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">Tutti assegnati</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($territoriDaAssegnare->count() >= $homeLimit)
|
||||
<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 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() >= $homeLimit)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
<div 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>
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
|
||||
<button wire:click="toggleActive({{ $proclamatore->id }})" class="text-xs {{ $proclamatore->attivo ? 'text-gray-500 hover:text-gray-700' : 'text-green-600 hover:text-green-800' }}">
|
||||
{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}
|
||||
</button>
|
||||
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
@endcan
|
||||
<td 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="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>
|
||||
@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,8 +65,8 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($assegnazioni as $a)
|
||||
<tr class="hover:bg-gray-50">
|
||||
@php($temporaryPdfUrl = !$a->returned_at ? $a->temporaryPdfViewerUrl() : null)
|
||||
<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)
|
||||
@@ -99,38 +100,24 @@
|
||||
</td>
|
||||
@can('settings.manage')
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
@can('territori.assign')
|
||||
@if($a->territorio?->attivo && !$a->territorio?->assegnazioneCorrente)
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $a->territorio_id]) }}"
|
||||
class="inline-block text-xs font-medium text-emerald-600 hover:text-emerald-800 mr-3">
|
||||
Assegna
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
@can('territori.return')
|
||||
@if(!$a->returned_at)
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $a->id]) }}"
|
||||
class="inline-block text-xs font-medium text-red-600 hover:text-red-800 mr-3">
|
||||
Rientra
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
<div class="flex items-center gap-1">
|
||||
@if($temporaryPdfUrl)
|
||||
<a href="{{ $temporaryPdfUrl }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block text-xs font-medium text-indigo-600 hover:text-indigo-800 mr-3">
|
||||
PDF
|
||||
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>
|
||||
|
||||
@@ -63,6 +63,14 @@
|
||||
@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>
|
||||
|
||||
@@ -246,47 +246,46 @@
|
||||
<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>
|
||||
|
||||
<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">
|
||||
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div>
|
||||
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
wire:click="convertLegacySqlToXml"
|
||||
type="button"
|
||||
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>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
|
||||
>
|
||||
Converti in XML
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
|
||||
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
|
||||
|
||||
<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 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>
|
||||
<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">
|
||||
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
wire:click="importXmlIntoApp"
|
||||
type="button"
|
||||
onclick="if(!confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.')) event.stopImmediatePropagation();"
|
||||
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>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
|
||||
>
|
||||
Importa XML
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(!empty($importStats))
|
||||
|
||||
@@ -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,13 +5,23 @@
|
||||
<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="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">
|
||||
@@ -60,14 +70,15 @@
|
||||
<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)
|
||||
Ordinamento attivo: prioritari prima, poi territori con piu tempo in reparto.
|
||||
<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 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition">
|
||||
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>
|
||||
@@ -76,30 +87,30 @@
|
||||
{{-- 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">
|
||||
<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">
|
||||
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">{{ $territorio->numero }}</a>
|
||||
<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>
|
||||
@@ -117,37 +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>
|
||||
@can('territori.assign')
|
||||
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="text-emerald-600 hover:text-emerald-800 text-xs font-medium">Assegna</a>
|
||||
@endif
|
||||
@endcan
|
||||
@can('territori.return')
|
||||
@if($territorio->assegnazioneCorrente)
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="text-red-600 hover:text-red-800 text-xs font-medium">Rientra</a>
|
||||
@endif
|
||||
@endcan
|
||||
<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' }}
|
||||
</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>
|
||||
<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="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,21 +1,33 @@
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<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="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition">Assegna</a>
|
||||
<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="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition">Rientra</a>
|
||||
<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="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('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>
|
||||
|
||||
@@ -69,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
@if($activeAssignment)
|
||||
@php($temporaryPdfUrl = $activeAssignment->temporaryPdfViewerUrl())
|
||||
@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>
|
||||
@@ -126,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
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
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;
|
||||
@@ -48,6 +50,7 @@ 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'])
|
||||
@@ -114,6 +117,8 @@ 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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user