Compare commits
13 Commits
b07e802f78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1534c84d45 | |||
| c85f2aaea0 | |||
| 6a65087449 | |||
| 0553d4ef74 | |||
| 465e7cf092 | |||
| 5e98423e7a | |||
| 9f9a3666c1 | |||
| c585979340 | |||
| 6f8010514d | |||
| 777f239c7a | |||
| aac13522e5 | |||
| be1ac25047 | |||
| 7fd5b0c3a0 |
@@ -3,15 +3,16 @@ 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
|
||||
RUN_DB_SEED_ON_FIRST_START=true
|
||||
ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=true
|
||||
INITIAL_ADMIN_NAME=Administrator
|
||||
INITIAL_ADMIN_EMAIL=info@termanager.it
|
||||
INITIAL_ADMIN_PASSWORD=Password123!
|
||||
INITIAL_ADMIN_NAME=
|
||||
INITIAL_ADMIN_EMAIL=
|
||||
INITIAL_ADMIN_PASSWORD=
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mariadb
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,6 +2,11 @@
|
||||
/node_modules/
|
||||
/.env
|
||||
/storage/*.key
|
||||
/storage/app/.app_key
|
||||
/storage/app/.db_seeded
|
||||
/storage/app/public/territori-pdf/*.*
|
||||
/storage/app/public/territori-thumbnails/*.*
|
||||
/storage/app/livewire-tmp/*.*
|
||||
/storage/logs/
|
||||
/storage/framework/
|
||||
/bootstrap/cache/
|
||||
@@ -13,6 +18,7 @@
|
||||
/.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sql
|
||||
docker-compose.override.yml
|
||||
db_data/
|
||||
redis_data/
|
||||
|
||||
509
README.md
509
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -267,6 +427,11 @@ TerManager2/
|
||||
- Soglia giorni per "da rientrare"
|
||||
- Retention giorni audit log
|
||||
|
||||
### XML Exchange
|
||||
- Conversione dump SQL legacy in XML compatibile con TerManager2
|
||||
- Import XML nell'app (sostituzione dati gestionali: impostazioni, zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni)
|
||||
- Export XML dei dati correnti
|
||||
|
||||
### Zone e Tipologie
|
||||
- CRUD inline (aggiungi, rinomina, attiva/disattiva, elimina)
|
||||
- Protezione: non eliminabile se ha territori associati
|
||||
@@ -372,27 +537,57 @@ Il menu sidebar mostra solo le voci per cui l'utente ha permesso.
|
||||
|
||||
## Comandi utili
|
||||
|
||||
### 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
|
||||
@@ -402,36 +597,160 @@ docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
@@ -19,10 +22,8 @@ class CreateInitialAdmin extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (User::count() > 0) {
|
||||
$this->info('Users already exist. Skipping initial admin creation.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
// Always ensure roles/permissions are present before assigning roles.
|
||||
Artisan::call('db:seed', ['--class' => RolesAndPermissionsSeeder::class, '--force' => true]);
|
||||
|
||||
$name = (string) ($this->option('name') ?? '');
|
||||
$email = (string) ($this->option('email') ?? '');
|
||||
@@ -45,6 +46,30 @@ class CreateInitialAdmin extends Command
|
||||
$password = $password !== '' ? $password : (string) $this->secret('Password amministratore (min 8 caratteri)');
|
||||
}
|
||||
|
||||
if (User::count() > 0) {
|
||||
$existingAdmin = User::role('amministratore')->first();
|
||||
if ($existingAdmin) {
|
||||
$this->info('An administrator already exists. Skipping initial admin creation.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($email !== '') {
|
||||
$existingUser = User::where('email', $email)->first();
|
||||
if ($existingUser) {
|
||||
$existingUser->assignRole('amministratore');
|
||||
$this->info("Granted admin role to existing user: {$existingUser->email}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$firstUser = User::query()->oldest('id')->first();
|
||||
if ($firstUser) {
|
||||
$firstUser->assignRole('amministratore');
|
||||
$this->warn("No admin role found. Granted admin role to first existing user: {$firstUser->email}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$validator = Validator::make([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
@@ -63,13 +88,17 @@ class CreateInitialAdmin extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$admin = User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
$admin = DB::transaction(function () use ($name, $email, $password) {
|
||||
$user = User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
|
||||
$admin->assignRole('amministratore');
|
||||
$user->assignRole('amministratore');
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
$this->info("Initial admin created: {$admin->email}");
|
||||
|
||||
|
||||
79
app/Http/Controllers/AssignmentPdfController.php
Normal file
79
app/Http/Controllers/AssignmentPdfController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AssignmentPdfController extends Controller
|
||||
{
|
||||
public function viewer(Request $request, Assegnazione $assignment, string $code): View
|
||||
{
|
||||
$this->validateAccess($assignment, $code);
|
||||
|
||||
if ($expired = $this->linkScaduto($assignment)) {
|
||||
return $expired;
|
||||
}
|
||||
|
||||
$pdfUrl = route('assignments.pdf.file', [
|
||||
'assignment' => $assignment->id,
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
return view('assignments.pdf-viewer', [
|
||||
'assignment' => $assignment,
|
||||
'pdfUrl' => $pdfUrl,
|
||||
'showDownload' => (bool) \App\Models\Setting::getValue('pdf_viewer_show_download', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse|View
|
||||
{
|
||||
$this->validateAccess($assignment, $code);
|
||||
|
||||
if ($expired = $this->linkScaduto($assignment)) {
|
||||
return $expired;
|
||||
}
|
||||
|
||||
$pdfPath = $assignment->territorio?->pdf_path;
|
||||
abort_unless($pdfPath && Storage::disk('public')->exists($pdfPath), 404);
|
||||
|
||||
return Storage::disk('public')->response(
|
||||
$pdfPath,
|
||||
'territorio-' . $assignment->territorio?->numero . '.pdf',
|
||||
[
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="territorio-' . $assignment->territorio?->numero . '.pdf"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function validateAccess(Assegnazione $assignment, string $code): void
|
||||
{
|
||||
abort_unless($assignment->pdf_access_code && hash_equals($assignment->pdf_access_code, $code), 404);
|
||||
abort_unless($assignment->is_aperta, 403);
|
||||
abort_unless($assignment->territorio?->pdf_path, 404);
|
||||
}
|
||||
|
||||
protected function linkScaduto(Assegnazione $assignment): ?View
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
|
||||
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
|
||||
return view('assignments.link-scaduto', [
|
||||
'numero' => $assignment->territorio?->numero,
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\TerritorioPdfImportDispatcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
class TerritoryPdfImportController extends Controller
|
||||
{
|
||||
public function storeZip(Request $request, TerritorioPdfImportDispatcher $dispatcher): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'pdfZip' => ['required', 'file', 'mimes:zip', 'max:256000'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$importId = $dispatcher->dispatchUploadedZip($request->file('pdfZip'), auth()->id());
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
'errors' => ['pdfZip' => [$exception->getMessage()]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return back()->withErrors(['pdfZip' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
$redirectUrl = route('xml.exchange', ['pdf-import' => $importId]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Import PDF avviato in background.',
|
||||
'import_id' => $importId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect($redirectUrl)
|
||||
->with('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class XmlExchangeUploadController extends Controller
|
||||
{
|
||||
public function convertSqlToXml(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sqlDump' => ['required', 'file', 'max:256000'],
|
||||
]);
|
||||
|
||||
$file = $request->file('sqlDump');
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
if (! in_array($ext, ['sql', 'txt'])) {
|
||||
return back()->withErrors(['sqlDump' => 'Il file deve essere .sql o .txt']);
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
if (! $content) {
|
||||
return back()->withErrors(['sqlDump' => 'File vuoto o non leggibile.']);
|
||||
}
|
||||
|
||||
$exchange = app(XmlExchange::class);
|
||||
$dataset = $exchange->legacySqlToDatasetPublic($content);
|
||||
$xml = $exchange->datasetToXmlPublic($dataset, 'legacy-sql-conversion');
|
||||
|
||||
return response()->streamDownload(function () use ($xml) {
|
||||
echo $xml;
|
||||
}, 'termanager-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function importXml(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'xmlImport' => ['required', 'file', 'max:256000'],
|
||||
]);
|
||||
|
||||
$file = $request->file('xmlImport');
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
if (! in_array($ext, ['xml', 'txt'])) {
|
||||
return back()->withErrors(['xmlImport' => 'Il file deve essere .xml o .txt']);
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
if (! $content) {
|
||||
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
|
||||
}
|
||||
|
||||
$exchange = new XmlExchange();
|
||||
$result = $exchange->importXmlFromContent($content);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return back()->withErrors(['xmlImport' => $result['error']]);
|
||||
}
|
||||
|
||||
$stats = $result['stats'];
|
||||
$issues = $result['issues'];
|
||||
|
||||
$message = 'Import XML completato con successo.';
|
||||
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
||||
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
||||
}
|
||||
|
||||
return redirect()->route('xml.exchange')
|
||||
->with('success', $message)
|
||||
->with('importStats', $stats)
|
||||
->with('importIssues', $issues);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/ShortPdfLinkController.php
Normal file
34
app/Http/Controllers/ShortPdfLinkController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class ShortPdfLinkController extends Controller
|
||||
{
|
||||
public function __invoke(string $code): RedirectResponse|View
|
||||
{
|
||||
$assignment = Assegnazione::where('pdf_access_code', $code)->firstOrFail();
|
||||
|
||||
abort_unless($assignment->is_aperta, 403);
|
||||
abort_unless($assignment->territorio?->pdf_path, 404);
|
||||
|
||||
// Unauthenticated users (proclamatori) are subject to link TTL
|
||||
if (! auth()->check()) {
|
||||
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
|
||||
return view('assignments.link-scaduto', [
|
||||
'numero' => $assignment->territorio?->numero,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('assignments.pdf.viewer', [
|
||||
'assignment' => $assignment->id,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
240
app/Jobs/ImportTerritoryPdfFolder.php
Normal file
240
app/Jobs/ImportTerritoryPdfFolder.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
public int $timeout = 0;
|
||||
|
||||
public function __construct(
|
||||
public string $importId,
|
||||
public array $files,
|
||||
public int $actorId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
|
||||
{
|
||||
$stateService->markRunning($this->importId);
|
||||
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
|
||||
|
||||
$territoriMap = [];
|
||||
foreach (Territorio::withTrashed()->get() as $territorio) {
|
||||
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
|
||||
}
|
||||
|
||||
$actor = User::find($this->actorId);
|
||||
$seenNumbers = [];
|
||||
|
||||
try {
|
||||
foreach ($this->files as $file) {
|
||||
$originalName = $file['original_name'] ?? 'file-sconosciuto.pdf';
|
||||
$stateService->increment($this->importId, 'processed');
|
||||
|
||||
$territoryMatch = $this->resolveTerritoryFromFilename($originalName, $territoriMap);
|
||||
|
||||
if ($territoryMatch === null) {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nessun numero territorio riconosciuto nel nome file.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'no-match',
|
||||
'message' => 'Nessun numero territorio riconosciuto nel nome file.',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($territoryMatch['type'] ?? 'single') === 'ambiguous') {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nome ambiguo, possibili territori: ' . implode(', ', $territoryMatch['matched_numbers']) . '.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'ambiguous',
|
||||
'message' => 'Nome ambiguo.',
|
||||
'matched_numbers' => $territoryMatch['matched_numbers'],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedNumber = $territoryMatch['normalized_number'];
|
||||
$matchedNumber = $territoryMatch['matched_number'];
|
||||
$territorio = $territoryMatch['territorio'];
|
||||
|
||||
if (isset($seenNumbers[$normalizedNumber])) {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - territorio ' . $matchedNumber . ' presente piu volte nella stessa importazione.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'duplicate-in-batch',
|
||||
'message' => 'Territorio presente piu volte nella stessa importazione.',
|
||||
'matched_numbers' => [$matchedNumber],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenNumbers[$normalizedNumber] = true;
|
||||
|
||||
$sourcePath = Storage::disk('local')->path($file['stored_path']);
|
||||
|
||||
if (! is_file($sourcePath)) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - file temporaneo non trovato.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'missing-temp-file',
|
||||
'message' => 'File temporaneo non trovato.',
|
||||
'matched_numbers' => [$matchedNumber],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($territorio->pdf_path);
|
||||
}
|
||||
|
||||
if ($territorio->thumbnail_path) {
|
||||
$thumbnailService->delete($territorio->thumbnail_path);
|
||||
}
|
||||
|
||||
$storedFilename = Str::slug('territorio-' . $territorio->numero) . '.pdf';
|
||||
$publicPath = 'territori-pdf/' . $storedFilename;
|
||||
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
|
||||
$thumbnailPath = $thumbnailService->generate($publicPath);
|
||||
|
||||
$territorio->update([
|
||||
'pdf_path' => $publicPath,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
]);
|
||||
|
||||
if ($actor) {
|
||||
activity()->causedBy($actor)
|
||||
->performedOn($territorio)
|
||||
->withProperties([
|
||||
'numero' => $territorio->numero,
|
||||
'pdf' => $originalName,
|
||||
'bulk_import' => true,
|
||||
])
|
||||
->log('bulk_uploaded_pdf');
|
||||
}
|
||||
|
||||
$stateService->increment($this->importId, 'updated');
|
||||
$stateService->appendLog(
|
||||
$this->importId,
|
||||
'[OK] ' . $originalName . ' - aggiornato territorio ' . $territorio->numero . ($thumbnailPath ? ' con thumbnail generata.' : ' ma la thumbnail non e stata generata.')
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - ' . $exception->getMessage());
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'processing-error',
|
||||
'message' => $exception->getMessage(),
|
||||
'matched_numbers' => [$matchedNumber],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$stateService->appendLog($this->importId, 'Import completato.');
|
||||
$stateService->markCompleted($this->importId);
|
||||
} catch (\Throwable $exception) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
||||
$stateService->markFailed($this->importId);
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
||||
{
|
||||
if ($territoriMap === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$basename = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalizedBasename = mb_strtoupper($basename);
|
||||
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
|
||||
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
|
||||
|
||||
$territoryKeys = array_keys($territoriMap);
|
||||
usort($territoryKeys, function (string $left, string $right) {
|
||||
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
|
||||
|
||||
if ($lengthComparison !== 0) {
|
||||
return $lengthComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp($left, $right);
|
||||
});
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($territoryKeys as $normalizedNumber) {
|
||||
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'normalized_number' => $normalizedNumber,
|
||||
'matched_number' => $territoriMap[$normalizedNumber]->numero,
|
||||
'territorio' => $territoriMap[$normalizedNumber],
|
||||
];
|
||||
}
|
||||
|
||||
if ($matches === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
|
||||
];
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
|
||||
{
|
||||
$escapedNumber = preg_quote($normalizedNumber, '/');
|
||||
|
||||
if (preg_match('/^\d+$/', $normalizedNumber)) {
|
||||
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
|
||||
}
|
||||
|
||||
protected function normalizeTerritoryNumber(string $number): string
|
||||
{
|
||||
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
|
||||
|
||||
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
|
||||
return ltrim($normalized, '0') ?: '0';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,19 @@
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Computed;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
public ?int $territorio_id = null;
|
||||
public ?int $proclamatore_id = null;
|
||||
public string $assigned_at = '';
|
||||
public string $territorioSearch = '';
|
||||
|
||||
// Optional pre-selection from parent context
|
||||
public ?int $preselectedTerritorioId = null;
|
||||
@@ -80,10 +83,39 @@ 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
|
||||
{
|
||||
if (!$this->territorio_id) {
|
||||
return null;
|
||||
}
|
||||
$t = Territorio::find($this->territorio_id);
|
||||
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$territoriDisponibili = Territorio::where('attivo', true)
|
||||
$territoriQuery = Territorio::where('attivo', true)
|
||||
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
|
||||
->with(['zona', 'tipologia']);
|
||||
|
||||
if (trim($this->territorioSearch) !== '') {
|
||||
$term = trim($this->territorioSearch);
|
||||
$territoriQuery->where(function ($q) use ($term) {
|
||||
$q->where('numero', 'like', "%{$term}%")
|
||||
->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%"))
|
||||
->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%"));
|
||||
});
|
||||
}
|
||||
|
||||
$territoriDisponibili = $territoriQuery
|
||||
->orderBy('numero')
|
||||
->get();
|
||||
|
||||
@@ -91,9 +123,19 @@ class Assegna extends Component
|
||||
->get()
|
||||
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
|
||||
|
||||
// All currently assigned territories with links
|
||||
$assegnazioniAperte = Assegnazione::aperte()
|
||||
->with(['territorio.zona', 'proclamatore'])
|
||||
->get()
|
||||
->sortBy(fn($a) => (int) $a->territorio?->numero);
|
||||
|
||||
$linkTtlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
|
||||
return view('livewire.assegnazioni.assegna', [
|
||||
'territoriDisponibili' => $territoriDisponibili,
|
||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
||||
'assegnazioniAperte' => $assegnazioniAperte,
|
||||
'linkTtlMonths' => $linkTtlMonths,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ class CampagnaShow extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// All assignments with returned_at in campaign range that were counted
|
||||
$conteggiate = Assegnazione::where('campagna_id', $this->campagna->id)
|
||||
// Assignments counted for this campaign:
|
||||
// - assigned on or after campaign start
|
||||
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
|
||||
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
|
||||
->where('counted_in_campaign', true)
|
||||
->where('assigned_at', '>=', $this->campagna->start_date)
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->orderBy('returned_at')
|
||||
->get();
|
||||
|
||||
@@ -9,12 +9,88 @@ use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Setting;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
class Home extends Component
|
||||
{
|
||||
public function downloadPdfDaAssegnare()
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
$settings = Setting::instance();
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
|
||||
$territori = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
);
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
if ($p !== 0) return $p;
|
||||
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
if ($g !== 0) return $g;
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->values();
|
||||
|
||||
$pdf = Pdf::loadView('pdf.territori-lista', [
|
||||
'titolo' => 'Territori da Assegnare',
|
||||
'congregazione' => $settings->congregazione_nome ?? 'TerManager2',
|
||||
'data' => now()->format('d/m/Y'),
|
||||
'territori' => $territori,
|
||||
'tipo' => 'assegnare',
|
||||
]);
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($pdf->output()),
|
||||
'territori-da-assegnare-' . now()->format('Y-m-d') . '.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadPdfDaRientrare()
|
||||
{
|
||||
$this->authorize('territori.return');
|
||||
|
||||
$settings = Setting::instance();
|
||||
|
||||
$territori = Territorio::daRientrare()
|
||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||
->get();
|
||||
|
||||
$pdf = Pdf::loadView('pdf.territori-lista', [
|
||||
'titolo' => 'Territori da Rientrare',
|
||||
'congregazione' => $settings->congregazione_nome ?? 'TerManager2',
|
||||
'data' => now()->format('d/m/Y'),
|
||||
'territori' => $territori,
|
||||
'tipo' => 'rientrare',
|
||||
]);
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($pdf->output()),
|
||||
'territori-da-rientrare-' . now()->format('Y-m-d') . '.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$settings = Setting::instance();
|
||||
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$campagnaAttiva = Campagna::attiva();
|
||||
|
||||
@@ -32,12 +108,24 @@ class Home extends Component
|
||||
->count('territorio_id');
|
||||
}
|
||||
|
||||
// Monthly average
|
||||
// Monthly average (territories/month this year)
|
||||
$mediaPercorrenzaMensile = 0;
|
||||
if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) {
|
||||
$mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1);
|
||||
}
|
||||
|
||||
// Average assignment duration in months (current year only, matches old app "Media Percorrenza Congregazione")
|
||||
$avgGiorni = null;
|
||||
if ($annoCorrente) {
|
||||
$avgGiorni = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
|
||||
->whereNotNull('returned_at')
|
||||
->whereRaw('YEAR(assigned_at) >= 1900')
|
||||
->whereRaw('DATEDIFF(returned_at, assigned_at) > 0')
|
||||
->selectRaw('AVG(DATEDIFF(returned_at, assigned_at)) as media_giorni')
|
||||
->value('media_giorni');
|
||||
}
|
||||
$mediaDurataPercorrenzaMesi = $avgGiorni ? round($avgGiorni / 30.44, 1) : 0;
|
||||
|
||||
// Campaign stats
|
||||
$campagnaStats = null;
|
||||
if ($campagnaAttiva) {
|
||||
@@ -49,19 +137,49 @@ class Home extends Component
|
||||
}
|
||||
|
||||
// Quick lists
|
||||
$daAssegnare = Territorio::daAssegnare()
|
||||
$territoriDaAssegnare = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->take(10)
|
||||
->get();
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
|
||||
$prioritari = Territorio::prioritari()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->take(10)
|
||||
->get();
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
);
|
||||
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
|
||||
if ($priorityComparison !== 0) {
|
||||
return $priorityComparison;
|
||||
}
|
||||
|
||||
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
|
||||
if ($giacenzaComparison !== 0) {
|
||||
return $giacenzaComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->take($homeLimit)
|
||||
->values();
|
||||
|
||||
$daRientrare = Territorio::daRientrare()
|
||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||
->take(10)
|
||||
->take($homeLimit)
|
||||
->get();
|
||||
|
||||
return view('livewire.home', [
|
||||
@@ -72,9 +190,10 @@ class Home extends Component
|
||||
'totInReparto' => $totInReparto,
|
||||
'territoriPercorsi' => $territoriPercorsi,
|
||||
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
|
||||
'mediaDurataPercorrenzaMesi' => $mediaDurataPercorrenzaMesi,
|
||||
'campagnaStats' => $campagnaStats,
|
||||
'daAssegnare' => $daAssegnare,
|
||||
'prioritari' => $prioritari,
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
'daRientrare' => $daRientrare,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Zona;
|
||||
use App\Models\Tipologia;
|
||||
|
||||
@@ -18,8 +21,25 @@ 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;
|
||||
public ?int $editingId = null;
|
||||
|
||||
public string $form_territorio_id = '';
|
||||
public string $form_proclamatore_id = '';
|
||||
public string $form_anno_id = '';
|
||||
public string $form_assigned_at = '';
|
||||
public string $form_returned_at = '';
|
||||
public bool $form_counted_in_campaign = false;
|
||||
public string $form_campaign_id = '';
|
||||
public string $form_note = '';
|
||||
|
||||
// ─── Delete confirm ─────────────────────────────────────────
|
||||
public bool $showDeleteConfirm = false;
|
||||
public ?int $deleteId = null;
|
||||
|
||||
protected $queryString = [
|
||||
'search' => ['except' => ''],
|
||||
@@ -44,9 +64,102 @@ class Registro extends Component
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin CRUD ─────────────────────────────────────────────
|
||||
|
||||
public function openCreate(): void
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
$this->editingId = null;
|
||||
$this->form_territorio_id = '';
|
||||
$this->form_proclamatore_id = '';
|
||||
$this->form_anno_id = '';
|
||||
$this->form_assigned_at = now()->format('Y-m-d');
|
||||
$this->form_returned_at = '';
|
||||
$this->form_counted_in_campaign = false;
|
||||
$this->form_campaign_id = '';
|
||||
$this->form_note = '';
|
||||
$this->resetValidation();
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function openEdit(int $id): void
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
$a = Assegnazione::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->form_territorio_id = (string) $a->territorio_id;
|
||||
$this->form_proclamatore_id = (string) $a->proclamatore_id;
|
||||
$this->form_anno_id = (string) $a->anno_teocratico_id;
|
||||
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
|
||||
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
|
||||
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
|
||||
$this->form_campaign_id = (string) ($a->campaign_id ?? '');
|
||||
$this->form_note = $a->note ?? '';
|
||||
$this->resetValidation();
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
$this->validate([
|
||||
'form_territorio_id' => 'required|exists:territori,id',
|
||||
'form_proclamatore_id' => 'required|exists:proclamatori,id',
|
||||
'form_anno_id' => 'required|exists:anni_teocratici,id',
|
||||
'form_assigned_at' => 'required|date',
|
||||
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
|
||||
'form_campaign_id' => 'nullable|exists:campagne,id',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'territorio_id' => $this->form_territorio_id,
|
||||
'proclamatore_id' => $this->form_proclamatore_id,
|
||||
'anno_teocratico_id' => $this->form_anno_id,
|
||||
'assigned_at' => $this->form_assigned_at,
|
||||
'returned_at' => $this->form_returned_at ?: null,
|
||||
'counted_in_campaign' => $this->form_counted_in_campaign,
|
||||
'campaign_id' => $this->form_campaign_id ?: null,
|
||||
'note' => $this->form_note ?: null,
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
Assegnazione::findOrFail($this->editingId)->update($data);
|
||||
} else {
|
||||
$data['created_by'] = auth()->id();
|
||||
Assegnazione::create($data);
|
||||
}
|
||||
|
||||
$this->showModal = false;
|
||||
}
|
||||
|
||||
public function askDelete(int $id): void
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
$this->deleteId = $id;
|
||||
$this->showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
public function deleteConfirmed(): void
|
||||
{
|
||||
abort_if(!auth()->user()->can('settings.manage'), 403);
|
||||
if ($this->deleteId) {
|
||||
Assegnazione::findOrFail($this->deleteId)->delete();
|
||||
}
|
||||
$this->deleteId = null;
|
||||
$this->showDeleteConfirm = false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if ($this->filtroAnno === '') {
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$this->filtroAnno = (string) $annoCorrente->id;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = Assegnazione::with(['territorio.zona', 'proclamatore', 'annoTeocratico', 'campagna']);
|
||||
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
|
||||
|
||||
if ($this->filtroAnno) {
|
||||
$query->where('anno_teocratico_id', $this->filtroAnno);
|
||||
@@ -66,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 !== '') {
|
||||
@@ -96,6 +216,9 @@ class Registro extends Component
|
||||
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
||||
'zone' => Zona::attive()->orderBy('nome')->get(),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(),
|
||||
'territori' => Territorio::attivi()->orderBy('numero')->get(),
|
||||
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
|
||||
'campagne' => Campagna::orderByDesc('created_at')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ class SettingsEdit extends Component
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
public int $giorni_per_smarrito = 120;
|
||||
public int $home_limit_list = 10;
|
||||
public int $assignment_link_ttl_months = 1;
|
||||
public bool $pdf_viewer_show_download = true;
|
||||
public int $audit_retention_days = 365;
|
||||
|
||||
public function mount()
|
||||
@@ -22,6 +24,8 @@ class SettingsEdit extends Component
|
||||
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
|
||||
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
|
||||
$this->home_limit_list = $settings->home_limit_list ?? 10;
|
||||
$this->assignment_link_ttl_months = $settings->assignment_link_ttl_hours ?? 1;
|
||||
$this->pdf_viewer_show_download = $settings->pdf_viewer_show_download ?? true;
|
||||
$this->audit_retention_days = $settings->audit_retention_days ?? 365;
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
|
||||
'giorni_per_smarrito' => 'required|integer|min:30|max:365',
|
||||
'home_limit_list' => 'required|integer|min:1|max:100',
|
||||
'assignment_link_ttl_months' => 'required|integer|min:1|max:24',
|
||||
'pdf_viewer_show_download' => 'required|boolean',
|
||||
'audit_retention_days' => 'required|integer|min:30|max:3650',
|
||||
];
|
||||
}
|
||||
@@ -48,6 +54,8 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
|
||||
'giorni_per_smarrito' => $this->giorni_per_smarrito,
|
||||
'home_limit_list' => $this->home_limit_list,
|
||||
'assignment_link_ttl_hours' => $this->assignment_link_ttl_months,
|
||||
'pdf_viewer_show_download' => $this->pdf_viewer_show_download,
|
||||
'audit_retention_days' => $this->audit_retention_days,
|
||||
]);
|
||||
|
||||
|
||||
192
app/Livewire/Settings/UsersIndex.php
Normal file
192
app/Livewire/Settings/UsersIndex.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UsersIndex extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
public string $selectedRole = '';
|
||||
public array $availableRoles = [];
|
||||
|
||||
public ?int $editingUserId = null;
|
||||
public string $editName = '';
|
||||
public string $editEmail = '';
|
||||
public string $editPassword = '';
|
||||
public string $editPassword_confirmation = '';
|
||||
public string $editSelectedRole = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->availableRoles = Role::query()
|
||||
->orderBy('name')
|
||||
->pluck('name')
|
||||
->all();
|
||||
|
||||
if (! empty($this->availableRoles)) {
|
||||
$this->selectedRole = $this->availableRoles[0];
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'selectedRole' => ['required', 'string', Rule::in($this->availableRoles)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function editRules(): array
|
||||
{
|
||||
return [
|
||||
'editName' => ['required', 'string', 'max:255'],
|
||||
'editEmail' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($this->editingUserId),
|
||||
],
|
||||
'editPassword' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'editSelectedRole' => ['required', 'string', Rule::in($this->availableRoles)],
|
||||
];
|
||||
}
|
||||
|
||||
public function createUser(): void
|
||||
{
|
||||
$validated = $this->validate();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
|
||||
$user->syncRoles([$validated['selectedRole']]);
|
||||
|
||||
$this->reset(['name', 'email', 'password', 'password_confirmation']);
|
||||
if (! empty($this->availableRoles)) {
|
||||
$this->selectedRole = $this->availableRoles[0];
|
||||
}
|
||||
session()->flash('success', 'Utente creato con successo.');
|
||||
}
|
||||
|
||||
public function startEdit(int $userId): void
|
||||
{
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
|
||||
$this->editingUserId = $user->id;
|
||||
$this->editName = $user->name;
|
||||
$this->editEmail = $user->email;
|
||||
$this->editPassword = '';
|
||||
$this->editPassword_confirmation = '';
|
||||
$this->editSelectedRole = $user->roles->first()?->name ?? ($this->availableRoles[0] ?? '');
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->reset([
|
||||
'editingUserId',
|
||||
'editName',
|
||||
'editEmail',
|
||||
'editPassword',
|
||||
'editPassword_confirmation',
|
||||
'editSelectedRole',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUser(): void
|
||||
{
|
||||
if (! $this->editingUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $this->validate($this->editRules());
|
||||
$user = User::query()->findOrFail($this->editingUserId);
|
||||
|
||||
$user->name = $validated['editName'];
|
||||
$user->email = $validated['editEmail'];
|
||||
|
||||
if (! empty($validated['editPassword'])) {
|
||||
$user->password = $validated['editPassword'];
|
||||
}
|
||||
|
||||
$user->save();
|
||||
$user->syncRoles([$validated['editSelectedRole']]);
|
||||
|
||||
$this->cancelEdit();
|
||||
session()->flash('success', 'Utente aggiornato con successo.');
|
||||
}
|
||||
|
||||
public function deleteUser(int $userId): void
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
|
||||
if (! $currentUser || $currentUser->id === $user->id) {
|
||||
session()->flash('error', 'Non puoi cancellare il tuo utente.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
|
||||
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $currentUser) {
|
||||
$causerName = $user->name;
|
||||
$causerEmail = $user->email;
|
||||
$deletedAt = now()->toDateTimeString();
|
||||
|
||||
Activity::query()
|
||||
->where('causer_type', User::class)
|
||||
->where('causer_id', $user->id)
|
||||
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
|
||||
foreach ($activities as $activity) {
|
||||
$properties = $activity->properties?->toArray() ?? [];
|
||||
$properties['causer_name'] = $causerName;
|
||||
$properties['causer_email'] = $causerEmail;
|
||||
$properties['causer_deleted_at'] = $deletedAt;
|
||||
|
||||
$activity->properties = $properties;
|
||||
$activity->save();
|
||||
}
|
||||
});
|
||||
|
||||
Assegnazione::query()
|
||||
->where('created_by', $user->id)
|
||||
->update(['created_by' => $currentUser->id]);
|
||||
|
||||
Assegnazione::query()
|
||||
->where('returned_by', $user->id)
|
||||
->update(['returned_by' => $currentUser->id]);
|
||||
|
||||
$user->syncRoles([]);
|
||||
$user->delete();
|
||||
});
|
||||
|
||||
if ($this->editingUserId === $userId) {
|
||||
$this->cancelEdit();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.users-index', [
|
||||
'users' => User::query()->with('roles')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
804
app/Livewire/Settings/XmlExchange.php
Normal file
804
app/Livewire/Settings/XmlExchange.php
Normal file
@@ -0,0 +1,804 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use App\Services\TerritorioPdfImportDispatcher;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class XmlExchange extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public array $importStats = [];
|
||||
public array $importIssues = [];
|
||||
public array $pdfFolder = [];
|
||||
public array $pdfImportLogs = [];
|
||||
public array $pdfImportStats = [];
|
||||
public array $pdfImportIssues = [];
|
||||
public ?string $currentPdfImportId = null;
|
||||
public string $pdfImportStatus = 'idle';
|
||||
public string $pdfImportLogText = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->currentPdfImportId = request()->query('pdf-import');
|
||||
|
||||
if ($this->currentPdfImportId) {
|
||||
$this->refreshPdfImportStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public function importTerritoryPdfFolder(): void
|
||||
{
|
||||
$this->validate([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$storedFiles = [];
|
||||
|
||||
foreach ($this->pdfFolder as $index => $file) {
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
||||
$storedPath = $file->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
|
||||
'local'
|
||||
);
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
}
|
||||
|
||||
$this->pdfFolder = [];
|
||||
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
{
|
||||
if (! $this->currentPdfImportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
|
||||
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdfImportStatus = $state['status'] ?? 'idle';
|
||||
$this->pdfImportStats = $state['stats'] ?? [];
|
||||
$this->pdfImportLogs = $state['logs'] ?? [];
|
||||
$this->pdfImportIssues = $state['issues'] ?? [];
|
||||
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
|
||||
}
|
||||
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
|
||||
{
|
||||
$state = app(TerritorioPdfImportDispatcher::class)
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
||||
|
||||
$this->currentPdfImportId = $importId;
|
||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||
$this->pdfImportStats = $state['stats'] ?? [];
|
||||
$this->pdfImportLogs = $state['logs'] ?? [];
|
||||
$this->pdfImportIssues = $state['issues'] ?? [];
|
||||
$this->refreshPdfImportStatus();
|
||||
|
||||
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
|
||||
}
|
||||
|
||||
public function downloadImportLogPdf()
|
||||
{
|
||||
if (empty($this->importStats)) {
|
||||
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
|
||||
return;
|
||||
}
|
||||
|
||||
$stats = $this->importStats;
|
||||
$issues = $this->importIssues;
|
||||
$generatedAt = now()->format('d/m/Y H:i:s');
|
||||
|
||||
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
|
||||
|
||||
return response()->streamDownload(function () use ($html) {
|
||||
echo Pdf::loadHTML($html)
|
||||
->setPaper('a4', 'portrait')
|
||||
->output();
|
||||
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
|
||||
}
|
||||
|
||||
public function exportCurrentAsXml()
|
||||
{
|
||||
$dataset = $this->currentDataset();
|
||||
$xml = $this->datasetToXml($dataset, 'current-app-export');
|
||||
|
||||
return response()->streamDownload(function () use ($xml) {
|
||||
echo $xml;
|
||||
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
if (session()->has('importStats')) {
|
||||
$this->importStats = session('importStats');
|
||||
}
|
||||
if (session()->has('importIssues')) {
|
||||
$this->importIssues = session('importIssues');
|
||||
}
|
||||
|
||||
return view('livewire.settings.xml-exchange');
|
||||
}
|
||||
|
||||
public function legacySqlToDatasetPublic(string $sql): array
|
||||
{
|
||||
return $this->legacySqlToDataset($sql);
|
||||
}
|
||||
|
||||
public function datasetToXmlPublic(array $dataset, string $source): string
|
||||
{
|
||||
return $this->datasetToXml($dataset, $source);
|
||||
}
|
||||
|
||||
public function importXmlFromContent(string $content): array
|
||||
{
|
||||
$this->importStats = [];
|
||||
$this->importIssues = [];
|
||||
|
||||
$xml = @simplexml_load_string($content);
|
||||
if (! $xml) {
|
||||
return ['error' => 'Impossibile leggere il file XML.'];
|
||||
}
|
||||
|
||||
$actorId = auth()->id() ?? User::query()->value('id');
|
||||
if (! $actorId) {
|
||||
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'zone_importate' => 0,
|
||||
'tipologie_importate' => 0,
|
||||
'proclamatori_importati' => 0,
|
||||
'anni_importati' => 0,
|
||||
'campagne_importate' => 0,
|
||||
'territori_importati' => 0,
|
||||
'assegnazioni_importate' => 0,
|
||||
'duplicate_territori' => 0,
|
||||
'assegnazioni_saltate' => 0,
|
||||
];
|
||||
|
||||
DB::transaction(function () use ($xml, $actorId, &$stats) {
|
||||
Assegnazione::query()->delete();
|
||||
Campagna::query()->delete();
|
||||
Territorio::query()->withTrashed()->forceDelete();
|
||||
Proclamatore::query()->withTrashed()->forceDelete();
|
||||
Tipologia::query()->delete();
|
||||
Zona::query()->delete();
|
||||
AnnoTeocratico::query()->delete();
|
||||
Setting::query()->delete();
|
||||
|
||||
$settingsNode = $xml->settings;
|
||||
if ($settingsNode) {
|
||||
Setting::instance()->update([
|
||||
'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''),
|
||||
'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120),
|
||||
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
|
||||
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
|
||||
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10),
|
||||
'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1),
|
||||
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
|
||||
'setup_completed' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$zoneMap = [];
|
||||
foreach (($xml->zones->zone ?? []) as $zoneNode) {
|
||||
$z = Zona::create([
|
||||
'nome' => (string) $zoneNode->nome,
|
||||
'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$stats['zone_importate']++;
|
||||
$legacyId = (string) ($zoneNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$zoneMap[$legacyId] = $z->id;
|
||||
}
|
||||
}
|
||||
|
||||
$tipologiaMap = [];
|
||||
foreach (($xml->tipologie->tipologia ?? []) as $tipologiaNode) {
|
||||
$t = Tipologia::create([
|
||||
'nome' => (string) $tipologiaNode->nome,
|
||||
'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$stats['tipologie_importate']++;
|
||||
$legacyId = (string) ($tipologiaNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$tipologiaMap[$legacyId] = $t->id;
|
||||
}
|
||||
}
|
||||
|
||||
$proclamatoreMap = [];
|
||||
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
||||
$p = Proclamatore::create([
|
||||
'nome' => (string) $proclamatoreNode->nome,
|
||||
'cognome' => (string) $proclamatoreNode->cognome,
|
||||
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$stats['proclamatori_importati']++;
|
||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$proclamatoreMap[$legacyId] = $p->id;
|
||||
}
|
||||
}
|
||||
|
||||
$annoMap = [];
|
||||
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
||||
$label = (string) $annoNode->label;
|
||||
$anno = AnnoTeocratico::create([
|
||||
'label' => $label,
|
||||
'start_date' => (string) $annoNode->start_date,
|
||||
'end_date' => (string) $annoNode->end_date,
|
||||
]);
|
||||
$stats['anni_importati']++;
|
||||
$legacyId = (string) ($annoNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$annoMap[$legacyId] = $anno->id;
|
||||
}
|
||||
}
|
||||
|
||||
$campagnaMap = [];
|
||||
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
|
||||
$campagna = Campagna::create([
|
||||
'descrizione' => (string) $campagnaNode->descrizione,
|
||||
'start_date' => (string) $campagnaNode->start_date,
|
||||
'end_date' => (string) $campagnaNode->end_date,
|
||||
]);
|
||||
$stats['campagne_importate']++;
|
||||
$legacyId = (string) ($campagnaNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$campagnaMap[$legacyId] = $campagna->id;
|
||||
}
|
||||
}
|
||||
|
||||
$territorioMap = [];
|
||||
$territoriNumeroVisti = [];
|
||||
foreach (($xml->territori->territorio ?? []) as $territorioNode) {
|
||||
$legacyZonaId = (string) ($territorioNode->legacy_zona_id ?? '');
|
||||
$legacyTipologiaId = (string) ($territorioNode->legacy_tipologia_id ?? '');
|
||||
$numero = trim((string) $territorioNode->numero);
|
||||
$legacyId = (string) ($territorioNode['legacy_id'] ?? '');
|
||||
|
||||
if ($numero === '' || isset($territoriNumeroVisti[$numero])) {
|
||||
$stats['duplicate_territori']++;
|
||||
$this->pushImportIssue('territorio', $legacyId, 'Territorio duplicato o numero vuoto (numero=' . ($numero !== '' ? $numero : 'vuoto') . ')');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
|
||||
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
|
||||
continue;
|
||||
}
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => $numero,
|
||||
'zona_id' => $zoneMap[$legacyZonaId],
|
||||
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
|
||||
'confini' => (string) ($territorioNode->confini ?? ''),
|
||||
'note' => (string) ($territorioNode->note ?? ''),
|
||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||
]);
|
||||
|
||||
$territoriNumeroVisti[$numero] = true;
|
||||
$stats['territori_importati']++;
|
||||
|
||||
if ($legacyId !== '') {
|
||||
$territorioMap[$legacyId] = $territorio->id;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (($xml->assegnazioni->assegnazione ?? []) as $assegnazioneNode) {
|
||||
$legacyTerritorio = (string) ($assegnazioneNode->legacy_territorio_id ?? '');
|
||||
$legacyProclamatore = (string) ($assegnazioneNode->legacy_proclamatore_id ?? '');
|
||||
$legacyAnno = (string) ($assegnazioneNode->legacy_anno_id ?? '');
|
||||
$legacyId = (string) ($assegnazioneNode['legacy_id'] ?? '');
|
||||
$assignedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->assigned_at ?? ''), false);
|
||||
$returnedAt = $this->normalizeDateForDb((string) ($assegnazioneNode->returned_at ?? ''), true);
|
||||
|
||||
if (!isset($territorioMap[$legacyTerritorio], $proclamatoreMap[$legacyProclamatore], $annoMap[$legacyAnno])) {
|
||||
$stats['assegnazioni_saltate']++;
|
||||
$this->pushImportIssue('assegnazione', $legacyId, 'Riferimenti mancanti (territorio=' . $legacyTerritorio . ', proclamatore=' . $legacyProclamatore . ', anno=' . $legacyAnno . ')');
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($assignedAt === null) {
|
||||
$stats['assegnazioni_saltate']++;
|
||||
$this->pushImportIssue('assegnazione', $legacyId, 'Data assigned_at non valida o vuota');
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? '');
|
||||
|
||||
Assegnazione::create([
|
||||
'territorio_id' => $territorioMap[$legacyTerritorio],
|
||||
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore],
|
||||
'anno_teocratico_id' => $annoMap[$legacyAnno],
|
||||
'assigned_at' => $assignedAt,
|
||||
'returned_at' => $returnedAt,
|
||||
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||
'created_by' => $actorId,
|
||||
'returned_by' => $returnedAt !== null ? $actorId : null,
|
||||
]);
|
||||
$stats['assegnazioni_importate']++;
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'issues' => $this->importIssues,
|
||||
];
|
||||
}
|
||||
|
||||
private function currentDataset(): array
|
||||
{
|
||||
$settings = Setting::instance();
|
||||
|
||||
return [
|
||||
'settings' => [
|
||||
'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''),
|
||||
'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120),
|
||||
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
|
||||
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
|
||||
'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
|
||||
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
|
||||
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
|
||||
],
|
||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
|
||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
|
||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo'])->toArray(),
|
||||
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario'])->toArray(),
|
||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date'])->toArray(),
|
||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date'])->toArray(),
|
||||
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note'])->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function legacySqlToDataset(string $sql): array
|
||||
{
|
||||
$rows = $this->extractInsertRows($sql);
|
||||
|
||||
$congregazione = $rows['congregazione'][0][1] ?? 'Congregazione';
|
||||
$impostazioni = $rows['impostazioni'][0] ?? [1, 120, 10, 120];
|
||||
|
||||
$settings = [
|
||||
'congregazione_nome' => (string) $congregazione,
|
||||
'giorni_giacenza_da_assegnare' => (int) ($impostazioni[3] ?? 120),
|
||||
'giorni_giacenza_prioritari' => (int) ($impostazioni[1] ?? 180),
|
||||
'giorni_per_smarrito' => (int) ($impostazioni[3] ?? 120),
|
||||
'home_limit_list' => (int) ($impostazioni[2] ?? 10),
|
||||
'assignment_link_ttl_months' => 1,
|
||||
'audit_retention_days' => 730,
|
||||
];
|
||||
|
||||
$zones = [];
|
||||
foreach (($rows['zona'] ?? []) as $r) {
|
||||
$zones[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'nome' => (string) ($r[1] ?? ''),
|
||||
'attivo' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
$tipologie = [];
|
||||
foreach (($rows['tipologia'] ?? []) as $r) {
|
||||
$tipologie[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'nome' => (string) ($r[1] ?? ''),
|
||||
'attivo' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
$proclamatori = [];
|
||||
foreach (($rows['proclamatore'] ?? []) as $r) {
|
||||
$proclamatori[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'cognome' => (string) ($r[1] ?? ''),
|
||||
'nome' => (string) ($r[2] ?? ''),
|
||||
'attivo' => ((int) ($r[3] ?? 1)) === 1 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$territori = [];
|
||||
foreach (($rows['territorio'] ?? []) as $r) {
|
||||
$territori[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'numero' => (string) ($r[1] ?? ''),
|
||||
'legacy_tipologia_id' => (string) ($r[2] ?? ''),
|
||||
'legacy_zona_id' => (string) ($r[3] ?? ''),
|
||||
'confini' => (string) ($r[4] ?? ''),
|
||||
'note' => (string) ($r[5] ?? ''),
|
||||
'attivo' => ((int) ($r[8] ?? 1)) === 1 ? 1 : 0,
|
||||
'prioritario' => ((int) ($r[11] ?? 0)) > 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$anni = [];
|
||||
foreach (($rows['annoServizio'] ?? []) as $r) {
|
||||
$label = (string) ($r[1] ?? '');
|
||||
[$startDate, $endDate] = $this->datesFromLegacyAnnoLabel($label);
|
||||
$anni[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'label' => str_replace(' - ', '-', $label),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
];
|
||||
}
|
||||
|
||||
$campagne = [];
|
||||
foreach (($rows['campagna'] ?? []) as $r) {
|
||||
$campagne[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'descrizione' => (string) ($r[1] ?? ''),
|
||||
'start_date' => $this->toDateOnly((string) ($r[2] ?? '')),
|
||||
'end_date' => $this->toDateOnly((string) ($r[3] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$assegnazioni = [];
|
||||
foreach (($rows['assegnazione'] ?? []) as $r) {
|
||||
$assegnazioni[] = [
|
||||
'legacy_id' => (string) ($r[0] ?? ''),
|
||||
'legacy_territorio_id' => (string) ($r[1] ?? ''),
|
||||
'legacy_proclamatore_id' => (string) ($r[2] ?? ''),
|
||||
'legacy_anno_id' => (string) ($r[9] ?? ''),
|
||||
'legacy_campagna_id' => $r[8] === null ? '' : (string) $r[8],
|
||||
'assigned_at' => $this->toDateOnly((string) ($r[4] ?? '')),
|
||||
'returned_at' => $this->toDateOnly((string) ($r[5] ?? '')),
|
||||
'counted_in_campaign' => ((int) ($r[7] ?? 0)) === 1 ? 1 : 0,
|
||||
'note' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'settings' => $settings,
|
||||
'zones' => $zones,
|
||||
'tipologie' => $tipologie,
|
||||
'proclamatori' => $proclamatori,
|
||||
'territori' => $territori,
|
||||
'anni_teocratici' => $anni,
|
||||
'campagne' => $campagne,
|
||||
'assegnazioni' => $assegnazioni,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractInsertRows(string $sql): array
|
||||
{
|
||||
$result = [];
|
||||
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$table = $match[1];
|
||||
$valuesPayload = $match[2];
|
||||
$tuples = $this->splitSqlTuples($valuesPayload);
|
||||
|
||||
foreach ($tuples as $tuple) {
|
||||
$result[$table][] = $this->parseSqlTuple($tuple);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function splitSqlTuples(string $payload): array
|
||||
{
|
||||
$tuples = [];
|
||||
$inString = false;
|
||||
$escape = false;
|
||||
$depth = 0;
|
||||
$current = '';
|
||||
|
||||
$len = strlen($payload);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $payload[$i];
|
||||
|
||||
if ($inString) {
|
||||
$current .= $ch;
|
||||
if ($escape) {
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
if ($ch === "'") {
|
||||
$inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === "'") {
|
||||
$inString = true;
|
||||
$current .= $ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '(') {
|
||||
if ($depth === 0) {
|
||||
$current = '';
|
||||
}
|
||||
$depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === ')') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$tuples[] = $current;
|
||||
$current = '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($depth > 0) {
|
||||
$current .= $ch;
|
||||
}
|
||||
}
|
||||
|
||||
return $tuples;
|
||||
}
|
||||
|
||||
private function parseSqlTuple(string $tuple): array
|
||||
{
|
||||
$values = [];
|
||||
$inString = false;
|
||||
$escape = false;
|
||||
$current = '';
|
||||
|
||||
$len = strlen($tuple);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $tuple[$i];
|
||||
|
||||
if ($inString) {
|
||||
$current .= $ch;
|
||||
if ($escape) {
|
||||
$escape = false;
|
||||
continue;
|
||||
}
|
||||
if ($ch === '\\') {
|
||||
$escape = true;
|
||||
continue;
|
||||
}
|
||||
if ($ch === "'") {
|
||||
$inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === "'") {
|
||||
$inString = true;
|
||||
$current .= $ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === ',') {
|
||||
$values[] = $this->normalizeSqlValue($current);
|
||||
$current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$current .= $ch;
|
||||
}
|
||||
|
||||
if ($current !== '') {
|
||||
$values[] = $this->normalizeSqlValue($current);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function normalizeSqlValue(string $raw)
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if (strcasecmp($raw, 'NULL') === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
|
||||
$v = substr($raw, 1, -1);
|
||||
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
|
||||
$v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return $this->normalizeUnicodeQuotes($v);
|
||||
}
|
||||
|
||||
if (is_numeric($raw)) {
|
||||
return str_contains($raw, '.') ? (float) $raw : (int) $raw;
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function normalizeUnicodeQuotes(string $value): string
|
||||
{
|
||||
return str_replace(
|
||||
["\u{2018}", "\u{2019}", "\u{2032}", "\u{2035}", "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}", "\u{2036}"],
|
||||
["'", "'", "'", "'", '"', '"', '"', '"', '"'],
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
private function datesFromLegacyAnnoLabel(string $label): array
|
||||
{
|
||||
if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) {
|
||||
return [$m[1] . '-09-01', $m[2] . '-08-31'];
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
return [$now->copy()->startOfYear()->toDateString(), $now->copy()->endOfYear()->toDateString()];
|
||||
}
|
||||
|
||||
private function toDateOnly(string $dateTime): string
|
||||
{
|
||||
if ($dateTime === '') {
|
||||
return '';
|
||||
}
|
||||
return substr($dateTime, 0, 10);
|
||||
}
|
||||
|
||||
private function normalizeDateForDb(string $raw, bool $nullable): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') {
|
||||
return $nullable ? null : null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateString();
|
||||
} catch (\Throwable $e) {
|
||||
return $nullable ? null : null;
|
||||
}
|
||||
}
|
||||
|
||||
private function pushImportIssue(string $entity, string $legacyId, string $reason): void
|
||||
{
|
||||
if (count($this->importIssues) >= 300) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->importIssues[] = [
|
||||
'entity' => $entity,
|
||||
'legacy_id' => $legacyId !== '' ? $legacyId : '-',
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
private function datasetToXml(array $dataset, string $source): string
|
||||
{
|
||||
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><termanager2/>');
|
||||
$xml->addAttribute('version', '1.0');
|
||||
$xml->addAttribute('source', $source);
|
||||
$xml->addAttribute('generated_at', now()->toIso8601String());
|
||||
|
||||
$settings = $xml->addChild('settings');
|
||||
foreach ($dataset['settings'] as $key => $value) {
|
||||
$this->addXmlText($settings, $key, (string) $value);
|
||||
}
|
||||
|
||||
$zonesNode = $xml->addChild('zones');
|
||||
foreach ($dataset['zones'] as $zone) {
|
||||
$node = $zonesNode->addChild('zone');
|
||||
if (isset($zone['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
$tipologieNode = $xml->addChild('tipologie');
|
||||
foreach ($dataset['tipologie'] as $tipologia) {
|
||||
$node = $tipologieNode->addChild('tipologia');
|
||||
if (isset($tipologia['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
$proclamatoriNode = $xml->addChild('proclamatori');
|
||||
foreach ($dataset['proclamatori'] as $proclamatore) {
|
||||
$node = $proclamatoriNode->addChild('proclamatore');
|
||||
if (isset($proclamatore['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
||||
}
|
||||
|
||||
$territoriNode = $xml->addChild('territori');
|
||||
foreach ($dataset['territori'] as $territorio) {
|
||||
$node = $territoriNode->addChild('territorio');
|
||||
if (isset($territorio['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
|
||||
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
|
||||
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
|
||||
$this->addXmlText($node, 'confini', (string) ($territorio['confini'] ?? ''));
|
||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
||||
}
|
||||
|
||||
$anniNode = $xml->addChild('anni_teocratici');
|
||||
foreach ($dataset['anni_teocratici'] as $anno) {
|
||||
$node = $anniNode->addChild('anno');
|
||||
if (isset($anno['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
|
||||
}
|
||||
|
||||
$campagneNode = $xml->addChild('campagne');
|
||||
foreach ($dataset['campagne'] as $campagna) {
|
||||
$node = $campagneNode->addChild('campagna');
|
||||
if (isset($campagna['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
|
||||
}
|
||||
|
||||
$assegnazioniNode = $xml->addChild('assegnazioni');
|
||||
foreach ($dataset['assegnazioni'] as $assegnazione) {
|
||||
$node = $assegnazioniNode->addChild('assegnazione');
|
||||
if (isset($assegnazione['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $assegnazione['legacy_id']);
|
||||
}
|
||||
$node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? '')));
|
||||
$node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? '')));
|
||||
$node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? '')));
|
||||
$node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? '')));
|
||||
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
|
||||
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
||||
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||
}
|
||||
|
||||
return $xml->asXML() ?: '';
|
||||
}
|
||||
|
||||
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
|
||||
{
|
||||
$child = $parent->addChild($name);
|
||||
$dom = dom_import_simplexml($child);
|
||||
$dom->textContent = $value;
|
||||
|
||||
return $child;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Zona;
|
||||
use App\Models\Tipologia;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
|
||||
class TerritorioCreate extends Component
|
||||
{
|
||||
@@ -48,7 +49,9 @@ class TerritorioCreate extends Component
|
||||
];
|
||||
|
||||
if ($this->pdf) {
|
||||
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public');
|
||||
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
||||
$data['pdf_path'] = $pdfPath;
|
||||
$data['thumbnail_path'] = app(TerritorioThumbnailService::class)->generate($pdfPath);
|
||||
}
|
||||
|
||||
$territorio = Territorio::create($data);
|
||||
|
||||
@@ -7,6 +7,7 @@ use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Zona;
|
||||
use App\Models\Tipologia;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioEdit extends Component
|
||||
@@ -60,11 +61,17 @@ class TerritorioEdit extends Component
|
||||
];
|
||||
|
||||
if ($this->pdf) {
|
||||
// Remove old PDF
|
||||
$thumbService = app(TerritorioThumbnailService::class);
|
||||
// Remove old PDF and thumbnail
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
}
|
||||
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public');
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
$thumbService->delete($this->territorio->thumbnail_path);
|
||||
}
|
||||
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
||||
$data['pdf_path'] = $pdfPath;
|
||||
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
|
||||
}
|
||||
|
||||
$this->territorio->update($data);
|
||||
@@ -77,7 +84,10 @@ class TerritorioEdit extends Component
|
||||
{
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
$this->territorio->update(['pdf_path' => null]);
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
app(TerritorioThumbnailService::class)->delete($this->territorio->thumbnail_path);
|
||||
}
|
||||
$this->territorio->update(['pdf_path' => null, 'thumbnail_path' => null]);
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($this->territorio)
|
||||
->log('removed_pdf');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -16,6 +17,9 @@ class TerritorioIndex extends Component
|
||||
public string $filterZona = '';
|
||||
public string $filterTipologia = '';
|
||||
public string $filterStato = '';
|
||||
public string $filterPriorita = '';
|
||||
public string $filterPdf = '';
|
||||
public string $filterContenuti = '';
|
||||
public string $sortField = 'numero';
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
@@ -24,6 +28,9 @@ class TerritorioIndex extends Component
|
||||
'filterZona' => ['except' => ''],
|
||||
'filterTipologia' => ['except' => ''],
|
||||
'filterStato' => ['except' => ''],
|
||||
'filterPriorita' => ['except' => ''],
|
||||
'filterPdf' => ['except' => ''],
|
||||
'filterContenuti' => ['except' => ''],
|
||||
];
|
||||
|
||||
public function updatingSearch()
|
||||
@@ -31,6 +38,36 @@ class TerritorioIndex extends Component
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterZona()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterTipologia()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterStato()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterPriorita()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterPdf()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingFilterContenuti()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function sortBy(string $field)
|
||||
{
|
||||
if ($this->sortField === $field) {
|
||||
@@ -41,6 +78,22 @@ class TerritorioIndex extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function clearFilters(): void
|
||||
{
|
||||
$this->reset([
|
||||
'search',
|
||||
'filterZona',
|
||||
'filterTipologia',
|
||||
'filterStato',
|
||||
'filterPriorita',
|
||||
'filterPdf',
|
||||
'filterContenuti',
|
||||
]);
|
||||
$this->sortField = 'numero';
|
||||
$this->sortDirection = 'asc';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function toggleActive(Territorio $territorio)
|
||||
{
|
||||
$territorio->update(['attivo' => !$territorio->attivo]);
|
||||
@@ -73,7 +126,14 @@ class TerritorioIndex extends Component
|
||||
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
|
||||
|
||||
if ($this->search) {
|
||||
$query->where('numero', 'like', "%{$this->search}%");
|
||||
$search = $this->search;
|
||||
$query->where(function ($subQuery) use ($search) {
|
||||
$subQuery->where('numero', 'like', "%{$search}%")
|
||||
->orWhere('note', 'like', "%{$search}%")
|
||||
->orWhere('confini', 'like', "%{$search}%")
|
||||
->orWhereHas('zona', fn($zonaQuery) => $zonaQuery->where('nome', 'like', "%{$search}%"))
|
||||
->orWhereHas('tipologia', fn($tipologiaQuery) => $tipologiaQuery->where('nome', 'like', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->filterZona) {
|
||||
@@ -90,16 +150,104 @@ class TerritorioIndex extends Component
|
||||
'assegnato' => $query->assegnato(),
|
||||
'da_rientrare' => $query->daRientrare(),
|
||||
'inattivo' => $query->where('attivo', false),
|
||||
'prioritari' => $query->inReparto(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$query->orderBy($this->sortField, $this->sortDirection);
|
||||
if ($this->filterPdf) {
|
||||
match ($this->filterPdf) {
|
||||
'con_pdf' => $query->whereNotNull('pdf_path'),
|
||||
'senza_pdf' => $query->whereNull('pdf_path'),
|
||||
'con_thumbnail' => $query->whereNotNull('thumbnail_path'),
|
||||
'senza_thumbnail' => $query->whereNull('thumbnail_path'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($this->filterContenuti) {
|
||||
match ($this->filterContenuti) {
|
||||
'con_note' => $query->whereNotNull('note')->where('note', '!=', ''),
|
||||
'senza_note' => $query->where(function ($subQuery) {
|
||||
$subQuery->whereNull('note')->orWhere('note', '');
|
||||
}),
|
||||
'con_confini' => $query->whereNotNull('confini')->where('confini', '!=', ''),
|
||||
'senza_confini' => $query->where(function ($subQuery) {
|
||||
$subQuery->whereNull('confini')->orWhere('confini', '');
|
||||
}),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$territori = $query->get();
|
||||
|
||||
if ($this->filterStato === 'prioritari') {
|
||||
$territori = $territori->filter(fn(Territorio $territorio) => $territorio->is_prioritario)->values();
|
||||
}
|
||||
|
||||
if ($this->filterPriorita) {
|
||||
$territori = $territori->filter(function (Territorio $territorio) {
|
||||
return match ($this->filterPriorita) {
|
||||
'prioritari' => $territorio->is_prioritario,
|
||||
'manuali' => $territorio->prioritario,
|
||||
'automatici' => $territorio->is_prioritario && !$territorio->prioritario,
|
||||
'non_prioritari' => !$territorio->is_prioritario,
|
||||
default => true,
|
||||
};
|
||||
})->values();
|
||||
}
|
||||
|
||||
if ($this->usesPriorityOrdering()) {
|
||||
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
|
||||
$priorityComparison = (int) $right->is_prioritario <=> (int) $left->is_prioritario;
|
||||
|
||||
if ($priorityComparison !== 0) {
|
||||
return $priorityComparison;
|
||||
}
|
||||
|
||||
$giacenzaComparison = $right->giorni_giacenza <=> $left->giorni_giacenza;
|
||||
|
||||
if ($giacenzaComparison !== 0) {
|
||||
return $giacenzaComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})->values();
|
||||
} else {
|
||||
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
|
||||
$result = strnatcasecmp((string) data_get($left, $this->sortField), (string) data_get($right, $this->sortField));
|
||||
|
||||
return $this->sortDirection === 'asc' ? $result : -$result;
|
||||
})->values();
|
||||
}
|
||||
|
||||
$perPage = 20;
|
||||
$page = $this->getPage();
|
||||
$items = $territori->slice(($page - 1) * $perPage, $perPage)->values();
|
||||
$paginatedTerritori = new LengthAwarePaginator(
|
||||
$items,
|
||||
$territori->count(),
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => request()->url(), 'query' => request()->query()]
|
||||
);
|
||||
|
||||
return view('livewire.territori.territorio-index', [
|
||||
'territori' => $query->paginate(20),
|
||||
'territori' => $paginatedTerritori,
|
||||
'zone' => Zona::attive()->get(),
|
||||
'tipologie' => Tipologia::attive()->get(),
|
||||
'usesPriorityOrdering' => $this->usesPriorityOrdering(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function usesPriorityOrdering(): bool
|
||||
{
|
||||
return $this->sortField === 'numero'
|
||||
&& $this->sortDirection === 'asc'
|
||||
&& (
|
||||
in_array($this->filterStato, ['in_reparto', 'prioritari'], true)
|
||||
|| $this->filterPriorita !== ''
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Livewire\Territori;
|
||||
use Livewire\Component;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Setting;
|
||||
|
||||
class TerritorioShow extends Component
|
||||
{
|
||||
@@ -13,7 +13,7 @@ class TerritorioShow extends Component
|
||||
|
||||
public function mount(Territorio $territorio)
|
||||
{
|
||||
$this->territorio = $territorio->load(['zona', 'tipologia']);
|
||||
$this->territorio = $territorio->load(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'ultimaAssegnazione']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -25,6 +25,8 @@ class TerritorioShow extends Component
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
|
||||
return view('livewire.territori.territorio-show', [
|
||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||
'assignmentLinkTtlMonths' => (int) Setting::getValue('assignment_link_ttl_hours', 1),
|
||||
'assegnazioniPerAnno' => $assegnazioni,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Assegnazione extends Model
|
||||
{
|
||||
@@ -17,6 +18,8 @@ class Assegnazione extends Model
|
||||
'returned_at',
|
||||
'counted_in_campaign',
|
||||
'campaign_id',
|
||||
'pdf_access_code',
|
||||
'link_sent',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
@@ -28,6 +31,7 @@ class Assegnazione extends Model
|
||||
'assigned_at' => 'date',
|
||||
'returned_at' => 'date',
|
||||
'counted_in_campaign' => 'boolean',
|
||||
'link_sent' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +83,47 @@ class Assegnazione extends Model
|
||||
return is_null($this->returned_at);
|
||||
}
|
||||
|
||||
public function ensurePdfAccessCode(): string
|
||||
{
|
||||
if ($this->pdf_access_code) {
|
||||
return $this->pdf_access_code;
|
||||
}
|
||||
|
||||
do {
|
||||
$code = strtoupper(Str::random(12));
|
||||
} while (static::query()->where('pdf_access_code', $code)->exists());
|
||||
|
||||
$this->forceFill(['pdf_access_code' => $code])->saveQuietly();
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function temporaryPdfViewerUrl(): ?string
|
||||
{
|
||||
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('assignments.pdf.viewer', [
|
||||
'assignment' => $this->id,
|
||||
'code' => $this->ensurePdfAccessCode(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function shortPdfUrl(): ?string
|
||||
{
|
||||
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
|
||||
}
|
||||
|
||||
public function markLinkSent(): void
|
||||
{
|
||||
$this->forceFill(['link_sent' => true])->saveQuietly();
|
||||
}
|
||||
|
||||
// ─── Scopes ─────────────────────────────────────────────────
|
||||
|
||||
public function scopeAperte($query)
|
||||
|
||||
@@ -72,19 +72,19 @@ class Campagna extends Model
|
||||
/**
|
||||
* Campaign coverage percentage.
|
||||
* Numerator: assignments counted for campaign
|
||||
* Denominator: ALL assignments with assigned_at in campaign range (returned or not)
|
||||
* Denominator: total active territories
|
||||
*/
|
||||
public function getPercentualePercorrenzaAttribute(): float
|
||||
{
|
||||
$totaleNelRange = $this->assegnazioniNelRange()->count();
|
||||
$totaleAttivi = Territorio::where('attivo', true)->count();
|
||||
|
||||
if ($totaleNelRange === 0) {
|
||||
if ($totaleAttivi === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$conteggiate = $this->assegnazioniConteggiate()->count();
|
||||
|
||||
return round(($conteggiate / $totaleNelRange) * 100, 1);
|
||||
return round(($conteggiate / $totaleAttivi) * 100, 1);
|
||||
}
|
||||
|
||||
public function scopeCompletate($query)
|
||||
|
||||
@@ -6,13 +6,18 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected static ?self $cachedInstance = null;
|
||||
|
||||
protected $fillable = [
|
||||
'congregazione_nome',
|
||||
'public_base_url',
|
||||
'logo_path',
|
||||
'giorni_giacenza_da_assegnare',
|
||||
'giorni_giacenza_prioritari',
|
||||
'giorni_per_smarrito',
|
||||
'home_limit_list',
|
||||
'assignment_link_ttl_hours',
|
||||
'pdf_viewer_show_download',
|
||||
'audit_retention_days',
|
||||
'setup_completed',
|
||||
];
|
||||
@@ -21,26 +26,46 @@ class Setting extends Model
|
||||
{
|
||||
return [
|
||||
'setup_completed' => 'boolean',
|
||||
'pdf_viewer_show_download' => 'boolean',
|
||||
'giorni_giacenza_da_assegnare' => 'integer',
|
||||
'giorni_giacenza_prioritari' => 'integer',
|
||||
'giorni_per_smarrito' => 'integer',
|
||||
'home_limit_list' => 'integer',
|
||||
'assignment_link_ttl_hours' => 'integer',
|
||||
'audit_retention_days' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(function (): void {
|
||||
static::$cachedInstance = null;
|
||||
});
|
||||
|
||||
static::deleted(function (): void {
|
||||
static::$cachedInstance = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton settings instance (first row).
|
||||
*/
|
||||
public static function instance(): static
|
||||
{
|
||||
return static::firstOrCreate([], [
|
||||
if (static::$cachedInstance instanceof static) {
|
||||
return static::$cachedInstance;
|
||||
}
|
||||
|
||||
static::$cachedInstance = static::firstOrCreate([], [
|
||||
'giorni_giacenza_da_assegnare' => 120,
|
||||
'giorni_giacenza_prioritari' => 180,
|
||||
'giorni_per_smarrito' => 120,
|
||||
'home_limit_list' => 10,
|
||||
'assignment_link_ttl_hours' => 1,
|
||||
'audit_retention_days' => 730,
|
||||
]);
|
||||
|
||||
return static::$cachedInstance;
|
||||
}
|
||||
|
||||
public static function isSetupComplete(): bool
|
||||
|
||||
@@ -21,6 +21,7 @@ class Territorio extends Model
|
||||
'note',
|
||||
'confini',
|
||||
'pdf_path',
|
||||
'thumbnail_path',
|
||||
'attivo',
|
||||
'prioritario',
|
||||
];
|
||||
@@ -106,11 +107,11 @@ class Territorio extends Model
|
||||
$ultima = $this->ultimaAssegnazione;
|
||||
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
return Carbon::parse($ultima->returned_at)->diffInDays(now());
|
||||
return Carbon::parse($ultima->returned_at)->startOfDay()->diffInDays(today());
|
||||
}
|
||||
|
||||
if (!$ultima) {
|
||||
return $this->created_at->diffInDays(now());
|
||||
return $this->created_at->startOfDay()->diffInDays(today());
|
||||
}
|
||||
|
||||
// Currently assigned, no giacenza concept
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -15,6 +16,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Force HTTPS when behind a reverse proxy
|
||||
if (str_starts_with(config('app.url'), 'https://')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Auto-generate APP_KEY if missing
|
||||
if (empty(config('app.key'))) {
|
||||
Artisan::call('key:generate', ['--force' => true]);
|
||||
|
||||
94
app/Services/TerritorioPdfImportDispatcher.php
Normal file
94
app/Services/TerritorioPdfImportDispatcher.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
class TerritorioPdfImportDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
protected TerritorioPdfImportState $stateService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
|
||||
{
|
||||
$state = $this->stateService->initialize($importId, count($storedFiles));
|
||||
$this->stateService->appendLog($importId, $initialLog);
|
||||
|
||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||
|
||||
return $this->stateService->get($importId) ?? $state;
|
||||
}
|
||||
|
||||
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
||||
{
|
||||
$importId = (string) Str::uuid();
|
||||
$zipStoredPath = $zipFile->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
'archivio-' . $importId . '.zip',
|
||||
'local'
|
||||
);
|
||||
|
||||
$zipAbsolutePath = storage_path('app/' . $zipStoredPath);
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipAbsolutePath) !== true) {
|
||||
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
||||
}
|
||||
|
||||
$storedFiles = [];
|
||||
$entryIndex = 0;
|
||||
|
||||
try {
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$entryName = $zip->getNameIndex($index);
|
||||
|
||||
if (! $entryName || str_ends_with($entryName, '/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $zip->getFromIndex($index);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalName = basename($entryName);
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$storedPath = 'bulk-territori-imports/' . $importId . '/zip-' . str_pad((string) $entryIndex, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf';
|
||||
|
||||
file_put_contents(storage_path('app/' . $storedPath), $content);
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
|
||||
$entryIndex++;
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if ($storedFiles === []) {
|
||||
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
||||
}
|
||||
|
||||
$this->dispatchStoredFiles(
|
||||
$importId,
|
||||
$storedFiles,
|
||||
$actorId,
|
||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
|
||||
);
|
||||
|
||||
return $importId;
|
||||
}
|
||||
}
|
||||
122
app/Services/TerritorioPdfImportState.php
Normal file
122
app/Services/TerritorioPdfImportState.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TerritorioPdfImportState
|
||||
{
|
||||
protected int $ttlSeconds = 86400;
|
||||
|
||||
public function initialize(string $importId, int $totalFiles): array
|
||||
{
|
||||
$state = [
|
||||
'id' => $importId,
|
||||
'status' => 'queued',
|
||||
'stats' => [
|
||||
'total' => $totalFiles,
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => 0,
|
||||
],
|
||||
'logs' => [
|
||||
'Import creato. In attesa del worker di coda.',
|
||||
],
|
||||
'issues' => [],
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
];
|
||||
|
||||
$this->put($importId, $state);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function get(string $importId): ?array
|
||||
{
|
||||
return Cache::get($this->key($importId));
|
||||
}
|
||||
|
||||
public function put(string $importId, array $state): void
|
||||
{
|
||||
Cache::put($this->key($importId), $state, $this->ttlSeconds);
|
||||
}
|
||||
|
||||
public function update(string $importId, callable $callback): ?array
|
||||
{
|
||||
$state = $this->get($importId);
|
||||
|
||||
if (! $state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updatedState = $callback($state) ?? $state;
|
||||
$this->put($importId, $updatedState);
|
||||
|
||||
return $updatedState;
|
||||
}
|
||||
|
||||
public function appendLog(string $importId, string $message): void
|
||||
{
|
||||
$this->update($importId, function (array $state) use ($message) {
|
||||
$timestamp = now()->format('H:i:s');
|
||||
$state['logs'][] = '[' . $timestamp . '] ' . $message;
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function increment(string $importId, string $metric, int $amount = 1): void
|
||||
{
|
||||
$this->update($importId, function (array $state) use ($metric, $amount) {
|
||||
$state['stats'][$metric] = ($state['stats'][$metric] ?? 0) + $amount;
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function addIssue(string $importId, array $issue): void
|
||||
{
|
||||
$this->update($importId, function (array $state) use ($issue) {
|
||||
$state['issues'][] = $issue;
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markRunning(string $importId): void
|
||||
{
|
||||
$this->update($importId, function (array $state) {
|
||||
$state['status'] = 'running';
|
||||
$state['started_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markCompleted(string $importId): void
|
||||
{
|
||||
$this->update($importId, function (array $state) {
|
||||
$state['status'] = 'completed';
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markFailed(string $importId): void
|
||||
{
|
||||
$this->update($importId, function (array $state) {
|
||||
$state['status'] = 'failed';
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
protected function key(string $importId): string
|
||||
{
|
||||
return 'territori-pdf-import:' . $importId;
|
||||
}
|
||||
}
|
||||
61
app/Services/TerritorioThumbnailService.php
Normal file
61
app/Services/TerritorioThumbnailService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioThumbnailService
|
||||
{
|
||||
/**
|
||||
* Generate a PNG thumbnail of the first page of a PDF stored on the public disk.
|
||||
*
|
||||
* @param string $pdfStoragePath Relative path within the public disk (e.g. "territori-pdf/abc.pdf")
|
||||
* @return string|null Relative path of the saved thumbnail, or null on failure
|
||||
*/
|
||||
public function generate(string $pdfStoragePath): ?string
|
||||
{
|
||||
$pdfAbsPath = Storage::disk('public')->path($pdfStoragePath);
|
||||
|
||||
if (!file_exists($pdfAbsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempPrefix = sys_get_temp_dir() . '/terr_thumb_' . uniqid();
|
||||
|
||||
exec(
|
||||
'pdftoppm -r 72 -png -l 1 ' . escapeshellarg($pdfAbsPath) . ' ' . escapeshellarg($tempPrefix),
|
||||
$output,
|
||||
$exitCode
|
||||
);
|
||||
|
||||
// pdftoppm may produce -1.png, -01.png or even -001.png depending on page count
|
||||
$generated = null;
|
||||
foreach (['-1.png', '-01.png', '-001.png'] as $suffix) {
|
||||
$candidate = $tempPrefix . $suffix;
|
||||
if (file_exists($candidate)) {
|
||||
$generated = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$generated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbRelPath = 'territori-thumbnails/' . basename($pdfStoragePath, '.pdf') . '.png';
|
||||
Storage::disk('public')->put($thumbRelPath, file_get_contents($generated));
|
||||
unlink($generated);
|
||||
|
||||
return $thumbRelPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing thumbnail from the public disk.
|
||||
*/
|
||||
public function delete(string $thumbnailPath): void
|
||||
{
|
||||
if ($thumbnailPath) {
|
||||
Storage::disk('public')->delete($thumbnailPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/tinker": "^2.9",
|
||||
"livewire/livewire": "^3.5",
|
||||
"spatie/laravel-permission": "^6.4",
|
||||
"spatie/laravel-activitylog": "^4.8"
|
||||
"spatie/laravel-activitylog": "^4.8",
|
||||
"spatie/laravel-permission": "^6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
524
composer.lock
generated
524
composer.lock
generated
@@ -4,8 +4,85 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f5778263babc2e758e3225284a64e9b3",
|
||||
"content-hash": "2501abb81bccf7d5db1e152bde41bd5b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T08:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -377,6 +454,161 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
|
||||
},
|
||||
"time": "2026-03-03T13:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -2089,6 +2321,73 @@
|
||||
],
|
||||
"time": "2026-04-03T13:08:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -3364,6 +3663,86 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.32 || 2.1.32",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.2.8",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.0",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
|
||||
},
|
||||
"time": "2026-03-03T17:31:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-activitylog",
|
||||
"version": "4.12.3",
|
||||
@@ -6021,6 +6400,149 @@
|
||||
],
|
||||
"time": "2026-03-30T13:44:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
|
||||
47
config/livewire.php
Normal file
47
config/livewire.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'class_namespace' => 'App\\Livewire',
|
||||
|
||||
'view_path' => resource_path('views/livewire'),
|
||||
|
||||
'layout' => 'components.layouts.app',
|
||||
|
||||
'lazy_placeholder' => null,
|
||||
|
||||
'temporary_file_upload' => [
|
||||
'disk' => null,
|
||||
'rules' => ['required', 'file', 'max:256000'],
|
||||
'directory' => null,
|
||||
'middleware' => null,
|
||||
'preview_mimes' => [
|
||||
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
],
|
||||
'max_upload_time' => 15,
|
||||
'cleanup' => true,
|
||||
],
|
||||
|
||||
'render_on_redirect' => false,
|
||||
|
||||
'legacy_model_binding' => false,
|
||||
|
||||
'inject_assets' => true,
|
||||
|
||||
'navigate' => [
|
||||
'show_progress_bar' => true,
|
||||
'progress_bar_color' => '#2299dd',
|
||||
],
|
||||
|
||||
'inject_morph_markers' => true,
|
||||
|
||||
'smart_wire_keys' => false,
|
||||
|
||||
'pagination_theme' => 'tailwind',
|
||||
|
||||
'release_token' => env('APP_VERSION'),
|
||||
|
||||
'inject_assets_after_styles' => false,
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->string('thumbnail_path')->nullable()->after('pdf_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('territori', 'thumbnail_path')) {
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->dropColumn('thumbnail_path');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->unsignedInteger('assignment_link_ttl_hours')->default(24)->after('home_limit_list');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn('assignment_link_ttl_hours');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->string('pdf_access_code', 32)->nullable()->unique()->after('campaign_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->dropUnique(['pdf_access_code']);
|
||||
$table->dropColumn('pdf_access_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('settings')
|
||||
->whereNotNull('assignment_link_ttl_hours')
|
||||
->update([
|
||||
'assignment_link_ttl_hours' => DB::raw('GREATEST(1, CEIL(assignment_link_ttl_hours / 720))'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('settings')
|
||||
->whereNotNull('assignment_link_ttl_hours')
|
||||
->update([
|
||||
'assignment_link_ttl_hours' => DB::raw('assignment_link_ttl_hours * 720'),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->string('public_base_url')->nullable()->after('congregazione_nome');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn('public_base_url');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->boolean('link_sent')->default(false)->after('pdf_access_code');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->dropColumn('link_sent');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->boolean('pdf_viewer_show_download')->default(true)->after('assignment_link_ttl_hours');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn('pdf_viewer_show_download');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
dockerfile: docker/php/Dockerfile
|
||||
container_name: termanager2_app
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
@@ -46,6 +47,26 @@ services:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
queue-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
container_name: termanager2_queue
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
networks:
|
||||
- termanager2
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
entrypoint: ["php"]
|
||||
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
|
||||
environment:
|
||||
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: termanager2_db
|
||||
@@ -57,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:
|
||||
@@ -74,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).* {
|
||||
|
||||
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
|
||||
zip \
|
||||
unzip \
|
||||
supervisor \
|
||||
poppler-utils \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install \
|
||||
pdo_mysql \
|
||||
|
||||
@@ -70,7 +70,9 @@ mkdir -p storage/framework/{cache,sessions,views}
|
||||
mkdir -p storage/logs
|
||||
mkdir -p storage/app
|
||||
mkdir -p bootstrap/cache
|
||||
chown -R www-data:www-data storage bootstrap/cache
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R www-data:www-data storage bootstrap/cache
|
||||
fi
|
||||
|
||||
# -----------------------------------------------
|
||||
# 1. .env file (must exist before composer/artisan)
|
||||
@@ -181,7 +183,7 @@ retry 10 3 php artisan migrate --force
|
||||
# -----------------------------------------------
|
||||
# 7b. Seed database on first container startup only
|
||||
# -----------------------------------------------
|
||||
SEED_MARKER_FILE="/var/www/html/storage/app/.db_seeded"
|
||||
SEED_MARKER_FILE="/var/www/html/storage/framework/.runtime_db_seeded"
|
||||
RUN_DB_SEED_ON_FIRST_START="${RUN_DB_SEED_ON_FIRST_START:-true}"
|
||||
|
||||
if [ "$RUN_DB_SEED_ON_FIRST_START" = "true" ]; then
|
||||
@@ -214,7 +216,6 @@ if [ "$ENSURE_INITIAL_ADMIN_ON_EMPTY_DB" = "true" ]; then
|
||||
--password="$INITIAL_ADMIN_PASSWORD_VALUE" \
|
||||
--no-interaction; then
|
||||
warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[PHP]
|
||||
upload_max_filesize = 64M
|
||||
post_max_size = 64M
|
||||
upload_max_filesize = 256M
|
||||
post_max_size = 256M
|
||||
memory_limit = 256M
|
||||
max_execution_time = 120
|
||||
max_input_vars = 3000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"resources/css/app.css": {
|
||||
"file": "assets/app-DTUtvTBm.css",
|
||||
"file": "assets/app-DL92hBto.css",
|
||||
"src": "resources/css/app.css",
|
||||
"isEntry": true
|
||||
},
|
||||
|
||||
@@ -1 +1,103 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── TerManager2 Custom UI ─── */
|
||||
|
||||
/* Card hover lift effect */
|
||||
.card-hover {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Colored left accent border for stat cards */
|
||||
.card-accent-indigo { border-left: 4px solid #6366f1; }
|
||||
.card-accent-green { border-left: 4px solid #22c55e; }
|
||||
.card-accent-blue { border-left: 4px solid #3b82f6; }
|
||||
.card-accent-red { border-left: 4px solid #ef4444; }
|
||||
.card-accent-amber { border-left: 4px solid #f59e0b; }
|
||||
|
||||
/* Table zebra striping */
|
||||
.table-striped tbody tr:nth-child(even) {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-action-green { background: #dcfce7; color: #166534; }
|
||||
.btn-action-green:hover { background: #bbf7d0; }
|
||||
.btn-action-red { background: #fee2e2; color: #991b1b; }
|
||||
.btn-action-red:hover { background: #fecaca; }
|
||||
.btn-action-blue { background: #dbeafe; color: #1e40af; }
|
||||
.btn-action-blue:hover { background: #bfdbfe; }
|
||||
.btn-action-amber { background: #fef3c7; color: #92400e; }
|
||||
.btn-action-amber:hover { background: #fde68a; }
|
||||
.btn-action-gray { background: #f3f4f6; color: #374151; }
|
||||
.btn-action-gray:hover { background: #e5e7eb; }
|
||||
.btn-action-indigo { background: #e0e7ff; color: #4338ca; }
|
||||
.btn-action-indigo:hover { background: #c7d2fe; }
|
||||
|
||||
/* Primary action buttons (filled) */
|
||||
.btn-primary-green { background: #16a34a; color: #fff; }
|
||||
.btn-primary-green:hover { background: #15803d; }
|
||||
.btn-primary-red { background: #dc2626; color: #fff; }
|
||||
.btn-primary-red:hover { background: #b91c1c; }
|
||||
|
||||
/* Gradient header */
|
||||
.header-gradient {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 50%, #818cf8 100%);
|
||||
}
|
||||
|
||||
/* Sidebar active state */
|
||||
.sidebar-active {
|
||||
background: linear-gradient(90deg, #eef2ff, #e0e7ff);
|
||||
border-right: 3px solid #6366f1;
|
||||
color: #4338ca;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
.page-enter {
|
||||
animation: fadeSlideIn 0.25s ease-out;
|
||||
}
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Badge pulse for active items */
|
||||
.badge-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
80
resources/views/assignments/link-scaduto.blade.php
Normal file
80
resources/views/assignments/link-scaduto.blade.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Link scaduto — TerManager2</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f1f5f9;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.08);
|
||||
padding: 40px 32px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: #fef2f2;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.territory {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 28px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">
|
||||
<svg width="26" height="26" fill="none" viewBox="0 0 24 24" stroke="#ef4444" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Link scaduto</h1>
|
||||
<p>Il link per questo territorio non è più valido.<br>Contatta il responsabile dei territori per ricevere un nuovo link.</p>
|
||||
@if($numero)
|
||||
<span class="territory">Territorio N° {{ $numero }}</span>
|
||||
@endif
|
||||
<p class="footer">TerManager2</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
352
resources/views/assignments/pdf-viewer.blade.php
Normal file
352
resources/views/assignments/pdf-viewer.blade.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
|
||||
<title>Territorio {{ $assignment->territorio?->numero }}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #4f46e5;
|
||||
letter-spacing: -.3px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.chip .label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
.chip .value {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.open-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
flex: none;
|
||||
transition: background .15s;
|
||||
}
|
||||
.open-btn:hover { background: #4338ca; }
|
||||
|
||||
/* Toolbar */
|
||||
.pdf-toolbar {
|
||||
background: #1e293b;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
flex: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pdf-toolbar button {
|
||||
background: rgba(255,255,255,.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background .15s;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pdf-toolbar button:hover { background: rgba(255,255,255,.2); }
|
||||
.pdf-toolbar button:disabled { opacity: .3; cursor: default; }
|
||||
|
||||
.page-info {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* PDF viewport */
|
||||
.pdf-viewport {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: #64748b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pdf-viewport canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,.25);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.pdf-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid rgba(255,255,255,.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.pdf-error {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
.pdf-error a {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
header { padding: 8px 10px; }
|
||||
.chip { font-size: 11px; padding: 2px 6px; }
|
||||
.info { gap: 3px 8px; }
|
||||
.pdf-toolbar { padding: 4px 8px; gap: 8px; }
|
||||
.pdf-toolbar button { width: 28px; height: 28px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">TerManager2</span>
|
||||
|
||||
<div class="info">
|
||||
<div class="chip">
|
||||
<span class="label">Territorio</span>
|
||||
<span class="value">N° {{ $assignment->territorio?->numero }}</span>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<span class="label">Assegnato a</span>
|
||||
<span class="value">{{ $assignment->proclamatore?->nome_completo ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<span class="label">Data</span>
|
||||
<span class="value">{{ $assignment->assigned_at->format('d/m/Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($showDownload)
|
||||
<a href="{{ $pdfUrl }}" download class="open-btn">
|
||||
<svg width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"/></svg>
|
||||
Scarica PDF
|
||||
</a>
|
||||
@endif
|
||||
</header>
|
||||
|
||||
<div class="pdf-toolbar">
|
||||
<button id="prevPage" title="Pagina precedente" disabled>‹</button>
|
||||
<span class="page-info" id="pageInfo">—</span>
|
||||
<button id="nextPage" title="Pagina successiva" disabled>›</button>
|
||||
<button id="zoomOut" title="Riduci">−</button>
|
||||
<button id="zoomIn" title="Ingrandisci">+</button>
|
||||
</div>
|
||||
|
||||
<div class="pdf-viewport" id="pdfViewport">
|
||||
<div class="pdf-loading" id="pdfLoading">
|
||||
<div class="spinner"></div>
|
||||
Caricamento PDF…
|
||||
</div>
|
||||
<div class="pdf-error" id="pdfError">
|
||||
<svg width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
|
||||
<p>Impossibile caricare il PDF.</p>
|
||||
@if($showDownload)
|
||||
<a href="{{ $pdfUrl }}" download>Scarica PDF</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs" type="module"></script>
|
||||
<script type="module">
|
||||
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs';
|
||||
|
||||
const url = @json($pdfUrl);
|
||||
const viewport = document.getElementById('pdfViewport');
|
||||
const loading = document.getElementById('pdfLoading');
|
||||
const error = document.getElementById('pdfError');
|
||||
const pageInfo = document.getElementById('pageInfo');
|
||||
const prevBtn = document.getElementById('prevPage');
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
const zoomIn = document.getElementById('zoomIn');
|
||||
const zoomOut = document.getElementById('zoomOut');
|
||||
|
||||
let pdfDoc = null;
|
||||
let scale = 1.5;
|
||||
let rendering = false;
|
||||
const canvases = [];
|
||||
|
||||
// Determine initial scale based on screen width
|
||||
if (window.innerWidth <= 480) {
|
||||
scale = 1.0;
|
||||
} else if (window.innerWidth <= 768) {
|
||||
scale = 1.2;
|
||||
}
|
||||
|
||||
async function renderPage(pageNum) {
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const vp = page.getViewport({ scale });
|
||||
|
||||
// Reuse or create canvas
|
||||
let canvas = canvases[pageNum - 1];
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvases[pageNum - 1] = canvas;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(vp.width * dpr);
|
||||
canvas.height = Math.floor(vp.height * dpr);
|
||||
canvas.style.width = Math.floor(vp.width) + 'px';
|
||||
canvas.style.height = Math.floor(vp.height) + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function renderAllPages() {
|
||||
if (rendering) return;
|
||||
rendering = true;
|
||||
|
||||
// Clear viewport
|
||||
viewport.querySelectorAll('canvas').forEach(c => c.remove());
|
||||
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const canvas = await renderPage(i);
|
||||
viewport.appendChild(canvas);
|
||||
}
|
||||
|
||||
pageInfo.textContent = pdfDoc.numPages + (pdfDoc.numPages === 1 ? ' pagina' : ' pagine');
|
||||
rendering = false;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
pdfDoc = await pdfjsLib.getDocument({ url, withCredentials: false }).promise;
|
||||
loading.style.display = 'none';
|
||||
|
||||
prevBtn.disabled = true;
|
||||
nextBtn.disabled = true;
|
||||
|
||||
// Render all pages as continuous scroll
|
||||
await renderAllPages();
|
||||
|
||||
zoomIn.disabled = false;
|
||||
zoomOut.disabled = false;
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err);
|
||||
loading.style.display = 'none';
|
||||
error.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
zoomIn.addEventListener('click', () => {
|
||||
scale = Math.min(scale + 0.25, 4);
|
||||
canvases.length = 0;
|
||||
renderAllPages();
|
||||
});
|
||||
|
||||
zoomOut.addEventListener('click', () => {
|
||||
scale = Math.max(scale - 0.25, 0.5);
|
||||
canvases.length = 0;
|
||||
renderAllPages();
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,11 +11,11 @@
|
||||
<body class="h-full">
|
||||
<div class="min-h-full">
|
||||
{{-- Header --}}
|
||||
<nav class="bg-indigo-700 shadow-lg">
|
||||
<nav class="header-gradient shadow-lg">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2"
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2 rounded-lg" style="background:rgba(255,255,255,0.1)"
|
||||
x-data x-on:click="$dispatch('toggle-sidebar')">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
@@ -23,22 +23,26 @@
|
||||
</button>
|
||||
@php $settings = \App\Models\Setting::first(); @endphp
|
||||
@if($settings && $settings->logo_path)
|
||||
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-8 w-8 rounded">
|
||||
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
|
||||
@else
|
||||
<div class="h-8 w-8 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-sm">T2</div>
|
||||
<div class="h-9 w-9 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm" style="background:rgba(255,255,255,0.2);backdrop-filter:blur(4px)">T2</div>
|
||||
@endif
|
||||
<span class="text-white font-semibold text-lg hidden sm:block">
|
||||
<span class="text-white font-semibold text-lg hidden sm:block" style="text-shadow:0 1px 2px rgba(0,0,0,0.1)">
|
||||
{{ $settings->congregazione_nome ?? 'TerManager2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-indigo-200 text-sm hidden sm:block">
|
||||
{{ 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,15 +80,23 @@
|
||||
|
||||
@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>
|
||||
@endcan
|
||||
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.assegna') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Assegnazioni
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
@can('campagne.manage')
|
||||
<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>
|
||||
@@ -92,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>
|
||||
@@ -100,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>
|
||||
@@ -114,6 +126,11 @@
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Impostazioni
|
||||
</a>
|
||||
<a href="{{ route('users.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('users.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Utenti
|
||||
</a>
|
||||
<a href="{{ route('zone.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
@@ -124,6 +141,11 @@
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Tipologie
|
||||
</a>
|
||||
<a href="{{ route('xml.exchange') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>
|
||||
Import
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
@@ -141,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,46 +1,175 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Assegna Territorio</h1>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna ai territori</a>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
<form wire:submit="save" class="space-y-4">
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
{{-- Toggle form --}}
|
||||
<button type="button"
|
||||
x-on:click="formOpen = !formOpen"
|
||||
class="mb-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800 transition">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path x-show="formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||
<path x-show="!formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
<span x-text="formOpen ? 'Nascondi form' : 'Nuova assegnazione'"></span>
|
||||
</button>
|
||||
|
||||
{{-- Form --}}
|
||||
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
|
||||
<form wire:submit="save" class="space-y-4">
|
||||
<div>
|
||||
@if(!$preselectedTerritorioId)
|
||||
<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>
|
||||
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
|
||||
<select wire:model="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
|
||||
<option value="">Seleziona un territorio</option>
|
||||
@foreach($territoriDisponibili as $t)
|
||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if($preselectedTerritorioId)
|
||||
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
|
||||
<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
|
||||
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
|
||||
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<option value="">Seleziona un proclamatore</option>
|
||||
@foreach($proclamatoriAttivi as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assigned_at" class="block text-sm font-medium text-gray-700">Data Assegnazione *</label>
|
||||
<input wire:model="assigned_at" type="date" id="assigned_at" max="{{ now()->format('Y-m-d') }}" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
|
||||
<a href="{{ route('territori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
|
||||
</div>
|
||||
</form>
|
||||
@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,21 +26,21 @@
|
||||
{{-- 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 ?? 'Sistema' }}</td>
|
||||
<td class="px-3 py-2 text-xs">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full
|
||||
{{ match($activity->description) {
|
||||
|
||||
@@ -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,138 +3,177 @@
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
@if($annoCorrente)
|
||||
<p class="text-sm text-gray-500">Anno Teocratico {{ $annoCorrente->label }} — {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Anno Teocratico {{ $annoCorrente->label }} — {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stats cards --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<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>
|
||||
|
||||
{{-- Quick lists --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Da assegnare --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-50 border-b border-green-100">
|
||||
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
|
||||
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
|
||||
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3>
|
||||
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
|
||||
</div>
|
||||
@hasanyrole('amministratore|assistente')
|
||||
<button wire:click="downloadPdfDaAssegnare" title="Scarica PDF" class="ml-auto btn-action btn-action-green" style="padding:6px 10px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</button>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($daAssegnare as $t)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
@forelse($territoriDaAssegnare as $t)
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<p class="text-xs text-gray-500">{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}</p>
|
||||
</div>
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna →</a>
|
||||
@endcan
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@if($daAssegnare->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Prioritari --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-amber-50 border-b border-amber-100">
|
||||
<h3 class="text-sm font-semibold text-amber-800">★ Prioritari</h3>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($prioritari as $t)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
<div>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
||||
@if($t->home_is_prioritario)
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $t->zona?->nome }}
|
||||
@if($t->ultimaAssegnazione?->returned_at)
|
||||
— ultimo rientro {{ $t->ultimaAssegnazione->returned_at->diffForHumans() }}
|
||||
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
||||
@if($t->home_giorni_giacenza > 0)
|
||||
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-amber-600">
|
||||
{{ $t->prioritario ? 'Man' : 'Auto' }}
|
||||
</span>
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna →</a>
|
||||
@endcan
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio prioritario</li>
|
||||
<li class="empty-state">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-sm">Tutti i territori sono assegnati</span>
|
||||
</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@if($prioritari->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=prioritari" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
@if($territoriDaAssegnare->count() >= $homeLimit)
|
||||
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
|
||||
<a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Da rientrare --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-red-50 border-b border-red-100">
|
||||
<h3 class="text-sm font-semibold text-red-800">Da Rientrare</h3>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
|
||||
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
|
||||
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444">
|
||||
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3>
|
||||
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
|
||||
</div>
|
||||
@hasanyrole('amministratore|assistente')
|
||||
<button wire:click="downloadPdfDaRientrare" title="Scarica PDF" class="ml-auto btn-action btn-action-red" style="padding:6px 10px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</button>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($daRientrare as $t)
|
||||
@php($assegnazioneCorrente = $t->assegnazioneCorrente)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600">N° {{ $t->numero }}</a>
|
||||
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
|
||||
— {{ $assegnazioneCorrente?->giorni }} giorni
|
||||
— <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
|
||||
</p>
|
||||
</div>
|
||||
@can('territori.return')
|
||||
@if($assegnazioneCorrente)
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="text-xs font-medium text-red-600 hover:text-red-800">Rientra →</a>
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">Rientra →</a>
|
||||
@endif
|
||||
@endcan
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li>
|
||||
<li class="empty-state">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-sm">Nessun territorio da rientrare</span>
|
||||
</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@if($daRientrare->count() >= 10)
|
||||
<div class="px-4 py-2 bg-gray-50 border-t text-center">
|
||||
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti →</a>
|
||||
@if($daRientrare->count() >= $homeLimit)
|
||||
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
|
||||
<a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Cestino Proclamatori</h1>
|
||||
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome Completo</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Nome Completo</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@forelse($proclamatori as $proclamatore)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $proclamatore->nome_completo }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<button wire:click="restore({{ $proclamatore->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina definitivamente</button>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button wire:click="restore({{ $proclamatore->id }})" class="btn-action btn-action-green">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
|
||||
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
|
||||
@can('proclamatori.manage')
|
||||
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
|
||||
+ Nuovo Proclamatore
|
||||
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Nuovo Proclamatore
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
@@ -28,23 +29,23 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
|
||||
Cognome @if($sortField==='cognome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
|
||||
Cognome @if($sortField==='cognome') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</th>
|
||||
<th wire:click="sortBy('nome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
|
||||
Nome @if($sortField==='nome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
|
||||
<th wire:click="sortBy('nome')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
|
||||
Nome @if($sortField==='nome') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Territori Assegnati</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Territori Assegnati</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($proclamatori as $proclamatore)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
@@ -55,20 +56,37 @@
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a>
|
||||
@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>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
|
||||
@can('settings.manage')
|
||||
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Nuova voce
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
@@ -36,28 +42,43 @@
|
||||
{{-- 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
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($assegnazioni as $a)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
@php($temporaryPdfUrl = !$a->returned_at ? $a->shortPdfUrl() : null)
|
||||
<td class="px-3 py-2">
|
||||
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium">N° {{ $a->territorio?->numero }}</a>
|
||||
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($a->territorio?->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
|
||||
alt="Thumbnail territorio {{ $a->territorio?->numero }}"
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium">N° {{ $a->territorio?->numero }}</a>
|
||||
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
@@ -77,10 +98,32 @@
|
||||
<span class="text-gray-300">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@can('settings.manage')
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<div class="flex items-center gap-1">
|
||||
@if($temporaryPdfUrl)
|
||||
<a href="{{ $temporaryPdfUrl }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-action btn-action-indigo" title="PDF">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
<button wire:click="openEdit({{ $a->id }})"
|
||||
class="btn-action btn-action-gray" title="Modifica">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button wire:click="askDelete({{ $a->id }})"
|
||||
class="btn-action btn-action-red" title="Elimina">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@endcan
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
@@ -90,4 +133,124 @@
|
||||
{{ $assegnazioni->links() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── Modal Crea / Modifica (solo admin) ────────────────── --}}
|
||||
@can('settings.manage')
|
||||
@if($showModal)
|
||||
<div style="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;">
|
||||
<div style="background:#fff;border-radius:12px;width:100%;max-width:560px;max-height:90vh;overflow-y:auto;padding:24px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827;margin-bottom:20px;">
|
||||
{{ $editingId ? 'Modifica voce' : 'Nuova voce' }}
|
||||
</h2>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
||||
{{-- Territorio --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
|
||||
<select wire:model="form_territorio_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
<option value="">-- seleziona --</option>
|
||||
@foreach($territori as $t)
|
||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Proclamatore --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
|
||||
<select wire:model="form_proclamatore_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
<option value="">-- seleziona --</option>
|
||||
@foreach($proclamatori as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Anno teocratico --}}
|
||||
<div>
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Anno teocratico *</label>
|
||||
<select wire:model="form_anno_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
<option value="">-- seleziona --</option>
|
||||
@foreach($anni as $anno)
|
||||
<option value="{{ $anno->id }}">{{ $anno->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('form_anno_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Campagna --}}
|
||||
<div>
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label>
|
||||
<select wire:model="form_campaign_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
<option value="">Nessuna</option>
|
||||
@foreach($campagne as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->descrizione }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('form_campaign_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Data assegnazione --}}
|
||||
<div>
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Data assegnazione *</label>
|
||||
<input type="date" wire:model="form_assigned_at" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
@error('form_assigned_at') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Data rientro --}}
|
||||
<div>
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Data rientro</label>
|
||||
<input type="date" wire:model="form_returned_at" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
|
||||
@error('form_returned_at') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Contata in campagna --}}
|
||||
<div style="grid-column:1/-1;display:flex;align-items:center;gap:8px;">
|
||||
<input type="checkbox" wire:model="form_counted_in_campaign" id="ccb" style="width:16px;height:16px;cursor:pointer;">
|
||||
<label for="ccb" style="font-size:14px;color:#374151;cursor:pointer;">Contata in campagna</label>
|
||||
</div>
|
||||
|
||||
{{-- Note --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Note</label>
|
||||
<textarea wire:model="form_note" rows="2" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;resize:vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
|
||||
<button wire:click="$set('showModal', false)"
|
||||
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
|
||||
Annulla
|
||||
</button>
|
||||
<button wire:click="save"
|
||||
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
|
||||
{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ─── Conferma eliminazione (solo admin) ─────────────────── --}}
|
||||
@if($showDeleteConfirm)
|
||||
<div style="position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;">
|
||||
<div style="background:#fff;border-radius:12px;width:100%;max-width:360px;padding:24px;">
|
||||
<h2 style="font-size:17px;font-weight:700;color:#111827;margin-bottom:10px;">Elimina voce</h2>
|
||||
<p style="font-size:14px;color:#6b7280;margin-bottom:20px;">Sei sicuro di voler eliminare questa assegnazione? L'operazione non può essere annullata.</p>
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;">
|
||||
<button wire:click="$set('showDeleteConfirm', false)"
|
||||
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
|
||||
Annulla
|
||||
</button>
|
||||
<button wire:click="deleteConfirmed"
|
||||
style="background:#dc2626;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
<h1 class="text-2xl font-bold text-gray-900">Impostazioni</h1>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
|
||||
<p class="text-sm font-medium text-gray-700 mb-3">Sezione Import</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
|
||||
Apri strumenti di import
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
@if (session()->has('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms
|
||||
@@ -46,6 +56,21 @@
|
||||
@error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assignment_link_ttl_months" class="block text-sm font-medium text-gray-700">Validità link PDF assegnazione (mesi)</label>
|
||||
<p class="text-xs text-gray-500 mb-1">Durata del link temporaneo condivisibile per il PDF dell'assegnazione attiva.</p>
|
||||
<input wire:model="assignment_link_ttl_months" type="number" min="1" max="24" id="assignment_link_ttl_months" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('assignment_link_ttl_months') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input wire:model="pdf_viewer_show_download" type="checkbox" value="1" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Mostra pulsante "Scarica PDF" nel viewer link temporaneo
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">Se disattivato, il proclamatore potrà solo visualizzare il PDF senza scaricarlo.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label>
|
||||
<p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p>
|
||||
|
||||
149
resources/views/livewire/settings/users-index.blade.php
Normal file
149
resources/views/livewire/settings/users-index.blade.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Utenti</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p>
|
||||
</div>
|
||||
|
||||
@if (session()->has('success'))
|
||||
<div class="rounded-lg bg-green-50 p-3 text-sm text-green-700 border border-green-200">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2>
|
||||
|
||||
<form wire:submit="createUser" class="space-y-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nome *</label>
|
||||
<input wire:model="name" id="name" type="text" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email *</label>
|
||||
<input wire:model="email" id="email" type="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password *</label>
|
||||
<input wire:model="password" id="password" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Conferma Password *</label>
|
||||
<input wire:model="password_confirmation" id="password_confirmation" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="selectedRole" class="block text-sm font-medium text-gray-700">Ruolo *</label>
|
||||
<select wire:model="selectedRole" id="selectedRole" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@foreach($availableRoles as $role)
|
||||
<option value="{{ $role }}">{{ ucfirst($role) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('selectedRole') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Crea Utente</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600">Nome</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600">Email</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600">Ruoli</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($users as $user)
|
||||
<tr>
|
||||
@if($editingUserId === $user->id)
|
||||
<td class="px-4 py-2 align-top" colspan="4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Nome</label>
|
||||
<input wire:model="editName" type="text" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('editName') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Email</label>
|
||||
<input wire:model="editEmail" type="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('editEmail') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Nuova password (opzionale)</label>
|
||||
<input wire:model="editPassword" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('editPassword') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Conferma password</label>
|
||||
<input wire:model="editPassword_confirmation" type="password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600">Ruolo</label>
|
||||
<select wire:model="editSelectedRole" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@foreach($availableRoles as $role)
|
||||
<option value="{{ $role }}">{{ ucfirst($role) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('editSelectedRole') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button wire:click="updateUser" type="button" class="px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">Salva</button>
|
||||
<button wire:click="cancelEdit" type="button" class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">Annulla</button>
|
||||
</div>
|
||||
</td>
|
||||
@else
|
||||
<td class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
|
||||
<td class="px-4 py-2 text-gray-700">{{ $user->email }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@forelse($user->roles as $role)
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">{{ $role->name }}</span>
|
||||
@empty
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endforelse
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex gap-2">
|
||||
<button wire:click="startEdit({{ $user->id }})" type="button" class="px-2 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 rounded hover:bg-indigo-100">Modifica</button>
|
||||
<button
|
||||
wire:click="deleteUser({{ $user->id }})"
|
||||
onclick="if(!confirm('Confermi la cancellazione dell\'utente? I log verranno preservati.')) event.stopImmediatePropagation();"
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium text-red-700 bg-red-50 rounded hover:bg-red-100"
|
||||
>
|
||||
Cancella
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-6 text-center text-gray-400">Nessun utente trovato</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
358
resources/views/livewire/settings/xml-exchange.blade.php
Normal file
358
resources/views/livewire/settings/xml-exchange.blade.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Import</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Centro importazioni: PDF territori, conversione legacy SQL, import XML ed export XML.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
|
||||
x-data="{
|
||||
uploadLog: '',
|
||||
uploadProgress: 0,
|
||||
uploading: false,
|
||||
zipUploading: false,
|
||||
zipProgress: 0,
|
||||
selectedFiles: 0,
|
||||
selectedZip: '',
|
||||
append(message) {
|
||||
this.uploadLog = this.uploadLog ? this.uploadLog + '\n' + message : message;
|
||||
},
|
||||
submitZip(event) {
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
this.uploadLog = '';
|
||||
this.zipUploading = true;
|
||||
this.zipProgress = 0;
|
||||
this.append('Upload ZIP diretto al server avviato...');
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', form.action);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
xhr.upload.addEventListener('progress', (uploadEvent) => {
|
||||
if (!uploadEvent.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
this.zipUploading = false;
|
||||
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText || '{}');
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
this.zipProgress = 100;
|
||||
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
|
||||
window.location = payload.redirect_url || window.location.href;
|
||||
return;
|
||||
}
|
||||
|
||||
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
|
||||
this.append(message);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
this.zipUploading = false;
|
||||
this.append('Errore di rete durante il caricamento dello ZIP.');
|
||||
});
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
}"
|
||||
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { uploading = true; uploadProgress = 0; append('Upload cartella avviato...'); }"
|
||||
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { uploadProgress = $event.detail.progress; append('Upload Livewire in corso: ' + $event.detail.progress + '%'); }"
|
||||
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { uploading = false; uploadProgress = 100; append('Upload completato. Avvio preparazione import lato server...'); }"
|
||||
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { uploading = false; append('Errore durante l\'upload temporaneo dei file.'); }">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Import PDF territori</h2>
|
||||
<p class="text-xs text-gray-500">Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.</p>
|
||||
|
||||
<form wire:submit.prevent="importTerritoryPdfFolder" class="space-y-4">
|
||||
<div>
|
||||
<input wire:model="pdfFolder"
|
||||
x-on:change="selectedFiles = $event.target.files.length; uploadLog = ''; if (selectedFiles > 0) { append('Cartella selezionata: ' + selectedFiles + ' file.'); append('In attesa dell\'upload temporaneo Livewire...'); }"
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory
|
||||
directory
|
||||
accept=".pdf,application/pdf"
|
||||
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
@error('pdfFolder') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
@error('pdfFolder.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div x-show="selectedFiles > 0 || uploading" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
|
||||
<span x-text="uploading ? 'Upload file in corso...' : 'Upload file pronto'"></span>
|
||||
<span x-text="selectedFiles > 0 ? selectedFiles + ' file selezionati' : ''"></span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
|
||||
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width:' + uploadProgress + '%' "></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
|
||||
wire:loading.attr="disabled"
|
||||
@disabled(empty($pdfFolder))
|
||||
>
|
||||
Importa PDF territori
|
||||
</button>
|
||||
<div wire:loading wire:target="importTerritoryPdfFolder" class="text-sm text-indigo-700">Preparazione import in corso...</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Import da archivio ZIP</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.</p>
|
||||
|
||||
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
|
||||
@csrf
|
||||
<div>
|
||||
<input name="pdfZip"
|
||||
x-on:change="selectedZip = $event.target.files[0] ? $event.target.files[0].name : ''; uploadLog = ''; zipProgress = 0; if (selectedZip) { append('Archivio ZIP selezionato: ' + selectedZip); append('Pronto per upload diretto al server.'); }"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
|
||||
@error('pdfZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div x-show="selectedZip || zipUploading" x-cloak class="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-emerald-800">
|
||||
<span x-text="zipUploading ? 'Upload ZIP in corso...' : 'ZIP pronto per l\'invio' "></span>
|
||||
<span x-text="selectedZip"></span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-emerald-100">
|
||||
<div class="h-full rounded-full bg-emerald-600 transition-all duration-300" :style="'width:' + zipProgress + '%' "></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
|
||||
x-bind:disabled="!selectedZip || zipUploading"
|
||||
>
|
||||
Importa ZIP PDF
|
||||
</button>
|
||||
<div x-show="zipUploading" x-cloak class="text-sm text-emerald-700">Caricamento ZIP diretto in corso...</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div x-show="uploadLog || uploading || zipUploading || selectedZip" x-cloak>
|
||||
<div class="mb-2 text-sm font-medium text-gray-800">Console upload</div>
|
||||
<textarea readonly rows="8" x-bind:value="uploadLog" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
|
||||
</div>
|
||||
|
||||
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs)))
|
||||
<div class="rounded-xl border border-indigo-200 bg-indigo-50/40 p-4" @if(in_array($pdfImportStatus, ['queued', 'running'], true)) wire:poll.1000ms="refreshPdfImportStatus" @endif>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">Stato import PDF</div>
|
||||
<div class="mt-1 text-xs text-gray-500">ID import: {{ $currentPdfImportId }}</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
|
||||
{{ match($pdfImportStatus) {
|
||||
'queued' => 'bg-amber-100 text-amber-800',
|
||||
'running' => 'bg-blue-100 text-blue-800',
|
||||
'completed' => 'bg-green-100 text-green-800',
|
||||
'failed' => 'bg-red-100 text-red-800',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
} }}">
|
||||
{{ match($pdfImportStatus) {
|
||||
'queued' => 'In coda',
|
||||
'running' => 'In esecuzione',
|
||||
'completed' => 'Completato',
|
||||
'failed' => 'Fallito',
|
||||
default => 'Inattivo',
|
||||
} }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(!empty($pdfImportStats))
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
|
||||
<div class="text-xs text-gray-500">Processati</div>
|
||||
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
|
||||
<div class="text-xs text-green-700">Aggiornati</div>
|
||||
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
|
||||
<div class="text-xs text-amber-700">Saltati</div>
|
||||
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
|
||||
<div class="text-xs text-red-700">Errori</div>
|
||||
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-gray-800">Log import PDF</div>
|
||||
<button type="button" wire:click="refreshPdfImportStatus" class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-white transition">
|
||||
Aggiorna log
|
||||
</button>
|
||||
</div>
|
||||
<textarea readonly rows="12" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 sm:rows-14">{{ $pdfImportLogText }}</textarea>
|
||||
</div>
|
||||
|
||||
@if(!empty($pdfImportIssues))
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 text-sm font-medium text-gray-800">Riepilogo file non associati o problematici</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($pdfImportIssues as $issue)
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
|
||||
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
|
||||
|
||||
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div>
|
||||
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
|
||||
>
|
||||
Converti in XML
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
|
||||
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
|
||||
|
||||
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
|
||||
@csrf
|
||||
<div>
|
||||
<input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
|
||||
>
|
||||
Importa XML
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(!empty($importStats))
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
|
||||
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
|
||||
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
|
||||
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
|
||||
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
|
||||
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
|
||||
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
|
||||
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
|
||||
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button
|
||||
wire:click="downloadImportLogPdf"
|
||||
type="button"
|
||||
style="display:inline-flex;align-items:center;gap:6px;background:#1d4ed8;color:#fff;border:1px solid #1e3a8a;border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;"
|
||||
>
|
||||
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
|
||||
Scarica log PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if(!empty($importIssues))
|
||||
<div class="mt-2" style="max-height:260px;overflow:auto;border:1px solid #e5e7eb;border-radius:10px;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<thead style="background:#f9fafb;position:sticky;top:0;">
|
||||
<tr>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Entità</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Legacy ID</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Motivo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($importIssues as $issue)
|
||||
<tr>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Export XML</h2>
|
||||
<p class="text-xs text-gray-500">Esporta i dati correnti dell'app in XML.</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
wire:click="exportCurrentAsXml"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
|
||||
>
|
||||
Esporta XML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,26 +1,31 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Cestino Territori</h1>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">N°</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">N°</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Eliminato il</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@forelse($territori as $territorio)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $territorio->numero }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-2">
|
||||
<button wire:click="restore({{ $territorio->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina definitivamente</button>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button wire:click="restore({{ $territorio->id }})" class="btn-action btn-action-green">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
|
||||
<form wire:submit="save" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 max-w-2xl">
|
||||
|
||||
@@ -5,15 +5,25 @@
|
||||
<p class="text-sm text-gray-500 mt-1">Gestione dei territori della congregazione</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-2">
|
||||
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cestino</a>
|
||||
<a href="{{ route('territori.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">+ Nuovo territorio</a>
|
||||
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
|
||||
<svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Cestino
|
||||
</a>
|
||||
<a href="{{ route('territori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Nuovo territorio
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero..."
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg>
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Filtri</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-3">
|
||||
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero, zona, tipologia, note..."
|
||||
class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<select wire:model.live="filterZona" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Tutte le zone</option>
|
||||
@@ -30,34 +40,78 @@
|
||||
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="in_reparto">In reparto</option>
|
||||
<option value="prioritari">Prioritari</option>
|
||||
<option value="assegnato">Assegnato</option>
|
||||
<option value="da_rientrare">Da rientrare</option>
|
||||
<option value="inattivo">Inattivo</option>
|
||||
</select>
|
||||
<select wire:model.live="filterPriorita" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Tutte le priorita</option>
|
||||
<option value="prioritari">Solo prioritari</option>
|
||||
<option value="manuali">Prioritari manuali</option>
|
||||
<option value="automatici">Prioritari automatici</option>
|
||||
<option value="non_prioritari">Solo non prioritari</option>
|
||||
</select>
|
||||
<select wire:model.live="filterPdf" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">PDF e thumbnail</option>
|
||||
<option value="con_pdf">Con PDF</option>
|
||||
<option value="senza_pdf">Senza PDF</option>
|
||||
<option value="con_thumbnail">Con thumbnail</option>
|
||||
<option value="senza_thumbnail">Senza thumbnail</option>
|
||||
</select>
|
||||
<select wire:model.live="filterContenuti" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Note e confini</option>
|
||||
<option value="con_note">Con note</option>
|
||||
<option value="senza_note">Senza note</option>
|
||||
<option value="con_confini">Con confini</option>
|
||||
<option value="senza_confini">Senza confini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-gray-500">
|
||||
@if($usesPriorityOrdering)
|
||||
<span style="color:#6366f1">⬆</span> Ordinamento attivo: prioritari prima, poi territori con piu tempo in reparto.
|
||||
@else
|
||||
Ordinamento predefinito: numero territorio dal piu piccolo al piu grande.
|
||||
@endif
|
||||
</p>
|
||||
<button wire:click="clearFilters"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Azzera filtri
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('numero')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
|
||||
N° @if($sortField === 'numero') <span>{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif
|
||||
<th wire:click="sortBy('numero')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
|
||||
N° @if($sortField === 'numero') <span style="color:#6366f1">{{ $sortDirection === 'asc' ? '▲' : '▼' }}</span> @endif
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zona</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tipologia</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Assegnatario</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Zona</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Tipologia</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Stato</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Assegnatario</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($territori as $territorio)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600">{{ $territorio->numero }}</a>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($territorio->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
|
||||
alt="Thumbnail territorio {{ $territorio->numero }}"
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
|
||||
@endif
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600 transition-colors">{{ $territorio->numero }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td>
|
||||
@@ -74,27 +128,62 @@
|
||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
||||
</span>
|
||||
@if($territorio->is_prioritario)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1"
|
||||
title="{{ $territorio->prioritario ? 'Prioritario (manuale)' : 'Prioritario (giacenza)' }}">
|
||||
★ {{ $territorio->prioritario ? 'Man.' : 'Auto' }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
|
||||
★ Prioritario
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ $territorio->assegnatario?->nome_completo ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right space-x-1">
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium">Dettaglio</a>
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="text-gray-600 hover:text-gray-800 text-xs font-medium">Modifica</a>
|
||||
<button wire:click="toggleActive({{ $territorio->id }})" class="text-xs font-medium {{ $territorio->attivo ? 'text-amber-600 hover:text-amber-800' : 'text-green-600 hover:text-green-800' }}">
|
||||
{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}
|
||||
</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,10 +1,34 @@
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
|
||||
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna alla lista</a>
|
||||
</div>
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@can('territori.assign')
|
||||
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
|
||||
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Assegna
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
@can('territori.return')
|
||||
@if($territorio->assegnazioneCorrente)
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
|
||||
Rientra
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Modifica
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info card --}}
|
||||
@@ -56,6 +80,48 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($activeAssignment)
|
||||
@php($temporaryPdfUrl = $activeAssignment->shortPdfUrl())
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" x-data="{ copied: false }">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Assegnazione attiva</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ $activeAssignment->proclamatore?->nome_completo ?? 'N/A' }}
|
||||
— assegnato il {{ $activeAssignment->assigned_at->format('d/m/Y') }}
|
||||
— {{ $activeAssignment->giorni }} giorni
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@can('territori.return')
|
||||
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id]) }}" class="inline-flex items-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">Rientra</a>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($temporaryPdfUrl)
|
||||
<div class="mt-4 rounded-xl border border-indigo-100 bg-indigo-50/70 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-indigo-900">Link PDF temporaneo</div>
|
||||
<div class="mt-1 text-xs text-indigo-700">Valido per {{ $assignmentLinkTtlMonths }} {{ $assignmentLinkTtlMonths === 1 ? 'mese' : 'mesi' }} o fino al rientro del territorio.</div>
|
||||
</div>
|
||||
<a href="{{ $temporaryPdfUrl }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition">Apri viewer</a>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||
<input x-ref="assignmentPdfLink" type="text" readonly value="{{ $temporaryPdfUrl }}" class="block w-full rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<button type="button" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">Copia link</button>
|
||||
</div>
|
||||
<p x-show="copied" x-cloak class="mt-2 text-xs font-medium text-green-700">Link copiato.</p>
|
||||
</div>
|
||||
@elseif($territorio->pdf_path)
|
||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Il link PDF temporaneo è disponibile solo per assegnazioni attive con PDF associato.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- PDF viewer --}}
|
||||
@if($territorio->pdf_path)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
@@ -72,7 +138,12 @@
|
||||
|
||||
{{-- Assignment history --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#eef2ff">
|
||||
<svg class="h-4 w-4" style="color:#6366f1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
|
||||
</div>
|
||||
|
||||
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||
<div class="mb-6">
|
||||
|
||||
66
resources/views/pdf/import-log.blade.php
Normal file
66
resources/views/pdf/import-log.blade.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Log Import XML</title>
|
||||
<style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111; margin: 20px; }
|
||||
h1 { font-size: 17px; margin-bottom: 4px; }
|
||||
.meta { font-size: 11px; color: #555; margin-bottom: 16px; }
|
||||
.stats-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.stat { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; min-width: 180px; }
|
||||
.stat-label { font-size: 11px; color: #6b7280; }
|
||||
.stat-value { font-size: 14px; font-weight: bold; color: #1f2937; }
|
||||
h2 { font-size: 14px; margin-top: 20px; margin-bottom: 8px; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||
thead tr { background: #f9fafb; }
|
||||
th { text-align: left; padding: 7px 8px; border-bottom: 1px solid #d1d5db; font-size: 11px; color: #374151; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
.badge-err { color: #b91c1c; font-weight: bold; }
|
||||
.footer { margin-top: 30px; font-size: 10px; color: #9ca3af; text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Log Import XML — TerManager2</h1>
|
||||
<div class="meta">Generato il {{ $generatedAt }}</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat"><div class="stat-label">Zone importate</div><div class="stat-value">{{ $stats['zone_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Tipologie importate</div><div class="stat-value">{{ $stats['tipologie_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Proclamatori importati</div><div class="stat-value">{{ $stats['proclamatori_importati'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Territori importati</div><div class="stat-value">{{ $stats['territori_importati'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Anni teocratici</div><div class="stat-value">{{ $stats['anni_importati'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Campagne importate</div><div class="stat-value">{{ $stats['campagne_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Assegnazioni importate</div><div class="stat-value">{{ $stats['assegnazioni_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Territori duplicati saltati</div><div class="stat-value badge-err">{{ $stats['duplicate_territori'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Assegnazioni saltate</div><div class="stat-value badge-err">{{ $stats['assegnazioni_saltate'] ?? 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
@if(!empty($issues))
|
||||
<h2>Dettaglio righe non importate</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:18%">Entità</th>
|
||||
<th style="width:14%">Legacy ID</th>
|
||||
<th>Motivo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($issues as $issue)
|
||||
<tr>
|
||||
<td>{{ $issue['entity'] }}</td>
|
||||
<td>{{ $issue['legacy_id'] }}</td>
|
||||
<td>{{ $issue['reason'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<p style="color:#16a34a;margin-top:12px;">✓ Nessun elemento saltato durante l'importazione.</p>
|
||||
@endif
|
||||
|
||||
<div class="footer">TerManager2 — Export generato automaticamente</div>
|
||||
</body>
|
||||
</html>
|
||||
102
resources/views/pdf/territori-lista.blade.php
Normal file
102
resources/views/pdf/territori-lista.blade.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $titolo }}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 11px; color: #1f2937; padding: 20px; }
|
||||
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #6366f1; padding-bottom: 12px; }
|
||||
.header h1 { font-size: 18px; color: #1e1b4b; margin-bottom: 4px; }
|
||||
.header .sub { font-size: 10px; color: #6b7280; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th { background: #eef2ff; color: #4338ca; font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 6px; text-align: left; border-bottom: 2px solid #c7d2fe; }
|
||||
td { padding: 7px 6px; border-bottom: 1px solid #e5e7eb; font-size: 10px; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
.prioritario { background: #fef3c7; color: #92400e; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
|
||||
.badge-green { background: #dcfce7; color: #166534; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; }
|
||||
.footer { margin-top: 20px; padding-top: 10px; border-top: 1px solid #e5e7eb; text-align: center; font-size: 9px; color: #9ca3af; }
|
||||
.total { margin-top: 12px; text-align: right; font-size: 11px; font-weight: bold; color: #4338ca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{ $titolo }}</h1>
|
||||
<div class="sub">{{ $congregazione }} — {{ $data }}</div>
|
||||
</div>
|
||||
|
||||
@if($tipo === 'assegnare')
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:10%">N°</th>
|
||||
<th style="width:20%">Zona</th>
|
||||
<th style="width:20%">Tipologia</th>
|
||||
<th style="width:20%">Giorni in reparto</th>
|
||||
<th style="width:15%">Priorità</th>
|
||||
<th style="width:15%">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($territori as $t)
|
||||
<tr>
|
||||
<td style="font-weight:bold">{{ $t->numero }}</td>
|
||||
<td>{{ $t->zona?->nome ?? '-' }}</td>
|
||||
<td>{{ $t->tipologia?->nome ?? '-' }}</td>
|
||||
<td>{{ $t->home_giorni_giacenza }} gg</td>
|
||||
<td>
|
||||
@if($t->home_is_prioritario)
|
||||
<span class="prioritario">★ Prioritario</span>
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td style="font-size:9px;color:#6b7280">{{ \Illuminate\Support\Str::limit($t->note, 40) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:20px">Nessun territorio da assegnare</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="total">Totale: {{ $territori->count() }} territori</div>
|
||||
@endif
|
||||
|
||||
@if($tipo === 'rientrare')
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:10%">N°</th>
|
||||
<th style="width:20%">Zona</th>
|
||||
<th style="width:25%">Assegnatario</th>
|
||||
<th style="width:15%">Assegnato il</th>
|
||||
<th style="width:15%">Giorni</th>
|
||||
<th style="width:15%">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($territori as $t)
|
||||
@php($a = $t->assegnazioneCorrente)
|
||||
<tr>
|
||||
<td style="font-weight:bold">{{ $t->numero }}</td>
|
||||
<td>{{ $t->zona?->nome ?? '-' }}</td>
|
||||
<td>{{ $a?->proclamatore?->nome_completo ?? '-' }}</td>
|
||||
<td>{{ $a?->assigned_at?->format('d/m/Y') }}</td>
|
||||
<td>
|
||||
<span class="badge-red">{{ $a?->giorni ?? '-' }} gg</span>
|
||||
</td>
|
||||
<td style="font-size:9px;color:#6b7280">{{ \Illuminate\Support\Str::limit($t->note, 40) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:20px">Nessun territorio da rientrare</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="total">Totale: {{ $territori->count() }} territori</div>
|
||||
@endif
|
||||
|
||||
<div class="footer">
|
||||
Generato da TerManager2 il {{ $data }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
resources/views/vendor/pagination/bootstrap-4.blade.php
vendored
Normal file
46
resources/views/vendor/pagination/bootstrap-4.blade.php
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
|
||||
<span class="page-link" aria-hidden="true">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
|
||||
@else
|
||||
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span class="page-link" aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
88
resources/views/vendor/pagination/bootstrap-5.blade.php
vendored
Normal file
88
resources/views/vendor/pagination/bootstrap-5.blade.php
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav class="d-flex justify-items-center justify-content-between">
|
||||
<div class="d-flex justify-content-between flex-fill d-sm-none">
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">@lang('pagination.previous')</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">@lang('pagination.next')</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-none flex-sm-fill d-sm-flex align-items-sm-center justify-content-sm-between">
|
||||
<div>
|
||||
<p class="small text-muted">
|
||||
{!! __('Showing') !!}
|
||||
<span class="fw-semibold">{{ $paginator->firstItem() }}</span>
|
||||
{!! __('to') !!}
|
||||
<span class="fw-semibold">{{ $paginator->lastItem() }}</span>
|
||||
{!! __('of') !!}
|
||||
<span class="fw-semibold">{{ $paginator->total() }}</span>
|
||||
{!! __('results') !!}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
|
||||
<span class="page-link" aria-hidden="true">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
|
||||
@else
|
||||
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span class="page-link" aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
46
resources/views/vendor/pagination/default.blade.php
vendored
Normal file
46
resources/views/vendor/pagination/default.blade.php
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
|
||||
<span aria-hidden="true">‹</span>
|
||||
</li>
|
||||
@else
|
||||
<li>
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">‹</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<li class="disabled" aria-disabled="true"><span>{{ $element }}</span></li>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<li class="active" aria-current="page"><span>{{ $page }}</span></li>
|
||||
@else
|
||||
<li><a href="{{ $url }}">{{ $page }}</a></li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li>
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">›</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
|
||||
<span aria-hidden="true">›</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
36
resources/views/vendor/pagination/semantic-ui.blade.php
vendored
Normal file
36
resources/views/vendor/pagination/semantic-ui.blade.php
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
@if ($paginator->hasPages())
|
||||
<div class="ui pagination menu" role="navigation">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
|
||||
@else
|
||||
<a class="icon item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
|
||||
@endif
|
||||
|
||||
{{-- Pagination Elements --}}
|
||||
@foreach ($elements as $element)
|
||||
{{-- "Three Dots" Separator --}}
|
||||
@if (is_string($element))
|
||||
<a class="icon item disabled" aria-disabled="true">{{ $element }}</a>
|
||||
@endif
|
||||
|
||||
{{-- Array Of Links --}}
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<a class="item active" href="{{ $url }}" aria-current="page">{{ $page }}</a>
|
||||
@else
|
||||
<a class="item" href="{{ $url }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a class="icon item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
|
||||
@else
|
||||
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
27
resources/views/vendor/pagination/simple-bootstrap-4.blade.php
vendored
Normal file
27
resources/views/vendor/pagination/simple-bootstrap-4.blade.php
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">@lang('pagination.previous')</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">@lang('pagination.next')</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
29
resources/views/vendor/pagination/simple-bootstrap-5.blade.php
vendored
Normal file
29
resources/views/vendor/pagination/simple-bootstrap-5.blade.php
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="Pagination Navigation">
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">{!! __('pagination.previous') !!}</span>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">
|
||||
{!! __('pagination.previous') !!}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">{!! __('pagination.next') !!}</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="page-item disabled" aria-disabled="true">
|
||||
<span class="page-link">{!! __('pagination.next') !!}</span>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
19
resources/views/vendor/pagination/simple-default.blade.php
vendored
Normal file
19
resources/views/vendor/pagination/simple-default.blade.php
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<li class="disabled" aria-disabled="true"><span>@lang('pagination.previous')</span></li>
|
||||
@else
|
||||
<li><a href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a></li>
|
||||
@endif
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<li><a href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a></li>
|
||||
@else
|
||||
<li class="disabled" aria-disabled="true"><span>@lang('pagination.next')</span></li>
|
||||
@endif
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
25
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal file
25
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between">
|
||||
{{-- Previous Page Link --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
|
||||
{!! __('pagination.previous') !!}
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
|
||||
{!! __('pagination.previous') !!}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Next Page Link --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
|
||||
{!! __('pagination.next') !!}
|
||||
</a>
|
||||
@else
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
|
||||
{!! __('pagination.next') !!}
|
||||
</span>
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
131
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
131
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="Navigazione pagine"
|
||||
class="flex flex-col sm:flex-row items-center justify-between gap-4 py-2">
|
||||
|
||||
{{-- Info testo --}}
|
||||
<p class="text-sm text-gray-500 order-2 sm:order-1 shrink-0">
|
||||
@if ($paginator->firstItem())
|
||||
Risultati <span class="font-semibold text-gray-700">{{ $paginator->firstItem() }}–{{ $paginator->lastItem() }}</span>
|
||||
di <span class="font-semibold text-gray-700">{{ $paginator->total() }}</span>
|
||||
@else
|
||||
{{ $paginator->count() }} risultati
|
||||
@endif
|
||||
</p>
|
||||
|
||||
{{-- Controlli --}}
|
||||
<div class="flex items-center gap-1.5 order-1 sm:order-2">
|
||||
|
||||
{{-- Prev --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="Pagina precedente">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Numeri pagina (solo desktop) --}}
|
||||
<div class="hidden sm:flex items-center gap-1.5">
|
||||
@foreach ($elements as $element)
|
||||
@if (is_string($element))
|
||||
<span class="inline-flex items-center justify-center w-9 h-9 text-sm text-gray-400 select-none">…</span>
|
||||
@endif
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<span aria-current="page"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-sm font-semibold text-white select-none"
|
||||
style="background:#4f46e5">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="Vai a pagina {{ $page }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Indicatore pagina corrente (solo mobile) --}}
|
||||
<span class="sm:hidden inline-flex items-center justify-center px-4 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 select-none">
|
||||
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
|
||||
</span>
|
||||
|
||||
{{-- Next --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="Pagina successiva">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
@else
|
||||
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
|
||||
|
||||
{{-- Prev --}}
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="{{ __('pagination.previous') }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Numeri pagina: nascosti su mobile, visibili da sm in su --}}
|
||||
<div class="hidden sm:flex items-center gap-1">
|
||||
@foreach ($elements as $element)
|
||||
@if (is_string($element))
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 text-xs text-gray-400 select-none">…</span>
|
||||
@endif
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<span aria-current="page"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-xs font-semibold text-white select-none"
|
||||
style="background:#4f46e5">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Indicatore pagina mobile --}}
|
||||
<span class="sm:hidden inline-flex items-center justify-center px-3 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 select-none">
|
||||
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
|
||||
</span>
|
||||
|
||||
{{-- Next --}}
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
|
||||
aria-label="{{ __('pagination.next') }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
@else
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Settings\TerritoryPdfImportController;
|
||||
use App\Http\Controllers\Settings\XmlExchangeUploadController;
|
||||
use App\Http\Controllers\AssignmentPdfController;
|
||||
use App\Http\Controllers\ShortPdfLinkController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Livewire\Home;
|
||||
use App\Livewire\Territori\TerritorioIndex;
|
||||
@@ -24,6 +28,8 @@ use App\Livewire\AuditLog;
|
||||
use App\Livewire\Settings\SettingsEdit;
|
||||
use App\Livewire\Settings\ZoneIndex;
|
||||
use App\Livewire\Settings\TipologieIndex;
|
||||
use App\Livewire\Settings\UsersIndex;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Livewire\Privacy;
|
||||
|
||||
/*
|
||||
@@ -44,6 +50,12 @@ Route::post('logout', function () {
|
||||
return redirect('/login');
|
||||
})->middleware('auth')->name('logout');
|
||||
|
||||
Route::get('p/{code}', ShortPdfLinkController::class)->name('assignments.pdf.short');
|
||||
Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer'])
|
||||
->name('assignments.pdf.viewer');
|
||||
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
|
||||
->name('assignments.pdf.file');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authenticated Routes
|
||||
@@ -101,8 +113,13 @@ Route::middleware('auth')->group(function () {
|
||||
// Settings (admin)
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
|
||||
Route::get('utenti', UsersIndex::class)->name('users.index');
|
||||
Route::get('zone', ZoneIndex::class)->name('zone.index');
|
||||
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
|
||||
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
|
||||
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
||||
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
|
||||
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
|
||||
});
|
||||
|
||||
// Privacy / Informativa GDPR
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
base64:7ycOQwH6FjKdElpvJW9JU33pxtNAbOHxGhj6s930X+U=
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Reference in New Issue
Block a user