Compare commits

...

7 Commits

58 changed files with 2652 additions and 648 deletions

View File

@@ -3,7 +3,8 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=Europe/Rome
APP_URL=http://localhost:8080
APP_URL=https://demo-termanager.pyconetwork.it
ASSET_URL=https://demo-termanager.pyconetwork.it
APP_PORT=8080
SEED_DEV_DATA=false

504
README.md
View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,13 @@ class Assegna extends Component
return $this->redirect(route('territori.show', $territorio), navigate: true);
}
public function toggleLinkSent(int $assegnazioneId): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
}
#[Computed]
public function selectedThumbnailUrl(): ?string
{
@@ -116,9 +123,19 @@ class Assegna extends Component
->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
// All currently assigned territories with links
$assegnazioniAperte = Assegnazione::aperte()
->with(['territorio.zona', 'proclamatore'])
->get()
->sortBy(fn($a) => (int) $a->territorio?->numero);
$linkTtlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
return view('livewire.assegnazioni.assegna', [
'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte,
'linkTtlMonths' => $linkTtlMonths,
]);
}
}

View File

@@ -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();

View File

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

View File

@@ -21,8 +21,8 @@ class Registro extends Component
public string $filtroZona = '';
public string $filtroTipologia = '';
public string $filtroStato = ''; // aperte, chiuse
public string $sortField = 'assigned_at';
public string $sortDirection = 'desc';
public string $sortField = 'territorio_numero';
public string $sortDirection = 'asc';
// ─── Modal create/edit ──────────────────────────────────────
public bool $showModal = false;
@@ -149,6 +149,14 @@ class Registro extends Component
$this->showDeleteConfirm = false;
}
public function mount(): void
{
if ($this->filtroAnno === '') {
$annoCorrente = AnnoTeocratico::corrente();
$this->filtroAnno = (string) $annoCorrente->id;
}
}
public function render()
{
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
@@ -171,7 +179,14 @@ class Registro extends Component
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
}
$query->orderBy($this->sortField, $this->sortDirection);
if ($this->sortField === 'territorio_numero') {
$dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
$query->orderByRaw(
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
);
} else {
$query->orderBy($this->sortField, $this->sortDirection);
}
// In-memory search for encrypted proclamatore fields / territorio numero
if ($this->search !== '') {

View File

@@ -13,6 +13,7 @@ class SettingsEdit extends Component
public int $giorni_per_smarrito = 120;
public int $home_limit_list = 10;
public int $assignment_link_ttl_months = 1;
public bool $pdf_viewer_show_download = true;
public int $audit_retention_days = 365;
public function mount()
@@ -24,6 +25,7 @@ class SettingsEdit extends Component
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
$this->home_limit_list = $settings->home_limit_list ?? 10;
$this->assignment_link_ttl_months = $settings->assignment_link_ttl_hours ?? 1;
$this->pdf_viewer_show_download = $settings->pdf_viewer_show_download ?? true;
$this->audit_retention_days = $settings->audit_retention_days ?? 365;
}
@@ -36,6 +38,7 @@ class SettingsEdit extends Component
'giorni_per_smarrito' => 'required|integer|min:30|max:365',
'home_limit_list' => 'required|integer|min:1|max:100',
'assignment_link_ttl_months' => 'required|integer|min:1|max:24',
'pdf_viewer_show_download' => 'required|boolean',
'audit_retention_days' => 'required|integer|min:30|max:3650',
];
}
@@ -52,6 +55,7 @@ class SettingsEdit extends Component
'giorni_per_smarrito' => $this->giorni_per_smarrito,
'home_limit_list' => $this->home_limit_list,
'assignment_link_ttl_hours' => $this->assignment_link_ttl_months,
'pdf_viewer_show_download' => $this->pdf_viewer_show_download,
'audit_retention_days' => $this->audit_retention_days,
]);

View File

@@ -25,8 +25,6 @@ class XmlExchange extends Component
{
use WithFileUploads;
public $sqlDump;
public $xmlImport;
public array $importStats = [];
public array $importIssues = [];
public array $pdfFolder = [];
@@ -46,46 +44,135 @@ class XmlExchange extends Component
}
}
public function convertLegacySqlToXml()
public function importTerritoryPdfFolder(): void
{
$this->validate([
'sqlDump' => ['required', 'file', 'mimes:sql,txt'],
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$content = file_get_contents($this->sqlDump->getRealPath());
$dataset = $this->legacySqlToDataset($content ?: '');
$xml = $this->datasetToXml($dataset, 'legacy-sql-conversion');
$importId = (string) Str::uuid();
$storedFiles = [];
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
$this->pdfFolder = [];
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$dataset = $this->currentDataset();
$xml = $this->datasetToXml($dataset, 'current-app-export');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function importXmlIntoApp(): void
public function render()
{
if (session()->has('importStats')) {
$this->importStats = session('importStats');
}
if (session()->has('importIssues')) {
$this->importIssues = session('importIssues');
}
return view('livewire.settings.xml-exchange');
}
public function legacySqlToDatasetPublic(string $sql): array
{
return $this->legacySqlToDataset($sql);
}
public function datasetToXmlPublic(array $dataset, string $source): string
{
return $this->datasetToXml($dataset, $source);
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
$this->importIssues = [];
$this->validate([
'xmlImport' => ['required', 'file', 'mimes:xml,txt'],
]);
$content = file_get_contents($this->xmlImport->getRealPath());
if (! $content) {
$this->addError('xmlImport', 'File XML non valido.');
return;
}
$xml = @simplexml_load_string($content);
if (! $xml) {
$this->addError('xmlImport', 'Impossibile leggere il file XML.');
return;
return ['error' => 'Impossibile leggere il file XML.'];
}
$actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) {
$this->addError('xmlImport', 'Serve almeno un utente nel sistema per importare i dati.');
return;
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
}
$stats = [
@@ -268,113 +355,10 @@ class XmlExchange extends Component
}
});
$this->importStats = $stats;
$message = 'Import XML completato con successo.';
if ($stats['duplicate_territori'] > 0 || $stats['assegnazioni_saltate'] > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
}
session()->flash('success', $message);
}
public function importTerritoryPdfFolder(): void
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$importId = (string) Str::uuid();
$storedFiles = [];
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
$this->pdfFolder = [];
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$dataset = $this->currentDataset();
$xml = $this->datasetToXml($dataset, 'current-app-export');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function render()
{
return view('livewire.settings.xml-exchange');
return [
'stats' => $stats,
'issues' => $this->importIssues,
];
}
private function currentDataset(): array
@@ -512,7 +496,7 @@ class XmlExchange extends Component
private function extractInsertRows(string $sql): array
{
$result = [];
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+?);/s', $sql, $matches, PREG_SET_ORDER);
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$table = $match[1];
@@ -545,7 +529,7 @@ class XmlExchange extends Component
$escape = false;
continue;
}
if ($ch === '\\\\') {
if ($ch === '\\') {
$escape = true;
continue;
}
@@ -603,7 +587,7 @@ class XmlExchange extends Component
$escape = false;
continue;
}
if ($ch === '\\\\') {
if ($ch === '\\') {
$escape = true;
continue;
}
@@ -645,7 +629,8 @@ class XmlExchange extends Component
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
$v = substr($raw, 1, -1);
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
return html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $this->normalizeUnicodeQuotes($v);
}
if (is_numeric($raw)) {
@@ -655,6 +640,15 @@ class XmlExchange extends Component
return $raw;
}
private function normalizeUnicodeQuotes(string $value): string
{
return str_replace(
["\u{2018}", "\u{2019}", "\u{2032}", "\u{2035}", "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}", "\u{2036}"],
["'", "'", "'", "'", '"', '"', '"', '"', '"'],
$value
);
}
private function datesFromLegacyAnnoLabel(string $label): array
{
if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) {
@@ -709,7 +703,7 @@ class XmlExchange extends Component
$settings = $xml->addChild('settings');
foreach ($dataset['settings'] as $key => $value) {
$settings->addChild($key, htmlspecialchars((string) $value, ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($settings, $key, (string) $value);
}
$zonesNode = $xml->addChild('zones');
@@ -718,7 +712,7 @@ class XmlExchange extends Component
if (isset($zone['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
}
$node->addChild('nome', htmlspecialchars((string) ($zone['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
}
@@ -728,7 +722,7 @@ class XmlExchange extends Component
if (isset($tipologia['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
}
$node->addChild('nome', htmlspecialchars((string) ($tipologia['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
}
@@ -738,8 +732,8 @@ class XmlExchange extends Component
if (isset($proclamatore['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
}
$node->addChild('nome', htmlspecialchars((string) ($proclamatore['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$node->addChild('cognome', htmlspecialchars((string) ($proclamatore['cognome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
}
@@ -749,11 +743,11 @@ class XmlExchange extends Component
if (isset($territorio['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
}
$node->addChild('numero', htmlspecialchars((string) ($territorio['numero'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
$node->addChild('confini', htmlspecialchars((string) ($territorio['confini'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$node->addChild('note', htmlspecialchars((string) ($territorio['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'confini', (string) ($territorio['confini'] ?? ''));
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
}
@@ -764,7 +758,7 @@ class XmlExchange extends Component
if (isset($anno['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
}
$node->addChild('label', htmlspecialchars((string) ($anno['label'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
}
@@ -775,7 +769,7 @@ class XmlExchange extends Component
if (isset($campagna['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
}
$node->addChild('descrizione', htmlspecialchars((string) ($campagna['descrizione'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
}
@@ -793,9 +787,18 @@ class XmlExchange extends Component
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('note', htmlspecialchars((string) ($assegnazione['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
}
return $xml->asXML() ?: '';
}
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
return $child;
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
class Assegnazione extends Model
@@ -20,6 +19,7 @@ class Assegnazione extends Model
'counted_in_campaign',
'campaign_id',
'pdf_access_code',
'link_sent',
'note',
'created_by',
'returned_by',
@@ -31,6 +31,7 @@ class Assegnazione extends Model
'assigned_at' => 'date',
'returned_at' => 'date',
'counted_in_campaign' => 'boolean',
'link_sent' => 'boolean',
];
}
@@ -103,16 +104,24 @@ class Assegnazione extends Model
return null;
}
$months = max(1, (int) Setting::getValue('assignment_link_ttl_hours', 1));
return route('assignments.pdf.viewer', [
'assignment' => $this->id,
'code' => $this->ensurePdfAccessCode(),
]);
}
return URL::temporarySignedRoute(
'assignments.pdf.viewer',
now()->addMonths($months),
[
'assignment' => $this->id,
'code' => $this->ensurePdfAccessCode(),
]
);
public function shortPdfUrl(): ?string
{
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
return null;
}
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
}
public function markLinkSent(): void
{
$this->forceFill(['link_sent' => true])->saveQuietly();
}
// ─── Scopes ─────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -10,12 +10,14 @@ class Setting extends Model
protected $fillable = [
'congregazione_nome',
'public_base_url',
'logo_path',
'giorni_giacenza_da_assegnare',
'giorni_giacenza_prioritari',
'giorni_per_smarrito',
'home_limit_list',
'assignment_link_ttl_hours',
'pdf_viewer_show_download',
'audit_retention_days',
'setup_completed',
];
@@ -24,6 +26,7 @@ class Setting extends Model
{
return [
'setup_completed' => 'boolean',
'pdf_viewer_show_download' => 'boolean',
'giorni_giacenza_da_assegnare' => 'integer',
'giorni_giacenza_prioritari' => 'integer',
'giorni_per_smarrito' => 'integer',

View File

@@ -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]);

View File

@@ -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,

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->string('public_base_url')->nullable()->after('congregazione_nome');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('public_base_url');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('link_sent')->default(false)->after('pdf_access_code');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('link_sent');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->boolean('pdf_viewer_show_download')->default(true)->after('assignment_link_ttl_hours');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('pdf_viewer_show_download');
});
}
};

View File

@@ -60,19 +60,12 @@ services:
networks:
- termanager2
depends_on:
mariadb:
app:
condition: service_healthy
redis:
condition: service_healthy
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
entrypoint: ["php"]
command: ["artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
- SEED_DEV_DATA=${SEED_DEV_DATA:-false}
- RUN_DB_SEED_ON_FIRST_START=${RUN_DB_SEED_ON_FIRST_START:-true}
- ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
mariadb:
image: mariadb:11
@@ -85,8 +78,6 @@ services:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
volumes:
- db_data:/var/lib/mysql
ports:
- "${DB_PORT:-3306}:3306"
networks:
- termanager2
healthcheck:
@@ -102,8 +93,6 @@ services:
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret}
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- termanager2
healthcheck:

View File

@@ -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).* {

View File

@@ -216,7 +216,6 @@ if [ "$ENSURE_INITIAL_ADMIN_ON_EMPTY_DB" = "true" ]; then
--password="$INITIAL_ADMIN_PASSWORD_VALUE" \
--no-interaction; then
warn "Initial admin creation failed. Set INITIAL_ADMIN_NAME, INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD."
exit 1
fi
else
echo "[i] ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=false, skipping initial admin creation check."

View File

@@ -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
},

View File

@@ -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; }
}

View File

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

View File

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

View File

@@ -11,11 +11,11 @@
<body class="h-full">
<div class="min-h-full">
{{-- Header --}}
<nav class="bg-indigo-700 shadow-lg">
<nav class="header-gradient shadow-lg">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2"
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2 rounded-lg" style="background:rgba(255,255,255,0.1)"
x-data x-on:click="$dispatch('toggle-sidebar')">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
@@ -23,22 +23,26 @@
</button>
@php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-8 w-8 rounded">
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@else
<div class="h-8 w-8 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-sm">T2</div>
<div class="h-9 w-9 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm" style="background:rgba(255,255,255,0.2);backdrop-filter:blur(4px)">T2</div>
@endif
<span class="text-white font-semibold text-lg hidden sm:block">
<span class="text-white font-semibold text-lg hidden sm:block" style="text-shadow:0 1px 2px rgba(0,0,0,0.1)">
{{ $settings->congregazione_nome ?? 'TerManager2' }}
</span>
</div>
<div class="flex items-center gap-4">
<span class="text-indigo-200 text-sm hidden sm:block">
{{ auth()->user()->name }}
<span class="text-indigo-300 text-xs">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
</span>
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg" style="background:rgba(255,255,255,0.1)">
<svg class="h-4 w-4" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="text-sm" style="color:rgba(255,255,255,0.9)">
{{ auth()->user()->name }}
<span class="text-xs" style="color:rgba(255,255,255,0.6)">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
</span>
</div>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-indigo-200 hover:text-white text-sm font-medium transition">
<button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Esci
</button>
</form>
@@ -59,16 +63,16 @@
{{-- Sidebar --}}
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
<nav class="mt-4 px-3 space-y-1">
<nav class="mt-4 px-3 space-y-0.5">
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('dashboard') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
Home
</a>
@can('territori.manage')
<a href="{{ route('territori.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('territori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('territori.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
Territori
</a>
@@ -76,7 +80,7 @@
@can('proclamatori.manage')
<a href="{{ route('proclamatori.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('proclamatori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('proclamatori.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Proclamatori
</a>
@@ -84,7 +88,7 @@
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('assegnazioni.assegna') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.assegna') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegnazioni
</a>
@@ -92,7 +96,7 @@
@can('campagne.manage')
<a href="{{ route('campagne.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('campagne.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('campagne.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
Campagne
</a>
@@ -100,7 +104,7 @@
@can('registro.view')
<a href="{{ route('registro.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('registro.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('registro.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Registro
</a>
@@ -108,7 +112,7 @@
@can('audit.view')
<a href="{{ route('audit.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('audit.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('audit.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Audit Log
</a>
@@ -159,17 +163,30 @@
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
{{-- Flash messages --}}
@if (session()->has('success'))
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#f0fdf4;border-color:#bbf7d0;color:#166534">
<svg class="h-5 w-5 flex-shrink-0" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('success') }}
</div>
@endif
@if (session()->has('error'))
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-700 border border-red-200">
<div x-data="{ show: true }" x-show="show"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#fef2f2;border-color:#fecaca;color:#991b1b">
<svg class="h-5 w-5 flex-shrink-0" style="color:#ef4444" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('error') }}
<button @click="show = false" class="ml-auto" style="color:#991b1b;opacity:0.5" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
@endif
{{ $slot }}
<div class="page-enter">
{{ $slot }}
</div>
</main>
</div>
</div>

View File

@@ -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

View File

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

View File

@@ -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 --}}

View File

@@ -26,19 +26,19 @@
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data/Ora</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Utente</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Evento</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Soggetto</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dettagli</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Data/Ora</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Utente</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Evento</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Soggetto</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dettagli</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($activities as $activity)
<tr class="hover:bg-gray-50">
<tr class="hover:bg-indigo-50/30 transition-colors">
<td class="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{{ $activity->created_at->format('d/m/Y H:i:s') }}</td>
<td class="px-3 py-2 text-xs">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
<td class="px-3 py-2">

View File

@@ -1,6 +1,6 @@
<div class="bg-white shadow-xl rounded-2xl p-8">
<div class="bg-white shadow-xl rounded-2xl p-8" style="border-top:4px solid #6366f1">
<div class="text-center mb-8">
<div class="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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -3,44 +3,77 @@
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
@if($annoCorrente)
<p class="text-sm text-gray-500">Anno Teocratico {{ $annoCorrente->label }} {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
<p class="text-sm text-gray-500 mt-1">Anno Teocratico {{ $annoCorrente->label }} {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
@endif
</div>
{{-- Stats cards --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attivi</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ $totTerritoriAttivi }}</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-indigo">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attivi</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ $totTerritoriAttivi }}</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#eef2ff">
<svg class="h-5 w-5" style="color:#6366f1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $totAssegnati }}</p>
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-blue">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
<p class="mt-1 text-3xl font-bold" style="color:#3b82f6">{{ $totAssegnati }}</p>
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dbeafe">
<svg class="h-5 w-5" style="color:#3b82f6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $territoriPercorsi }}</p>
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-green">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
<p class="mt-1 text-3xl font-bold" style="color:#22c55e">{{ $territoriPercorsi }}</p>
<p class="text-xs text-gray-500">durata media {{ $mediaDurataPercorrenzaMesi }} mesi</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dcfce7">
<svg class="h-5 w-5" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
</div>
</div>
@if($campagnaStats)
<div class="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-4">
<p class="text-xs font-medium text-amber-600 uppercase">Campagna</p>
<p class="mt-1 text-lg font-bold text-amber-800">{{ $campagnaStats['descrizione'] }}</p>
<div class="rounded-xl shadow-sm border p-4 card-hover" style="background:#fffbeb;border-color:#fde68a;border-left:4px solid #f59e0b">
<div class="flex items-center justify-between mb-1">
<p class="text-xs font-medium uppercase" style="color:#d97706">Campagna</p>
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#fef3c7">
<svg class="h-4 w-4" style="color:#f59e0b" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
</div>
</div>
<p class="text-sm font-bold" style="color:#92400e">{{ $campagnaStats['descrizione'] }}</p>
<div class="mt-2">
<div class="flex justify-between text-xs text-amber-700 mb-1">
<div class="flex justify-between text-xs mb-1" style="color:#b45309">
<span>{{ $campagnaStats['percentuale'] }}%</span>
<span>scade {{ $campagnaStats['fine'] }}</span>
</div>
<div class="w-full bg-amber-200 rounded-full h-2">
<div class="bg-amber-500 h-2 rounded-full transition-all" style="width: {{ min($campagnaStats['percentuale'], 100) }}%"></div>
<div class="w-full rounded-full h-2" style="background:#fde68a">
<div class="h-2 rounded-full transition-all" style="background:#f59e0b;width:{{ min($campagnaStats['percentuale'], 100) }}%"></div>
</div>
</div>
</div>
@else
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Campagna</p>
<p class="mt-1 text-sm text-gray-400">Nessuna campagna attiva</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 card-hover card-accent-amber">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Campagna</p>
<p class="mt-1 text-sm text-gray-400">Nessuna campagna attiva</p>
</div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#fef3c7">
<svg class="h-5 w-5" style="color:#d97706" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
</div>
</div>
</div>
@endif
</div>
@@ -48,72 +81,99 @@
{{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-green-50 border-b border-green-100">
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3>
<p class="mt-1 text-xs text-green-700">Prima i prioritari, poi i territori con piu tempo in reparto</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3>
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaAssegnare" title="Scarica PDF" class="ml-auto btn-action btn-action-green" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@forelse($territoriDaAssegnare as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div>
<div class="flex items-center gap-2">
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors"> {{ $t->numero }}</a>
@if($t->home_is_prioritario)
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">Prioritario</span>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e"> Prioritario</span>
@endif
</div>
<p class="text-xs text-gray-500">
{{ $t->zona?->nome }} {{ $t->tipologia?->nome }}
@if($t->home_giorni_giacenza > 0)
in reparto da {{ $t->home_giorni_giacenza }} giorni
<span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
@endif
</p>
</div>
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna </a>
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna </a>
@endcan
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
<li class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-sm">Tutti i territori sono assegnati</span>
</li>
@endforelse
</ul>
@if($territoriDaAssegnare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti </a>
</div>
@endif
</div>
{{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-red-50 border-b border-red-100">
<h3 class="text-sm font-semibold text-red-800">Da Rientrare</h3>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3>
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaRientrare" title="Scarica PDF" class="ml-auto btn-action btn-action-red" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@forelse($daRientrare as $t)
@php($assegnazioneCorrente = $t->assegnazioneCorrente)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500">
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
{{ $assegnazioneCorrente?->giorni }} giorni
<span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
</p>
</div>
@can('territori.return')
@if($assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="text-xs font-medium text-red-600 hover:text-red-800">Rientra </a>
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">Rientra </a>
@endif
@endcan
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li>
<li class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-sm">Nessun territorio da rientrare</span>
</li>
@endforelse
</ul>
@if($daRientrare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti </a>
</div>
@endif
</div>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,9 +2,8 @@
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
@can('settings.manage')
<button wire:click="openCreate"
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova voce
</button>
@endcan
@@ -43,20 +42,22 @@
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
<tr>
<th wire:click="sortBy('territorio_id')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">Territorio</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Proclamatore</th>
<th wire:click="sortBy('assigned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
Assegnato @if($sortField==='assigned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
<th wire:click="sortBy('territorio_numero')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
Territorio @if($sortField==='territorio_numero') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
</th>
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
Rientrato @if($sortField==='returned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
<th wire:click="sortBy('assigned_at')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
Assegnato @if($sortField==='assigned_at') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Giorni</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anno</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campagna</th>
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
Rientrato @if($sortField==='returned_at') <span style="color:#6366f1">{{ $sortDirection==='asc'?'▲':'▼' }}</span> @endif
</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Giorni</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Anno</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Campagna</th>
@can('settings.manage')
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Azioni</th>
@endcan
@@ -64,8 +65,8 @@
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($assegnazioni as $a)
<tr class="hover:bg-gray-50">
@php($temporaryPdfUrl = !$a->returned_at ? $a->temporaryPdfViewerUrl() : null)
<tr class="hover:bg-indigo-50/30 transition-colors">
@php($temporaryPdfUrl = !$a->returned_at ? $a->shortPdfUrl() : null)
<td class="px-3 py-2">
<div class="flex items-center gap-2">
@if($a->territorio?->thumbnail_path)
@@ -99,38 +100,24 @@
</td>
@can('settings.manage')
<td class="px-3 py-2 whitespace-nowrap">
@can('territori.assign')
@if($a->territorio?->attivo && !$a->territorio?->assegnazioneCorrente)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $a->territorio_id]) }}"
class="inline-block text-xs font-medium text-emerald-600 hover:text-emerald-800 mr-3">
Assegna
</a>
@endif
@endcan
@can('territori.return')
@if(!$a->returned_at)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $a->id]) }}"
class="inline-block text-xs font-medium text-red-600 hover:text-red-800 mr-3">
Rientra
</a>
@endif
@endcan
<div class="flex items-center gap-1">
@if($temporaryPdfUrl)
<a href="{{ $temporaryPdfUrl }}"
target="_blank"
rel="noopener noreferrer"
class="inline-block text-xs font-medium text-indigo-600 hover:text-indigo-800 mr-3">
PDF
class="btn-action btn-action-indigo" title="PDF">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</a>
@endif
<button wire:click="openEdit({{ $a->id }})"
style="background:#e0e7ff;color:#4338ca;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;margin-right:4px;">
Modifica
class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="askDelete({{ $a->id }})"
style="background:#fee2e2;color:#b91c1c;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">
Elimina
class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
@endcan
</tr>

View File

@@ -63,6 +63,14 @@
@error('assignment_link_ttl_months') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input wire:model="pdf_viewer_show_download" type="checkbox" value="1" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Mostra pulsante "Scarica PDF" nel viewer link temporaneo
</label>
<p class="text-xs text-gray-500 mt-1">Se disattivato, il proclamatore potrà solo visualizzare il PDF senza scaricarlo.</p>
</div>
<div>
<label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label>
<p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p>

View File

@@ -246,47 +246,46 @@
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
<div>
<input wire:model="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
@csrf
<div>
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2">
<button
wire:click="convertLegacySqlToXml"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
>
Converti in XML
</button>
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
>
Converti in XML
</button>
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
<div wire:loading wire:target="importXmlIntoApp" style="padding:10px 12px;border-radius:10px;background:#fffbeb;border:1px solid #f59e0b;color:#92400e;font-size:13px;">
Importazione in corso... attendi il completamento.
</div>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
@csrf
<div>
<input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<input wire:model="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2">
<button
wire:click="importXmlIntoApp"
type="button"
onclick="if(!confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.')) event.stopImmediatePropagation();"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
>
Importa XML
</button>
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
>
Importa XML
</button>
</div>
</form>
</div>
@if(!empty($importStats))

View File

@@ -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"></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"></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

View File

@@ -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">

View File

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

View File

@@ -1,21 +1,33 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition">Assegna</a>
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition">Rientra</a>
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
@endif
@endcan
<a href="{{ route('territori.edit', $territorio) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
</div>
</div>
@@ -69,7 +81,7 @@
</div>
@if($activeAssignment)
@php($temporaryPdfUrl = $activeAssignment->temporaryPdfViewerUrl())
@php($temporaryPdfUrl = $activeAssignment->shortPdfUrl())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" x-data="{ copied: false }">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -126,7 +138,12 @@
{{-- Assignment history --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
<div class="flex items-center gap-3 mb-4">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#eef2ff">
<svg class="h-4 w-4" style="color:#6366f1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
</div>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<div class="mb-6">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Settings\TerritoryPdfImportController;
use App\Http\Controllers\Settings\XmlExchangeUploadController;
use App\Http\Controllers\AssignmentPdfController;
use App\Http\Controllers\ShortPdfLinkController;
use App\Http\Controllers\Auth\LoginController;
use App\Livewire\Home;
use App\Livewire\Territori\TerritorioIndex;
@@ -48,6 +50,7 @@ Route::post('logout', function () {
return redirect('/login');
})->middleware('auth')->name('logout');
Route::get('p/{code}', ShortPdfLinkController::class)->name('assignments.pdf.short');
Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer'])
->name('assignments.pdf.viewer');
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
@@ -114,6 +117,8 @@ Route::middleware('auth')->group(function () {
Route::get('zone', ZoneIndex::class)->name('zone.index');
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
});