Compare commits

26 Commits

Author SHA1 Message Date
Mariano 005fa5b4a3 sistemazione migrate 2026-06-17 19:13:40 +02:00
inext 7302146a77 Merge branch 'dev'
merge dev
2026-06-10 14:04:40 +02:00
inext 43a2c81312 setup viste 2026-06-10 14:03:43 +02:00
inext 0dd567cf7a email documenti 2026-06-10 06:42:49 +02:00
inext d9421c9159 ver 2.0.1.1 2026-06-09 12:04:03 +02:00
inext c959f72c66 Merge branch 'dev'
da dev version 2
2026-06-09 11:46:09 +02:00
inext 7999c663fe ver 2.0.1 2026-06-09 11:44:40 +02:00
inext 708cadfac2 Merge branch 'dev'
-
2026-06-09 11:27:06 +02:00
inext a35b0056e1 finale 2.0.0.0 2026-06-09 11:26:32 +02:00
inext b7842bf2e0 finale 2.0.0 2026-06-09 11:22:56 +02:00
inext 06387568cb finale 2.0.0 2026-06-09 11:09:00 +02:00
inext d82b123bfd versione 2.0.0 2026-06-09 11:02:01 +02:00
inext 6f106e3130 vers 1.2.10 2026-06-08 21:04:06 +02:00
inext ce48481bc8 fix 1.2.9 2026-06-08 21:02:12 +02:00
inext 7d7d384a0d fix 1.2.8 - logo app 2026-06-08 20:10:33 +02:00
inext 8194b8c034 fix 1.2.7 2026-06-08 20:03:14 +02:00
inext 90093a086b fix 1.2.6 2026-06-08 12:09:45 +02:00
inext cbe3470c8c 1.2.5 - fix for email settings 2026-06-08 09:47:24 +02:00
inext 469e8c014f 9Merge branch 'dev'
fix piccoli
2026-06-07 23:06:37 +02:00
inext 4dd4da7f4b fix 1.2.4 2026-06-07 23:05:48 +02:00
inext cd10d100ec final 1.2.3 - file sh per generare zip 2026-06-07 20:56:38 +02:00
inext 406985eb1e Merge branch 'dev'
final 1.2.2
2026-06-07 17:27:25 +02:00
inext f9c1268466 finale 1.2.2 2026-06-07 17:27:00 +02:00
inext 89382fe4ff latest fix 2026-06-07 16:28:13 +02:00
inext dd76b473f7 Merge branch 'dev'
1.2.1 fix gitignore
2026-06-06 23:44:56 +02:00
inext cf0ca170ce 1.2.1 fix gitignore 2026-06-06 23:41:16 +02:00
2554 changed files with 5540 additions and 326502 deletions
+3 -1
View File
@@ -1 +1,3 @@
glastree-image.tar glastree*.tar.gz
glastree*.tar
.env
+415 -51
View File
@@ -1,61 +1,425 @@
# 🧠 MEMORY.md — Stato Progetto # 🧠 MEMORY.md — Stato Progetto
## Goal ## Obiettivo
- Risolvere login non funzionante dopo fresh install (causa: tinker senza escaping + `2>/dev/null`) App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
- Sostituire tinker per creazione admin con PDO prepared statement + SQL file per schema
## Constraints & Preferences ## Funzionalità Implementate
- Laravel 13, PHP 8.4, AdminLTE 4, MySQL/MariaDB (install.sql solo MySQL)
- Zero placeholder, zero TODO, codice funzionante
- Compatibilità cross-DB MySQL ↔ SQLite (fallback migration per SQLite)
## Progress ### Page-length selector
### Done - **4 pagine**: Individui, Eventi, Email, Gruppi
- **Creazione `database/install.sql`** — schema MySQL completo + seed data (~1059 righe): - **Valori**: 10, 20, 25, 50, 100 (default 20)
- Tutte le 45 tabelle nello stato finale (consolidate da 58 migration) - Dropdown nel card-footer con campi hidden per preservare query params
- Seed: 119 diocesi, 54 comuni, 8 tipologie documenti, 8 tipologie eventi, 8 ruoli, 23 permessi Spatie, 2 ruoli Spatie, 3 role preset, 5 email folders, 1 riga app_settings - GruppoController: usa `LengthAwarePaginator` per paginare la collezione ordinata gerarchicamente
- **NON contiene l'admin** (creato via PDO da install.php)
- `SET FOREIGN_KEY_CHECKS=0` per ordine tabelle, riattivato alla fine
- **Riscritto `install.php`** — eliminata dipendenza da tinker per la creazione admin:
- Aggiunta funzione `createAdminUser()`: PDO prepared statement + `password_hash()` + Spatie role assignment
- Fresh Install Apache: importa `install.sql` via `mysql` CLI, poi crea admin via PDO
- Fresh Install Docker: copia `install.sql` nel container, importa via mysql CLI, crea admin via script PHP temporaneo con `var_export()` (safe)
- Restore: crea admin via PDO se non esiste
- Fallback SQLite: mantiene `php artisan migrate --seed` + tinker ma con `var_export()` per escaping
- Step 5 rinominato da "MIGRATION E SEED" a "SCHEMA DATABASE E DATI BASE"
- Step 6 usa PDO invece di tinker (MySQL)
- **Unified MySQL setup**: singolo tentativo PDO con `dbname` nel DSN — se fallisce (1044/1045/1049), passa automaticamente in root-mode: chiede password root, crea database + utente + GRANT ALL su `localhost` e `%`
- **Sostituito loop 30-retry** con connessione singola PDO (timeout 5s)
- **Fix parse error**: `\$adminName``$adminName` in `var_export()` nel ramo SQLite
- **Fix composer**: rimosso `sudo -u www-data` da composer (causa git dubious ownership + permission denied), aggiunto `COMPOSER_ROOT_VERSION=dev-main`; `vendor` aggiunto al chown finale
### In Progress ### CSV Import Gruppi
- *(nessuno)* - Stesso pattern di `IndividuoController` (import GET, importStore POST, downloadTemplate GET)
- Colonne: nome, descrizione, parent_id, diocesi_id, indirizzo_incontro, cap_incontro, città_incontro, sigla_provincia_incontro
- Fault-tolerant: skip righe vuote, log errori, try/catch su ogni riga
### Blocked ### ICS Import Eventi
- *(nessuno)* - Usa `Sabre\VObject\Reader::read()` già in vendor/
- Mapping: SUMMARY→nome_evento, DESCRIPTION→descrizione, DTSTART→data_specifica+ora_inizio, DTEND→durata_minuti, LOCATION→luogo_indirizzo, UID→uid_esterno
- Dedup via uid_esterno
- RRULE: import come evento singolo con prima occorrenza
## Key Decisions ### build-dist.sh (script di distribuzione)
- **PDO > tinker**: `createAdminUser()` usa PDO prepared statement con `password_hash()`, niente interpolazione PHP in stringhe shell → zero escaping issues - Genera `glastree-YYYYMMDD_HHMM.tar.gz`
- **`install.sql` > migration sequenziali**: unico file SQL per fresh install, più veloce e debuggabile (si può lanciare `mysql < install.sql` a mano) - Include: sorgenti, vendor (production)
- **`var_export()` per tinker fallback**: nei rari casi SQLite, si usa `var_export()` che produce stringhe PHP valide con escaping automatico - Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker
- **`2>/dev/null` rimosso** dal MySQL main path (non serve più, non c'è tinker) - **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear
- **Docker MySQL credenziali**: hardcoded `mysql:glastree:secret:glastree` (dal docker-compose.mysql.yml)
- **Composer senza sudo**: `COMPOSER_ROOT_VERSION=dev-main` per evitare git dubious ownership; composer eseguito come utente corrente (root), non più come www-data — dopo, `chown -R www-data vendor` per permessi lettura webserver
- **Unified MySQL setup**: PDO connect con `dbname` nel DSN → se fallisce 1044/1045/1049, root-mode: crea DB + utente + GRANT ALL su `localhost` e `%` + FLUSH
## Next Steps ## Tag System
- *(nessuno)*
## Critical Context ### Implementazione Completa (Opzione B)
- `install.sql` è solo per MySQL. SQLite usa `php artisan migrate --seed` + `DatabaseSeeder` - **Migration**: `2026_06_08_194155_create_tags_tables.php` — tabelle `tags` + `taggables`
- `DatabaseSeeder` crea ancora admin `admin@glastree.local / password` ma per MySQL viene cancellato dal PDO script - **Model `Tag.php`**: morphToMany a 5 entity types, auto-slug su creating, color badge
- `createAdminUser()` setta `is_admin=1`, `status='active'`, `permissions` completo, `role_preset_id=1`, e Spatie `model_has_roles` con role_id=1 - **Trait `HasTagsLight.php`**: `tags()` morphToMany, scopes `withAllTags`/`withAnyTags`
- `password_hash($pass, PASSWORD_BCRYPT)` produce hash `$2y$...` compatibile con `Hash::check()` di Laravel - **5 Models aggiornati**: Individuo, Gruppo, Evento, Documento, MailingList — usano `HasTagsLight`
- Composer ora eseguito come utente corrente (root) con `COMPOSER_ROOT_VERSION=dev-main` per evitare git dubious ownership e permission denied su `vendor/` - **ImpostazioniController**: CRUD completo per Tag (store, update, destroy, reorder) con tab nella pagina Impostazioni
- `vendor/` aggiunto al `chown` finale in entrambi i modi (Apache e Restore) per garantire leggibilità da www-data - **Tag selector**: `_tag-selector.blade.php` — search + pill badges con colori, incluso in create/edit di Individui, Gruppi, Eventi, MailingList
- `ensureMysqlUserAndDb()` sanitizza input (rimuove `' " \0 \`) prima di passarli a SQL exec - **Tag filtering**: `?tag[]=slug` supportato in `IndividuoController@index`, `GruppoController@index`, `EventoController@index`, `DocumentoController@index`, `MailingListController@index` via `withAnyTags` scope
- **Tag column in index views**: Colonna "Tag" con badge colorati cliccabili (collegamento a index filtrato) in individui, gruppi, eventi, documenti, mailing-liste
- **Tag filter widget**: `_tag-filter-bar.blade.php` — barra cliccabile con badge colorati sopra ogni index table (individui, gruppi, eventi, documenti, mailing-liste). Attiva/disattiva filtro `?tag[]=` con un click, evidenzia tag attivi, include "Cancella filtri" quando attivo
- **RicercaController**: Controller per ricerca unificata per tag — mostra risultati aggregati da tutte 5 entity types con info-box counters
- **Route `GET /ricerca`**: Registrata come `ricerca.index``RicercaController@index`
- **Sidebar link**: "Ricerca per Tag" (icona `fas fa-tags`) sotto la sezione Report, visibile con permesso `individui`
- **Mass tag action for Documenti**: `POST /documenti/mass-tag``DocumentoController@massTag` — modal with tag selector + assign/remove radio, processes selected documents in chunks of 100 via `syncWithoutDetaching()` (assign) or `detach()` (remove)
- **Report tag columns**: `tags.nome` disponibile come colonna per individui/gruppi/eventi/documenti nei report personalizzati
- **Report tag filters**: Custom report form include tag multi-select (Select2) + modalità OR/AND; `runCustomReport()` applica filtri via `withAllTags`/`withAnyTags`
## Relevant Files ## Bug Fix Recenti
- `database/install.sql`: NEW — schema MySQL completo + seed data (1059 righe)
- `install.php`: MODIFIED — `createAdminUser()` + `ensureMysqlUserAndDb()` + composer fix + auto-create MySQL user ### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo
- `descrizione_evento` e `is_incontro_gruppo` mancanti in install.sql → creata migration + ALTER TABLE
- `ruolo_nel_gruppo` mancante in install.sql per pivot `gruppo_individuo`
- `VistaReport.php`: aggiunto `is_default` a `$fillable`
### 2026-06-08 — build-dist.sh: "provide valid cache path" su server remoto
**Problema**: Le esclusioni in `build-dist.sh` usavano `--exclude='storage/framework/cache'` (e simili per sessions, views, logs), che escludevano **l'intera directory** dall'archivio. Dopo l'estrazione, le directory necessarie a Laravel non esistevano.
**Fix**:
1. Cambiati exclude patterns per **contenuti soltanto** (non la directory):
- `--exclude='storage/framework/cache/*'` (non `/cache`)
- `--exclude='storage/framework/sessions/*'`
- `--exclude='storage/framework/views/*'`
- `--exclude='storage/logs/*'`
- `--exclude='storage/debugbar/*'`
2. Aggiunte esclusioni per upload utente:
- `--exclude='storage/app/public'`
- `--exclude='storage/app/private'`
3. Creato `.gitignore` in `storage/framework/views/` (mancante)
4. Istruzioni post-estrazione riscritte con:
- `mkdir -p` per tutte le directory necessarie (cache, sessions, views, logs, public, backups, documenti, bootstrap/cache)
- `chmod -R 775` e `chown -R www-data:www-data` per permessi
- `php artisan config:clear`, `route:clear`, `view:clear`
## Problema 419 (Page Expired) su Produzione
Il codice NON ha errori di CSRF — tutte le form hanno `@csrf`. Il problema è configurazione sessione del server:
1. `storage/framework/sessions/` non scrivibile dal web server
2. `APP_URL` in `.env` non corrisponde all'URL reale
3. `SESSION_SECURE_COOKIE=true` ma sito in HTTP
Soluzione:
```bash
chmod -R 775 storage/framework/sessions
php artisan config:clear
```
### 2026-06-08 — build-dist.sh: impossibilità salvare logo su server remoto
**Problema**:
- `public/storage` era un symlink **assoluto** (`/var/www/html/glastree/storage/app/public`) incluso nell'archivio → all'estrazione su altro server puntava a path inesistente.
- `php artisan storage:link` falliva perché il symlink rotto esisteva già.
- Sottodirectory `logos/`, `documenti/eventi/`, `avatars/gruppi/` non create dal `mkdir -p`.
**Fix**:
1. Aggiunto `--exclude='public/storage'` al tar — il symlink va ricreato sul target.
2. Aggiunto `rm -f public/storage` prima di `php artisan storage:link` per evitare conflitto.
3. Usato `ln -sf ../storage/app/public public/storage` (invece di `php artisan storage:link --relative` che richiede `symfony/filesystem` non sempre presente).
4. Aggiunte al `mkdir -p` le sottodirectory mancanti:
- `storage/app/public/logos` (logo upload)
- `storage/app/public/documenti/eventi` (event documenti)
- `storage/app/private/avatars/gruppi` (avatar gruppi)
5. Aggiunta creazione `.gitignore` in `storage/app/public/` (placeholder Laravel).
### Fix form annidato salvataggio email
**2026-06-08 — Problema**: Il form di DELETE era **annidato dentro** il form di SAVE in entrambe le pagine:
- `resources/views/impostazioni/index.blade.php`
- `resources/views/admin/email-settings/index.blade.php`
- HTML invalido: il browser chiudeva implicitamente il form SAVE all'apertura del form DELETE, causando l'invio alla route sbagliata.
**Fix**: Spostato il form DELETE fuori dal form SAVE, separato in un `div` autonomo.
### Fix campi mancanti in validazione EmailSettingsController@save
**2026-06-08**: `signature`, `signature_enabled`, `smtp_username`, `smtp_password` non erano nelle regole di validazione di `save()`. Laravel scarta i campi non validati, quindi non venivano mai scritti nel DB.
**Fix**: Aggiunte le regole mancanti a `$request->validate()`.
### DiagnoseEmail Command
- `php artisan diagnose:email` — controlla APP_KEY, tabella email_settings, record, decrittazione password, sender_accounts, log errori, estensioni PHP.
### 2026-06-08 — Firma email ancora non salvata (secondo tentativo)
**Problema**: La firma (campo `signature`) non veniva salvata nonostante le regole di validazione fossero state aggiunte.
**Root cause analysis**:
1. HTML checkbox `signature_enabled` senza hidden fallback: quando non spuntato, il campo non veniva inviato → non presente in `$validated``updateOrCreate` non lo aggiornava mai a `false`.
2. La vecchia pagina `/impostazioni/` aveva l'handler `shown.bs.tab` per il tab Firma (`#email-firma`) in un solo punto su due, causando possibile mancata inizializzazione dell'editor Quill su alcuni flussi di navigazione.
3. La verifica nel controller controllava solo `imap_host` e `email_address` — anche se signature/signature_enabled fallivano, l'utente vedeva "salvato con successo".
**Fix**:
1. `resources/views/admin/email-settings/index.blade.php:268` — Aggiunto `<input type="hidden" name="signature_enabled" value="0">` prima della checkbox
2. `resources/views/impostazioni/index.blade.php:603` — Stesso hidden fallback
3. `resources/views/impostazioni/index.blade.php:1633` — Aggiunto `#email-firma` al primo handler `shown.bs.tab` (il secondo handler esisteva già ma il primo no)
4. `app/Http/Controllers/Admin/EmailSettingsController.php:70-82` — Aggiunto log di warning se signature o signature_enabled non corrispondono dopo il salvataggio
### 2026-06-08 — Sync email: "Nessuna configurazione email attiva" nonostante test IMAP funzioni
**Problema**: Cliccando "Sincronizza Ora" nelle impostazioni email o "Ricevi/Invia" nella pagina email, viene mostrato "Nessuna configurazione email attiva". Il test IMAP funziona correttamente.
**Root cause**: Stesso bug dei checkbox senza hidden fallback. Il campo `is_active` nel tab Sincronizzazione non veniva inviato quando non spuntato. Alla prima configurazione, se l'utente non visitava esplicitamente il tab Sync e attivava lo switch, `is_active` rimaneva `false` (default DB). Il test IMAP usa `EmailSetting::first()` (ignora `is_active`), mentre `syncEmails()` e `syncNow()` usano `EmailSetting::getActive()` che filtra per `is_active = true`.
**Fix**:
1. `resources/views/admin/email-settings/index.blade.php:251` — Aggiunto `<input type="hidden" name="is_active" value="0">` prima della checkbox
2. `resources/views/impostazioni/index.blade.php:586` — Stesso hidden fallback
3. `EmailSetting::getActive()` ora riceve sempre `is_active` dal form (0 o 1) grazie all'hidden fallback
## 2026-06-08 — Help page: aggiunto tab "Calendario" con documentazione OAuth
- **`resources/views/help/index.blade.php`**: Aggiunto tab "Calendario" nel menu laterale (dopo Google Drive) e relativo tab-pane content con:
- Requisiti (account Google, API Calendar, OAuth credentials)
- Passo 1: Google Cloud Console — abilitare Google Calendar API, creare OAuth Client ID (Web Application), redirect URI `https://developers.google.com/oauthplayground`
- Passo 2: Ottenere Refresh Token via Google OAuth Playground (Use your own OAuth credentials, scope `calendar`)
- Passo 3: Configurazione nell'app (Impostazioni → Calendario, nuova connessione Google Calendar, inserire Client ID/Secret/Refresh Token)
- Test connessione e sincronizzazione
- Nota: refresh token permanente finché non revocato
- **`resources/views/help/pdf.blade.php`**: Aggiunta sezione "3. Google Calendar" nel TOC e contenuto, rinumerazione da 4 a 8 per sezioni successive
### Differenza chiave Google Calendar vs Google Drive
- **Drive**: endpoint server `/auth/google-drive/callback` che gestisce automaticamente il callback OAuth e salva refresh token in `storage_repositories`
- **Calendar**: **NON** ha endpoint server dedicato. Refresh token ottenuto manualmente via Google OAuth Playground e incollato nel campo "Refresh Token" del form
## 2026-06-08 — Pulsante Sync nella pagina calendario eventi
- **`app/Http/Controllers/CalendarioConnessioneController.php`**: Aggiunto metodo `syncAll()` che itera tutte le connessioni attive (`is_active = true`), esegue `syncEvents()` per ognuna, e restituisce JSON con risultati aggregati.
- **`routes/web.php`**: Aggiunta route `POST /calendario-connessioni/sync-all``syncAll` (name: `calendario-connessioni.sync-all`).
- **`resources/views/eventi/calendar.blade.php`**: Aggiunto pulsante "Sync" nel card-tools che:
- Invia POST AJAX a `sync-all`
- Mostra spinner durante l'operazione
- Al completamento mostra alert con riepilogo (connessioni, importati, esportati)
- Richiama `window.calendar.refetchEvents()` per aggiornare la vista FullCalendar
- Esposto `window.calendar` globalmente per accesso dall'handler sync
## 2026-06-08 — Fix logo non visibile su installazione remota
**Problema**: Il logo uploadato non veniva visualizzato su installazioni remote, pur salvandosi correttamente su disco.
**Root cause**: `getLogoPath()` e `getLogoSmallPath()` in `AppSetting.php` usavano `file_exists(public_path('storage/' . $path))` per verificare l'esistenza del file prima di restituire l'URL. Questo controllo dipendeva dal symlink `public/storage → storage/app/public`. Su installazioni remote dove il symlink era assente o mal configurato, `file_exists` restituiva `false` → metodo restituiva `null` → logo non mostrato.
**Fix**:
1. **`app/Models/AppSetting.php`**: Sostituito `file_exists(public_path('storage/' . $path))` con `Storage::disk('public')->exists($path)` e `asset('storage/' . $path)` con `Storage::disk('public')->url($path)`. Il controllo ora avviene direttamente su `storage/app/public/` (disk root), bypassando il symlink.
2. **`app/Http/Controllers/ImpostazioniController.php`**: In `uploadLogo()`, aggiunta creazione automatica del symlink `public/storage → storage/app/public` se mancante, per garantire che il browser possa servire l'immagine.
## 2026-06-08 — Guardie Schema::hasColumn() aggiunte a 18 migration
**Problema**: 18 migration files aggiungevano colonne a tabelle esistenti senza `Schema::hasColumn()` guard. Se eseguite su DB dove le colonne già esistevano (es. da install.sql), causavano errore "Duplicate column".
**Fix**: Aggiunto `if (!Schema::hasColumn(...))` wrapper a ogni colonna aggiunta nelle seguenti migration:
1. `2026_06_02_000002_add_uid_esterno_to_eventi_table.php``uid_esterno`
2. `2026_05_12_000002_change_ruolo_to_multi.php``ruolo_ids` (up), `ruolo_id` (down)
3. `2026_05_28_000001_add_storage_disk_to_documenti.php``storage_disk`
4. `2026_05_27_113246_add_repository_id_to_documenti.php``repository_id`
5. `2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php``log_falliti`, `mittente_nome`, `mittente_email`
6. `2026_05_27_000002_add_cartella_id_to_documenti.php``cartella_id`
7. `2026_05_16_000004_add_signature_enabled_to_email_settings.php``signature_enabled`
8. `2026_05_16_000003_add_signature_to_email_settings.php``signature`
9. `2024_01_01_000017_add_occorrenza_mese_to_eventi.php``occorrenza_mese`
10. `2024_01_01_000016_add_eventi_recurrence_fields.php``giorno_mese`, `mesi_recorrenza`, `mese_annuale`
11. `2026_05_10_000002_add_acl_tables.php``permissions` (users)
12. `2026_05_12_142009_add_location_to_eventi_table.php``luogo_indirizzo`, `luogo_url_maps`
13. `2026_05_12_000003_change_responsabile_to_multi.php``responsabile_ids` (up), `responsabile_id` (down)
14. `2024_01_01_000023_add_is_default_to_viste_report_table.php``is_default`
15. `2024_01_01_000024_add_user_id_to_mailing_lists_table.php``user_id`
16. `2026_05_12_000001_create_ruoli_table.php``ruolo_id` (gruppo_individuo) + data migration annessa
17. `2024_01_01_000021_add_user_id_to_documenti_table.php``user_id`
18. `2024_01_01_000013_add_numero_documento_to_individui_table.php``numero_documento`
Tutte verificate con `php -l` (nessun errore di sintassi).
### 2026-06-09 — `@stack('scripts')` mancante nel layout
**Problema**: Il partial `_tag-selector.blade.php` usa `@push('scripts')` per iniettare le funzioni JS `toggleTag`, `removeTag`, `filterTags`. Il layout `adminlte.blade.php` aveva solo `@yield('scripts')` (per `@section`), ma **non** `@stack('scripts')` (per `@push`). Di conseguenza il JavaScript interattivo del tag selector (clic sui badge, ricerca, rimozione) non veniva mai caricato su nessuna pagina create/edit.
**Fix**: `resources/views/layouts/adminlte.blade.php:305` — Aggiunto `@stack('scripts')` subito prima di `@yield('scripts')`.
### 2026-06-09 — Tag support completato per MailingList + Report + Ricerca
**MailingList — Controller & Views** (`app/Http/Controllers/MailingListController.php`):
- `index()`: eager-load `tags`, supporto `?tag[]=` filter via `withAnyTags`, pass `$allTags` per filter bar
- `create()`: pass `$tags` per tag selector
- `store()`: validazione `tags.* exists:tags,id`, sync dopo create. Auto-fetch email da `Individuo::contatti()` quando email non fornita
- `edit()`: eager-load `tags`, pass `$tags` + `$selectedTags`
- `update()`: validazione `tags.*`, sync se presente, detach se assente. Auto-fetch email da `Individuo::contatti()` quando email non fornita. Diff logica: `$toRemove = array_diff($existingIds, $newIds)` corretto — rimuove solo contatti deselezionati/esplicitamente rimossi
- `show()`: eager-load `tags`
**MailingList — Views**:
- `index.blade.php`: aggiunto `@include('partials._tag-filter-bar')`, colonna "Tag" con badge colorati cliccabili
- `create.blade.php`: incluso `_tag-selector` dopo descrizione
- `edit.blade.php`: incluso `_tag-selector` dopo descrizione (prima della sezione contatti). Submit handler include `#individui-select.selectedOptions` in `contatti_json` con `email: ''` (già presente da fix precedente)
- `show.blade.php`: aggiunta card Tag nel pannello sinistro con badge colorati (o "Nessun tag assegnato")
**ReportColumnRegistry** (`app/Helpers/ReportColumnRegistry.php`):
- Aggiunta colonna `'tags.nome'` con label 'Tag' a 4 entity types: individui, gruppi, eventi, documenti
**ReportController** (`app/Http/Controllers/ReportController.php`):
- `runCustomReport()`: supporta `config['tag_filter']` (array di tag IDs) + `config['tag_filter_mode']` ('any'/'all')
- `storeCustom()`: ora salva `tag_filter` e `tag_filter_mode` nella config del report salvato
- Carica `$allTags` nell'index e passa alla vista
- Converte IDs → slugs via `Tag::whereIn('id', ...)->pluck('slug')`, applica `withAllTags`/`withAnyTags`
- Eager-load `tags` automaticamente quando la colonna `tags.nome` è selezionata
**Report — View** (`resources/views/report/index.blade.php`):
- Aggiunto filtro tag multi-select nel form "Crea Report Personalizzato" (Select2 con badge colorati)
- Aggiunto select per modalità filtro (OR/AND)
- JS: `initTagFilterSelect2()` / `destroyTagFilterSelect2()` con template colorato
**RicercaController** (`app/Http/Controllers/RicercaController.php`):
- Aggiunta MailingList alle query per tag slug
- `results['mailingLists']` e `totals['mailingLists']` passati alla view
**Ricerca — View** (`resources/views/ricerca/index.blade.php`):
- Aggiunto info-box Mailing List (colore bg-purple) con link "Vedi tutti" → `mailing-liste.index?tag[]=`
- Aggiunta tabella dettaglio Mailing List nel risultato di ricerca (nome, contatti, stato, azione show)
- Fix: `route('documenti.edit')``url('/documenti/' . $doc->id . '/edit')` (route non esiste)
### 2026-06-09 — Fix: storeCustom() non salvava tag_filter
**Problema**: `ReportController@storeCustom()` non salvava `tag_filter` e `tag_filter_mode` nella config del report personalizzato. Quando un report salvato veniva eseguito, `runCustomReport()` non trovava i filtri tag nella config e non li applicava.
**Fix**: Aggiunte righe 143-148 in `ReportController.php`:
```php
if ($request->has('tag_filter')) {
$config['tag_filter'] = $request->input('tag_filter');
}
if ($request->filled('tag_filter_mode')) {
$config['tag_filter_mode'] = $request->input('tag_filter_mode');
}
```
### 2026-06-09 — Fix: MailingList edit — "Carica contatti" non inseriva i selezionati
**Problema**: Due bug nel JS `caricaSelezionati()` in `mailing-liste/edit.blade.php`:
1. **Typo**: `ind.cogname` invece di `ind.cognome` — il campo Cognome nella tabella era sempre vuoto/undefined (API restituisce `cognome`)
2. **Email gate**: `if (ind.emails && ind.emails.length > 0)` filtro bloccante — individui senza email salvata in `contatti` venivano SILENZIOSAMENTE scartati, mai aggiunti alla tabella
**Fix**:
1. `ind.cogname``ind.cognome` (righe 227 e 256)
2. Rimossa condizione `if (ind.emails...)` — ora anche individui senza email vengono aggiunti alla tabella
3. `email: ind.emails[0]``email: (ind.emails && ind.emails.length > 0) ? ind.emails[0] : ''` — email vuota se nessuna presente
4. Controller già fixato per auto-risolvere email da `Individuo::contatti()` al salvataggio
### 2026-06-09 — Fix: MailingListController email gate bloccava nuovi contatti senza email
**Problema**: `store()` e `update()` in `MailingListController` usavano `!empty($contatto['email'])` per decidere se creare un contatto. L'email era richiesta ma il MailingContact non memorizza l'email — la risolve dall'Individuo al momento del display/invio. Questo impediva di aggiungere individui senza email (o con email non nei contatti) alla mailing list.
**Fix**: Rimossa completamente la verifica email sia in `store()` che in `update()`. Ora ogni `individuo_id` valido viene aggiunto come MailingContact. L'email viene risolta al display dal model `$contact->individuo->contatti->where('tipo', 'email')->first()?->valore`.
Prima (bloccante):
```php
if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) { create }
```
Dopo (semplice):
```php
if (!empty($contatto['individuo_id'])) { create }
```
## Prossimi Passi
- [DONE] ... (existing items)
- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag
- [DONE] Fix performance: spostata query `VistaReport::where(...)->get()` da `@php` nel partial `table-settings.blade.php` a tutti i 5 controller (prima veniva eseguita 1 query per ogni pagina load per ogni entity, anche senza mai aprire la modale)
- [DONE] Rimosse vecchie modal legacy (`#saveVistaModal`, `#colonneModal`, `#vistaListModal`) da `individui` e `gruppi` — duplicate rispetto al nuovo modal unificato
- [DONE] Rimosse vecchie funzioni JS (`saveVista()`, `toggleColumn()`, `showSaveVistaModal()`) da `individui` e `gruppi`
- [DONE] Individui: `$allColumns` espanse da 6 a 17 colonne (aggiunte: data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at) — tutte con `<th>`/`<td>` condizionali nella vista
- [DONE] Individui: `getColumnIndex()` introdotta per lookup dinamico dell'indice colonna (sostituisce hardcoded `{codice:1, cognome:2, ...}` in `sortTable()` e `applyColumnFilter()`)
- [DONE] I controller per eventi, documenti, mailing-liste hanno già `$tableColumns` con flag `visible` — stesso meccanismo di gruppi. Nessuna modifica necessaria.
- [DONE] Fix: pulsante edit vista usa `data-*` attributi invece di `onclick` con `addslashes(json_encode(...))` — previene rottura HTML per JSON contenente `"`
- [DA FARE] Test browser: caricare ogni pagina entity, verificare default vista, toggle colonne, resize, drag-reorder, print
- [DA FARE] Test browser: flusso edit vista (✏️ → popola form → modifica → Aggiorna → reload)
- [DA FARE] Test browser: salvare nuova vista, switching tra viste, cancellazione vista
## 2026-06-10 — Per-User Column Views (colonne visibili, larghezze, ordine)
**Obiettivo**: Implementare viste colonne personalizzabili per utente su tutte le 5 entity list pages (individui, gruppi, eventi, documenti, mailing-liste), con persistenza tra login.
### Cosa è stato fatto
**Migration** (`2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php`):
- Aggiunge `colonne_larghezze` (json) e `colonne_ordine` (json) a `viste_report`
- Guardia `Schema::hasColumn` per compatibilità cross-DB (MySQL ↔ SQLite)
- Già eseguita in batch 38
**Backend**:
- `VistaReport.php`: `$fillable` + `$casts` (array) per entrambi i nuovi campi
- `VistaReportController@store`/`@update`: validazione estesa con `colonne_larghezze`, `colonne_ordine` (array)
- Tutti i 5 controller passano alla vista: `$tableColumns` (array `['key','label','visible']`), `$entityType`, `$columnWidths`, `$visibleColumns`, `$allColumns` (varia nome in base al controller)
- Tutti i 5 controller ora passano `$userVistas` (precaricate) invece di eseguire query nella view
- `IndividuoController`: `$allColumns` espanse con tutte le colonne DB disponibili (da 6 a 17)
- `IndividuoController`: aggiunto `use App\Models\VistaReport` per la query `$userVistas`
**Frontend JS** (`public/js/column-manager.js`):
- Classe `ColumnManager`: resize via mouse drag su handle, reorder via HTML5 DnD, persistenza larghezze in `sessionStorage`, fallback da `vista-data` DOM element, `printWithSettings()` che clona la tabella in finestra print-ready
**Modal** (`resources/views/partials/table-settings.blade.php`):
- Modal unificato con 3 tab: Colonne (checkbox visibilità + input larghezza + drag-reorder list), Viste (salva/carica/cancella), Stampa (pulsante print)
- Definisce `initTableSettings()` (NON auto-esegue) per evitare race condition con ColumnManager
- **RIMOSSA** query `VistaReport::where(...)->get()` dal `@php` block — ora usa `$userVistas` passato dal controller
- Non usa più `Auth::id()` direttamente — riceve `$userVistas` già popolato
**Views** — tutte e 5 aggiornate:
- `individui/index.blade.php`:
- `$allColumns` nella vista ora ha 17 colonne (vs 6 prima)
- `<th>` e `<td>` condizionali per tutte le nuove colonne (data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at)
- RIMOSSE modal legacy: `#saveVistaModal`, `#colonneModal`
- RIMOSSE funzioni JS legacy: `saveVista()`, `toggleColumn()`
- RIMOSSO sync checkboxes nel `DOMContentLoaded` (non più necessari)
- NUOVA: `getColumnIndex(colKey)` — lookup dinamico dell'indice colonna basato su `data-column`
- `sortTable()` e `applyColumnFilter()` ora usano `getColumnIndex()` invece di hardcoded mappa
- `gruppi/index.blade.php`:
- RIMOSSE modal legacy: `#saveVistaModal`, `#vistaListModal`
- RIMOSSO pulsante "Salva Vista" legacy
- RIMOSSE funzioni JS legacy: `showSaveVistaModal()`, `saveVista()`
- `eventi/index.blade.php`: colonne dinamiche via `$visibleColumns`, `@section('scripts')` con ColumnManager + `initTableSettings()`
- `mailing-liste/index.blade.php`: stessa struttura
- `documenti/index.blade.php`: ColumnManager init (non reorderable), column-manager.js incluso
- Tutti i `<th>` hanno `data-column` attributo per mapping JS
- Tutte chiamano `initTableSettings()` dopo `new ColumnManager()` nel `DOMContentLoaded`
**Performance fix**:
- Rimossa query `VistaReport::where(...)->get()` dal `@php` block in `table-settings.blade.php` (1 query extra per ogni pagina load su 5 entità)
- Spostata nei 5 controller come `$userVistas` passata via `compact()`
- Totale: -5 query per pagina load (una per ogni entity, anche su pagine non di index)
**Verifica**:
- JS brace balance: OK su tutti i 5 file (node check)
- PHP lint: OK su tutti i 5 controller
- Presenza `data-column`: individui=19, gruppi=14, eventi=9, mailing-liste=7, documenti=8
- Fallback `$visibleColumns` quando `$vista === null`: presente e corretto in tutti i 5 controller
- Nessun residuo di vecchie modal legacy in individui e gruppi
- Rimosso bottone "Salva Vista" orfano da `individui/index.blade.php` (chiamava `showSaveVistaModal()` non più esistente)
- Aggiunto pulsante modifica ✏️ nel tab Viste per ogni vista salvata: popola il form "Salva vista corrente" con nome, default flag e colonne visibili; cambia bottone in "Aggiorna" e fa PUT `/viste/{id}` invece di POST; zero query extra
### 2026-06-10 — Fix: editVista onclick → data-* attributes
**Problema**: Il pulsante modifica vista usava `onclick` con `addslashes(json_encode(...))`. In un attributo HTML delimitato da `"`, il backslash-escaping di `\"` non è gestito in modo standard dai browser — il JSON contenente `"` poteva rompere l'attributo HTML.
**Fix**:
1. Sostituito `onclick` con `data-*` attributi (`data-vista-id`, `data-vista-nome`, `data-vista-default`, `data-vista-colonne`) usando `{{ }}` di Blade (che applica `htmlspecialchars`, codificando `"``&quot;` sicuro in HTML).
2. `editVista()` ora accetta un array direttamente (non più JSON string da parsare).
3. Click handler registrato in `initTableSettings()` via `document.querySelectorAll('.edit-vista-btn')` + `dataset` (browser decodifica automaticamente `&quot;``"`).
### 2026-06-10 — Fix: 405 Method Not Allowed su update vista (via AJAX fetch)
**Problema**: Modificando una vista e cliccando "Aggiorna", `fetch()` inviava `PUT /viste/{id}` ma il controller `VistaReportController@update` restituiva `return back()` (302 redirect). `fetch()` seguiva il redirect — in alcuni browser il metodo PUT veniva preservato, ma la route `gruppi` accetta solo GET → 405 Method Not Allowed.
**Fix**:
1. `VistaReportController@update` (linea 101-103): aggiunto `if ($request->expectsJson()) { return response()->json([...]); }` — così il fetch riceve JSON 200, non un redirect 302.
2. Fetch headers in `table-settings.blade.php`: aggiunto `'Accept': 'application/json'` — necessario perché `expectsJson()` controlla l'header `Accept`, non `Content-Type`.
## 2026-06-10 — Fix: tutte le migration idempotenti + seeders idempotenti
**Problema**: `php artisan migrate` falliva su tabelle già esistenti. `php artisan db:seed` creava duplicati su re-run.
### Migration: hasTable guard su tutti i 27 file mancanti
Aggiunto `if (!Schema::hasTable('table_name')) { ... }` wrapper a tutte le 27 migration file (44 `Schema::create()` calls) che ne erano prive, usando script Node.js di trasformazione automatica:
- `2024_01_01_000002_create_comuni_diocesi_tables.php`
- `2024_01_01_000003_create_individui_table.php`
- `2024_01_01_000004_create_contatti_table.php`
- `2024_01_01_000005_create_gruppi_table.php`
- `2024_01_01_000006_create_gruppo_individuo_table.php`
- `2024_01_01_000007_create_eventi_table.php`
- `2024_01_01_000008_create_documenti_table.php`
- `2024_01_01_000009_create_mailing_tables.php`
- `2024_01_01_000010_create_notifiche_table.php`
- `2024_01_01_000011_create_users_table.php`
- `2024_01_01_000012_create_cache_and_jobs_tables.php`
- `2024_01_01_000019_create_eventi_documenti_table.php`
- `2024_01_01_000022_create_table_viste_report.php`
- `2026_05_10_000001_create_tipologie_documenti_table.php`
- `2026_05_10_000002_add_acl_tables.php`
- `2026_05_10_125210_create_permission_tables.php` (usa `$tableNames[...]` dinamici)
- `2026_05_11_000001_create_email_settings_table.php`
- `2026_05_11_000002_create_email_folders_table.php`
- `2026_05_11_000003_create_email_messages_table.php`
- `2026_05_11_000007_create_email_attachments_table.php`
- `2026_05_12_000001_create_ruoli_table.php`
- `2026_05_25_000001_create_report_custom_table.php`
- `2026_05_26_000001_create_tipologie_eventi_table.php`
- `2026_05_26_000002_create_sender_accounts_table.php`
- `2026_05_27_000001_create_documenti_cartelle_table.php`
- `2026_05_27_113245_create_storage_repositories_table.php`
- `2026_06_02_000001_create_calendario_connessioni_table.php`
### Seeder: firstOrCreate per idempotenza
- `DiocesiSeeder.php`: `Diocesi::create()``Diocesi::firstOrCreate(['nome' => $d['nome']], $d)`
- `ComuniSeeder.php`: `Comune::create()``Comune::firstOrCreate(['codice_istat' => $c['codice_istat']], $c)`
- `DatabaseSeeder.php`: `User::create()``User::firstOrCreate(['email' => 'admin@glastree.local'], [...])`, con guardia su `role_preset_id`
**Risultato**: `php artisan migrate --seed` è ora completamente idempotente. Zero errori su 62 migration + 6 seeder.
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\EmailSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class DiagnoseEmail extends Command
{
protected $signature = 'diagnose:email';
protected $description = 'Diagnostica configurazione email e credenziali';
public function handle(): int
{
$this->info('=== Diagnostica Email Settings ===');
$this->newLine();
// 1. APP_KEY
$this->section('APP_KEY');
$appKey = config('app.key');
if ($appKey && str_starts_with($appKey, 'base64:')) {
$this->line('APP_KEY: presente e valida ✓');
} elseif ($appKey) {
$this->warn('APP_KEY: presente ma non in formato base64 (potrebbe funzionare lo stesso)');
} else {
$this->error('APP_KEY: NON IMPOSTATA — crittografia password non funzionerà!');
}
// 2. Tabella email_settings
$this->newLine();
$this->section('Tabella email_settings');
if (Schema::hasTable('email_settings')) {
$this->line('Tabella email_settings: esiste ✓');
$columns = Schema::getColumnListing('email_settings');
$this->line('Colonne: ' . implode(', ', $columns));
$required = ['imap_host', 'imap_username', 'imap_password', 'email_address'];
$missing = array_diff($required, $columns);
if ($missing) {
$this->error('Colonne mancanti: ' . implode(', ', $missing));
} else {
$this->line('Colonne richieste presenti ✓');
}
} else {
$this->error('Tabella email_settings NON ESISTE! Esegui: php artisan migrate');
}
// 3. Record email_settings
$this->newLine();
$this->section('Record email_settings');
$settings = EmailSetting::first();
if ($settings) {
$this->line('Record trovato (ID: ' . $settings->id . ')');
$this->line(' imap_host: ' . ($settings->imap_host ?: 'VUOTO'));
$this->line(' imap_username: ' . ($settings->imap_username ?: 'VUOTO'));
$this->line(' imap_password: ' . ($settings->imap_password ? '[CRITTATO]' : 'VUOTO'));
$this->line(' email_address: ' . ($settings->email_address ?: 'VUOTO'));
$this->line(' smtp_host: ' . ($settings->smtp_host ?: 'VUOTO'));
// Prova decrittazione
if ($settings->imap_password) {
try {
$decrypted = Crypt::decryptString($settings->imap_password);
$this->line(' -> password IMAP decrittata: ' . (strlen($decrypted) > 0 ? 'OK (' . strlen($decrypted) . ' caratteri)' : 'VUOTA'));
} catch (\Exception $e) {
$this->error(' -> ERRORE decrittazione password IMAP: ' . $e->getMessage());
$this->warn(' Causa probabile: APP_KEY diversa da quando è stata salvata.');
}
}
} else {
$this->warn('NESSUN record in email_settings — nessuna configurazione salvata.');
$this->line('Vai su /impostazioni#email-imap, compila e clicca "Salva Impostazioni Email"');
}
// 4. Tabella sender_accounts
$this->newLine();
$this->section('Tabella sender_accounts');
if (Schema::hasTable('sender_accounts')) {
$this->line('Tabella sender_accounts: esiste ✓');
$count = DB::table('sender_accounts')->count();
$this->line('Record presenti: ' . $count);
} else {
$this->error('Tabella sender_accounts NON ESISTE!');
}
// 5. Log recenti
$this->newLine();
$this->section('Errori recenti (storage/logs/laravel.log)');
$logPath = storage_path('logs/laravel.log');
if (file_exists($logPath)) {
$lines = $this->getLastErrorLines($logPath, 20);
if ($lines) {
foreach ($lines as $line) {
$this->line($line);
}
} else {
$this->line('Nessun errore recente trovato.');
}
} else {
$this->warn('File di log non trovato: ' . $logPath);
}
// 6. Estensioni PHP
$this->newLine();
$this->section('Estensioni PHP');
$requiredExts = ['openssl', 'json', 'mbstring', 'pdo', 'fileinfo'];
foreach ($requiredExts as $ext) {
if (extension_loaded($ext)) {
$this->line($ext . ': OK ✓');
} else {
$this->error($ext . ': MANCANTE!');
}
}
$this->newLine();
$this->info('=== Diagnostica completata ===');
return self::SUCCESS;
}
private function section(string $title): void
{
$this->line("--- {$title} ---");
}
private function getLastErrorLines(string $path, int $maxLines): array
{
$lines = [];
$fp = fopen($path, 'r');
if (!$fp) {
return $lines;
}
fseek($fp, -1, SEEK_END);
$pos = ftell($fp);
$buffer = '';
while ($pos > 0 && count($lines) < $maxLines) {
$char = fgetc($fp);
if ($char === "\n") {
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
$buffer = '';
} else {
$buffer = $char . $buffer;
}
$pos--;
fseek($fp, $pos);
}
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
fclose($fp);
return $lines;
}
}
+4
View File
@@ -132,6 +132,7 @@ class ReportColumnRegistry
'contatti.telefono' => ['label' => 'Telefono (da contatti)', 'type' => 'relation'], 'contatti.telefono' => ['label' => 'Telefono (da contatti)', 'type' => 'relation'],
'contatti.cellulare' => ['label' => 'Cellulare (da contatti)', 'type' => 'relation'], 'contatti.cellulare' => ['label' => 'Cellulare (da contatti)', 'type' => 'relation'],
'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'], 'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
], ],
'gruppi' => [ 'gruppi' => [
@@ -151,6 +152,7 @@ class ReportColumnRegistry
'parent.nome' => ['label' => 'Gruppo Padre', 'type' => 'relation'], 'parent.nome' => ['label' => 'Gruppo Padre', 'type' => 'relation'],
'diocesi.nome' => ['label' => 'Diocesi', 'type' => 'relation'], 'diocesi.nome' => ['label' => 'Diocesi', 'type' => 'relation'],
'individui_count' => ['label' => 'Numero Membri', 'type' => 'relation'], 'individui_count' => ['label' => 'Numero Membri', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
], ],
'eventi' => [ 'eventi' => [
@@ -171,6 +173,7 @@ class ReportColumnRegistry
'info_ricorrenza' => ['label' => 'Info Ricorrenza', 'type' => 'accessor'], 'info_ricorrenza' => ['label' => 'Info Ricorrenza', 'type' => 'accessor'],
'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'], 'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'],
'responsabili.nome_completo' => ['label' => 'Responsabili', 'type' => 'relation'], 'responsabili.nome_completo' => ['label' => 'Responsabili', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
], ],
'documenti' => [ 'documenti' => [
@@ -183,6 +186,7 @@ class ReportColumnRegistry
'note' => ['label' => 'Note', 'type' => 'direct'], 'note' => ['label' => 'Note', 'type' => 'direct'],
'created_at' => ['label' => 'Data Caricamento', 'type' => 'direct'], 'created_at' => ['label' => 'Data Caricamento', 'type' => 'direct'],
'user.name' => ['label' => 'Caricato Da', 'type' => 'relation'], 'user.name' => ['label' => 'Caricato Da', 'type' => 'relation'],
'tags.nome' => ['label' => 'Tag', 'type' => 'relation'],
], ],
'contatti' => [ 'contatti' => [
@@ -34,11 +34,15 @@ class EmailSettingsController extends Controller
'smtp_host' => 'nullable|string|max:255', 'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535', 'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:ssl,tls,none', 'smtp_encryption' => 'nullable|in:ssl,tls,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'email_address' => 'required|email', 'email_address' => 'required|email',
'email_name' => 'nullable|string|max:255', 'email_name' => 'nullable|string|max:255',
'reply_to' => 'nullable|email', 'reply_to' => 'nullable|email',
'sync_interval_minutes' => 'nullable|integer|min:1|max:60', 'sync_interval_minutes' => 'nullable|integer|min:1|max:60',
'is_active' => 'boolean', 'is_active' => 'boolean',
'signature' => 'nullable|string',
'signature_enabled' => 'boolean',
]); ]);
if (!empty($validated['imap_password'])) { if (!empty($validated['imap_password'])) {
@@ -62,6 +66,24 @@ class EmailSettingsController extends Controller
if ($verify && if ($verify &&
$verify->imap_host === $validated['imap_host'] && $verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) { $verify->email_address === $validated['email_address']) {
$signatureMatch = !array_key_exists('signature', $validated) || $verify->signature === $validated['signature'];
$signatureEnabledMatch = !array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled'];
if (!$signatureMatch) {
\Illuminate\Support\Facades\Log::warning('Signature mismatch after save', [
'expected' => $validated['signature'] ?? null,
'actual' => $verify->signature,
]);
}
if (!$signatureEnabledMatch) {
\Illuminate\Support\Facades\Log::warning('Signature enabled mismatch after save', [
'expected' => $validated['signature_enabled'] ?? null,
'actual' => $verify->signature_enabled,
]);
}
return back()->with('success', 'Impostazioni email salvate e verificate nel database.'); return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else { } else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.'); return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -126,6 +126,52 @@ class CalendarioConnessioneController extends Controller
return redirect('/impostazioni#calendario')->with('error', 'Errore sync: ' . $errorMsg); return redirect('/impostazioni#calendario')->with('error', 'Errore sync: ' . $errorMsg);
} }
public function syncAll(): JsonResponse
{
$this->authorizeWrite('settings');
$connections = CalendarioConnessione::where('is_active', true)->get();
$results = [];
foreach ($connections as $connessione) {
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->syncEvents($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->syncEvents($connessione),
default => ['success' => false, 'imported' => 0, 'exported' => 0, 'errors' => ['Tipo sconosciuto']],
};
$connessione->update(['last_sync_at' => now()]);
if ($result['success']) {
$connessione->update(['stato' => 'connesso']);
} else {
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $errorMsg]);
}
$results[] = [
'id' => $connessione->id,
'nome' => $connessione->nome,
'success' => $result['success'],
'imported' => $result['imported'],
'exported' => $result['exported'],
'errors' => $result['errors'],
];
}
$totalImported = array_sum(array_column($results, 'imported'));
$totalExported = array_sum(array_column($results, 'exported'));
$hasErrors = in_array(false, array_column($results, 'success'), true);
return response()->json([
'success' => !$hasErrors,
'results' => $results,
'total_imported' => $totalImported,
'total_exported' => $totalExported,
'connections' => count($results),
]);
}
public function reorder(Request $request): JsonResponse public function reorder(Request $request): JsonResponse
{ {
$this->authorizeWrite('settings'); $this->authorizeWrite('settings');
+90 -4
View File
@@ -11,6 +11,7 @@ use App\Models\Individuo;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Evento; use App\Models\Evento;
use App\Models\StorageRepository; use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento; use App\Models\TipologiaDocumento;
use App\Services\StorageRepositoryService; use App\Services\StorageRepositoryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -42,9 +43,14 @@ class DocumentoController extends Controller
$folderId = $request->get('folder_id'); $folderId = $request->get('folder_id');
$repoId = $request->get('repository_id'); $repoId = $request->get('repository_id');
$query = Documento::with(['user', 'target', 'cartella', 'repository']) $query = Documento::with(['user', 'target', 'cartella', 'repository', 'tags'])
->orderByDesc('created_at'); ->orderByDesc('created_at');
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
if ($repoId) { if ($repoId) {
$query->where('repository_id', $repoId); $query->where('repository_id', $repoId);
} elseif ($folderId) { } elseif ($folderId) {
@@ -54,6 +60,7 @@ class DocumentoController extends Controller
} }
$documenti = $query->paginate(20); $documenti = $query->paginate(20);
$allTags = Tag::orderBy('name')->get();
$cartelle = DocumentoCartella::with('children') $cartelle = DocumentoCartella::with('children')
->whereNull('parent_id') ->whereNull('parent_id')
->orderBy('nome') ->orderBy('nome')
@@ -85,11 +92,45 @@ class DocumentoController extends Controller
'children' => $f->children->map(fn($c) => ['id' => $c->id, 'nome' => $c->nome]), 'children' => $f->children->map(fn($c) => ['id' => $c->id, 'nome' => $c->nome]),
]); ]);
$vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'documenti')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'documenti')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome_file', 'label' => 'Nome'],
['key' => 'tipologia', 'label' => 'Tipologia'],
['key' => 'dimensione', 'label' => 'Dimensione'],
['key' => 'visibilita', 'label' => 'Contesto'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'created_at', 'label' => 'Data'],
];
$defaultVisible = ['nome_file', 'tipologia', 'dimensione', 'visibilita', 'tag', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'documenti';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('documenti.index', compact( return view('documenti.index', compact(
'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle', 'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle',
'currentRepo', 'repositories', 'currentRepo', 'repositories',
'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie',
'cartelleMoveOptions' 'cartelleMoveOptions', 'allTags',
'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths',
'userVistas'
)); ));
} }
@@ -106,13 +147,15 @@ class DocumentoController extends Controller
public function edit($documento) public function edit($documento)
{ {
$this->authorizeWrite('documenti'); $this->authorizeWrite('documenti');
$documento = Documento::with('target')->findOrFail($documento); $documento = Documento::with(['target', 'tags'])->findOrFail($documento);
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
$eventi = Evento::orderBy('nome_evento')->get(); $eventi = Evento::orderBy('nome_evento')->get();
$mailingLists = \App\Models\MailingList::orderBy('nome')->get(); $mailingLists = \App\Models\MailingList::orderBy('nome')->get();
$tipologie = TipologiaDocumento::attive(); $tipologie = TipologiaDocumento::attive();
return view('documenti.edit', compact('documento', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie')); $tags = Tag::orderBy('name')->get();
$selectedTags = $documento->tags->pluck('id')->toArray();
return view('documenti.edit', compact('documento', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie', 'tags', 'selectedTags'));
} }
public function options(Request $request) public function options(Request $request)
@@ -165,6 +208,8 @@ class DocumentoController extends Controller
'visibilita_target_id' => 'nullable|integer', 'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string', 'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico'; $data['visibilita'] = $request->contesto_tipo ?: 'pubblico';
@@ -185,6 +230,12 @@ class DocumentoController extends Controller
$documento->update($data); $documento->update($data);
if ($request->has('tags')) {
$documento->tags()->sync($request->tags);
} else {
$documento->tags()->detach();
}
return redirect('/documenti')->with('success', 'Documento aggiornato.'); return redirect('/documenti')->with('success', 'Documento aggiornato.');
} }
@@ -706,6 +757,41 @@ class DocumentoController extends Controller
return back()->with('success', count($ids) . ' documenti associati.'); return back()->with('success', count($ids) . ' documenti associati.');
} }
public function massTag(Request $request)
{
$this->authorizeWrite('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Documento::whereIn('id', $ids)->chunk(100, function ($documenti) use ($tagIds, $mode, &$count) {
foreach ($documenti as $documento) {
if ($mode === 'assign') {
$documento->tags()->syncWithoutDetaching($tagIds);
} else {
$documento->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count documenti.");
}
public function massMove(Request $request): RedirectResponse public function massMove(Request $request): RedirectResponse
{ {
$this->authorizeWrite('documenti'); $this->authorizeWrite('documenti');
+19 -3
View File
@@ -59,7 +59,8 @@ class EmailController extends Controller
$query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir); $query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir);
$messages = $query->paginate(20)->withQueryString(); $perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$messages = $query->paginate($perPage)->withQueryString();
$folders = EmailFolder::orderBy('sort_order')->get(); $folders = EmailFolder::orderBy('sort_order')->get();
$mailingLists = MailingList::where('attiva', true)->get(); $mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
@@ -93,6 +94,8 @@ class EmailController extends Controller
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$senderAccounts = SenderAccount::active()->get(); $senderAccounts = SenderAccount::active()->get();
$documenti = Documento::orderBy('nome_file')->get();
$prefill = []; $prefill = [];
if ($request->reply_to) { if ($request->reply_to) {
$original = EmailMessage::find($request->reply_to); $original = EmailMessage::find($request->reply_to);
@@ -110,7 +113,7 @@ class EmailController extends Controller
} }
} }
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill', 'senderAccounts')); return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'documenti', 'prefill', 'senderAccounts'));
} }
public function send(Request $request) public function send(Request $request)
@@ -340,7 +343,7 @@ class EmailController extends Controller
$message->delete(); $message->delete();
} }
return back()->with('success', 'Email spostata nel cestino.'); return redirect()->route('email.index', ['folder' => 'inbox'])->with('success', 'Email spostata nel cestino.');
} }
public function downloadAttachment($id) public function downloadAttachment($id)
@@ -564,6 +567,7 @@ class EmailController extends Controller
private function processAttachments(Request $request): array private function processAttachments(Request $request): array
{ {
$paths = []; $paths = [];
if ($request->hasFile('allegati')) { if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) { foreach ($request->file('allegati') as $file) {
if ($file->isValid()) { if ($file->isValid()) {
@@ -571,6 +575,18 @@ class EmailController extends Controller
} }
} }
} }
$documentIds = $request->input('documenti_selezionati');
if (!empty($documentIds)) {
$ids = is_array($documentIds) ? $documentIds : explode(',', $documentIds);
$docs = Documento::whereIn('id', $ids)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
}
return $paths; return $paths;
} }
+160 -7
View File
@@ -5,9 +5,11 @@ namespace App\Http\Controllers;
use App\Models\Evento; use App\Models\Evento;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Tag;
use App\Models\TipologiaEvento; use App\Models\TipologiaEvento;
use App\Services\IcsExportService; use App\Services\IcsExportService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
class EventoController extends Controller class EventoController extends Controller
@@ -15,7 +17,12 @@ class EventoController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
$this->authorizeRead('eventi'); $this->authorizeRead('eventi');
$query = Evento::with(['gruppi', 'responsabili']); $query = Evento::with(['gruppi', 'responsabili', 'tags']);
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
if ($request->filled('search')) { if ($request->filled('search')) {
$query->where('nome_evento', 'like', '%' . $request->search . '%'); $query->where('nome_evento', 'like', '%' . $request->search . '%');
@@ -43,10 +50,47 @@ class EventoController extends Controller
$query->orderByDesc('created_at'); $query->orderByDesc('created_at');
} }
$eventi = $query->paginate(20); $perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$eventi = $query->paginate($perPage);
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
return view('eventi.index', compact('eventi', 'gruppi')); $vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'eventi')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'eventi')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome_evento', 'label' => 'Nome Evento'],
['key' => 'descrizione_evento', 'label' => 'Descrizione'],
['key' => 'tipo_evento', 'label' => 'Tipologia'],
['key' => 'tipo_recorrenza', 'label' => 'Ricorrenza'],
['key' => 'gruppi', 'label' => 'Gruppi'],
['key' => 'responsabili', 'label' => 'Responsabili'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'data_specifica', 'label' => 'Data'],
['key' => 'created_at', 'label' => 'Creato'],
];
$defaultVisible = ['nome_evento', 'descrizione_evento', 'tipo_evento', 'tipo_recorrenza', 'gruppi', 'responsabili', 'tag', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'eventi';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('eventi.index', compact('eventi', 'gruppi', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
} }
public function create(Request $request) public function create(Request $request)
@@ -56,8 +100,9 @@ class EventoController extends Controller
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null; $selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null;
$tipologieEventi = TipologiaEvento::attive(); $tipologieEventi = TipologiaEvento::attive();
$tags = Tag::orderBy('name')->get();
return view('eventi.create', compact('gruppi', 'individui', 'selectedGruppo', 'tipologieEventi')); return view('eventi.create', compact('gruppi', 'individui', 'selectedGruppo', 'tipologieEventi', 'tags'));
} }
public function store(Request $request) public function store(Request $request)
@@ -86,6 +131,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id', 'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string', 'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$evento = Evento::create([ $evento = Evento::create([
@@ -108,6 +155,10 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null, 'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]); ]);
if (!empty($data['tags'])) {
$evento->tags()->attach($data['tags']);
}
if (!empty($data['gruppi'])) { if (!empty($data['gruppi'])) {
$evento->gruppi()->attach($data['gruppi']); $evento->gruppi()->attach($data['gruppi']);
} }
@@ -122,19 +173,21 @@ class EventoController extends Controller
public function show($evento) public function show($evento)
{ {
$this->authorizeRead('eventi'); $this->authorizeRead('eventi');
$evento = Evento::with(['gruppi', 'responsabili.contatti', 'documenti'])->findOrFail($evento); $evento = Evento::with(['gruppi', 'responsabili.contatti', 'documenti', 'tags'])->findOrFail($evento);
return view('eventi.show', compact('evento')); return view('eventi.show', compact('evento'));
} }
public function edit($evento) public function edit($evento)
{ {
$this->authorizeWrite('eventi'); $this->authorizeWrite('eventi');
$evento = Evento::with(['gruppi', 'responsabili', 'documenti'])->findOrFail($evento); $evento = Evento::with(['gruppi', 'responsabili', 'documenti', 'tags'])->findOrFail($evento);
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$tipologieEventi = TipologiaEvento::attive(); $tipologieEventi = TipologiaEvento::attive();
$tags = Tag::orderBy('name')->get();
$selectedTags = $evento->tags->pluck('id')->toArray();
return view('eventi.edit', compact('evento', 'gruppi', 'individui', 'tipologieEventi')); return view('eventi.edit', compact('evento', 'gruppi', 'individui', 'tipologieEventi', 'tags', 'selectedTags'));
} }
public function update(Request $request, $evento) public function update(Request $request, $evento)
@@ -165,6 +218,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id', 'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string', 'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$evento->update([ $evento->update([
@@ -187,6 +242,7 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null, 'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]); ]);
$evento->tags()->sync($data['tags'] ?? []);
$evento->gruppi()->sync($data['gruppi'] ?? []); $evento->gruppi()->sync($data['gruppi'] ?? []);
$evento->responsabili()->sync($data['responsabili'] ?? []); $evento->responsabili()->sync($data['responsabili'] ?? []);
@@ -477,4 +533,101 @@ class EventoController extends Controller
return $colors['primary']; return $colors['primary'];
} }
public function importIcs()
{
$this->authorizeWrite('eventi');
return view('eventi.import');
}
public function importIcsStore(Request $request)
{
$this->authorizeWrite('eventi');
$request->validate([
'file' => 'required|file|mimes:ics,text/calendar',
]);
$file = $request->file('file');
$content = file_get_contents($file->path());
try {
$vcalendar = \Sabre\VObject\Reader::read($content);
} catch (\Exception $e) {
Log::warning('Errore lettura file ICS: ' . $e->getMessage());
return back()->with('error', 'Il file ICS non è valido: ' . $e->getMessage());
}
$imported = 0;
$skipped = 0;
$errors = [];
foreach ($vcalendar->VEVENT as $vevent) {
try {
$uid = (string) ($vevent->UID ?? '');
if (!empty($uid)) {
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
$skipped++;
continue;
}
}
$nomeEvento = (string) ($vevent->SUMMARY ?? '');
if (empty($nomeEvento)) {
$skipped++;
continue;
}
$dtStart = $vevent->DTSTART ?? null;
$dtEnd = $vevent->DTEND ?? null;
$dataSpecifica = null;
$oraInizio = null;
$durataMinuti = null;
if ($dtStart) {
$dtStartDateTime = new \DateTime((string) $dtStart);
$dataSpecifica = $dtStartDateTime->format('Y-m-d');
$oraInizio = $dtStartDateTime->format('H:i');
if ($dtEnd) {
$dtEndDateTime = new \DateTime((string) $dtEnd);
$durataMinuti = (int) ($dtEndDateTime->getTimestamp() - $dtStartDateTime->getTimestamp()) / 60;
if ($durataMinuti < 1) {
$durataMinuti = null;
}
}
}
$descrizione = (string) ($vevent->DESCRIPTION ?? '');
$luogo = (string) ($vevent->LOCATION ?? '');
Evento::create([
'nome_evento' => $nomeEvento,
'descrizione' => !empty($descrizione) ? $descrizione : null,
'tipo_recorrenza' => 'singolo',
'data_specifica' => $dataSpecifica,
'ora_inizio' => $oraInizio,
'durata_minuti' => $durataMinuti,
'luogo_indirizzo' => !empty($luogo) ? $luogo : null,
'uid_esterno' => !empty($uid) ? $uid : null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import evento ICS: ' . $e->getMessage());
$errors[] = 'Errore: ' . $e->getMessage();
}
}
$message = 'Importati ' . $imported . ' eventi.';
if ($skipped > 0) {
$message .= ' Saltati ' . $skipped . ' eventi (già presenti o senza nome).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect('/eventi')->with('success', $message);
}
} }
+234 -11
View File
@@ -7,7 +7,11 @@ namespace App\Http\Controllers;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Diocesi; use App\Models\Diocesi;
use App\Models\Tag;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller class GruppoController extends Controller
{ {
@@ -16,6 +20,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi'); $this->authorizeRead('gruppi');
$viewMode = request('view', 'table'); $viewMode = request('view', 'table');
$perPage = in_array((int) request('perPage'), [10, 20, 25, 50, 100]) ? (int) request('perPage') : 20;
$user = auth()->user(); $user = auth()->user();
$vista = \App\Models\VistaReport::where('user_id', $user->id) $vista = \App\Models\VistaReport::where('user_id', $user->id)
@@ -23,8 +28,28 @@ class GruppoController extends Controller
->where('is_default', true) ->where('is_default', true)
->first(); ->first();
$allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email']; $userVistas = \App\Models\VistaReport::where('user_id', $user->id)
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili']; ->where('tipo', 'gruppi')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$allColumnDefs = [
['key' => 'nome', 'label' => 'Nome'],
['key' => 'descrizione', 'label' => 'Descrizione'],
['key' => 'diocesi', 'label' => 'Diocesi'],
['key' => 'livello', 'label' => 'Livello'],
['key' => 'padre', 'label' => 'Gruppo Padre'],
['key' => 'membri', 'label' => 'Membri'],
['key' => 'responsabili', 'label' => 'Responsabili'],
['key' => 'indirizzo', 'label' => 'Indirizzo'],
['key' => 'citta', 'label' => 'Città'],
['key' => 'telefono', 'label' => 'Telefono'],
['key' => 'email', 'label' => 'Email'],
['key' => 'tag', 'label' => 'Tag'],
];
$allColumns = array_column($allColumnDefs, 'key');
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili', 'tag'];
if ($vista && !empty($vista->colonne_visibili)) { if ($vista && !empty($vista->colonne_visibili)) {
$visibleColumns = $vista->colonne_visibili; $visibleColumns = $vista->colonne_visibili;
@@ -32,9 +57,22 @@ class GruppoController extends Controller
$visibleColumns = $defaultVisible; $visibleColumns = $defaultVisible;
} }
$allGruppi = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar']) $tableColumns = array_map(fn($col) => [
->orderBy('nome') 'key' => $col['key'],
->get() 'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'gruppi';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
$gruppiQuery = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar', 'tags']);
if (request()->filled('tag')) {
$tagSlugs = (array) request()->input('tag');
$gruppiQuery->withAnyTags($tagSlugs);
}
$allGruppi = $gruppiQuery->orderBy('nome')->get()
->map(function ($gruppo) { ->map(function ($gruppo) {
$depth = 0; $depth = 0;
$parent = $gruppo->parent; $parent = $gruppo->parent;
@@ -47,17 +85,33 @@ class GruppoController extends Controller
}); });
if ($viewMode === 'table') { if ($viewMode === 'table') {
$gruppi = $this->sortGruppiHierarchically($allGruppi); $sorted = $this->sortGruppiHierarchically($allGruppi);
$page = Paginator::resolveCurrentPage();
$gruppi = new LengthAwarePaginator(
$sorted->forPage($page, $perPage)->values(),
$sorted->count(),
$perPage,
$page,
['path' => Paginator::resolveCurrentPath()]
);
$gruppi->appends(request()->except('page'));
} else { } else {
$gruppi = $allGruppi; $gruppi = $allGruppi;
} }
$allTags = Tag::orderBy('name')->get();
return view('gruppi.index', compact( return view('gruppi.index', compact(
'gruppi', 'gruppi',
'allColumns', 'allColumns',
'visibleColumns', 'visibleColumns',
'viewMode', 'viewMode',
'vista' 'vista',
'allTags',
'tableColumns',
'entityType',
'columnWidths',
'userVistas'
)); ));
} }
@@ -91,7 +145,8 @@ class GruppoController extends Controller
$allGruppi = Gruppo::orderBy('nome')->get(); $allGruppi = Gruppo::orderBy('nome')->get();
$selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null; $selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null;
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
return view('gruppi.create', compact('diocesi', 'allGruppi', 'selectedParent', 'individui')); $tags = Tag::orderBy('name')->get();
return view('gruppi.create', compact('diocesi', 'allGruppi', 'selectedParent', 'individui', 'tags'));
} }
public function store(Request $request) public function store(Request $request)
@@ -109,6 +164,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255', 'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2', 'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string', 'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -117,6 +174,10 @@ class GruppoController extends Controller
$gruppo = Gruppo::create($data); $gruppo = Gruppo::create($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
}
if ($request->has('individui')) { if ($request->has('individui')) {
foreach ($request->individui as $individuoData) { foreach ($request->individui as $individuoData) {
if (!empty($individuoData['individuo_id'])) { if (!empty($individuoData['individuo_id'])) {
@@ -136,7 +197,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi'); $this->authorizeRead('gruppi');
$gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) { $gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar', 'eventi' => function ($q) { }, 'avatar', 'tags', 'eventi' => function ($q) {
$q->where('is_incontro_gruppo', true); $q->where('is_incontro_gruppo', true);
}, 'eventi.responsabili.contatti'])->findOrFail($id); }, 'eventi.responsabili.contatti'])->findOrFail($id);
return view('gruppi.show', compact('gruppo')); return view('gruppi.show', compact('gruppo'));
@@ -145,11 +206,13 @@ class GruppoController extends Controller
public function edit($id) public function edit($id)
{ {
$this->authorizeWrite('gruppi'); $this->authorizeWrite('gruppi');
$gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'avatar'])->findOrFail($id); $gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'avatar', 'tags'])->findOrFail($id);
$diocesi = Diocesi::orderBy('nome')->get(); $diocesi = Diocesi::orderBy('nome')->get();
$gruppi = Gruppo::where('id', '!=', $gruppo->id)->orderBy('nome')->get(); $gruppi = Gruppo::where('id', '!=', $gruppo->id)->orderBy('nome')->get();
$membri = $gruppo->individui()->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione')->orderBy('cognome')->orderBy('nome')->get(); $membri = $gruppo->individui()->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione')->orderBy('cognome')->orderBy('nome')->get();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri')); $tags = Tag::orderBy('name')->get();
$selectedTags = $gruppo->tags->pluck('id')->toArray();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri', 'tags', 'selectedTags'));
} }
public function update(Request $request, $id) public function update(Request $request, $id)
@@ -168,6 +231,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255', 'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2', 'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string', 'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -178,6 +243,12 @@ class GruppoController extends Controller
$gruppo->update($data); $gruppo->update($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
} else {
$gruppo->tags()->detach();
}
if ($request->has('individui')) { if ($request->has('individui')) {
$gruppo->individui()->detach(); $gruppo->individui()->detach();
foreach ($request->individui as $individuoData) { foreach ($request->individui as $individuoData) {
@@ -300,4 +371,156 @@ class GruppoController extends Controller
return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.'); return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.');
} }
public function import()
{
$this->authorizeWrite('gruppi');
return view('gruppi.import');
}
public function importStore(Request $request)
{
$this->authorizeWrite('gruppi');
$request->validate([
'file' => 'required|file|mimes:csv,txt',
]);
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
$skipped++;
continue;
}
$data = array_combine($header, $row);
if ($data === false) {
$skipped++;
continue;
}
$data = array_map('trim', $data);
$nome = $data['nome'] ?? '';
if (empty($nome)) {
$skipped++;
continue;
}
try {
Gruppo::create([
'nome' => $nome,
'descrizione' => $data['descrizione'] ?? null,
'parent_id' => !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
'diocesi_id' => !empty($data['diocesi_id']) ? (int) $data['diocesi_id'] : null,
'indirizzo_incontro' => $data['indirizzo_incontro'] ?? null,
'cap_incontro' => $data['cap_incontro'] ?? null,
'città_incontro' => $data['città_incontro'] ?? null,
'sigla_provincia_incontro' => $data['sigla_provincia_incontro'] ?? null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import gruppo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
}
}
fclose($handle);
$message = 'Importati ' . $imported . ' gruppi.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect()->route('gruppi.index')->with('success', $message);
}
public function downloadTemplate()
{
$this->authorizeRead('gruppi');
$headers = ['nome', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', 'Giovani dai 18 ai 30 anni', '', '', 'Via Roma 10', '00100', 'Roma', 'RM'];
$csv = implode(',', $headers) . "\n" . implode(',', $example);
return response($csv, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="template_gruppi.csv"',
]);
}
public function massTag(Request $request)
{
$this->authorizeWrite('gruppi');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('gruppi.index')->with('error', 'Nessun gruppo selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Gruppo::whereIn('id', $ids)->chunk(100, function ($gruppi) use ($tagIds, $mode, &$count) {
foreach ($gruppi as $gruppo) {
if ($mode === 'assign') {
$gruppo->tags()->syncWithoutDetaching($tagIds);
} else {
$gruppo->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('gruppi.index')->with('success', "Tag {$actionLabel} per {$count} gruppi.");
}
public function massElimina(Request $request)
{
$this->authorizeDelete('gruppi');
$ids = $request->input('ids', []);
if (!is_array($ids) || empty($ids)) {
return redirect()->route('gruppi.index')->with('error', 'Nessun gruppo selezionato.');
}
$gruppi = Gruppo::whereIn('id', $ids)->get();
$count = 0;
foreach ($gruppi as $gruppo) {
$gruppo->individui()->detach();
$gruppo->delete();
$count++;
}
return redirect()->route('gruppi.index')->with('success', "{$count} gruppi eliminati con successo.");
}
} }
@@ -9,6 +9,7 @@ use App\Models\EmailSetting;
use App\Models\Ruolo; use App\Models\Ruolo;
use App\Models\SenderAccount; use App\Models\SenderAccount;
use App\Models\StorageRepository; use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento; use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento; use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -26,6 +27,7 @@ class ImpostazioniController extends Controller
$tipologie = TipologiaDocumento::orderBy('ordine')->get(); $tipologie = TipologiaDocumento::orderBy('ordine')->get();
$tipologieEventi = TipologiaEvento::orderBy('ordine')->get(); $tipologieEventi = TipologiaEvento::orderBy('ordine')->get();
$ruoli = Ruolo::orderBy('ordine')->get(); $ruoli = Ruolo::orderBy('ordine')->get();
$tags = Tag::orderBy('order_column')->get();
$appSettings = AppSetting::first(); $appSettings = AppSetting::first();
$emailSettings = EmailSetting::first() ?? new EmailSetting(); $emailSettings = EmailSetting::first() ?? new EmailSetting();
$senderAccounts = SenderAccount::orderBy('email_address')->get(); $senderAccounts = SenderAccount::orderBy('email_address')->get();
@@ -33,7 +35,7 @@ class ImpostazioniController extends Controller
$googleDriveNewToken = session('google_drive_new_token'); $googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get(); $calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni')); return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
} }
public function saveAppSettings(Request $request) public function saveAppSettings(Request $request)
@@ -371,6 +373,70 @@ class ImpostazioniController extends Controller
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
public function tagStore(Request $request)
{
$this->authorizeWrite('settings');
$validated = $request->validate([
'name' => 'required|string|max:100|unique:tags,name',
'color' => 'nullable|string|max:7',
]);
$maxOrdine = Tag::max('order_column') ?? 0;
Tag::create([
'name' => $validated['name'],
'slug' => str()->slug($validated['name']),
'color' => $validated['color'] ?? null,
'order_column' => $maxOrdine + 1,
]);
return redirect('/impostazioni#tag')->with('success', 'Tag aggiunto.');
}
public function tagUpdate(Request $request, $id)
{
$this->authorizeWrite('settings');
$tag = Tag::findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:100|unique:tags,name,' . $id,
'color' => 'nullable|string|max:7',
]);
$tag->update([
'name' => $validated['name'],
'slug' => str()->slug($validated['name']),
'color' => $validated['color'] ?? null,
]);
return redirect('/impostazioni#tag')->with('success', 'Tag aggiornato.');
}
public function tagDestroy($id)
{
$this->authorizeDelete('settings');
$tag = Tag::findOrFail($id);
$tag->delete();
return redirect('/impostazioni#tag')->with('success', 'Tag eliminato.');
}
public function tagReorder(Request $request)
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
Tag::where('id', $id)->update(['order_column' => $index]);
}
return response()->json(['success' => true]);
}
public function uploadLogo(Request $request) public function uploadLogo(Request $request)
{ {
$this->authorizeWrite('settings'); $this->authorizeWrite('settings');
@@ -398,6 +464,15 @@ class ImpostazioniController extends Controller
$settings->save(); $settings->save();
} }
$linkPath = public_path('storage');
if (!file_exists($linkPath) && !is_link($linkPath)) {
try {
symlink(storage_path('app/public'), $linkPath);
} catch (\Throwable $e) {
Log::warning('Impossibile creare symlink storage: ' . $e->getMessage());
}
}
return redirect('/impostazioni#logo')->with('success', 'Logo caricato con successo.'); return redirect('/impostazioni#logo')->with('success', 'Logo caricato con successo.');
} }
+139 -13
View File
@@ -5,19 +5,32 @@ namespace App\Http\Controllers;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Contatto; use App\Models\Contatto;
use App\Models\Documento; use App\Models\Documento;
use App\Models\Comune; use App\Models\Gruppo;
use App\Models\Diocesi; use App\Models\Comune;
use Illuminate\Http\Request; use App\Models\Diocesi;
use Illuminate\Support\Facades\Storage; use App\Models\Tag;
use Illuminate\Validation\Rule; use App\Models\VistaReport;
use Symfony\Component\HttpFoundation\StreamedResponse; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
class IndividuoController extends Controller class IndividuoController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$this->authorizeRead('individui'); $this->authorizeRead('individui');
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate(20); $perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$query = Individuo::with('contatti', 'tags');
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
$individui = $query->orderBy('cognome')->orderBy('nome')->paginate($perPage);
$allTags = Tag::orderBy('name')->get();
$vista = null; $vista = null;
@@ -38,16 +51,43 @@ class IndividuoController extends Controller
['key' => 'codice', 'label' => 'Codice'], ['key' => 'codice', 'label' => 'Codice'],
['key' => 'cognome', 'label' => 'Cognome'], ['key' => 'cognome', 'label' => 'Cognome'],
['key' => 'nome', 'label' => 'Nome'], ['key' => 'nome', 'label' => 'Nome'],
['key' => 'data_nascita', 'label' => 'Data Nascita'],
['key' => 'genere', 'label' => 'Genere'],
['key' => 'indirizzo', 'label' => 'Indirizzo'],
['key' => 'cap', 'label' => 'CAP'],
['key' => 'citta', 'label' => 'Città'],
['key' => 'provincia', 'label' => 'Provincia'],
['key' => 'tipo_documento', 'label' => 'Tipo Documento'],
['key' => 'numero_documento', 'label' => 'Numero Documento'],
['key' => 'scadenza_documento', 'label' => 'Scadenza Documento'],
['key' => 'email', 'label' => 'Email'], ['key' => 'email', 'label' => 'Email'],
['key' => 'telefono', 'label' => 'Telefono'], ['key' => 'telefono', 'label' => 'Telefono'],
['key' => 'note', 'label' => 'Note'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'created_at', 'label' => 'Creato il'],
]; ];
$defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono']; $defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono', 'tag'];
$visibleColumns = $vista && !empty($vista->colonne_visibili) $visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili ? $vista->colonne_visibili
: $defaultVisible; : $defaultVisible;
return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns')); $entityType = 'individui';
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumns);
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
$userVistas = VistaReport::where('user_id', auth()->id())
->where('tipo', 'individui')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
$gruppi = Gruppo::orderBy('nome')->get(['id', 'nome']);
return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns', 'allTags', 'gruppi', 'entityType', 'tableColumns', 'columnWidths', 'userVistas'));
} }
public function exportCSV(Request $request) public function exportCSV(Request $request)
@@ -109,7 +149,8 @@ public function create()
$this->authorizeWrite('individui'); $this->authorizeWrite('individui');
$comuni = Comune::orderBy('nome')->get(); $comuni = Comune::orderBy('nome')->get();
$diocesi = Diocesi::orderBy('nome')->get(); $diocesi = Diocesi::orderBy('nome')->get();
return view('individui.create', compact('comuni', 'diocesi')); $tags = Tag::orderBy('name')->get();
return view('individui.create', compact('comuni', 'diocesi', 'tags'));
} }
public function store(Request $request) public function store(Request $request)
@@ -128,10 +169,16 @@ public function create()
'numero_documento' => 'nullable|string|max:50', 'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date', 'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$individuo = Individuo::create($data); $individuo = Individuo::create($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
}
if ($request->has('contatti')) { if ($request->has('contatti')) {
foreach ($request->contatti as $contatto) { foreach ($request->contatti as $contatto) {
if (!empty($contatto['tipo']) && !empty($contatto['valore'])) { if (!empty($contatto['tipo']) && !empty($contatto['valore'])) {
@@ -153,7 +200,7 @@ public function create()
$this->authorizeRead('individui'); $this->authorizeRead('individui');
$individuo = Individuo::with(['contatti', 'gruppi' => function ($q) { $individuo = Individuo::with(['contatti', 'gruppi' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar'])->findOrFail($individuo); }, 'avatar', 'tags'])->findOrFail($individuo);
return view('individui.show', compact('individuo')); return view('individui.show', compact('individuo'));
} }
@@ -179,8 +226,10 @@ public function create()
public function edit($individuo) public function edit($individuo)
{ {
$this->authorizeWrite('individui'); $this->authorizeWrite('individui');
$individuo = Individuo::with(['contatti', 'gruppi', 'avatar'])->findOrFail($individuo); $individuo = Individuo::with(['contatti', 'gruppi', 'avatar', 'tags'])->findOrFail($individuo);
return view('individui.edit', compact('individuo')); $tags = Tag::orderBy('name')->get();
$selectedTags = $individuo->tags->pluck('id')->toArray();
return view('individui.edit', compact('individuo', 'tags', 'selectedTags'));
} }
public function update(Request $request, $individuo) public function update(Request $request, $individuo)
@@ -204,10 +253,18 @@ public function create()
'numero_documento' => 'nullable|string|max:50', 'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date', 'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$individuo->update($data); $individuo->update($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
} else {
$individuo->tags()->detach();
}
\Log::info('Individuo dati salvati', ['id' => $individuo->id]); \Log::info('Individuo dati salvati', ['id' => $individuo->id]);
if ($request->has('contatti')) { if ($request->has('contatti')) {
@@ -307,6 +364,75 @@ public function create()
return redirect()->route('individui.index')->with('success', count($individui) . ' individui eliminati con successo.'); return redirect()->route('individui.index')->with('success', count($individui) . ' individui eliminati con successo.');
} }
public function massGruppo(Request $request)
{
$this->authorizeWrite('individui');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('individui.index')->with('error', 'Nessun individuo selezionato.');
}
$data = $request->validate([
'gruppi' => 'required|array',
'gruppi.*' => 'exists:gruppi,id',
'mode' => 'required|in:assign,remove',
]);
$gruppiIds = $data['gruppi'];
$mode = $data['mode'];
$count = 0;
foreach ($ids as $id) {
$individuo = Individuo::find($id);
if (!$individuo) continue;
if ($mode === 'assign') {
$individuo->gruppi()->syncWithoutDetaching($gruppiIds);
} else {
$individuo->gruppi()->detach($gruppiIds);
}
$count++;
}
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('individui.index')->with('success', "Gruppi {$actionLabel} per {$count} individui.");
}
public function massTag(Request $request)
{
$this->authorizeWrite('individui');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return redirect()->route('individui.index')->with('error', 'Nessun individuo selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Individuo::whereIn('id', $ids)->chunk(100, function ($individui) use ($tagIds, $mode, &$count) {
foreach ($individui as $individuo) {
if ($mode === 'assign') {
$individuo->tags()->syncWithoutDetaching($tagIds);
} else {
$individuo->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return redirect()->route('individui.index')->with('success', "Tag {$actionLabel} per {$count} individui.");
}
public function elementiCollegati($individuo) public function elementiCollegati($individuo)
{ {
$individuo = Individuo::findOrFail($individuo); $individuo = Individuo::findOrFail($individuo);
+17 -1
View File
@@ -95,9 +95,17 @@ class MailingController extends Controller
} }
} }
$emails = $destinatari->flatMap(fn($ind) => $ind->contatti->where('tipo', 'email')->pluck('valore')) $totaleDestinatari = $destinatari->count();
$emails = $destinatari->map(fn($ind) => $ind->contatti->where('tipo', 'email')->first()?->valore)
->filter()->unique()->values(); ->filter()->unique()->values();
$skipped = $totaleDestinatari - $emails->count();
if ($skipped > 0) {
Log::info("Mailing invia: {$skipped} contatti saltati perché senza email", [
'lista_id' => $data['lista_id'] ?? null,
]);
}
if ($emails->isEmpty()) { if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput(); return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput();
} }
@@ -229,9 +237,17 @@ class MailingController extends Controller
->with('individuo.contatti') ->with('individuo.contatti')
->get(); ->get();
$totaleContatti = $contatti->count();
$emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore) $emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore)
->filter()->unique()->values(); ->filter()->unique()->values();
$skipped = $totaleContatti - $emails->count();
if ($skipped > 0) {
Log::info("Mailing invio elabora: {$skipped} contatti saltati perché senza email", [
'liste_ids' => $data['liste'],
]);
}
if ($emails->isEmpty()) { if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.'); return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.');
} }
+66 -9
View File
@@ -3,23 +3,64 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\Tag;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class MailingListController extends Controller class MailingListController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$this->authorizeRead('mailing'); $this->authorizeRead('mailing');
$mailingLists = MailingList::with(['user', 'contatti']) $query = MailingList::with(['user', 'contatti', 'tags']);
if ($request->filled('tag')) {
$tagSlugs = (array) $request->input('tag');
$query->withAnyTags($tagSlugs);
}
$mailingLists = $query->orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
$vista = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'mailing-liste')
->where('is_default', true)
->first();
$userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
->where('tipo', 'mailing-liste')
->orderBy('is_default', 'desc')
->orderBy('nome') ->orderBy('nome')
->get(); ->get();
return view('mailing-liste.index', compact('mailingLists'));
$allColumnDefs = [
['key' => 'nome', 'label' => 'Nome'],
['key' => 'descrizione', 'label' => 'Descrizione'],
['key' => 'contatti_count', 'label' => 'Contatti'],
['key' => 'tag', 'label' => 'Tag'],
['key' => 'attiva', 'label' => 'Stato'],
['key' => 'created_at', 'label' => 'Creata il'],
];
$defaultVisible = ['nome', 'descrizione', 'contatti_count', 'tag', 'attiva', 'created_at'];
$visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili
: $defaultVisible;
$tableColumns = array_map(fn($col) => [
'key' => $col['key'],
'label' => $col['label'],
'visible' => in_array($col['key'], $visibleColumns),
], $allColumnDefs);
$entityType = 'mailing-liste';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
return view('mailing-liste.index', compact('mailingLists', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
} }
public function create() public function create()
{ {
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
return view('mailing-liste.create'); $tags = Tag::orderBy('name')->get();
return view('mailing-liste.create', compact('tags'));
} }
public function store(Request $request) public function store(Request $request)
@@ -30,6 +71,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'attiva' => 'boolean', 'attiva' => 'boolean',
'contatti_json' => 'nullable|string', 'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['user_id'] = auth()->id(); $data['user_id'] = auth()->id();
@@ -42,11 +85,15 @@ class MailingListController extends Controller
'user_id' => $data['user_id'], 'user_id' => $data['user_id'],
]); ]);
if ($request->has('tags')) {
$lista->tags()->sync($request->tags);
}
if (!empty($data['contatti_json'])) { if (!empty($data['contatti_json'])) {
$contatti = json_decode($data['contatti_json'], true); $contatti = json_decode($data['contatti_json'], true);
if (is_array($contatti)) { if (is_array($contatti)) {
foreach ($contatti as $contatto) { foreach ($contatti as $contatto) {
if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) { if (!empty($contatto['individuo_id'])) {
$lista->contatti()->create([ $lista->contatti()->create([
'individuo_id' => $contatto['individuo_id'], 'individuo_id' => $contatto['individuo_id'],
'opt_in' => true, 'opt_in' => true,
@@ -63,15 +110,17 @@ class MailingListController extends Controller
public function show($mailing_liste) public function show($mailing_liste)
{ {
$this->authorizeRead('mailing'); $this->authorizeRead('mailing');
$mailingList = MailingList::with(['contatti.individuo.contatti'])->findOrFail($mailing_liste); $mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailing_liste);
return view('mailing-liste.show', compact('mailingList')); return view('mailing-liste.show', compact('mailingList'));
} }
public function edit($mailingList) public function edit($mailingList)
{ {
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
$mailingList = MailingList::with('contatti.individuo.contatti')->findOrFail($mailingList); $mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailingList);
return view('mailing-liste.edit', compact('mailingList')); $tags = Tag::orderBy('name')->get();
$selectedTags = $mailingList->tags->pluck('id')->toArray();
return view('mailing-liste.edit', compact('mailingList', 'tags', 'selectedTags'));
} }
public function update(Request $request, $mailingList) public function update(Request $request, $mailingList)
@@ -84,6 +133,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'attiva' => 'boolean', 'attiva' => 'boolean',
'contatti_json' => 'nullable|string', 'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['attiva'] = $data['attiva'] ?? false; $data['attiva'] = $data['attiva'] ?? false;
@@ -94,6 +145,12 @@ class MailingListController extends Controller
'attiva' => $data['attiva'], 'attiva' => $data['attiva'],
]); ]);
if ($request->has('tags')) {
$mailingList->tags()->sync($request->tags);
} else {
$mailingList->tags()->detach();
}
if (!empty($data['contatti_json'])) { if (!empty($data['contatti_json'])) {
$newContatti = json_decode($data['contatti_json'], true); $newContatti = json_decode($data['contatti_json'], true);
if (is_array($newContatti)) { if (is_array($newContatti)) {
@@ -107,7 +164,7 @@ class MailingListController extends Controller
$toAdd = array_diff($newIds, $existingIds); $toAdd = array_diff($newIds, $existingIds);
foreach ($newContatti as $contatto) { foreach ($newContatti as $contatto) {
if (in_array($contatto['individuo_id'], $toAdd) && !empty($contatto['email'])) { if (in_array($contatto['individuo_id'], $toAdd)) {
$mailingList->contatti()->create([ $mailingList->contatti()->create([
'individuo_id' => $contatto['individuo_id'], 'individuo_id' => $contatto['individuo_id'],
'opt_in' => true, 'opt_in' => true,
+158 -2
View File
@@ -11,6 +11,7 @@ use App\Models\Evento;
use App\Models\Documento; use App\Models\Documento;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\Contatto; use App\Models\Contatto;
use App\Models\Tag;
use App\Models\TipologiaDocumento; use App\Models\TipologiaDocumento;
use App\Models\ReportCustom; use App\Models\ReportCustom;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -47,12 +48,15 @@ class ReportController extends Controller
$columnOptions[$type] = ReportColumnRegistry::getColumns($type); $columnOptions[$type] = ReportColumnRegistry::getColumns($type);
} }
$allTags = Tag::orderBy('name')->get();
return view('report.index', compact( return view('report.index', compact(
'stats', 'stats',
'prebuiltReports', 'prebuiltReports',
'customReports', 'customReports',
'tipologieDocumento', 'tipologieDocumento',
'columnOptions' 'columnOptions',
'allTags'
)); ));
} }
@@ -136,6 +140,14 @@ class ReportController extends Controller
$config['search_columns'] = $request->input('search_columns'); $config['search_columns'] = $request->input('search_columns');
} }
if ($request->has('tag_filter')) {
$config['tag_filter'] = $request->input('tag_filter');
}
if ($request->filled('tag_filter_mode')) {
$config['tag_filter_mode'] = $request->input('tag_filter_mode');
}
ReportCustom::create([ ReportCustom::create([
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'nome' => $data['nome'], 'nome' => $data['nome'],
@@ -252,6 +264,9 @@ class ReportController extends Controller
'documenti_completo' => $this->reportDocumentiCompleto($tipologiaFilter), 'documenti_completo' => $this->reportDocumentiCompleto($tipologiaFilter),
'contatti_completo' => $this->reportContattiCompleto(), 'contatti_completo' => $this->reportContattiCompleto(),
'scadenze_documenti' => $this->reportScadenzeDocumenti($tipologiaFilter), 'scadenze_documenti' => $this->reportScadenzeDocumenti($tipologiaFilter),
'tag_statistiche' => $this->reportTagStatistiche(),
'individui_per_tag' => $this->reportIndividuiPerTag(),
'documenti_per_tag' => $this->reportDocumentiPerTag(),
default => null, default => null,
}; };
} }
@@ -403,6 +418,30 @@ class ReportController extends Controller
'colore' => 'teal', 'colore' => 'teal',
'has_tipologia_filter' => false, 'has_tipologia_filter' => false,
], ],
[
'id' => 'tag_statistiche',
'nome' => 'Statistiche Tag',
'descrizione' => 'Distribuzione degli elementi per tag (individui, gruppi, eventi, documenti, mailing list)',
'icona' => 'fa-tags',
'colore' => 'indigo',
'has_tipologia_filter' => false,
],
[
'id' => 'individui_per_tag',
'nome' => 'Individui per Tag',
'descrizione' => 'Elenco completo degli individui raggruppati per tag assegnato',
'icona' => 'fa-user-tag',
'colore' => 'info',
'has_tipologia_filter' => false,
],
[
'id' => 'documenti_per_tag',
'nome' => 'Documenti per Tag',
'descrizione' => 'Elenco completo dei documenti raggruppati per tag assegnato',
'icona' => 'fa-tags',
'colore' => 'secondary',
'has_tipologia_filter' => false,
],
]; ];
} }
@@ -927,6 +966,102 @@ class ReportController extends Controller
]; ];
} }
protected function reportTagStatistiche(): array
{
$tags = Tag::orderBy('name')->get();
$rows = $tags->map(function ($tag) {
return [
'name' => $tag->name,
'color' => $tag->color ?? '#6c757d',
'individui' => $tag->individui()->count(),
'gruppi' => $tag->gruppi()->count(),
'eventi' => $tag->eventi()->count(),
'documenti' => $tag->documenti()->count(),
'mailing' => $tag->mailingLists()->count(),
'totale' => $tag->count,
];
})->toArray();
$totalIndividui = array_sum(array_column($rows, 'individui'));
$totalGruppi = array_sum(array_column($rows, 'gruppi'));
$totalEventi = array_sum(array_column($rows, 'eventi'));
$totalDocumenti = array_sum(array_column($rows, 'documenti'));
$totalMailing = array_sum(array_column($rows, 'mailing'));
return [
'title' => 'Statistiche Tag',
'headers' => ['Tag', 'Individui', 'Gruppi', 'Eventi', 'Documenti', 'Mailing List', 'Totale Elementi'],
'rows' => array_map(fn($r) => [$r['name'], $r['individui'], $r['gruppi'], $r['eventi'], $r['documenti'], $r['mailing'], $r['totale']], $rows),
'summary' => 'Tag: ' . count($tags) . ' | Individui: ' . $totalIndividui . ' | Gruppi: ' . $totalGruppi . ' | Eventi: ' . $totalEventi . ' | Documenti: ' . $totalDocumenti . ' | Mailing List: ' . $totalMailing,
'detail_rows' => $rows,
];
}
protected function reportIndividuiPerTag(): array
{
$individui = Individuo::with('tags')
->whereHas('tags')
->orderBy('cognome')
->orderBy('nome')
->get();
$rows = $individui->flatMap(function ($ind) {
if ($ind->tags->isEmpty()) {
return collect();
}
return $ind->tags->map(function ($tag) use ($ind) {
return [
'tag' => $tag->name,
'tag_color' => $tag->color ?? '#6c757d',
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'email' => $ind->email_primaria ?? '-',
];
});
})->toArray();
return [
'title' => 'Individui per Tag',
'headers' => ['Tag', 'Codice', 'Cognome', 'Nome', 'Email'],
'rows' => array_map(fn($r) => [$r['tag'], $r['codice'], $r['cognome'], $r['nome'], $r['email']], $rows),
'summary' => 'Totale righe (individuo × tag): ' . count($rows),
'detail_rows' => $rows,
];
}
protected function reportDocumentiPerTag(): array
{
$documenti = Documento::with('tags')
->whereHas('tags')
->orderBy('nome_file')
->get();
$rows = $documenti->flatMap(function ($doc) {
if ($doc->tags->isEmpty()) {
return collect();
}
return $doc->tags->map(function ($tag) use ($doc) {
return [
'tag' => $tag->name,
'tag_color' => $tag->color ?? '#6c757d',
'nome_file' => $doc->nome_file,
'tipologia' => ucfirst((string) $doc->tipologia),
'data' => $doc->created_at->format('d/m/Y'),
];
});
})->toArray();
return [
'title' => 'Documenti per Tag',
'headers' => ['Tag', 'Nome File', 'Tipologia', 'Data Caricamento'],
'rows' => array_map(fn($r) => [$r['tag'], $r['nome_file'], $r['tipologia'], $r['data']], $rows),
'summary' => 'Totale righe (documento × tag): ' . count($rows),
'detail_rows' => $rows,
];
}
protected function runCustomReport(ReportCustom $report): array protected function runCustomReport(ReportCustom $report): array
{ {
$tipo = $report->tipo_report; $tipo = $report->tipo_report;
@@ -954,11 +1089,32 @@ class ReportController extends Controller
$columns = $config['columns'] ?? []; $columns = $config['columns'] ?? [];
$hasRelations = false; $hasRelations = false;
// Always load tags if tags column or tag filter is present
$needsTags = in_array('tags.nome', $columns) || !empty($config['tag_filter']);
if ($needsTags) {
$query->with('tags');
}
foreach ($columns as $col) { foreach ($columns as $col) {
if (str_contains($col, '.')) { if (str_contains($col, '.')) {
$hasRelations = true; $hasRelations = true;
$relation = explode('.', $col)[0]; $relation = explode('.', $col)[0];
$query->with($relation); if ($relation !== 'tags') {
$query->with($relation);
}
}
}
// Apply tag filter
if (!empty($config['tag_filter'])) {
$tagSlugs = Tag::whereIn('id', (array) $config['tag_filter'])->pluck('slug')->toArray();
if (!empty($tagSlugs)) {
$mode = $config['tag_filter_mode'] ?? 'any';
if ($mode === 'all') {
$query->withAllTags($tagSlugs);
} else {
$query->withAnyTags($tagSlugs);
}
} }
} }
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tag;
use App\Models\Individuo;
use App\Models\Gruppo;
use App\Models\Evento;
use App\Models\Documento;
use App\Models\MailingList;
use Illuminate\Http\Request;
class RicercaController extends Controller
{
public function index(Request $request)
{
$this->authorizeRead('individui');
$allTags = Tag::orderBy('name')->get();
$tagSlugs = (array) $request->input('tag', []);
if (empty($tagSlugs)) {
return view('ricerca.index', compact('allTags'));
}
$tagSlugs = array_filter($tagSlugs);
$selectedTags = Tag::whereIn('slug', $tagSlugs)->get();
$results = [];
$totals = [];
if (count($tagSlugs) === 1) {
$tag = $selectedTags->first();
$results['individui'] = $tag->individui()->orderBy('cognome')->orderBy('nome')->get();
$results['gruppi'] = $tag->gruppi()->orderBy('nome')->get();
$results['eventi'] = $tag->eventi()->orderBy('data_specifica')->orderBy('nome_evento')->get();
$results['documenti'] = $tag->documenti()->orderBy('nome_file')->get();
$results['mailingLists'] = $tag->mailingLists()->orderBy('nome')->get();
} else {
$results['individui'] = Individuo::withAllTags($tagSlugs)->orderBy('cognome')->orderBy('nome')->get();
$results['gruppi'] = Gruppo::withAllTags($tagSlugs)->orderBy('nome')->get();
$results['eventi'] = Evento::withAllTags($tagSlugs)->orderBy('data_specifica')->orderBy('nome_evento')->get();
$results['documenti'] = Documento::withAllTags($tagSlugs)->orderBy('nome_file')->get();
$results['mailingLists'] = MailingList::withAllTags($tagSlugs)->orderBy('nome')->get();
}
$totals = [
'individui' => $results['individui']->count(),
'gruppi' => $results['gruppi']->count(),
'eventi' => $results['eventi']->count(),
'documenti' => $results['documenti']->count(),
'mailingLists' => $results['mailingLists']->count(),
];
return view('ricerca.index', compact('selectedTags', 'tagSlugs', 'results', 'totals', 'allTags'));
}
}
+11 -1
View File
@@ -33,9 +33,11 @@ class VistaReportController extends Controller
$this->authorizeWrite('viste'); $this->authorizeWrite('viste');
$data = $request->validate([ $data = $request->validate([
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'tipo' => 'required|in:individui,gruppi,documenti,eventi', 'tipo' => 'required|in:individui,gruppi,documenti,eventi,mailing-liste',
'colonne_visibili' => 'nullable|array', 'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array', 'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array', 'filtri' => 'nullable|array',
'ricerca' => 'nullable|string', 'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean', 'is_default' => 'nullable|boolean',
@@ -55,6 +57,8 @@ class VistaReportController extends Controller
'tipo' => $data['tipo'], 'tipo' => $data['tipo'],
'colonne_visibili' => $data['colonne_visibili'] ?? null, 'colonne_visibili' => $data['colonne_visibili'] ?? null,
'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null, 'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null,
'colonne_larghezze' => $data['colonne_larghezze'] ?? null,
'colonne_ordine' => $data['colonne_ordine'] ?? null,
'filtri' => $data['filtri'] ?? null, 'filtri' => $data['filtri'] ?? null,
'ricerca' => $data['ricerca'] ?? null, 'ricerca' => $data['ricerca'] ?? null,
'is_default' => $isDefault, 'is_default' => $isDefault,
@@ -78,6 +82,8 @@ class VistaReportController extends Controller
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'colonne_visibili' => 'nullable|array', 'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array', 'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array', 'filtri' => 'nullable|array',
'ricerca' => 'nullable|string', 'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean', 'is_default' => 'nullable|boolean',
@@ -92,6 +98,10 @@ class VistaReportController extends Controller
$vistaReport->update($data); $vistaReport->update($data);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Vista aggiornata.']);
}
return back()->with('success', 'Vista aggiornata.'); return back()->with('success', 'Vista aggiornata.');
} }
+14 -12
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
class AppSetting extends Model class AppSetting extends Model
{ {
@@ -107,38 +109,38 @@ class AppSetting extends Model
public function getLogoUrlInstance(): ?string public function getLogoUrlInstance(): ?string
{ {
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) { if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return asset('storage/' . $this->logo); return Storage::disk('public')->url($this->logo);
} }
return null; return null;
} }
public function getLogoSmallUrlInstance(): ?string public function getLogoSmallUrlInstance(): ?string
{ {
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) { if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return asset('storage/' . $this->logo_small); return Storage::disk('public')->url($this->logo_small);
} }
return null; return null;
} }
public function getLogoPath(): ?string public function getLogoPath(): ?string
{ {
if ($this->logo_path && file_exists(public_path('storage/' . $this->logo_path))) { if ($this->logo_path && Storage::disk('public')->exists($this->logo_path)) {
return asset('storage/' . $this->logo_path); return Storage::disk('public')->url($this->logo_path);
} }
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) { if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return asset('storage/' . $this->logo); return Storage::disk('public')->url($this->logo);
} }
return null; return null;
} }
public function getLogoSmallPath(): ?string public function getLogoSmallPath(): ?string
{ {
if ($this->logo_small_path && file_exists(public_path('storage/' . $this->logo_small_path))) { if ($this->logo_small_path && Storage::disk('public')->exists($this->logo_small_path)) {
return asset('storage/' . $this->logo_small_path); return Storage::disk('public')->url($this->logo_small_path);
} }
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) { if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return asset('storage/' . $this->logo_small); return Storage::disk('public')->url($this->logo_small);
} }
return null; return null;
} }
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Documento extends Model class Documento extends Model
{ {
use HasTagsLight;
protected $table = 'documenti'; protected $table = 'documenti';
protected $fillable = [ protected $fillable = [
'tenant_id', 'user_id', 'cartella_id', 'repository_id', 'storage_disk', 'tenant_id', 'user_id', 'cartella_id', 'repository_id', 'storage_disk',
+3
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Evento extends Model class Evento extends Model
{ {
use HasTagsLight;
protected $table = 'eventi'; protected $table = 'eventi';
protected $fillable = [ protected $fillable = [
@@ -30,6 +32,7 @@ class Evento extends Model
'is_incontro_gruppo', 'is_incontro_gruppo',
'luogo_indirizzo', 'luogo_indirizzo',
'luogo_url_maps', 'luogo_url_maps',
'uid_esterno',
]; ];
protected $casts = [ protected $casts = [
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Gruppo extends Model class Gruppo extends Model
{ {
use HasTagsLight;
protected $table = 'gruppi'; protected $table = 'gruppi';
protected $fillable = [ protected $fillable = [
'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids', 'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids',
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Individuo extends Model class Individuo extends Model
{ {
use HasTagsLight;
protected $table = 'individui'; protected $table = 'individui';
protected $fillable = [ protected $fillable = [
'tenant_id', 'codice_id', 'cognome', 'nome', 'data_nascita', 'tenant_id', 'codice_id', 'cognome', 'nome', 'data_nascita',
+3
View File
@@ -5,9 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Traits\HasTagsLight;
class MailingList extends Model class MailingList extends Model
{ {
use HasTagsLight;
protected $table = 'mailing_lists'; protected $table = 'mailing_lists';
protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva']; protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva'];
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Tag extends Model
{
protected $table = 'tags';
protected $fillable = ['name', 'slug', 'color', 'order_column'];
protected $casts = [
'order_column' => 'integer',
];
protected static function boot(): void
{
parent::boot();
static::creating(function (self $tag) {
if (empty($tag->slug)) {
$tag->slug = str()->slug($tag->name);
}
});
}
public function individui(): MorphToMany
{
return $this->morphedByMany(Individuo::class, 'taggable');
}
public function gruppi(): MorphToMany
{
return $this->morphedByMany(Gruppo::class, 'taggable');
}
public function eventi(): MorphToMany
{
return $this->morphedByMany(Evento::class, 'taggable');
}
public function documenti(): MorphToMany
{
return $this->morphedByMany(Documento::class, 'taggable');
}
public function mailingLists(): MorphToMany
{
return $this->morphedByMany(MailingList::class, 'taggable');
}
public function getCountAttribute(): int
{
return $this->individui()->count()
+ $this->gruppi()->count()
+ $this->eventi()->count()
+ $this->documenti()->count()
+ $this->mailingLists()->count();
}
}
+4 -1
View File
@@ -10,13 +10,16 @@ class VistaReport extends Model
protected $table = 'viste_report'; protected $table = 'viste_report';
protected $fillable = [ protected $fillable = [
'user_id', 'nome', 'tipo', 'colonne_visibili', 'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca' 'colonne_ordinamento', 'filtri', 'ricerca', 'is_default',
'colonne_larghezze', 'colonne_ordine',
]; ];
protected $casts = [ protected $casts = [
'colonne_visibili' => 'array', 'colonne_visibili' => 'array',
'colonne_ordinamento' => 'array', 'colonne_ordinamento' => 'array',
'filtri' => 'array', 'filtri' => 'array',
'colonne_larghezze' => 'array',
'colonne_ordine' => 'array',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Traits;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasTagsLight
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function scopeWithAllTags($query, array $tags)
{
foreach ($tags as $tag) {
$query->whereHas('tags', fn ($q) => $q->where('slug', $tag));
}
return $query;
}
public function scopeWithAnyTags($query, array $tags)
{
$query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tags));
return $query;
}
}
Executable
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
NAME="${APP_NAME:-glastree}"
VERSION="$(date +%Y%m%d_%H%M)"
ARCHIVE="${NAME}-${VERSION}.tar.gz"
echo "==> Installazione dipendenze PHP production..."
composer install --no-dev --optimize-autoloader --no-scripts --quiet
echo " OK"
echo "==> Build asset Vite..."
if [ -f "node_modules/.package-lock.json" ] || [ -d "node_modules" ]; then
npm run build --quiet 2>/dev/null && echo " OK" || echo " (skip — node_modules non presente)"
else
echo " (skip — node_modules assente, build Vite non eseguita)"
fi
echo "==> Pulizia cache..."
rm -rf bootstrap/cache/*.php storage/framework/cache/data/* storage/framework/sessions/* storage/framework/views/* storage/logs/* 2>/dev/null || true
echo " OK"
echo "==> Creazione archivio: ${ARCHIVE}"
tar czf "${ARCHIVE}" \
--exclude='.git' \
--exclude='.gitignore' \
--exclude='.env' \
--exclude='.env.example' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='vendor/bin' \
--exclude='vendor/*/tests' \
--exclude='vendor/*/test' \
--exclude='vendor/*/.git' \
--exclude='vendor/*/docs' \
--exclude='vendor/*/doc' \
--exclude='storage/app/backups' \
--exclude='storage/app/documenti' \
--exclude='storage/app/public' \
--exclude='storage/app/private' \
--exclude='public/storage' \
--exclude='storage/framework/cache/*' \
--exclude='storage/framework/sessions/*' \
--exclude='storage/framework/views/*' \
--exclude='storage/logs/*' \
--exclude='storage/debugbar/*' \
--exclude='bootstrap/cache/*.php' \
--exclude='public/hot' \
--exclude='*.tar.gz' \
--exclude='*.tar' \
--exclude='MEMORY.md' \
--exclude='AGENTS.md' \
--exclude='build-dist.sh' \
--exclude='Dockerfile' \
--exclude='docker-compose.yml' \
--exclude='docker-compose.mysql.yml' \
--exclude='docker-entrypoint.sh' \
--exclude='.dockerignore' \
--warning=no-file-changed \
.
echo ""
echo "============================================"
echo " Archivio creato: ${ARCHIVE}"
echo " Dimensione: $(du -h "${ARCHIVE}" | cut -f1)"
echo "============================================"
echo ""
echo "Per estrarre sul server di destinazione:"
echo " tar xzf ${ARCHIVE}"
echo ""
echo "Poi esegui:"
echo " # 1. Crea directory necessarie (se non esistono già)"
echo ' mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs bootstrap/cache storage/app/public storage/app/public/logos storage/app/public/documenti/eventi storage/app/backups storage/app/documenti storage/app/private/avatars/gruppi'
echo ""
echo " # 2. Crea file .gitignore in storage/app/public (serve a Laravel)"
echo ' echo "*" > storage/app/public/.gitignore'
echo ' echo "!.gitignore" >> storage/app/public/.gitignore'
echo ""
echo " # 3. Permessi (web server = www-data)"
echo ' chmod -R 775 storage bootstrap/cache'
echo ' chown -R www-data:www-data storage bootstrap/cache'
echo ""
echo " # 4. Configura ambiente"
echo " cp .env.example .env # modifica DB, APP_URL, etc."
echo " php artisan key:generate"
echo ""
echo " # 5. Pulisci cache e ricrea symlink storage (relativo per portabilità)"
echo ' rm -f public/storage'
echo ' ln -sf ../storage/app/public public/storage'
echo " php artisan config:clear"
echo " php artisan route:clear"
echo " php artisan view:clear"
echo ""
echo " # 6. Avvia installazione MySQL"
echo " php install.php"
+3
View File
@@ -197,6 +197,7 @@ CREATE TABLE `gruppo_individuo` (
`gruppo_id` bigint unsigned NOT NULL, `gruppo_id` bigint unsigned NOT NULL,
`individuo_id` bigint unsigned NOT NULL, `individuo_id` bigint unsigned NOT NULL,
`ruolo_ids` json DEFAULT NULL, `ruolo_ids` json DEFAULT NULL,
`ruolo_nel_gruppo` varchar(255) DEFAULT NULL,
`data_adesione` date DEFAULT NULL, `data_adesione` date DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL, `created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL,
@@ -511,6 +512,7 @@ CREATE TABLE `eventi` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tenant_id` bigint unsigned DEFAULT NULL, `tenant_id` bigint unsigned DEFAULT NULL,
`nome_evento` varchar(255) NOT NULL, `nome_evento` varchar(255) NOT NULL,
`descrizione_evento` text,
`tipo_evento` varchar(255) DEFAULT NULL, `tipo_evento` varchar(255) DEFAULT NULL,
`tipo_recorrenza` enum('singolo','settimanale','mensile','annuale','altro') DEFAULT 'singolo', `tipo_recorrenza` enum('singolo','settimanale','mensile','annuale','altro') DEFAULT 'singolo',
`giorno_settimana` tinyint unsigned DEFAULT NULL, `giorno_settimana` tinyint unsigned DEFAULT NULL,
@@ -526,6 +528,7 @@ CREATE TABLE `eventi` (
`luogo_indirizzo` varchar(500) DEFAULT NULL, `luogo_indirizzo` varchar(500) DEFAULT NULL,
`luogo_url_maps` text, `luogo_url_maps` text,
`uid_esterno` varchar(500) DEFAULT NULL, `uid_esterno` varchar(500) DEFAULT NULL,
`is_incontro_gruppo` tinyint(1) NOT NULL DEFAULT '0',
`created_at` timestamp NULL DEFAULT NULL, `created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -8,6 +8,10 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (Schema::hasTable('tenants')) {
return;
}
Schema::create('tenants', function (Blueprint $table) { Schema::create('tenants', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
@@ -8,26 +8,30 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('diocesi', function (Blueprint $table) { if (!Schema::hasTable('diocesi')) {
$table->id(); Schema::create('diocesi', function (Blueprint $table) {
$table->string('nome'); $table->id();
$table->string('regione')->nullable(); $table->string('nome');
$table->timestamps(); $table->string('regione')->nullable();
}); $table->timestamps();
});
}
Schema::create('comuni', function (Blueprint $table) { if (!Schema::hasTable('comuni')) {
$table->id(); Schema::create('comuni', function (Blueprint $table) {
$table->string('nome'); $table->id();
$table->string('codice_istat', 10)->nullable(); $table->string('nome');
$table->string('cap')->nullable(); $table->string('codice_istat', 10)->nullable();
$table->string('sigla_provincia', 2)->nullable(); $table->string('cap')->nullable();
$table->string('regione')->nullable(); $table->string('sigla_provincia', 2)->nullable();
$table->float('latitudine')->nullable(); $table->string('regione')->nullable();
$table->float('longitudine')->nullable(); $table->float('latitudine')->nullable();
$table->timestamps(); $table->float('longitudine')->nullable();
$table->index('nome'); $table->timestamps();
$table->index('sigla_provincia'); $table->index('nome');
}); $table->index('sigla_provincia');
});
}
} }
public function down(): void public function down(): void
@@ -8,27 +8,29 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('individui', function (Blueprint $table) { if (!Schema::hasTable('individui')) {
$table->id(); Schema::create('individui', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->string('codice_id', 5)->unique(); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('cognome'); $table->string('codice_id', 5)->unique();
$table->string('nome'); $table->string('cognome');
$table->date('data_nascita')->nullable(); $table->string('nome');
$table->string('indirizzo')->nullable(); $table->date('data_nascita')->nullable();
$table->string('cap', 10)->nullable(); $table->string('indirizzo')->nullable();
$table->string('città')->nullable(); $table->string('cap', 10)->nullable();
$table->string('sigla_provincia', 2)->nullable(); $table->string('città')->nullable();
$table->enum('genere', ['M', 'F'])->nullable(); $table->string('sigla_provincia', 2)->nullable();
$table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable(); $table->enum('genere', ['M', 'F'])->nullable();
$table->date('scadenza_documento')->nullable(); $table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable();
$table->text('note')->nullable(); $table->date('scadenza_documento')->nullable();
$table->string('avatar_path')->nullable(); $table->text('note')->nullable();
$table->timestamps(); $table->string('avatar_path')->nullable();
$table->index('tenant_id'); $table->timestamps();
$table->index('codice_id'); $table->index('tenant_id');
$table->index('cognome'); $table->index('codice_id');
}); $table->index('cognome');
});
}
} }
public function down(): void public function down(): void
@@ -8,16 +8,18 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('contatti', function (Blueprint $table) { if (!Schema::hasTable('contatti')) {
$table->id(); Schema::create('contatti', function (Blueprint $table) {
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->id();
$table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->string('valore'); $table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']);
$table->string('etichetta')->nullable(); $table->string('valore');
$table->boolean('is_primary')->default(false); $table->string('etichetta')->nullable();
$table->timestamps(); $table->boolean('is_primary')->default(false);
$table->index('individuo_id'); $table->timestamps();
}); $table->index('individuo_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,24 +8,26 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('gruppi', function (Blueprint $table) { if (!Schema::hasTable('gruppi')) {
$table->id(); Schema::create('gruppi', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null'); $table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null');
$table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null'); $table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null');
$table->string('nome'); $table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null');
$table->text('descrizione')->nullable(); $table->string('nome');
$table->string('indirizzo_incontro')->nullable(); $table->text('descrizione')->nullable();
$table->string('cap_incontro', 10)->nullable(); $table->string('indirizzo_incontro')->nullable();
$table->string('città_incontro')->nullable(); $table->string('cap_incontro', 10)->nullable();
$table->string('sigla_provincia_incontro', 2)->nullable(); $table->string('città_incontro')->nullable();
$table->string('mappa_posizione')->nullable(); $table->string('sigla_provincia_incontro', 2)->nullable();
$table->json('metadata')->nullable(); $table->string('mappa_posizione')->nullable();
$table->timestamps(); $table->json('metadata')->nullable();
$table->index('tenant_id'); $table->timestamps();
$table->index('parent_id'); $table->index('tenant_id');
}); $table->index('parent_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('gruppo_individuo', function (Blueprint $table) { if (!Schema::hasTable('gruppo_individuo')) {
$table->id(); Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->string('ruolo_nel_gruppo')->nullable(); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->date('data_adesione')->nullable(); $table->string('ruolo_nel_gruppo')->nullable();
$table->timestamps(); $table->date('data_adesione')->nullable();
$table->unique(['gruppo_id', 'individuo_id']); $table->timestamps();
}); $table->unique(['gruppo_id', 'individuo_id']);
});
}
} }
public function down(): void public function down(): void
@@ -8,35 +8,41 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('eventi', function (Blueprint $table) { if (!Schema::hasTable('eventi')) {
$table->id(); Schema::create('eventi', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->string('nome_evento'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('tipo_evento')->nullable(); $table->string('nome_evento');
$table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo'); $table->string('tipo_evento')->nullable();
$table->unsignedTinyInteger('giorno_settimana')->nullable(); $table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo');
$table->time('ora_inizio')->nullable(); $table->unsignedTinyInteger('giorno_settimana')->nullable();
$table->date('data_specifica')->nullable(); $table->time('ora_inizio')->nullable();
$table->unsignedInteger('durata_minuti')->nullable(); $table->date('data_specifica')->nullable();
$table->text('descrizione')->nullable(); $table->unsignedInteger('durata_minuti')->nullable();
$table->text('note')->nullable(); $table->text('descrizione')->nullable();
$table->timestamps(); $table->text('note')->nullable();
$table->index('tenant_id'); $table->timestamps();
}); $table->index('tenant_id');
});
}
Schema::create('eventi_gruppi', function (Blueprint $table) { if (!Schema::hasTable('eventi_gruppi')) {
$table->id(); Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->timestamps(); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
}); $table->timestamps();
});
}
Schema::create('eventi_responsabili', function (Blueprint $table) { if (!Schema::hasTable('eventi_responsabili')) {
$table->id(); Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->timestamps(); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,22 +8,24 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('documenti', function (Blueprint $table) { if (!Schema::hasTable('documenti')) {
$table->id(); Schema::create('documenti', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->string('nome_file'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('file_path'); $table->string('nome_file');
$table->string('tipo')->nullable(); $table->string('file_path');
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento'); $table->string('tipo')->nullable();
$table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo'); $table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento');
$table->unsignedBigInteger('visibilita_target_id')->nullable(); $table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo');
$table->string('visibilita_target_type')->nullable(); $table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('mime_type')->nullable(); $table->string('visibilita_target_type')->nullable();
$table->unsignedBigInteger('dimensione')->nullable(); $table->string('mime_type')->nullable();
$table->text('note')->nullable(); $table->unsignedBigInteger('dimensione')->nullable();
$table->timestamps(); $table->text('note')->nullable();
$table->index('tenant_id'); $table->timestamps();
}); $table->index('tenant_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,43 +8,49 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('mailing_lists', function (Blueprint $table) { if (!Schema::hasTable('mailing_lists')) {
$table->id(); Schema::create('mailing_lists', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->string('nome'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->text('descrizione')->nullable(); $table->string('nome');
$table->boolean('attiva')->default(true); $table->text('descrizione')->nullable();
$table->timestamps(); $table->boolean('attiva')->default(true);
$table->index('tenant_id'); $table->timestamps();
}); $table->index('tenant_id');
});
}
Schema::create('mailing_contacts', function (Blueprint $table) { if (!Schema::hasTable('mailing_contacts')) {
$table->id(); Schema::create('mailing_contacts', function (Blueprint $table) {
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade'); $table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
$table->boolean('opt_in')->default(true); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->dateTime('opt_in_data')->nullable(); $table->boolean('opt_in')->default(true);
$table->dateTime('opt_out_data')->nullable(); $table->dateTime('opt_in_data')->nullable();
$table->timestamps(); $table->dateTime('opt_out_data')->nullable();
$table->unique(['mailing_list_id', 'individuo_id']); $table->timestamps();
}); $table->unique(['mailing_list_id', 'individuo_id']);
});
}
Schema::create('mailing_messaggi', function (Blueprint $table) { if (!Schema::hasTable('mailing_messaggi')) {
$table->id(); Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->unsignedBigInteger('user_id')->nullable(); $table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null');
$table->string('oggetto'); $table->unsignedBigInteger('user_id')->nullable();
$table->text('corpo')->nullable(); $table->string('oggetto');
$table->enum('canale', ['email', 'telegram'])->default('email'); $table->text('corpo')->nullable();
$table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza'); $table->enum('canale', ['email', 'telegram'])->default('email');
$table->timestamp('inviato_al')->nullable(); $table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza');
$table->integer('totale_destinatari')->default(0); $table->timestamp('inviato_al')->nullable();
$table->integer('invii_ok')->default(0); $table->integer('totale_destinatari')->default(0);
$table->integer('invii_falliti')->default(0); $table->integer('invii_ok')->default(0);
$table->timestamps(); $table->integer('invii_falliti')->default(0);
$table->index('tenant_id'); $table->timestamps();
}); $table->index('tenant_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,18 +8,20 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('notifiche', function (Blueprint $table) { if (!Schema::hasTable('notifiche')) {
$table->id(); Schema::create('notifiche', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->nullable(); $table->id();
$table->string('tipo'); $table->unsignedBigInteger('user_id')->nullable();
$table->string('titolo'); $table->string('tipo');
$table->text('messaggio'); $table->string('titolo');
$table->string('link')->nullable(); $table->text('messaggio');
$table->boolean('is_read')->default(false); $table->string('link')->nullable();
$table->timestamp('read_at')->nullable(); $table->boolean('is_read')->default(false);
$table->timestamps(); $table->timestamp('read_at')->nullable();
$table->index('user_id'); $table->timestamps();
}); $table->index('user_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,34 +8,40 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('users', function (Blueprint $table) { if (!Schema::hasTable('users')) {
$table->id(); Schema::create('users', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->id();
$table->string('name'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('email')->unique(); $table->string('name');
$table->timestamp('email_verified_at')->nullable(); $table->string('email')->unique();
$table->string('password'); $table->timestamp('email_verified_at')->nullable();
$table->boolean('is_admin')->default(false); $table->string('password');
$table->rememberToken(); $table->boolean('is_admin')->default(false);
$table->timestamps(); $table->rememberToken();
$table->index('tenant_id'); $table->timestamps();
$table->index('email'); $table->index('tenant_id');
}); $table->index('email');
});
}
Schema::create('password_reset_tokens', function (Blueprint $table) { if (!Schema::hasTable('password_reset_tokens')) {
$table->string('email')->primary(); Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('token'); $table->string('email')->primary();
$table->timestamp('created_at')->nullable(); $table->string('token');
}); $table->timestamp('created_at')->nullable();
});
}
Schema::create('sessions', function (Blueprint $table) { if (!Schema::hasTable('sessions')) {
$table->string('id')->primary(); Schema::create('sessions', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->index(); $table->string('id')->primary();
$table->string('ip_address', 45)->nullable(); $table->foreignId('user_id')->nullable()->index();
$table->text('user_agent')->nullable(); $table->string('ip_address', 45)->nullable();
$table->longText('payload'); $table->text('user_agent')->nullable();
$table->integer('last_activity')->index(); $table->longText('payload');
}); $table->integer('last_activity')->index();
});
}
} }
public function down(): void public function down(): void
@@ -8,50 +8,60 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('cache', function (Blueprint $table) { if (!Schema::hasTable('cache')) {
$table->string('key')->primary(); Schema::create('cache', function (Blueprint $table) {
$table->mediumText('value'); $table->string('key')->primary();
$table->integer('expiration'); $table->mediumText('value');
}); $table->integer('expiration');
});
}
Schema::create('cache_locks', function (Blueprint $table) { if (!Schema::hasTable('cache_locks')) {
$table->string('key')->primary(); Schema::create('cache_locks', function (Blueprint $table) {
$table->string('owner'); $table->string('key')->primary();
$table->integer('expiration'); $table->string('owner');
}); $table->integer('expiration');
});
}
Schema::create('jobs', function (Blueprint $table) { if (!Schema::hasTable('jobs')) {
$table->id(); Schema::create('jobs', function (Blueprint $table) {
$table->string('queue')->index(); $table->id();
$table->longText('payload'); $table->string('queue')->index();
$table->unsignedTinyInteger('attempts'); $table->longText('payload');
$table->unsignedInteger('reserved_at')->nullable(); $table->unsignedTinyInteger('attempts');
$table->unsignedInteger('available_at'); $table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('created_at'); $table->unsignedInteger('available_at');
}); $table->unsignedInteger('created_at');
});
}
Schema::create('job_batches', function (Blueprint $table) { if (!Schema::hasTable('job_batches')) {
$table->string('id')->primary(); Schema::create('job_batches', function (Blueprint $table) {
$table->string('name'); $table->string('id')->primary();
$table->integer('total_jobs'); $table->string('name');
$table->integer('pending_jobs'); $table->integer('total_jobs');
$table->integer('failed_jobs'); $table->integer('pending_jobs');
$table->longText('failed_job_ids'); $table->integer('failed_jobs');
$table->mediumText('options')->nullable(); $table->longText('failed_job_ids');
$table->integer('cancelled_at')->nullable(); $table->mediumText('options')->nullable();
$table->integer('created_at'); $table->integer('cancelled_at')->nullable();
$table->integer('finished_at')->nullable(); $table->integer('created_at');
}); $table->integer('finished_at')->nullable();
});
}
Schema::create('failed_jobs', function (Blueprint $table) { if (!Schema::hasTable('failed_jobs')) {
$table->id(); Schema::create('failed_jobs', function (Blueprint $table) {
$table->string('uuid')->unique(); $table->id();
$table->text('connection'); $table->string('uuid')->unique();
$table->text('queue'); $table->text('connection');
$table->longText('payload'); $table->text('queue');
$table->longText('exception'); $table->longText('payload');
$table->timestamp('failed_at'); $table->longText('exception');
}); $table->timestamp('failed_at');
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('individui', function (Blueprint $table) { if (!Schema::hasColumn('individui', 'numero_documento')) {
$table->string('numero_documento', 50)->nullable()->after('tipo_documento'); Schema::table('individui', function (Blueprint $table) {
}); $table->string('numero_documento', 50)->nullable()->after('tipo_documento');
});
}
} }
public function down(): void public function down(): void
@@ -8,11 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('eventi', function (Blueprint $table) { if (!Schema::hasColumn('eventi', 'giorno_mese')) {
$table->string('giorno_mese')->nullable()->after('giorno_settimana'); Schema::table('eventi', function (Blueprint $table) {
$table->string('mesi_recorrenza')->nullable()->after('giorno_mese'); $table->string('giorno_mese')->nullable()->after('giorno_settimana');
$table->string('mese_annuale')->nullable()->after('mesi_recorrenza'); $table->string('mesi_recorrenza')->nullable()->after('giorno_mese');
}); $table->string('mese_annuale')->nullable()->after('mesi_recorrenza');
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('eventi', function (Blueprint $table) { if (!Schema::hasColumn('eventi', 'occorrenza_mese')) {
$table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese'); Schema::table('eventi', function (Blueprint $table) {
}); $table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese');
});
}
} }
public function down(): void public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('eventi_documenti', function (Blueprint $table) { if (!Schema::hasTable('eventi_documenti')) {
$table->id(); Schema::create('eventi_documenti', function (Blueprint $table) {
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->id();
$table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->timestamps(); $table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade');
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('documenti', function (Blueprint $table) { if (!Schema::hasColumn('documenti', 'user_id')) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); Schema::table('documenti', function (Blueprint $table) {
}); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,19 +8,21 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('viste_report', function (Blueprint $table) { if (!Schema::hasTable('viste_report')) {
$table->id(); Schema::create('viste_report', function (Blueprint $table) {
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->id();
$table->string('nome'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('tipo'); // individui, gruppi, documenti, eventi $table->string('nome');
$table->json('colonne_visibili')->nullable(); $table->string('tipo'); // individui, gruppi, documenti, eventi
$table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']] $table->json('colonne_visibili')->nullable();
$table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']] $table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']]
$table->string('ricerca')->nullable(); $table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']]
$table->timestamps(); $table->string('ricerca')->nullable();
$table->index('tipo'); $table->timestamps();
$table->index('user_id'); $table->index('tipo');
}); $table->index('user_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('viste_report', function (Blueprint $table) { if (!Schema::hasColumn('viste_report', 'is_default')) {
$table->boolean('is_default')->default(false)->after('nome'); Schema::table('viste_report', function (Blueprint $table) {
$table->index('is_default'); $table->boolean('is_default')->default(false)->after('nome');
}); $table->index('is_default');
});
}
} }
public function down(): void public function down(): void
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('mailing_lists', function (Blueprint $table) { if (!Schema::hasColumn('mailing_lists', 'user_id')) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); Schema::table('mailing_lists', function (Blueprint $table) {
$table->index('user_id'); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
}); $table->index('user_id');
});
}
} }
public function down(): void public function down(): void
@@ -8,14 +8,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('tipologie_documenti', function (Blueprint $table) { if (!Schema::hasTable('tipologie_documenti')) {
$table->id(); Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->string('nome', 50)->unique(); $table->id();
$table->string('descrizione', 255)->nullable(); $table->string('nome', 50)->unique();
$table->integer('ordine')->default(0); $table->string('descrizione', 255)->nullable();
$table->boolean('attiva')->default(true); $table->integer('ordine')->default(0);
$table->timestamps(); $table->boolean('attiva')->default(true);
}); $table->timestamps();
});
}
$defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro']; $defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro'];
foreach ($defaultTipologie as $i => $nome) { foreach ($defaultTipologie as $i => $nome) {
@@ -8,32 +8,38 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('users', function (Blueprint $table) { if (!Schema::hasColumn('users', 'permissions')) {
$table->json('permissions')->nullable()->after('is_admin'); Schema::table('users', function (Blueprint $table) {
}); $table->json('permissions')->nullable()->after('is_admin');
});
}
Schema::create('activity_logs', function (Blueprint $table) { if (!Schema::hasTable('activity_logs')) {
$table->id(); Schema::create('activity_logs', function (Blueprint $table) {
$table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->id();
$table->string('action'); $table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('module'); $table->string('action');
$table->string('description')->nullable(); $table->string('module');
$table->json('details')->nullable(); $table->string('description')->nullable();
$table->string('ip_address', 45)->nullable(); $table->json('details')->nullable();
$table->timestamp('created_at')->useCurrent(); $table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'module']); $table->index(['user_id', 'module']);
$table->index(['created_at']); $table->index(['created_at']);
$table->index(['action']); $table->index(['action']);
}); });
}
Schema::create('role_presets', function (Blueprint $table) { if (!Schema::hasTable('role_presets')) {
$table->id(); Schema::create('role_presets', function (Blueprint $table) {
$table->string('name', 50)->unique(); $table->id();
$table->string('description')->nullable(); $table->string('name', 50)->unique();
$table->json('permissions'); $table->string('description')->nullable();
$table->timestamps(); $table->json('permissions');
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -23,96 +23,106 @@ return new class extends Migration
/** /**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/ */
Schema::create($tableNames['permissions'], static function (Blueprint $table) { if (!Schema::hasTable($tableNames['permissions'])) {
$table->id(); // permission id Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->string('name'); $table->id(); // permission id
$table->string('guard_name'); $table->string('name');
$table->timestamps(); $table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']); $table->unique(['name', 'guard_name']);
}); });
}
/** /**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/ */
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { if (!Schema::hasTable($tableNames['roles'])) {
$table->id(); // role id Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing $table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
} }
$table->string('name'); $table->string('name');
$table->string('guard_name'); $table->string('guard_name');
$table->timestamps(); $table->timestamps();
if ($teams || config('permission.testing')) { if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else { } else {
$table->unique(['name', 'guard_name']); $table->unique(['name', 'guard_name']);
} }
}); });
}
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { if (!Schema::hasTable($tableNames['model_has_permissions'])) {
$table->unsignedBigInteger($pivotPermission); Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type'); $table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']); $table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission) $table->foreign($pivotPermission)
->references('id') // permission id ->references('id') // permission id
->on($tableNames['permissions']) ->on($tableNames['permissions'])
->cascadeOnDelete(); ->cascadeOnDelete();
if ($teams) { if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']); $table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary'); 'model_has_permissions_permission_model_type_primary');
} else { } else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary'); 'model_has_permissions_permission_model_type_primary');
} }
}); });
}
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { if (!Schema::hasTable($tableNames['model_has_roles'])) {
$table->unsignedBigInteger($pivotRole); Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type'); $table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']); $table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole) $table->foreign($pivotRole)
->references('id') // role id ->references('id') // role id
->on($tableNames['roles']) ->on($tableNames['roles'])
->cascadeOnDelete(); ->cascadeOnDelete();
if ($teams) { if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']); $table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary'); 'model_has_roles_role_model_type_primary');
} else { } else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary'); 'model_has_roles_role_model_type_primary');
} }
}); });
}
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { if (!Schema::hasTable($tableNames['role_has_permissions'])) {
$table->unsignedBigInteger($pivotPermission); Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission) $table->foreign($pivotPermission)
->references('id') // permission id ->references('id') // permission id
->on($tableNames['permissions']) ->on($tableNames['permissions'])
->cascadeOnDelete(); ->cascadeOnDelete();
$table->foreign($pivotRole) $table->foreign($pivotRole)
->references('id') // role id ->references('id') // role id
->on($tableNames['roles']) ->on($tableNames['roles'])
->cascadeOnDelete(); ->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
}); });
}
app('cache') app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
@@ -8,26 +8,28 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('email_settings', function (Blueprint $table) { if (!Schema::hasTable('email_settings')) {
$table->id(); Schema::create('email_settings', function (Blueprint $table) {
$table->string('imap_host')->nullable(); $table->id();
$table->integer('imap_port')->nullable()->default(993); $table->string('imap_host')->nullable();
$table->string('imap_encryption')->nullable()->default('ssl'); $table->integer('imap_port')->nullable()->default(993);
$table->string('imap_username')->nullable(); $table->string('imap_encryption')->nullable()->default('ssl');
$table->string('imap_password')->nullable(); $table->string('imap_username')->nullable();
$table->string('smtp_host')->nullable(); $table->string('imap_password')->nullable();
$table->integer('smtp_port')->nullable()->default(587); $table->string('smtp_host')->nullable();
$table->string('smtp_encryption')->nullable()->default('tls'); $table->integer('smtp_port')->nullable()->default(587);
$table->string('smtp_username')->nullable(); $table->string('smtp_encryption')->nullable()->default('tls');
$table->string('smtp_password')->nullable(); $table->string('smtp_username')->nullable();
$table->string('email_address')->nullable(); $table->string('smtp_password')->nullable();
$table->string('email_name')->nullable(); $table->string('email_address')->nullable();
$table->string('reply_to')->nullable(); $table->string('email_name')->nullable();
$table->integer('sync_interval_minutes')->default(5); $table->string('reply_to')->nullable();
$table->timestamp('last_sync_at')->nullable(); $table->integer('sync_interval_minutes')->default(5);
$table->boolean('is_active')->default(false); $table->timestamp('last_sync_at')->nullable();
$table->timestamps(); $table->boolean('is_active')->default(false);
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('email_folders', function (Blueprint $table) { if (!Schema::hasTable('email_folders')) {
$table->id(); Schema::create('email_folders', function (Blueprint $table) {
$table->string('name'); $table->id();
$table->string('imap_folder_name')->nullable(); $table->string('name');
$table->string('type')->default('custom'); $table->string('imap_folder_name')->nullable();
$table->string('icon')->default('fa-folder'); $table->string('type')->default('custom');
$table->string('color')->nullable(); $table->string('icon')->default('fa-folder');
$table->integer('sort_order')->default(0); $table->string('color')->nullable();
$table->boolean('is_system')->default(false); $table->integer('sort_order')->default(0);
$table->timestamps(); $table->boolean('is_system')->default(false);
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,35 +8,37 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('email_messages', function (Blueprint $table) { if (!Schema::hasTable('email_messages')) {
$table->id(); Schema::create('email_messages', function (Blueprint $table) {
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade'); $table->id();
$table->string('message_id')->nullable()->index(); $table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
$table->string('subject')->nullable(); $table->string('message_id')->nullable()->index();
$table->string('from_name')->nullable(); $table->string('subject')->nullable();
$table->string('from_email')->nullable(); $table->string('from_name')->nullable();
$table->string('to_name')->nullable(); $table->string('from_email')->nullable();
$table->string('to_email')->nullable(); $table->string('to_name')->nullable();
$table->text('cc')->nullable(); $table->string('to_email')->nullable();
$table->text('bcc')->nullable(); $table->text('cc')->nullable();
$table->longText('body_text')->nullable(); $table->text('bcc')->nullable();
$table->longText('body_html')->nullable(); $table->longText('body_text')->nullable();
$table->boolean('is_read')->default(false); $table->longText('body_html')->nullable();
$table->boolean('is_starred')->default(false); $table->boolean('is_read')->default(false);
$table->boolean('is_important')->default(false); $table->boolean('is_starred')->default(false);
$table->boolean('is_sent')->default(false); $table->boolean('is_important')->default(false);
$table->boolean('is_draft')->default(false); $table->boolean('is_sent')->default(false);
$table->boolean('is_trash')->default(false); $table->boolean('is_draft')->default(false);
$table->timestamp('received_at')->nullable(); $table->boolean('is_trash')->default(false);
$table->timestamp('sent_at')->nullable(); $table->timestamp('received_at')->nullable();
$table->unsignedBigInteger('imap_uid')->nullable(); $table->timestamp('sent_at')->nullable();
$table->timestamps(); $table->unsignedBigInteger('imap_uid')->nullable();
$table->timestamps();
$table->index('is_read'); $table->index('is_read');
$table->index('is_starred'); $table->index('is_starred');
$table->index('is_sent'); $table->index('is_sent');
$table->index('received_at'); $table->index('received_at');
}); });
}
} }
public function down(): void public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('email_attachments', function (Blueprint $table) { if (!Schema::hasTable('email_attachments')) {
$table->id(); Schema::create('email_attachments', function (Blueprint $table) {
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade'); $table->id();
$table->string('filename'); $table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
$table->string('mime_type')->nullable(); $table->string('filename');
$table->bigInteger('size')->default(0); $table->string('mime_type')->nullable();
$table->string('file_path'); $table->bigInteger('size')->default(0);
$table->boolean('is_saved_to_documenti')->default(false); $table->string('file_path');
$table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null'); $table->boolean('is_saved_to_documenti')->default(false);
$table->timestamps(); $table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null');
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,14 +8,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('ruoli', function (Blueprint $table) { if (!Schema::hasTable('ruoli')) {
$table->id(); Schema::create('ruoli', function (Blueprint $table) {
$table->string('nome', 50)->unique(); $table->id();
$table->string('descrizione', 255)->nullable(); $table->string('nome', 50)->unique();
$table->integer('ordine')->default(0); $table->string('descrizione', 255)->nullable();
$table->boolean('attiva')->default(true); $table->integer('ordine')->default(0);
$table->timestamps(); $table->boolean('attiva')->default(true);
}); $table->timestamps();
});
}
$defaultRuoli = [ $defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'], ['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
@@ -38,25 +40,27 @@ return new class extends Migration
]); ]);
} }
Schema::table('gruppo_individuo', function (Blueprint $table) { if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo'); Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo');
$table->index('ruolo_id'); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
}); $table->index('ruolo_id');
});
$ruoliMap = DB::table('ruoli')->pluck('id', 'nome')->toArray(); $ruoliMap = DB::table('ruoli')->pluck('id', 'nome')->toArray();
$pivotRecords = DB::table('gruppo_individuo') $pivotRecords = DB::table('gruppo_individuo')
->whereNotNull('ruolo_nel_gruppo') ->whereNotNull('ruolo_nel_gruppo')
->where('ruolo_nel_gruppo', '!=', '') ->where('ruolo_nel_gruppo', '!=', '')
->distinct() ->distinct()
->pluck('ruolo_nel_gruppo'); ->pluck('ruolo_nel_gruppo');
foreach ($pivotRecords as $ruoloNome) { foreach ($pivotRecords as $ruoloNome) {
if (isset($ruoliMap[$ruoloNome])) { if (isset($ruoliMap[$ruoloNome])) {
DB::table('gruppo_individuo') DB::table('gruppo_individuo')
->where('ruolo_nel_gruppo', $ruoloNome) ->where('ruolo_nel_gruppo', $ruoloNome)
->update(['ruolo_id' => $ruoliMap[$ruoloNome]]); ->update(['ruolo_id' => $ruoliMap[$ruoloNome]]);
}
} }
} }
} }
@@ -13,9 +13,11 @@ return new class extends Migration
$table->dropColumn('ruolo_id'); $table->dropColumn('ruolo_id');
}); });
Schema::table('gruppo_individuo', function (Blueprint $table) { if (!Schema::hasColumn('gruppo_individuo', 'ruolo_ids')) {
$table->json('ruolo_ids')->nullable()->after('individuo_id'); Schema::table('gruppo_individuo', function (Blueprint $table) {
}); $table->json('ruolo_ids')->nullable()->after('individuo_id');
});
}
} }
public function down(): void public function down(): void
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('ruolo_ids'); $table->dropColumn('ruolo_ids');
}); });
Schema::table('gruppo_individuo', function (Blueprint $table) { if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id'); Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id');
}); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
});
}
} }
}; };
@@ -13,9 +13,11 @@ return new class extends Migration
$table->dropColumn('responsabile_id'); $table->dropColumn('responsabile_id');
}); });
Schema::table('gruppi', function (Blueprint $table) { if (!Schema::hasColumn('gruppi', 'responsabile_ids')) {
$table->json('responsabile_ids')->nullable()->after('diocesi_id'); Schema::table('gruppi', function (Blueprint $table) {
}); $table->json('responsabile_ids')->nullable()->after('diocesi_id');
});
}
} }
public function down(): void public function down(): void
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('responsabile_ids'); $table->dropColumn('responsabile_ids');
}); });
Schema::table('gruppi', function (Blueprint $table) { if (!Schema::hasColumn('gruppi', 'responsabile_id')) {
$table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id'); Schema::table('gruppi', function (Blueprint $table) {
$table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null'); $table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id');
}); $table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null');
});
}
} }
}; };
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('eventi', function (Blueprint $table) { if (!Schema::hasColumn('eventi', 'luogo_indirizzo')) {
$table->string('luogo_indirizzo', 500)->nullable()->after('note'); Schema::table('eventi', function (Blueprint $table) {
$table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo'); $table->string('luogo_indirizzo', 500)->nullable()->after('note');
}); $table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo');
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('email_settings', function (Blueprint $table) { if (!Schema::hasColumn('email_settings', 'signature')) {
$table->longText('signature')->nullable()->after('reply_to'); Schema::table('email_settings', function (Blueprint $table) {
}); $table->longText('signature')->nullable()->after('reply_to');
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('email_settings', function (Blueprint $table) { if (!Schema::hasColumn('email_settings', 'signature_enabled')) {
$table->boolean('signature_enabled')->default(true)->after('signature'); Schema::table('email_settings', function (Blueprint $table) {
}); $table->boolean('signature_enabled')->default(true)->after('signature');
});
}
} }
public function down(): void public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('report_custom', function (Blueprint $table) { if (!Schema::hasTable('report_custom')) {
$table->id(); Schema::create('report_custom', function (Blueprint $table) {
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->id();
$table->string('nome'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->text('descrizione')->nullable(); $table->string('nome');
$table->string('tipo_report'); $table->text('descrizione')->nullable();
$table->json('config')->nullable(); $table->string('tipo_report');
$table->timestamps(); $table->json('config')->nullable();
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -10,14 +10,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('tipologie_eventi', function (Blueprint $table) { if (!Schema::hasTable('tipologie_eventi')) {
$table->id(); Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->string('nome', 50)->unique(); $table->id();
$table->string('descrizione', 255)->nullable(); $table->string('nome', 50)->unique();
$table->integer('ordine')->default(0); $table->string('descrizione', 255)->nullable();
$table->boolean('attiva')->default(true); $table->integer('ordine')->default(0);
$table->timestamps(); $table->boolean('attiva')->default(true);
}); $table->timestamps();
});
}
DB::table('tipologie_eventi')->insert([ DB::table('tipologie_eventi')->insert([
['nome' => 'catechesi', 'descrizione' => 'Catechesi', 'ordine' => 0, 'attiva' => true], ['nome' => 'catechesi', 'descrizione' => 'Catechesi', 'ordine' => 0, 'attiva' => true],
@@ -10,21 +10,23 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('sender_accounts', function (Blueprint $table) { if (!Schema::hasTable('sender_accounts')) {
$table->id(); Schema::create('sender_accounts', function (Blueprint $table) {
$table->string('email_address')->unique(); $table->id();
$table->string('email_name')->nullable(); $table->string('email_address')->unique();
$table->string('smtp_host')->nullable(); $table->string('email_name')->nullable();
$table->integer('smtp_port')->default(587); $table->string('smtp_host')->nullable();
$table->string('smtp_encryption')->default('tls'); $table->integer('smtp_port')->default(587);
$table->string('smtp_username')->nullable(); $table->string('smtp_encryption')->default('tls');
$table->text('smtp_password')->nullable(); $table->string('smtp_username')->nullable();
$table->string('reply_to')->nullable(); $table->text('smtp_password')->nullable();
$table->string('verify_email')->nullable(); $table->string('reply_to')->nullable();
$table->text('note')->nullable(); $table->string('verify_email')->nullable();
$table->boolean('is_active')->default(true); $table->text('note')->nullable();
$table->timestamps(); $table->boolean('is_active')->default(true);
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -10,11 +10,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('mailing_messaggi', function (Blueprint $table) { if (!Schema::hasColumn('mailing_messaggi', 'log_falliti')) {
$table->json('log_falliti')->nullable()->after('invii_falliti'); Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->string('mittente_nome')->nullable()->after('log_falliti'); $table->json('log_falliti')->nullable()->after('invii_falliti');
$table->string('mittente_email')->nullable()->after('mittente_nome'); $table->string('mittente_nome')->nullable()->after('log_falliti');
}); $table->string('mittente_email')->nullable()->after('mittente_nome');
});
}
} }
public function down(): void public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('documenti_cartelle', function (Blueprint $table) { if (!Schema::hasTable('documenti_cartelle')) {
$table->id(); Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade'); $table->id();
$table->string('nome'); $table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->timestamps(); $table->string('nome');
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,9 +8,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('documenti', function (Blueprint $table) { if (!Schema::hasColumn('documenti', 'cartella_id')) {
$table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null'); Schema::table('documenti', function (Blueprint $table) {
}); $table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null');
});
}
} }
public function down(): void public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('storage_repositories', function (Blueprint $table) { if (!Schema::hasTable('storage_repositories')) {
$table->id(); Schema::create('storage_repositories', function (Blueprint $table) {
$table->string('nome'); $table->id();
$table->string('tipo', 50); $table->string('nome');
$table->text('config'); $table->string('tipo', 50);
$table->boolean('is_active')->default(true); $table->text('config');
$table->integer('ordine')->default(0); $table->boolean('is_active')->default(true);
$table->timestamps(); $table->integer('ordine')->default(0);
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -8,13 +8,15 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('documenti', function (Blueprint $table) { if (!Schema::hasColumn('documenti', 'repository_id')) {
$table->foreignId('repository_id') Schema::table('documenti', function (Blueprint $table) {
->nullable() $table->foreignId('repository_id')
->constrained('storage_repositories') ->nullable()
->nullOnDelete() ->constrained('storage_repositories')
->after('cartella_id'); ->nullOnDelete()
}); ->after('cartella_id');
});
}
} }
public function down(): void public function down(): void
@@ -10,9 +10,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('documenti', function (Blueprint $table) { if (!Schema::hasColumn('documenti', 'storage_disk')) {
$table->string('storage_disk', 50)->nullable()->after('repository_id'); Schema::table('documenti', function (Blueprint $table) {
}); $table->string('storage_disk', 50)->nullable()->after('repository_id');
});
}
} }
public function down(): void public function down(): void
@@ -10,21 +10,23 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::create('calendario_connessioni', function (Blueprint $table) { if (!Schema::hasTable('calendario_connessioni')) {
$table->id(); Schema::create('calendario_connessioni', function (Blueprint $table) {
$table->string('nome'); $table->id();
$table->string('tipo'); // google_calendar, caldav $table->string('nome');
$table->text('config'); // JSON $table->string('tipo'); // google_calendar, caldav
$table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore $table->text('config'); // JSON
$table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional $table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore
$table->integer('sync_interval_minutes')->default(60); $table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional
$table->timestamp('last_sync_at')->nullable(); $table->integer('sync_interval_minutes')->default(60);
$table->timestamp('last_error_at')->nullable(); $table->timestamp('last_sync_at')->nullable();
$table->text('last_error_message')->nullable(); $table->timestamp('last_error_at')->nullable();
$table->boolean('is_active')->default(true); $table->text('last_error_message')->nullable();
$table->integer('ordine')->default(0); $table->boolean('is_active')->default(true);
$table->timestamps(); $table->integer('ordine')->default(0);
}); $table->timestamps();
});
}
} }
public function down(): void public function down(): void
@@ -10,10 +10,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('eventi', function (Blueprint $table) { if (!Schema::hasColumn('eventi', 'uid_esterno')) {
$table->string('uid_esterno', 500)->nullable()->after('luogo_url_maps'); Schema::table('eventi', function (Blueprint $table) {
$table->index('uid_esterno'); $table->string('uid_esterno', 500)->nullable()->after('luogo_url_maps');
}); $table->index('uid_esterno');
});
}
} }
public function down(): void public function down(): void
@@ -0,0 +1,27 @@
<?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
{
if (!Schema::hasColumn('eventi', 'descrizione_evento')) {
Schema::table('eventi', function (Blueprint $table) {
$table->text('descrizione_evento')->nullable()->after('nome_evento');
});
}
if (!Schema::hasColumn('eventi', 'is_incontro_gruppo')) {
Schema::table('eventi', function (Blueprint $table) {
$table->boolean('is_incontro_gruppo')->default(false)->after('uid_esterno');
});
}
}
public function down(): void
{
}
};
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_nel_gruppo')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->string('ruolo_nel_gruppo')->nullable()->after('ruolo_ids');
});
}
}
public function down(): void
{
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('tags')) {
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('slug', 100)->unique();
$table->string('color', 7)->nullable();
$table->integer('order_column')->default(0);
$table->timestamps();
});
}
if (!Schema::hasTable('taggables')) {
Schema::create('taggables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});
}
}
public function down(): void
{
Schema::dropIfExists('taggables');
Schema::dropIfExists('tags');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('viste_report', function (Blueprint $table) {
if (!Schema::hasColumn('viste_report', 'colonne_larghezze')) {
$table->json('colonne_larghezze')->nullable()->after('colonne_visibili');
}
if (!Schema::hasColumn('viste_report', 'colonne_ordine')) {
$table->json('colonne_ordine')->nullable()->after('colonne_larghezze');
}
});
}
public function down(): void
{
Schema::table('viste_report', function (Blueprint $table) {
if (Schema::hasColumn('viste_report', 'colonne_larghezze')) {
$table->dropColumn('colonne_larghezze');
}
if (Schema::hasColumn('viste_report', 'colonne_ordine')) {
$table->dropColumn('colonne_ordine');
}
});
}
};
+1 -1
View File
@@ -68,7 +68,7 @@ class ComuniSeeder extends Seeder
]; ];
foreach ($comuni as $c) { foreach ($comuni as $c) {
Comune::create($c); Comune::firstOrCreate(['codice_istat' => $c['codice_istat']], $c);
} }
} }
} }
+21 -17
View File
@@ -20,23 +20,27 @@ class DatabaseSeeder extends Seeder
DockerBaseDataSeeder::class, DockerBaseDataSeeder::class,
]); ]);
$admin = User::create([ $admin = User::firstOrCreate(
'name' => 'Admin', ['email' => 'admin@glastree.local'],
'email' => 'admin@glastree.local', [
'password' => bcrypt('password'), 'name' => 'Admin',
'is_admin' => true, 'password' => bcrypt('password'),
'status' => 'active', 'is_admin' => true,
'permissions' => [ 'status' => 'active',
'individui' => 2, 'permissions' => [
'gruppi' => 2, 'individui' => 2,
'eventi' => 2, 'gruppi' => 2,
'documenti' => 2, 'eventi' => 2,
'mailing' => 2, 'documenti' => 2,
'viste' => 2, 'mailing' => 2,
], 'viste' => 2,
]); ],
]
);
$admin->role_preset_id = 1; if (!$admin->role_preset_id) {
$admin->save(); $admin->role_preset_id = 1;
$admin->save();
}
} }
} }
+1 -1
View File
@@ -149,7 +149,7 @@ class DiocesiSeeder extends Seeder
]; ];
foreach ($diocesi as $d) { foreach ($diocesi as $d) {
Diocesi::create($d); Diocesi::firstOrCreate(['nome' => $d['nome']], $d);
} }
} }
} }
+384
View File
@@ -0,0 +1,384 @@
class ColumnManager {
constructor(tableId, options = {}) {
this.tableId = tableId;
this.table = document.getElementById(tableId);
if (!this.table) return;
this.options = Object.assign({
entityType: 'individui',
storageKey: null,
resizable: true,
reorderable: true,
persistWidths: true,
persistOrder: true,
}, options);
this.columns = [];
this.columnWidths = {};
this.columnOrder = [];
this.dragState = null;
this.resizeState = null;
this.init();
}
init() {
this.loadState();
this.initColumns();
if (this.options.resizable) this.initResize();
if (this.options.reorderable) this.initReorder();
this.applyState();
}
initColumns() {
const ths = this.table.querySelectorAll('thead th');
ths.forEach((th, idx) => {
const colKey = th.dataset.column || `col_${idx}`;
const col = {
index: idx,
key: colKey,
element: th,
width: th.style.width || '',
visible: th.style.display !== 'none',
label: th.textContent.trim(),
};
this.columns.push(col);
});
}
loadState() {
if (this.options.storageKey) {
try {
const saved = sessionStorage.getItem(this.options.storageKey);
if (saved) {
const data = JSON.parse(saved);
this.columnWidths = data.widths || {};
this.columnOrder = data.order || [];
return;
}
} catch (e) {}
}
const vistaEl = document.getElementById('vista-data');
if (vistaEl && vistaEl.textContent && vistaEl.textContent !== 'null') {
try {
const vista = JSON.parse(vistaEl.textContent);
this.columnWidths = vista.colonne_larghezze || {};
this.columnOrder = vista.colonne_ordine || [];
} catch (e) {}
}
}
persistState() {
if (!this.options.storageKey) return;
try {
sessionStorage.setItem(this.options.storageKey, JSON.stringify({
widths: this.columnWidths,
order: this.columnOrder,
}));
} catch (e) {}
}
applyState() {
if (this.columnOrder.length > 0) {
this.reorderColumns(this.columnOrder, false);
}
if (Object.keys(this.columnWidths).length > 0) {
this.applyWidths();
}
}
applyWidths() {
this.columns.forEach(col => {
const w = this.columnWidths[col.key];
if (w) {
col.element.style.width = w;
}
});
}
initResize() {
this.table.querySelectorAll('thead th').forEach(th => {
const handle = document.createElement('div');
handle.className = 'col-resize-handle';
handle.style.cssText = 'position:absolute;right:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:10;';
th.style.position = 'relative';
th.appendChild(handle);
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const thEl = th;
const startX = e.clientX;
const startWidth = thEl.offsetWidth;
const onMouseMove = (ev) => {
const diff = ev.clientX - startX;
const newWidth = Math.max(30, startWidth + diff);
thEl.style.width = newWidth + 'px';
const colKey = thEl.dataset.column;
if (colKey) {
this.columnWidths[colKey] = newWidth + 'px';
this.persistState();
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
});
}
initReorder() {
const thead = this.table.querySelector('thead');
if (!thead) return;
let dragCol = null;
let dragIndex = -1;
let placeholder = null;
thead.querySelectorAll('th').forEach(th => {
if (th.querySelector('.col-resize-handle')) return;
th.draggable = true;
th.addEventListener('dragstart', (e) => {
dragCol = th;
dragIndex = Array.from(thead.querySelectorAll('th')).indexOf(th);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
th.classList.add('dragging');
});
th.addEventListener('dragend', () => {
th.classList.remove('dragging');
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
dragCol = null;
dragIndex = -1;
placeholder = null;
});
th.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragCol || dragCol === th) return;
if (!placeholder) {
placeholder = document.createElement('th');
placeholder.style.cssText = 'border:2px dashed #007bff;background:rgba(0,123,255,0.1);width:0;padding:0;';
dragCol.parentNode.insertBefore(placeholder, dragCol);
}
const rect = th.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
const parent = th.parentNode;
if (e.clientX < midX) {
parent.insertBefore(placeholder, th);
} else {
parent.insertBefore(placeholder, th.nextSibling);
}
});
th.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragCol || dragCol === th) return;
const parent = th.parentNode;
const allTh = Array.from(parent.querySelectorAll('th')).filter(el => el !== placeholder);
const fromIdx = allTh.indexOf(dragCol);
let toIdx = allTh.indexOf(th);
if (placeholder) {
toIdx = Array.from(parent.children).indexOf(placeholder);
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
placeholder = null;
}
if (fromIdx < 0) return;
const actualRows = this.table.querySelectorAll('tr');
actualRows.forEach(row => {
const cells = row.querySelectorAll('td, th');
if (cells[fromIdx] && cells[toIdx]) {
if (toIdx > fromIdx) {
row.insertBefore(cells[fromIdx], cells[toIdx].nextSibling);
} else {
row.insertBefore(cells[fromIdx], cells[toIdx]);
}
}
});
this.updateColumnOrder();
});
});
}
updateColumnOrder() {
const ths = this.table.querySelectorAll('thead th');
this.columnOrder = [];
const keysInOrder = [];
ths.forEach((th, idx) => {
const colKey = th.dataset.column || `col_${idx}`;
keysInOrder.push(colKey);
this.columnOrder.push(colKey);
});
this.columns.sort((a, b) => keysInOrder.indexOf(a.key) - keysInOrder.indexOf(b.key));
this.columns.forEach((col, idx) => col.index = idx);
this.persistState();
}
reorderColumns(keyOrder, persist = true) {
if (!keyOrder || keyOrder.length === 0) return;
const thead = this.table.querySelector('thead');
if (!thead) return;
const rows = this.table.querySelectorAll('tr');
const keyToThIndex = {};
const allTh = Array.from(thead.querySelectorAll('th'));
allTh.forEach((th, idx) => {
const key = th.dataset.column || `col_${idx}`;
keyToThIndex[key] = idx;
});
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
const reordered = [];
let lastRef = null;
keyOrder.forEach(key => {
const srcIdx = keyToThIndex[key];
if (srcIdx !== undefined && cells[srcIdx]) {
reordered.push(cells[srcIdx]);
}
});
reordered.forEach(cell => {
if (lastRef) {
row.insertBefore(cell, lastRef.nextSibling);
} else {
row.insertBefore(cell, row.firstChild);
}
lastRef = cell;
});
});
this.columnOrder = [...keyOrder];
this.columns.sort((a, b) => keyOrder.indexOf(a.key) - keyOrder.indexOf(b.key));
this.columns.forEach((col, idx) => col.index = idx);
if (persist) this.persistState();
}
setVisible(columnKey, visible) {
const th = this.table.querySelector(`th[data-column="${columnKey}"]`);
if (th) th.style.display = visible ? '' : 'none';
const colIdx = this.columns.findIndex(c => c.key === columnKey);
if (colIdx >= 0) {
this.columns[colIdx].visible = visible;
const rows = this.table.querySelectorAll('tbody tr, thead tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
if (cells[colIdx]) {
cells[colIdx].style.display = visible ? '' : 'none';
}
});
}
}
getColumnWidths() {
return { ...this.columnWidths };
}
getColumnOrder() {
return [...this.columnOrder];
}
getVisibleColumns() {
return this.columns.filter(c => c.visible).map(c => c.key);
}
getColumnMetadata() {
return {
larghezze: this.getColumnWidths(),
ordine: this.getColumnOrder(),
visibili: this.getVisibleColumns(),
};
}
printWithSettings() {
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const styles = Array.from(document.styleSheets)
.filter(sheet => {
try { return sheet.cssRules; } catch (e) { return false; }
})
.map(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText).join('\n'))
.join('\n');
const printStyles = `
@media print {
body { font-family: 'DejaVu Sans', Arial, sans-serif; font-size: 10pt; }
.no-print { display: none !important; }
.card { border: none !important; box-shadow: none !important; }
.table { border-collapse: collapse; width: 100%; }
.table th { background: #f5f5f5 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.table th, .table td { border: 1px solid #ddd; padding: 4px 6px; }
a { text-decoration: none; color: #000; }
.badge { border: 1px solid #999; padding: 1px 4px; font-size: 8pt; }
.pagination, .card-header .card-tools { display: none !important; }
.page-break { page-break-before: always; }
}
`;
const title = document.title || 'Stampa';
const pageTitle = document.querySelector('.page_title, .card-title h1, h1')?.textContent?.trim() || title;
let content = '';
const printArea = this.table?.cloneNode(true);
if (printArea) {
printArea.querySelectorAll('.col-resize-handle').forEach(h => h.remove());
content = printArea.outerHTML;
} else {
content = document.getElementById(this.tableId)?.outerHTML || '';
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>${styles}${printStyles}</style>
</head>
<body>
<div class="no-print" style="text-align:right;margin-bottom:10px;">
<button onclick="window.print()" style="padding:6px 16px;font-size:12pt;">🖨️ Stampa</button>
<button onclick="window.close()" style="padding:6px 16px;font-size:12pt;">✖ Chiudi</button>
</div>
<h2 style="margin-bottom:16px;">${pageTitle}</h2>
${content}
<p style="text-align:center;margin-top:16px;color:#999;font-size:8pt;">
Stampato il ${new Date().toLocaleDateString('it-IT')}${document.location.href}
</p>
</body>
</html>
`);
printWindow.document.close();
}
}
window.ColumnManager = ColumnManager;
@@ -248,6 +248,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" class="custom-control-input" id="is_active" <input type="checkbox" class="custom-control-input" id="is_active"
name="is_active" value="1" {{ ($settings->is_active ?? false) ? 'checked' : '' }}> name="is_active" value="1" {{ ($settings->is_active ?? false) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">Account Email Attivo</label> <label class="custom-control-label" for="is_active">Account Email Attivo</label>
@@ -265,6 +266,7 @@
<div class="card-body"> <div class="card-body">
<div class="form-group"> <div class="form-group">
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="hidden" name="signature_enabled" value="0">
<input type="checkbox" class="custom-control-input" id="signature_enabled" <input type="checkbox" class="custom-control-input" id="signature_enabled"
name="signature_enabled" value="1" {{ ($settings->signature_enabled ?? true) ? 'checked' : '' }}> name="signature_enabled" value="1" {{ ($settings->signature_enabled ?? true) ? 'checked' : '' }}>
<label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label> <label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label>
@@ -390,14 +392,17 @@
<a href="{{ route('impostazioni.email.sync') }}" class="btn btn-warning ml-2"> <a href="{{ route('impostazioni.email.sync') }}" class="btn btn-warning ml-2">
<i class="fas fa-sync mr-1"></i> Sincronizza Ora <i class="fas fa-sync mr-1"></i> Sincronizza Ora
</a> </a>
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" class="ml-auto" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div> </div>
</form> </form>
<div class="d-flex justify-content-end mt-2">
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div>
</div> </div>
</div> </div>
@endsection @endsection
+36
View File
@@ -103,6 +103,42 @@
@endif @endif
</div> </div>
</div> </div>
@include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette'])
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<td style="width: 100px;"><strong>File:</strong></td>
<td>{{ $documento->file_path }}</td>
</tr>
<tr>
<td><strong>Tipo MIME:</strong></td>
<td>{{ $documento->mime_type ?? '-' }}</td>
</tr>
<tr>
<td><strong>Dimensione:</strong></td>
<td>{{ $documento->dimensione ? number_format($documento->dimensione / 1024, 1) . ' KB' : '-' }}</td>
</tr>
<tr>
<td><strong>Creato:</strong></td>
<td>{{ $documento->created_at->format('d/m/Y H:i') }}</td>
</tr>
@if($documento->user)
<tr>
<td><strong>Utente:</strong></td>
<td>{{ $documento->user->name }}</td>
</tr>
@endif
</table>
@if($documento->file_path)
<hr>
<a href="/documenti/{{ $documento->id }}/download" class="btn btn-primary btn-block">
<i class="fas fa-download mr-1"></i> Scarica
</a>
@endif
</div>
</div>
</div> </div>
</div> </div>
+102 -8
View File
@@ -5,6 +5,9 @@
@php @php
$canWriteDocumenti = Auth::user()->canManage('documenti'); $canWriteDocumenti = Auth::user()->canManage('documenti');
$canDeleteDocumenti = Auth::user()->canDelete('documenti'); $canDeleteDocumenti = Auth::user()->canDelete('documenti');
$vistaDefaultJson = isset($vista) ? $vista->toJson() : 'null';
$visibleColumnsJson = json_encode($visibleColumns ?? []);
$tableColumnsJson = json_encode($tableColumns ?? []);
@endphp @endphp
@section('css') @section('css')
@@ -123,6 +126,8 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div> </div>
</div> </div>
@include('partials._tag-filter-bar', ['filterTags' => $allTags])
<div class="card-body p-0"> <div class="card-body p-0">
{{-- Toolbar azioni massive --}} {{-- Toolbar azioni massive --}}
<form id="massForm" method="POST"> <form id="massForm" method="POST">
@@ -144,6 +149,9 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<button type="button" class="btn btn-sm btn-secondary" onclick="$('#massMoveModal').modal('show')"> <button type="button" class="btn btn-sm btn-secondary" onclick="$('#massMoveModal').modal('show')">
<i class="fas fa-folder-open mr-1"></i> Sposta selezionati in... <i class="fas fa-folder-open mr-1"></i> Sposta selezionati in...
</button> </button>
<button type="button" class="btn btn-sm btn-info" onclick="$('#massTagModal').modal('show')">
<i class="fas fa-tags mr-1"></i> Tag
</button>
<button type="button" class="btn btn-sm btn-success" onclick="massDownload()"> <button type="button" class="btn btn-sm btn-success" onclick="massDownload()">
<i class="fas fa-download mr-1"></i> Scarica <i class="fas fa-download mr-1"></i> Scarica
</button> </button>
@@ -226,6 +234,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</span> </span>
@endif @endif
</div> </div>
<div class="text-center mb-1" style="display:flex;flex-wrap:wrap;gap:2px;justify-content:center;">
@foreach($documento->tags as $tag)
<a href="{{ route('documenti.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff; font-size:0.7rem;">{{ $tag->name }}</a>
@endforeach
</div>
<div class="small text-muted text-center"> <div class="small text-muted text-center">
@if($documento->user) @if($documento->user)
{{ $documento->user->name }} {{ $documento->user->name }}
@@ -283,20 +296,21 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
{{-- View: Lista --}} {{-- View: Lista --}}
<div id="viewList" style="display:none;"> <div id="viewList" style="display:none;">
@if($sottoCartelle->count() > 0 || $documenti->count() > 0) @if($sottoCartelle->count() > 0 || $documenti->count() > 0)
<table class="table table-bordered table-hover table-striped mb-0"> <table class="table table-bordered table-hover table-striped mb-0" id="documenti-table">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
@if($canDeleteDocumenti) @if($canDeleteDocumenti)
<th style="width: 40px;"> <th style="width: 40px;" data-column="select">
<input type="checkbox" id="selectAll" onchange="toggleAll(this)"> <input type="checkbox" id="selectAll" onchange="toggleAll(this)">
</th> </th>
@endif @endif
<th>Nome</th> <th data-column="nome">Nome</th>
<th style="width: 100px;">Tipologia</th> <th style="width: 100px;" data-column="tipologia">Tipologia</th>
<th style="width: 90px;">Dimensione</th> <th style="width: 90px;" data-column="dimensione">Dimensione</th>
<th style="width: 120px;">Contesto</th> <th style="width: 120px;" data-column="visibilita">Contesto</th>
<th style="width: 110px;">Data</th> <th style="width: 100px;" data-column="tag">Tag</th>
<th style="width: 130px;">Azioni</th> <th style="width: 110px;" data-column="created_at">Data</th>
<th style="width: 130px;" data-column="azioni">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -314,6 +328,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<td class="small text-muted">-</td> <td class="small text-muted">-</td>
<td class="small text-muted">-</td> <td class="small text-muted">-</td>
<td class="small text-muted">-</td> <td class="small text-muted">-</td>
<td class="small text-muted">-</td>
<td> <td>
<a href="/documenti?folder_id={{ $cartella->id }}" class="btn btn-xs btn-warning" title="Apri cartella"> <a href="/documenti?folder_id={{ $cartella->id }}" class="btn btn-xs btn-warning" title="Apri cartella">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
@@ -423,6 +438,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
<span class="text-muted small">-</span> <span class="text-muted small">-</span>
@endswitch @endswitch
</td> </td>
<td>
@foreach($documento->tags as $tag)
<a href="{{ route('documenti.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff;">{{ $tag->name }}</a>
@endforeach
</td>
<td class="small">{{ $documento->created_at->format('d/m/Y') }}</td> <td class="small">{{ $documento->created_at->format('d/m/Y') }}</td>
<td> <td>
@if($documento->file_path) @if($documento->file_path)
@@ -498,6 +518,10 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div> </div>
</form>{{-- /massForm --}} </form>{{-- /massForm --}}
@include('partials.table-settings')
<div id="vista-data" style="display:none;">{{ $vistaDefaultJson }}</div>
{{-- MODAL: Nuova Cartella --}} {{-- MODAL: Nuova Cartella --}}
<div class="modal fade" id="newFolderModal" tabindex="-1" role="dialog"> <div class="modal fade" id="newFolderModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-dialog-scrollable" role="document">
@@ -834,6 +858,50 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
</div> </div>
</div> </div>
{{-- MODAL: Gestione Tag massiva --}}
<div class="modal fade" id="massTagModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-tags mr-2"></i>Gestione Tag</h5>
<button type="button" class="close" onclick="$('#massTagModal').modal('hide')">
<span>&times;</span>
</button>
</div>
<form id="massTagForm" method="POST" action="/documenti/mass-tag">
@csrf
<div class="modal-body">
<p class="mb-2">Operazione su <strong id="massTagCount">0</strong> documenti selezionati.</p>
<div class="form-group mb-3">
<label class="font-weight-bold">Azione</label>
<div class="d-flex" style="gap:1.5rem;">
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeAssign" value="assign" checked>
<label class="form-check-label" for="mtModeAssign">Assegna tag</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeRemove" value="remove">
<label class="form-check-label" for="mtModeRemove">Rimuovi tag</label>
</div>
</div>
</div>
@include('partials._tag-selector', ['label' => 'Seleziona tag'])
<input type="hidden" name="ids" id="massTagIds" value="">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="$('#massTagModal').modal('hide')">Annulla</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-tags mr-1"></i> Applica
</button>
</div>
</form>
</div>
</div>
</div>
{{-- MODAL: Sposta documento --}} {{-- MODAL: Sposta documento --}}
<div class="modal fade" id="moveModal" tabindex="-1" role="dialog"> <div class="modal fade" id="moveModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-dialog-scrollable" role="document">
@@ -877,6 +945,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
@endsection @endsection
@section('scripts') @section('scripts')
<script src="{{ asset('js/column-manager.js') }}"></script>
<script> <script>
const individui = @json($individui->map(fn($i) => ['id' => $i->id, 'label' => $i->cognome . ' ' . $i->nome])); const individui = @json($individui->map(fn($i) => ['id' => $i->id, 'label' => $i->cognome . ' ' . $i->nome]));
const gruppi = @json($gruppi->map(fn($g) => ['id' => $g->id, 'label' => $g->nome])); const gruppi = @json($gruppi->map(fn($g) => ['id' => $g->id, 'label' => $g->nome]));
@@ -1218,6 +1287,12 @@ document.getElementById('massMoveModal')?.addEventListener('show.bs.modal', func
document.getElementById('massMoveCount').textContent = ids.length; document.getElementById('massMoveCount').textContent = ids.length;
}); });
document.getElementById('massTagModal')?.addEventListener('show.bs.modal', function() {
const ids = getSelectedIds();
document.getElementById('massTagIds').value = ids.join(',');
document.getElementById('massTagCount').textContent = ids.length;
});
document.getElementById('massMoveNewFolderBtn')?.addEventListener('click', function() { document.getElementById('massMoveNewFolderBtn')?.addEventListener('click', function() {
Swal.fire({ Swal.fire({
title: 'Nuova cartella', title: 'Nuova cartella',
@@ -1874,5 +1949,24 @@ function deleteFolder(id, nome) {
} }
}); });
} }
document.addEventListener('DOMContentLoaded', function () {
const table = document.getElementById('documenti-table');
if (table) {
window.columnManager = new ColumnManager('documenti-table', {
entityType: 'documenti',
storageKey: 'cm_documenti',
resizable: true,
reorderable: false,
});
initTableSettings();
}
});
window.printWithCurrentSettings = function () {
if (window.columnManager) {
window.columnManager.printWithSettings();
}
};
</script> </script>
@endsection @endsection
+110 -4
View File
@@ -87,10 +87,17 @@
<div class="form-group"> <div class="form-group">
<label>Allegati</label> <label>Allegati</label>
<div class="border rounded p-3 bg-light"> <div class="border rounded p-3 bg-light">
<label class="btn btn-sm btn-success mb-0"> <div class="mb-3">
<i class="fas fa-upload mr-1"></i> Carica file <button type="button" class="btn btn-sm btn-primary" data-toggle="modal" data-target="#documentiModal">
<input type="file" name="allegati[]" multiple class="d-none"> <i class="fas fa-folder-open mr-1"></i> Seleziona da Documenti
</label> </button>
<label class="btn btn-sm btn-success mb-0">
<i class="fas fa-upload mr-1"></i> Carica file
<input type="file" name="allegati[]" id="allegati" multiple class="d-none" onchange="handleFileUpload(this)">
</label>
</div>
<div id="allegati-list"></div>
<input type="hidden" name="documenti_selezionati" id="documenti_selezionati" value="">
</div> </div>
</div> </div>
@@ -110,10 +117,62 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="documentiModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-folder-open mr-2"></i>Seleziona Documenti</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
@if($documenti->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Nome File</th>
<th>Tipologia</th>
</tr>
</thead>
<tbody>
@foreach($documenti as $doc)
<tr>
<td>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input doc-checkbox" id="doc-{{ $doc->id }}" value="{{ $doc->id }}" data-nome="{{ $doc->nome_file }}">
<label class="custom-control-label" for="doc-{{ $doc->id }}"></label>
</div>
</td>
<td>{{ $doc->nome_file }}</td>
<td><span class="badge badge-info">{{ $doc->tipologia ?: $doc->tipo }}</span></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted">Nessun documento disponibile.</p>
@endif
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button>
<button type="button" class="btn btn-primary" onclick="aggiungiDocumenti()">
<i class="fas fa-plus mr-1"></i> Aggiungi
</button>
</div>
</div>
</div>
</div>
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
let allegatiSelezionati = [];
function toggleDestinatari() { function toggleDestinatari() {
const tipo = document.getElementById('destinatario_tipo').value; const tipo = document.getElementById('destinatario_tipo').value;
@@ -160,6 +219,53 @@ function loadGruppiContatti() {
}); });
} }
function aggiungiDocumenti() {
const checkboxes = document.querySelectorAll('.doc-checkbox:checked');
checkboxes.forEach(function(cb) {
const id = parseInt(cb.value);
const nome = cb.dataset.nome;
if (!allegatiSelezionati.includes(id)) {
allegatiSelezionati.push(id);
const container = document.getElementById('allegati-list');
const div = document.createElement('div');
div.className = 'd-flex align-items-center justify-content-between bg-white p-2 mb-2 rounded';
div.id = 'allegato-' + id;
div.innerHTML = '<span><i class="fas fa-file mr-2"></i>' + nome + '</span>' +
'<button type="button" class="btn btn-xs btn-danger" onclick="removeAllegato(' + id + ')">' +
'<i class="fas fa-times"></i></button>';
container.appendChild(div);
}
cb.checked = false;
});
document.getElementById('documenti_selezionati').value = allegatiSelezionati.join(',');
$('#documentiModal').modal('hide');
}
function removeAllegato(id) {
allegatiSelezionati = allegatiSelezionati.filter(function(a) { return a !== id; });
const el = document.getElementById('allegato-' + id);
if (el) el.remove();
document.getElementById('documenti_selezionati').value = allegatiSelezionati.join(',');
}
function handleFileUpload(input) {
if (!input.files) return;
const container = document.getElementById('allegati-list');
for (const file of input.files) {
const div = document.createElement('div');
div.className = 'd-flex align-items-center justify-content-between bg-white p-2 mb-2 rounded';
div.innerHTML = '<span><i class="fas fa-file mr-2"></i>' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>' +
'<button type="button" class="btn btn-xs btn-danger" onclick="this.parentElement.remove()">' +
'<i class="fas fa-times"></i></button>';
container.appendChild(div);
}
input.value = '';
}
function saveDraft() { function saveDraft() {
const form = document.getElementById('emailForm'); const form = document.getElementById('emailForm');
const formData = new FormData(form); const formData = new FormData(form);
+23 -2
View File
@@ -131,8 +131,29 @@ $currentFolder = $folders->firstWhere('type', $folder);
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer d-flex justify-content-between align-items-center">
{{ $messages->withQueryString()->links('vendor.pagination.simple-bootstrap-4') }} <div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $messages->links('vendor.pagination.simple-bootstrap-4') }}
</div>
</div> </div>
</div> </div>
@endsection @endsection
+39 -2
View File
@@ -17,6 +17,9 @@
<a href="{{ route('eventi.create') }}" class="btn btn-success btn-sm"> <a href="{{ route('eventi.create') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus mr-1"></i> Nuovo Evento <i class="fas fa-plus mr-1"></i> Nuovo Evento
</a> </a>
<button type="button" class="btn btn-info btn-sm" id="syncCalendarsBtn" onclick="syncCalendars()">
<i class="fas fa-sync mr-1"></i> Sync
</button>
<form action="{{ route('eventi.export-selected-ics') }}" method="POST" class="d-inline"> <form action="{{ route('eventi.export-selected-ics') }}" method="POST" class="d-inline">
@csrf @csrf
<button type="submit" class="btn btn-success btn-sm"> <button type="submit" class="btn btn-success btn-sm">
@@ -40,10 +43,44 @@
@section('scripts') @section('scripts')
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
<script> <script>
function syncCalendars() {
var btn = document.getElementById('syncCalendarsBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Sync...';
fetch('{{ route('calendario-connessioni.sync-all') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
'Content-Type': 'application/json',
},
})
.then(function(r) { return r.json(); })
.then(function(data) {
var msg = data.connections + ' connessioni sincronizzate.';
msg += ' Importati: ' + data.total_imported + ', Esportati: ' + data.total_exported + '.';
if (data.success) {
alert('✅ Sync completato!\n' + msg);
} else {
alert('⚠️ Sync completato con errori.\n' + msg);
}
if (window.calendar) {
window.calendar.refetchEvents();
}
})
.catch(function(err) {
alert('❌ Errore sync: ' + err.message);
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync mr-1"></i> Sync';
});
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar'); var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, { window.calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
locale: 'it', locale: 'it',
headerToolbar: { headerToolbar: {
@@ -97,7 +134,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
calendar.render(); window.calendar.render();
}); });
</script> </script>
@endsection @endsection
+9
View File
@@ -247,6 +247,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3>
</div>
<div class="card-body">
@include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette'])
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="fas fa-save mr-1"></i> Salva <i class="fas fa-save mr-1"></i> Salva
+9
View File
@@ -252,6 +252,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3>
</div>
<div class="card-body">
@include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette'])
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="fas fa-save mr-1"></i> Salva Modifiche <i class="fas fa-save mr-1"></i> Salva Modifiche
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.adminlte')
@section('title', 'Importa Eventi')
@section('page_title', 'Importa Eventi da ICS')
@section('content')
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file ICS</h3>
</div>
<div class="card-body">
<form action="{{ route('eventi.import.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="file">Seleziona file ICS *</label>
<input type="file" name="file" id="file" class="form-control" accept=".ics" required>
<small class="form-text text-muted">
Il file deve essere in formato ICS (iCalendar).
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa
</button>
<a href="/eventi" class="btn btn-secondary ml-2">
Annulla
</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Informazioni</h3>
</div>
<div class="card-body">
<h6>Campi importati:</h6>
<ul>
<li><code>SUMMARY</code> Nome evento (obbligatorio)</li>
<li><code>DESCRIPTION</code> Descrizione</li>
<li><code>DTSTART</code> Data/Ora inizio</li>
<li><code>DTEND</code> Durata</li>
<li><code>LOCATION</code> Luogo</li>
<li><code>UID</code> ID esterno (per evitare duplicati)</li>
</ul>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle mr-1"></i>
Gli eventi con UID già presente nel sistema verranno saltati automaticamente.
</p>
</div>
</div>
</div>
</div>
@endsection
+107 -12
View File
@@ -5,6 +5,10 @@
@php @php
$canWriteEventi = Auth::user()->canManage('eventi'); $canWriteEventi = Auth::user()->canManage('eventi');
$canDeleteEventi = Auth::user()->canDelete('eventi'); $canDeleteEventi = Auth::user()->canDelete('eventi');
$vistaDefaultJson = isset($vista) ? $vista->toJson() : 'null';
$visibleColumnsJson = json_encode($visibleColumns ?? []);
$tableColumnsJson = json_encode($tableColumns ?? []);
$entityType = $entityType ?? 'eventi';
@endphp @endphp
@section('content') @section('content')
@@ -20,6 +24,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Tutti gli Eventi</h3> <h3 class="card-title">Tutti gli Eventi</h3>
<div class="card-tools"> <div class="card-tools">
<button type="button" class="btn btn-sm btn-secondary mr-1" onclick="$('#table-settings-modal').modal('show')" title="Impostazioni tabella">
<i class="fas fa-cog"></i>
</button>
<a href="/eventi/calendar" class="btn btn-sm btn-outline-primary mr-2"> <a href="/eventi/calendar" class="btn btn-sm btn-outline-primary mr-2">
<i class="fas fa-calendar-alt mr-1"></i> Calendario <i class="fas fa-calendar-alt mr-1"></i> Calendario
</a> </a>
@@ -34,12 +41,17 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div> </div>
@endif @endif
@if($canWriteEventi) @if($canWriteEventi)
<a href="/eventi/import" class="btn btn-sm btn-warning mr-2">
<i class="fas fa-file-import mr-1"></i> Importa ICS
</a>
<a href="/eventi/create" class="btn btn-xs btn-success"> <a href="/eventi/create" class="btn btn-xs btn-success">
<i class="fas fa-plus mr-1"></i> Nuovo Evento <i class="fas fa-plus mr-1"></i> Nuovo Evento
</a> </a>
@endif @endif
</div> </div>
</div> </div>
@include('partials._tag-filter-bar', ['filterTags' => $allTags])
<div class="card-body"> <div class="card-body">
<form method="GET" class="mb-3"> <form method="GET" class="mb-3">
<div class="row"> <div class="row">
@@ -70,10 +82,10 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div> </div>
</form> </form>
<table class="table table-bordered table-hover"> <table class="table table-bordered table-hover" id="eventi-table">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
<th style="width: 40px;"> <th style="width: 40px;" data-column="select">
@if($canDeleteEventi) @if($canDeleteEventi)
<div class="custom-control custom-checkbox"> <div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="select-all" onchange="toggleSelectAll(this)"> <input type="checkbox" class="custom-control-input" id="select-all" onchange="toggleSelectAll(this)">
@@ -81,7 +93,8 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div> </div>
@endif @endif
</th> </th>
<th> @if(in_array('nome_evento', $visibleColumns))
<th data-column="nome_evento" data-sortable="true">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'nome_evento', 'direction' => request('sort') === 'nome_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'nome_evento', 'direction' => request('sort') === 'nome_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Nome Evento Nome Evento
@if(request('sort') === 'nome_evento') @if(request('sort') === 'nome_evento')
@@ -91,7 +104,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th> @endif
@if(in_array('descrizione_evento', $visibleColumns))
<th data-column="descrizione_evento" data-sortable="true">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'descrizione_evento', 'direction' => request('sort') === 'descrizione_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'descrizione_evento', 'direction' => request('sort') === 'descrizione_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Descrizione Descrizione
@if(request('sort') === 'descrizione_evento') @if(request('sort') === 'descrizione_evento')
@@ -101,7 +116,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th> @endif
@if(in_array('tipo_evento', $visibleColumns))
<th data-column="tipo_evento" data-sortable="true">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'tipo_evento', 'direction' => request('sort') === 'tipo_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'tipo_evento', 'direction' => request('sort') === 'tipo_evento' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Tipologia Tipologia
@if(request('sort') === 'tipo_evento') @if(request('sort') === 'tipo_evento')
@@ -111,7 +128,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th> @endif
@if(in_array('tipo_recorrenza', $visibleColumns))
<th data-column="tipo_recorrenza" data-sortable="true">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'tipo_recorrenza', 'direction' => request('sort') === 'tipo_recorrenza' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'tipo_recorrenza', 'direction' => request('sort') === 'tipo_recorrenza' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Ricorrenza Ricorrenza
@if(request('sort') === 'tipo_recorrenza') @if(request('sort') === 'tipo_recorrenza')
@@ -121,7 +140,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th> @endif
@if(in_array('gruppi', $visibleColumns))
<th data-column="gruppi">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'gruppi', 'direction' => request('sort') === 'gruppi' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'gruppi', 'direction' => request('sort') === 'gruppi' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Gruppi Gruppi
@if(request('sort') === 'gruppi') @if(request('sort') === 'gruppi')
@@ -131,7 +152,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th> @endif
@if(in_array('responsabili', $visibleColumns))
<th data-column="responsabili">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'responsabili', 'direction' => request('sort') === 'responsabili' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'responsabili', 'direction' => request('sort') === 'responsabili' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Responsabili Responsabili
@if(request('sort') === 'responsabili') @if(request('sort') === 'responsabili')
@@ -141,7 +164,12 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
<th style="width: 120px;"> @endif
@if(in_array('tag', $visibleColumns))
<th data-column="tag">Tag</th>
@endif
@if(in_array('created_at', $visibleColumns))
<th style="width: 120px;" data-column="created_at">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'created_at', 'direction' => request('sort') === 'created_at' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark"> <a href="{{ request()->fullUrlWithQuery(['sort' => 'created_at', 'direction' => request('sort') === 'created_at' && request('direction') === 'asc' ? 'desc' : 'asc']) }}" class="text-dark">
Creato Creato
@if(request('sort') === 'created_at') @if(request('sort') === 'created_at')
@@ -151,6 +179,7 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endif @endif
</a> </a>
</th> </th>
@endif
<th style="width: 120px;">Azioni</th> <th style="width: 120px;">Azioni</th>
</tr> </tr>
</thead> </thead>
@@ -165,6 +194,7 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div> </div>
@endif @endif
</td> </td>
@if(in_array('nome_evento', $visibleColumns))
<td> <td>
<a href="/eventi/{{ $evento->id }}"> <a href="/eventi/{{ $evento->id }}">
<i class="fas fa-calendar mr-1 text-primary"></i> <i class="fas fa-calendar mr-1 text-primary"></i>
@@ -174,7 +204,11 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<span class="badge badge-success ml-1">Incluso</span> <span class="badge badge-success ml-1">Incluso</span>
@endif @endif
</td> </td>
@endif
@if(in_array('descrizione_evento', $visibleColumns))
<td>{{ Str::limit($evento->descrizione_evento, 50) ?: '-' }}</td> <td>{{ Str::limit($evento->descrizione_evento, 50) ?: '-' }}</td>
@endif
@if(in_array('tipo_evento', $visibleColumns))
<td> <td>
@if($evento->tipo_evento) @if($evento->tipo_evento)
@php @php
@@ -189,6 +223,8 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<span class="text-muted">-</span> <span class="text-muted">-</span>
@endif @endif
</td> </td>
@endif
@if(in_array('tipo_recorrenza', $visibleColumns))
<td> <td>
@if($evento->tipo_recorrenza && $evento->tipo_recorrenza !== 'singolo') @if($evento->tipo_recorrenza && $evento->tipo_recorrenza !== 'singolo')
<span class="badge badge-info">{{ $evento->periodicita_label }}</span> <span class="badge badge-info">{{ $evento->periodicita_label }}</span>
@@ -196,6 +232,8 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<span class="badge badge-secondary">Singolo</span> <span class="badge badge-secondary">Singolo</span>
@endif @endif
</td> </td>
@endif
@if(in_array('gruppi', $visibleColumns))
<td> <td>
@forelse($evento->gruppi->take(2) as $gruppo) @forelse($evento->gruppi->take(2) as $gruppo)
<a href="/gruppi/{{ $gruppo->id }}"> <a href="/gruppi/{{ $gruppo->id }}">
@@ -208,6 +246,8 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<small class="text-muted">+{{ $evento->gruppi->count() - 2 }}</small> <small class="text-muted">+{{ $evento->gruppi->count() - 2 }}</small>
@endif @endif
</td> </td>
@endif
@if(in_array('responsabili', $visibleColumns))
<td> <td>
@forelse($evento->responsabili->take(2) as $resp) @forelse($evento->responsabili->take(2) as $resp)
<small>{{ $resp->nome_completo }}</small><br> <small>{{ $resp->nome_completo }}</small><br>
@@ -218,7 +258,17 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<small class="text-muted">+{{ $evento->responsabili->count() - 2 }} altri</small> <small class="text-muted">+{{ $evento->responsabili->count() - 2 }} altri</small>
@endif @endif
</td> </td>
@endif
@if(in_array('tag', $visibleColumns))
<td>
@foreach($evento->tags as $tag)
<a href="{{ route('eventi.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff;">{{ $tag->name }}</a>
@endforeach
</td>
@endif
@if(in_array('created_at', $visibleColumns))
<td><small class="text-muted">{{ $evento->created_at->format('d/m/Y') }}</small></td> <td><small class="text-muted">{{ $evento->created_at->format('d/m/Y') }}</small></td>
@endif
<td> <td>
<a href="{{ url('/eventi/' . $evento->id) }}" class="btn btn-xs btn-info" title="Visualizza"> <a href="{{ url('/eventi/' . $evento->id) }}" class="btn btn-xs btn-info" title="Visualizza">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
@@ -238,7 +288,7 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="9" class="text-center text-muted py-4"> <td colspan="20" class="text-center text-muted py-4">
<i class="fas fa-calendar fa-2x mb-2"></i> <i class="fas fa-calendar fa-2x mb-2"></i>
<p class="mb-0">Nessun evento trovato</p> <p class="mb-0">Nessun evento trovato</p>
</td> </td>
@@ -247,11 +297,36 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card-footer"> <div class="card-footer d-flex justify-content-between align-items-center">
{{ $eventi->withQueryString()->links() }} <div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $eventi->links() }}
</div>
</div> </div>
</div> </div>
@include('partials.table-settings')
<div id="vista-data" style="display:none;">{{ $vistaDefaultJson }}</div>
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog"> <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog">
@if($canDeleteEventi) @if($canDeleteEventi)
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
@@ -277,6 +352,7 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
@endsection @endsection
@section('scripts') @section('scripts')
<script src="{{ asset('js/column-manager.js') }}"></script>
<script> <script>
function toggleSelectAll(source) { function toggleSelectAll(source) {
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = source.checked); document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = source.checked);
@@ -342,5 +418,24 @@ function exportSelectedIcs() {
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
} }
document.addEventListener('DOMContentLoaded', function () {
const table = document.getElementById('eventi-table');
if (table) {
window.columnManager = new ColumnManager('eventi-table', {
entityType: 'eventi',
storageKey: 'cm_eventi',
resizable: true,
reorderable: true,
});
initTableSettings();
}
});
window.printWithCurrentSettings = function () {
if (window.columnManager) {
window.columnManager.printWithSettings();
}
};
</script> </script>
@endsection @endsection
+11
View File
@@ -202,6 +202,17 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3></div>
<div class="card-body">
@forelse($evento->tags as $tag)
<span class="badge" style="background-color:{{ $tag->color ?? '#6c757d' }};color:#fff;padding:4px 10px;border-radius:12px;font-size:0.85rem;">{{ $tag->name }}</span>
@empty
<p class="text-muted mb-0"><i class="fas fa-info-circle mr-1"></i>Nessun tag assegnato.</p>
@endforelse
</div>
</div>
@if($evento->note) @if($evento->note)
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"><h3 class="card-title"><i class="fas fa-sticky-note mr-2"></i>Note</h3></div> <div class="card-header"><h3 class="card-title"><i class="fas fa-sticky-note mr-2"></i>Note</h3></div>
+9
View File
@@ -117,6 +117,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3>
</div>
<div class="card-body">
@include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette'])
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="fas fa-save mr-1"></i> Salva <i class="fas fa-save mr-1"></i> Salva
+9
View File
@@ -270,6 +270,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3>
</div>
<div class="card-body">
@include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette'])
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="fas fa-save mr-1"></i> Salva Modifiche <i class="fas fa-save mr-1"></i> Salva Modifiche
@@ -1,7 +1,8 @@
<?php $__env->startSection('title', 'Importa Gruppi'); ?> @extends('layouts.adminlte')
<?php $__env->startSection('page_title', 'Importa Gruppi da CSV'); ?> @section('title', 'Importa Gruppi')
@section('page_title', 'Importa Gruppi da CSV')
<?php $__env->startSection('content'); ?> @section('content')
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div class="card"> <div class="card">
@@ -9,8 +10,8 @@
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file CSV</h3> <h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file CSV</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="<?php echo e(route('gruppi.import.store')); ?>" method="POST" enctype="multipart/form-data"> <form action="{{ route('gruppi.import.store') }}" method="POST" enctype="multipart/form-data">
<?php echo csrf_field(); ?> @csrf
<div class="form-group"> <div class="form-group">
<label for="file">Seleziona file CSV *</label> <label for="file">Seleziona file CSV *</label>
<input type="file" name="file" id="file" class="form-control" accept=".csv,.txt" required> <input type="file" name="file" id="file" class="form-control" accept=".csv,.txt" required>
@@ -21,7 +22,7 @@
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa <i class="fas fa-upload mr-1"></i> Importa
</button> </button>
<a href="<?php echo e(route('gruppi.index')); ?>" class="btn btn-secondary ml-2"> <a href="{{ route('gruppi.index') }}" class="btn btn-secondary ml-2">
Annulla Annulla
</a> </a>
</form> </form>
@@ -35,7 +36,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p>Scarica il template CSV per l'importazione:</p> <p>Scarica il template CSV per l'importazione:</p>
<a href="<?php echo e(route('gruppi.template')); ?>" class="btn btn-success btn-block"> <a href="{{ route('gruppi.template') }}" class="btn btn-success btn-block">
<i class="fas fa-file-csv mr-1"></i> Scarica Template <i class="fas fa-file-csv mr-1"></i> Scarica Template
</a> </a>
</div> </div>
@@ -60,6 +61,4 @@
</div> </div>
</div> </div>
</div> </div>
<?php $__env->stopSection(); ?> @endsection
<?php echo $__env->make('layouts.adminlte', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH /var/www/html/glastree/resources/views/gruppi/import.blade.php ENDPATH**/ ?>
+208 -133
View File
@@ -17,10 +17,12 @@ $columnLabels = [
'citta' => 'Città', 'citta' => 'Città',
'telefono' => 'Telefono', 'telefono' => 'Telefono',
'email' => 'Email', 'email' => 'Email',
'tag' => 'Tag',
]; ];
$vistaDefaultJson = $vista ? $vista->toJson() : 'null'; $vistaDefaultJson = $vista ? $vista->toJson() : 'null';
$allColumnsJson = json_encode($allColumns); $allColumnsJson = json_encode($allColumns);
$visibleColumnsJson = json_encode($visibleColumns); $visibleColumnsJson = json_encode($visibleColumns);
$entityType = $entityType ?? 'gruppi';
@endphp @endphp
@section('content') @section('content')
@@ -43,6 +45,9 @@ $visibleColumnsJson = json_encode($visibleColumns);
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><i class="fas fa-sitemap mr-2"></i>Elenco Gruppi</h3> <h3 class="card-title"><i class="fas fa-sitemap mr-2"></i>Elenco Gruppi</h3>
<div class="card-tools"> <div class="card-tools">
<button type="button" class="btn btn-sm btn-secondary mr-1" onclick="$('#table-settings-modal').modal('show')" title="Impostazioni tabella">
<i class="fas fa-cog"></i>
</button>
<div class="btn-group mr-2"> <div class="btn-group mr-2">
<button type="button" class="btn btn-sm btn-outline-primary {{ $viewMode === 'table' ? 'active' : '' }}" onclick="switchView('table')" id="btn-table-view"> <button type="button" class="btn btn-sm btn-outline-primary {{ $viewMode === 'table' ? 'active' : '' }}" onclick="switchView('table')" id="btn-table-view">
<i class="fas fa-table mr-1"></i> Tabella <i class="fas fa-table mr-1"></i> Tabella
@@ -51,10 +56,27 @@ $visibleColumnsJson = json_encode($visibleColumns);
<i class="fas fa-sitemap mr-1"></i> Albero <i class="fas fa-sitemap mr-1"></i> Albero
</button> </button>
</div> </div>
<button type="button" class="btn btn-primary btn-sm mr-1" onclick="showSaveVistaModal()"> <div class="btn-group mr-1">
<i class="fas fa-save mr-1"></i> Salva Vista <button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-toggle="dropdown">
</button> <i class="fas fa-cog mr-1"></i> Azioni
</button>
<div class="dropdown-menu">
@if($canWriteGruppi)
<a class="dropdown-item" href="#" onclick="showMassTagModal(); return false;">
<i class="fas fa-tags mr-2"></i>Assegna Tag
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="#" onclick="deleteSelected(); return false;">
<i class="fas fa-trash mr-2"></i>Elimina Selezionati
</a>
@endif
</div>
</div>
@if($canWriteGruppi) @if($canWriteGruppi)
<a href="{{ route('gruppi.import') }}" class="btn btn-warning btn-sm mr-1">
<i class="fas fa-file-import mr-1"></i> Importa
</a>
<a href="{{ route('gruppi.create') }}" class="btn btn-success btn-sm"> <a href="{{ route('gruppi.create') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Nuovo Gruppo <i class="fas fa-plus"></i> Nuovo Gruppo
</a> </a>
@@ -62,50 +84,71 @@ $visibleColumnsJson = json_encode($visibleColumns);
</div> </div>
</div> </div>
@include('partials._tag-filter-bar', ['filterTags' => $allTags])
@include('partials.table-settings')
<div id="vista-data" style="display:none;">{{ $vistaDefaultJson }}</div>
@if($viewMode === 'table') @if($viewMode === 'table')
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-bordered table-hover table-striped mb-0" id="gruppi-table"> <table class="table table-bordered table-hover table-striped mb-0" id="gruppi-table">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
<th style="width: 40px;" data-column="select">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="select-all" onchange="toggleSelectAll(this)">
<label class="custom-control-label" for="select-all"></label>
</div>
</th>
@if(in_array('nome', $visibleColumns)) @if(in_array('nome', $visibleColumns))
<th data-sortable="true" onclick="sortTable('nome')" style="cursor:pointer;">Nome <i class="fas fa-sort float-right text-muted"></i></th> <th data-sortable="true" data-column="nome" onclick="sortTable('nome')" style="cursor:pointer;">Nome <i class="fas fa-sort float-right text-muted"></i></th>
@endif @endif
@if(in_array('descrizione', $visibleColumns)) @if(in_array('descrizione', $visibleColumns))
<th data-sortable="true" onclick="sortTable('descrizione')" style="cursor:pointer;">Descrizione <i class="fas fa-sort float-right text-muted"></i></th> <th data-sortable="true" data-column="descrizione" onclick="sortTable('descrizione')" style="cursor:pointer;">Descrizione <i class="fas fa-sort float-right text-muted"></i></th>
@endif @endif
@if(in_array('diocesi', $visibleColumns)) @if(in_array('diocesi', $visibleColumns))
<th>Diocesi</th> <th data-column="diocesi">Diocesi</th>
@endif @endif
@if(in_array('livello', $visibleColumns)) @if(in_array('livello', $visibleColumns))
<th style="width: 80px;">Livello</th> <th style="width: 80px;" data-column="livello">Livello</th>
@endif @endif
@if(in_array('padre', $visibleColumns)) @if(in_array('padre', $visibleColumns))
<th>Gruppo Padre</th> <th data-column="padre">Gruppo Padre</th>
@endif @endif
@if(in_array('membri', $visibleColumns)) @if(in_array('membri', $visibleColumns))
<th style="width: 100px;">Membri</th> <th style="width: 100px;" data-column="membri">Membri</th>
@endif @endif
@if(in_array('responsabili', $visibleColumns)) @if(in_array('responsabili', $visibleColumns))
<th>Responsabili</th> <th data-column="responsabili">Responsabili</th>
@endif @endif
@if(in_array('indirizzo', $visibleColumns)) @if(in_array('indirizzo', $visibleColumns))
<th>Indirizzo</th> <th data-column="indirizzo">Indirizzo</th>
@endif @endif
@if(in_array('citta', $visibleColumns)) @if(in_array('citta', $visibleColumns))
<th>Città</th> <th data-column="citta">Città</th>
@endif @endif
@if(in_array('telefono', $visibleColumns)) @if(in_array('telefono', $visibleColumns))
<th>Telefono</th> <th data-column="telefono">Telefono</th>
@endif @endif
@if(in_array('email', $visibleColumns)) @if(in_array('email', $visibleColumns))
<th>Email</th> <th data-column="email">Email</th>
@endif @endif
<th style="width: 120px;">Azioni</th> @if(in_array('tag', $visibleColumns))
<th data-column="tag">Tag</th>
@endif
<th style="width: 120px;" data-column="azioni">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody id="table-body"> <tbody id="table-body">
@foreach($gruppi as $gruppo) @foreach($gruppi as $gruppo)
<tr data-id="{{ $gruppo->id }}"> <tr data-id="{{ $gruppo->id }}">
<td>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input row-checkbox" id="select-{{ $gruppo->id }}" value="{{ $gruppo->id }}">
<label class="custom-control-label" for="select-{{ $gruppo->id }}"></label>
</div>
</td>
@if(in_array('nome', $visibleColumns)) @if(in_array('nome', $visibleColumns))
<td> <td>
<span style="padding-left: {{ ($gruppo->depth ?? 0) * 20 }}px;"> <span style="padding-left: {{ ($gruppo->depth ?? 0) * 20 }}px;">
@@ -151,6 +194,13 @@ $visibleColumnsJson = json_encode($visibleColumns);
@if(in_array('email', $visibleColumns)) @if(in_array('email', $visibleColumns))
<td>-</td> <td>-</td>
@endif @endif
@if(in_array('tag', $visibleColumns))
<td>
@foreach($gruppo->tags as $tag)
<a href="{{ route('gruppi.index', ['tag[]' => $tag->slug]) }}" class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff;">{{ $tag->name }}</a>
@endforeach
</td>
@endif
<td> <td>
<a href="{{ route('gruppi.show', $gruppo->id) }}" class="btn btn-xs btn-info" title="Visualizza"> <a href="{{ route('gruppi.show', $gruppo->id) }}" class="btn btn-xs btn-info" title="Visualizza">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
@@ -174,6 +224,30 @@ $visibleColumnsJson = json_encode($visibleColumns);
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $gruppi->links() }}
</div>
</div>
@else @else
<div class="card-body p-0"> <div class="card-body p-0">
<div class="tree-view" style="padding: 15px;"> <div class="tree-view" style="padding: 15px;">
@@ -195,101 +269,122 @@ $visibleColumnsJson = json_encode($visibleColumns);
@endif @endif
</div> </div>
<div class="modal fade" id="saveVistaModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
{{-- MODAL: Gestione Tag massiva --}}
<div class="modal fade" id="massTagModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header bg-info">
<h5 class="modal-title"><i class="fas fa-save mr-2"></i>Salva Vista</h5> <h5 class="modal-title text-white"><i class="fas fa-tags mr-2"></i>Gestione Tag</h5>
<button type="button" class="close" data-dismiss="modal"> <button type="button" class="close text-white" data-dismiss="modal"><span>&times;</span></button>
<span>&times;</span>
</button>
</div> </div>
<div class="modal-body"> <form id="massTagForm" method="POST" action="/gruppi/mass-tag">
<div class="form-group"> @csrf
<label>Nome Vista *</label> <div class="modal-body">
<input type="text" id="vista-nome" class="form-control" placeholder="Es. Elenco gruppi attivi"> <p class="mb-2">Operazione su <strong id="massTagCount">0</strong> gruppi selezionati.</p>
</div> <div class="form-group mb-3">
<div class="form-group"> <label class="font-weight-bold">Azione</label>
<label>Colonne visibili</label> <div class="d-flex" style="gap:1.5rem;">
<div class="row" id="colonne-checkboxes"> <div class="form-check">
@foreach($allColumns as $col) <input class="form-check-input" type="radio" name="mode" id="grMtModeAssign" value="assign" checked>
<div class="col-md-4 mb-2"> <label class="form-check-label" for="grMtModeAssign">Assegna tag</label>
<div class="custom-control custom-checkbox"> </div>
<input type="checkbox" class="custom-control-input col-checkbox" id="col-{{ $col }}" value="{{ $col }}" {{ in_array($col, $visibleColumns) ? 'checked' : '' }}> <div class="form-check">
<label class="custom-control-label" for="col-{{ $col }}">{{ $columnLabels[$col] ?? $col }}</label> <input class="form-check-input" type="radio" name="mode" id="grMtModeRemove" value="remove">
<label class="form-check-label" for="grMtModeRemove">Rimuovi tag</label>
</div> </div>
</div> </div>
@endforeach
</div> </div>
@include('partials._tag-selector', ['label' => 'Seleziona tag'])
<input type="hidden" name="ids" id="massTagIds" value="">
</div> </div>
<div class="form-group"> <div class="modal-footer">
<div class="custom-control custom-checkbox"> <button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button>
<input type="checkbox" class="custom-control-input" id="vista-default"> <button type="submit" class="btn btn-primary"><i class="fas fa-tags mr-1"></i> Applica</button>
<label class="custom-control-label" for="vista-default">Imposta come vista predefinita</label> </div>
</div> </form>
</div>
</div>
</div>
{{-- MODAL: Conferma Eliminazione massiva --}}
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog">
@if($canDeleteGruppi)
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger">
<h5 class="modal-title text-white"><i class="fas fa-trash-alt mr-2"></i>Conferma Eliminazione</h5>
<button type="button" class="close text-white" data-dismiss="modal"><span>&times;</span></button>
</div>
<div class="modal-body">
<p>Stai per eliminare <strong id="delete-count">0</strong> gruppi.</p>
<div class="alert alert-warning mt-3 mb-0">
<small><i class="fas fa-info-circle mr-1"></i>I membri verranno scollegati ma non eliminati.</small>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button>
<button type="button" class="btn btn-primary" onclick="saveVista()"> <button type="button" class="btn btn-danger" id="confirm-delete-btn"><i class="fas fa-trash mr-1"></i> Elimina</button>
<i class="fas fa-save mr-1"></i> Salva
</button>
</div> </div>
</div> </div>
</div> </div>
@endif
</div> </div>
<div class="modal fade" id="vistaListModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-bookmark mr-2"></i>Viste Salvate</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
@php
$visteSalvate = \App\Models\VistaReport::where('user_id', Auth::id())
->where('tipo', 'gruppi')
->orderBy('is_default', 'desc')
->orderBy('nome')
->get();
@endphp
@if($visteSalvate->count() > 0)
<div class="list-group">
@foreach($visteSalvate as $v)
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="?vista_id={{ $v->id }}">{{ $v->nome }}</a>
@if($v->is_default)
<span class="badge badge-success ml-1">Predefinita</span>
@endif
</div>
<form action="{{ route('gruppi.delete-vista', $v->id) }}" method="POST" class="d-inline">
@csrf @method('DELETE')
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('Eliminare questa vista?')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
@endforeach
</div>
@else
<p class="text-muted text-center">Nessuna vista salvata</p>
@endif
</div>
</div>
</div>
</div>
@endsection @endsection
@section('scripts') @section('scripts')
<script src="{{ asset('js/column-manager.js') }}"></script>
<script> <script>
function switchView(mode) { function switchView(mode) {
window.location.href = '?view=' + mode; window.location.href = '?view=' + mode;
} }
function toggleSelectAll(source) {
document.querySelectorAll('.row-checkbox').forEach(function(cb) {
cb.checked = source.checked;
});
}
function getSelectedIds() {
return Array.from(document.querySelectorAll('.row-checkbox:checked')).map(cb => cb.value);
}
function showMassTagModal() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Seleziona almeno un gruppo');
return;
}
document.getElementById('massTagIds').value = ids.join(',');
document.getElementById('massTagCount').textContent = ids.length;
$('#massTagModal').modal('show');
}
function deleteSelected() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Seleziona almeno un gruppo');
return;
}
document.getElementById('delete-count').textContent = ids.length;
document.getElementById('confirm-delete-btn').onclick = function() {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/gruppi/mass-elimina';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '{{ csrf_token() }}';
let html = '<input type="hidden" name="_token" value="' + csrfToken + '">';
ids.forEach(function(id) {
html += '<input type="hidden" name="ids[]" value="' + id + '">';
});
form.innerHTML = html;
document.body.appendChild(form);
form.submit();
};
$('#deleteModal').modal('show');
}
function toggleItem(id) { function toggleItem(id) {
const el = document.getElementById('group-' + id); const el = document.getElementById('group-' + id);
const icon = document.getElementById('icon-' + id); const icon = document.getElementById('icon-' + id);
@@ -358,48 +453,7 @@ function getColumnIndex(column) {
return 1; return 1;
} }
function showSaveVistaModal() {
document.getElementById('vista-nome').value = '';
document.getElementById('vista-default').checked = false;
$('#saveVistaModal').modal('show');
}
function saveVista() {
const nome = document.getElementById('vista-nome').value.trim();
if (!nome) {
alert('Inserisci un nome per la vista');
return;
}
const colonneVisibili = Array.from(document.querySelectorAll('.col-checkbox:checked')).map(cb => cb.value);
const isDefault = document.getElementById('vista-default').checked;
fetch('/gruppi/save-vista', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
nome: nome,
colonne_visibili: colonneVisibili,
is_default: isDefault,
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
$('#saveVistaModal').modal('hide');
window.location.reload();
} else {
alert('Errore nel salvataggio della vista');
}
})
.catch(error => {
alert('Errore: ' + error.message);
});
}
</script> </script>
<style> <style>
.tree-item { margin-bottom: 5px; } .tree-item { margin-bottom: 5px; }
@@ -425,10 +479,31 @@ function saveVista() {
font-size: 0.7em; font-size: 0.7em;
padding: 2px 5px; padding: 2px 5px;
} }
.btn-group .btn.active { .btn-group .btn.active {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
border-color: #007bff; border-color: #007bff;
} }
</style> </style>
<script>
document.addEventListener('DOMContentLoaded', function () {
const table = document.getElementById('gruppi-table');
if (table) {
window.columnManager = new ColumnManager('gruppi-table', {
entityType: 'gruppi',
storageKey: 'cm_gruppi',
resizable: true,
reorderable: true,
});
initTableSettings();
}
});
window.printWithCurrentSettings = function () {
if (window.columnManager) {
window.columnManager.printWithSettings();
}
};
</script>
@endsection @endsection
+11
View File
@@ -247,6 +247,17 @@ $canDeleteGruppi = Auth::user()->canDelete('gruppi');
</div> </div>
@endif @endif
<div class="card mt-3">
<div class="card-header"><h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3></div>
<div class="card-body">
@forelse($gruppo->tags as $tag)
<span class="badge" style="background-color:{{ $tag->color ?? '#6c757d' }};color:#fff;padding:4px 10px;border-radius:12px;font-size:0.85rem;">{{ $tag->name }}</span>
@empty
<p class="text-muted mb-0"><i class="fas fa-info-circle mr-1"></i>Nessun tag assegnato.</p>
@endforelse
</div>
</div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
+126
View File
@@ -27,6 +27,16 @@
<i class="fab fa-google-drive mr-2"></i> Google Drive <i class="fab fa-google-drive mr-2"></i> Google Drive
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="#help-calendario" data-toggle="tab">
<i class="fas fa-calendar-alt mr-2"></i> Calendario
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#help-tag" data-toggle="tab">
<i class="fas fa-tags mr-2"></i> Tag e Ricerca
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#help-tipologie" data-toggle="tab"> <a class="nav-link" href="#help-tipologie" data-toggle="tab">
<i class="fas fa-tags mr-2"></i> Tipologie e Dati <i class="fas fa-tags mr-2"></i> Tipologie e Dati
@@ -194,6 +204,118 @@
</div> </div>
</div> </div>
{{-- Calendario --}}
<div class="tab-pane" id="help-calendario">
<div class="card card-outline card-info">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-calendar-alt mr-2"></i> Google Calendar</h3>
</div>
<div class="card-body">
<h5>Cos'è</h5>
<p>Integrazione con Google Calendar per sincronizzare gli eventi tra l'app e il tuo calendario Google. Supporta import, export e sincronizzazione bidirezionale.</p>
<h5>Requisiti</h5>
<ol>
<li>Un account Google / Google Workspace</li>
<li>Un progetto sulla <strong>Google Cloud Console</strong> con l'API Google Calendar abilitata</li>
<li>Credenziali OAuth 2.0 (Client ID e Client Secret)</li>
</ol>
<h5>Passo 1: Creare il progetto Google Cloud</h5>
<ol>
<li>Vai su <a href="https://console.developers.google.com/" target="_blank">Google Cloud Console</a></li>
<li>Crea un nuovo progetto o selezionane uno esistente</li>
<li>Vai a <strong>API e servizi Libreria</strong></li>
<li>Cerca "<strong>Google Calendar API</strong>" e <strong>abilitala</strong></li>
<li>Vai a <strong>API e servizi Credenziali</strong></li>
<li>Clicca "<strong>Crea credenziali → ID client OAuth</strong>"</li>
<li>Tipo applicazione: "<strong>Applicazione Web</strong>"</li>
<li><strong>URI di reindirizzamento autorizzato:</strong> aggiungi <code>https://developers.google.com/oauthplayground</code></li>
<li>Completa la creazione e <strong>copia Client ID e Client Secret</strong></li>
</ol>
<h5>Passo 2: Ottenere il Refresh Token (Google OAuth Playground)</h5>
<p>A differenza di Google Drive, per Google Calendar il refresh token va ottenuto manualmente tramite il Google OAuth Playground:</p>
<ol>
<li>Vai su <a href="https://developers.google.com/oauthplayground" target="_blank">Google OAuth Playground</a></li>
<li>Clicca l'ingranaggio ⚙️ in alto a destra</li>
<li>Spunta "<strong>Use your own OAuth credentials</strong>"</li>
<li>Incolla <strong>Client ID</strong> e <strong>Client Secret</strong> creati al Passo 1</li>
<li>Nella colonna sinistra, espandi "<strong>Google Calendar API v3</strong>" e seleziona lo scope <code>https://www.googleapis.com/auth/calendar</code></li>
<li>Clicca "<strong>Authorize APIs</strong>" e autorizza con l'account Google</li>
<li>Clicca "<strong>Exchange authorization code for tokens</strong>"</li>
<li><strong>Copia il Refresh Token</strong> generato</li>
</ol>
<h5>Passo 3: Configurazione nell'app</h5>
<ol>
<li>Vai in <strong>Impostazioni → Calendario</strong></li>
<li>Clicca "<strong>Nuova Connessione</strong>"</li>
<li>Inserisci un nome descrittivo (es. "Calendario Lavoro")</li>
<li>Seleziona tipo <strong>Google Calendar</strong></li>
<li>Incolla <strong>Client ID</strong>, <strong>Client Secret</strong> e il <strong>Refresh Token</strong> ottenuto</li>
<li><strong>Calendar ID (opzionale):</strong> lascia <code>primary</code> per il calendario principale, oppure inserisci l'ID di un calendario specifico (lo trovi in Google Calendar Impostazioni calendario ID calendario)</li>
<li>Seleziona la <strong>direzione di sincronizzazione</strong> (import, export o bidirezionale)</li>
<li>Clicca "<strong>Salva</strong>"</li>
</ol>
<h5>Test connessione</h5>
<p>Dopo aver salvato, usa il pulsante <span class="badge badge-success"><i class="fas fa-plug"></i> Test</span> nella tabella delle connessioni per verificare che l'autorizzazione funzioni correttamente.</p>
<h5>Sincronizzazione</h5>
<p>Dopo aver configurato la connessione, puoi sincronizzare gli eventi manualmente cliccando <span class="badge badge-primary"><i class="fas fa-sync"></i> Sync</span>. La sincronizzazione automatica viene eseguita in base all'intervallo impostato (richiede il cron di Laravel configurato).</p>
<div class="alert alert-info">
<i class="fas fa-info-circle mr-2"></i>
Il Refresh Token è <strong>permanente</strong> finché non viene revocato dall'utente. Se la connessione smette di funzionare, potrebbe essere necessario rigenerarlo ripetendo il Passo 2.
</div>
</div>
</div>
</div>
{{-- Tag e Ricerca --}}
<div class="tab-pane" id="help-tag">
<div class="card card-outline card-info">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i> Tag e Ricerca per Tag</h3>
</div>
<div class="card-body">
<h5>Cos'è il sistema di Tag</h5>
<p>I tag permettono di classificare e raggruppare trasversalmente elementi di tipo diverso: individui, gruppi, eventi, documenti e mailing list. A differenza delle tipologie (specifiche per ogni entità), i tag sono <strong>condivisi</strong> tra tutte le entità.</p>
<h5>Gestione Tag</h5>
<p>Vai in <strong>Impostazioni Tag</strong> per creare, modificare, riordinare ed eliminare i tag. Ogni tag ha:</p>
<ul>
<li><strong>Nome</strong> il nome visualizzato</li>
<li><strong>Colore</strong> colore personalizzato per il badge (es. <code>#dc3545</code> per rosso)</li>
<li><strong>Slug</strong> identificatore univoco generato automaticamente dal nome</li>
</ul>
<h5>Assegnare Tag</h5>
<p>I tag si assegnano nelle pagine di creazione e modifica di ciascuna entità individui, gruppi, eventi, documenti, mailing list tramite il selettore a pillole colorate. Clicca su un tag per selezionarlo (il badge diventa opaco), clicca di nuovo per deselezionarlo. Usa la barra di ricerca per filtrare i tag per nome.</p>
<h5>Assegnazione Massiva di Tag</h5>
<p>Puoi assegnare o rimuovere tag a più elementi contemporaneamente:</p>
<ul>
<li><strong>Individui:</strong> seleziona gli individui nella tabella, apri <strong>Azioni Assegna Tag</strong>, scegli Assegna/Rimuovi e seleziona i tag</li>
<li><strong>Gruppi:</strong> stessa procedura seleziona i gruppi, <strong>Azioni Assegna Tag</strong></li>
<li><strong>Documenti:</strong> seleziona i documenti, pulsante <strong>Tag</strong> nella toolbar</li>
</ul>
<h5>Filtrare per Tag</h5>
<p>In ogni pagina elenco (Individui, Gruppi, Eventi, Documenti, Mailing List) trovi una barra con tutti i tag. Clicca su un tag per filtrare la tabella mostrando solo gli elementi con quel tag. Clicca <strong>Cancella filtri</strong> per rimuovere il filtro.</p>
<h5>Ricerca per Tag</h5>
<p>La sezione <strong>Ricerca per Tag</strong> (sidebar Ricerca per Tag) permette di trovare elementi di qualsiasi tipo in base ai tag:</p>
<ul>
<li><strong>Tag singolo:</strong> seleziona un tag per vedere tutti gli elementi associati</li>
<li><strong>Tag multipli (AND):</strong> seleziona più tag (Ctrl+click) per trovare elementi che hanno <strong>tutti</strong> i tag selezionati utile per ricerche precise (es. "animatore" + "giovane")</li>
</ul>
<p>I risultati sono raggruppati per tipo con info-box colorati e tabelle dettagliate.</p>
</div>
</div>
</div>
{{-- Tipologie e Dati --}} {{-- Tipologie e Dati --}}
<div class="tab-pane" id="help-tipologie"> <div class="tab-pane" id="help-tipologie">
<div class="card card-outline card-success"> <div class="card card-outline card-success">
@@ -223,6 +345,10 @@
<li><strong>Evento</strong> visibile solo ai partecipanti di un evento</li> <li><strong>Evento</strong> visibile solo ai partecipanti di un evento</li>
<li><strong>Mailing</strong> visibile solo ai membri di una mailing list</li> <li><strong>Mailing</strong> visibile solo ai membri di una mailing list</li>
</ul> </ul>
<h5>Tag</h5>
<p>I <strong>tag</strong> affiancano le tipologie con un sistema trasversale di classificazione applicabile a individui, gruppi, eventi, documenti e mailing list. A differenza delle tipologie (specifiche per ogni entità), i tag sono condivisi tra tutte le entità e permettono ricerche incrociate.</p>
<p>Vedi la sezione <strong>Tag e Ricerca per Tag</strong> (nel menu laterale) per la gestione e l'assegnazione.</p>
</div> </div>
</div> </div>
</div> </div>
+123 -10
View File
@@ -62,11 +62,13 @@
<ol> <ol>
<li><a href="#webdav">WebDAV / Nextcloud</a></li> <li><a href="#webdav">WebDAV / Nextcloud</a></li>
<li><a href="#googledrive">Google Drive</a></li> <li><a href="#googledrive">Google Drive</a></li>
<li><a href="#calendario">Google Calendar</a></li>
<li><a href="#tipologie">Tipologie e Gestione Dati</a></li> <li><a href="#tipologie">Tipologie e Gestione Dati</a></li>
<li><a href="#email">Email / SMTP</a></li> <li><a href="#email">Email / SMTP</a></li>
<li><a href="#installazione">Guida all'Installazione</a></li> <li><a href="#installazione">Guida all'Installazione</a></li>
<li><a href="#backup">Backup e Migrazione Server</a></li> <li><a href="#backup">Backup e Migrazione Server</a></li>
<li><a href="#docker">Docker</a></li> <li><a href="#docker">Docker</a></li>
<li><a href="#tag">Tag e Ricerca per Tag</a></li>
</ol> </ol>
</div> </div>
@@ -182,8 +184,72 @@
<div class="page-break"></div> <div class="page-break"></div>
{{-- 3. Tipologie e Dati --}} {{-- 3. Google Calendar --}}
<h2 id="tipologie">3. Tipologie e Gestione Dati</h2> <h2 id="calendario">3. Google Calendar</h2>
<h3>Cos'è</h3>
<p>Integrazione con Google Calendar per sincronizzare gli eventi tra l'app e il tuo calendario Google. Supporta import, export e sincronizzazione bidirezionale.</p>
<h3>Requisiti</h3>
<ol>
<li>Un account Google / Google Workspace</li>
<li>Un progetto sulla <strong>Google Cloud Console</strong> con l'API Google Calendar abilitata</li>
<li>Credenziali OAuth 2.0 (Client ID e Client Secret)</li>
</ol>
<h3>Passo 1: Creare il progetto Google Cloud</h3>
<ol>
<li>Vai su <strong>Google Cloud Console</strong> (console.developers.google.com)</li>
<li>Crea un nuovo progetto o selezionane uno esistente</li>
<li>Vai a <strong>API e servizi Libreria</strong></li>
<li>Cerca "<strong>Google Calendar API</strong>" e <strong>abilitala</strong></li>
<li>Vai a <strong>API e servizi Credenziali</strong></li>
<li>Clicca "<strong>Crea credenziali → ID client OAuth</strong>"</li>
<li>Tipo applicazione: "<strong>Applicazione Web</strong>"</li>
<li><strong>URI di reindirizzamento autorizzato:</strong> aggiungi <code>https://developers.google.com/oauthplayground</code></li>
<li>Completa la creazione e <strong>copia Client ID e Client Secret</strong></li>
</ol>
<h3>Passo 2: Ottenere il Refresh Token</h3>
<p>A differenza di Google Drive, per Google Calendar il refresh token va ottenuto manualmente tramite il Google OAuth Playground:</p>
<ol>
<li>Vai su <strong>Google OAuth Playground</strong> (developers.google.com/oauthplayground)</li>
<li>Clicca l'ingranaggio ⚙️ in alto a destra</li>
<li>Spunta "<strong>Use your own OAuth credentials</strong>"</li>
<li>Incolla <strong>Client ID</strong> e <strong>Client Secret</strong> creati al Passo 1</li>
<li>Nella colonna sinistra, espandi "<strong>Google Calendar API v3</strong>" e seleziona lo scope <code>https://www.googleapis.com/auth/calendar</code></li>
<li>Clicca "<strong>Authorize APIs</strong>" e autorizza con l'account Google</li>
<li>Clicca "<strong>Exchange authorization code for tokens</strong>"</li>
<li><strong>Copia il Refresh Token</strong> generato</li>
</ol>
<h3>Passo 3: Configurazione nell'app</h3>
<ol>
<li>Vai in <strong>Impostazioni Calendario</strong></li>
<li>Clicca "<strong>Nuova Connessione</strong>"</li>
<li>Inserisci un nome descrittivo (es. "Calendario Lavoro")</li>
<li>Seleziona tipo <strong>Google Calendar</strong></li>
<li>Incolla <strong>Client ID</strong>, <strong>Client Secret</strong> e il <strong>Refresh Token</strong> ottenuto</li>
<li><strong>Calendar ID (opzionale):</strong> lascia <code>primary</code> per il calendario principale, oppure inserisci l'ID di un calendario specifico</li>
<li>Seleziona la <strong>direzione di sincronizzazione</strong> (import, export o bidirezionale)</li>
<li>Clicca "<strong>Salva</strong>"</li>
</ol>
<h3>Test connessione</h3>
<p>Dopo aver salvato, usa il pulsante <strong>Test</strong> nella tabella delle connessioni per verificare che l'autorizzazione funzioni correttamente.</p>
<h3>Sincronizzazione</h3>
<p>Dopo aver configurato la connessione, puoi sincronizzare gli eventi manualmente cliccando <strong>Sync</strong>. La sincronizzazione automatica viene eseguita in base all'intervallo impostato (richiede il cron di Laravel configurato).</p>
<div class="callout callout-info">
<h4>Refresh Token permanente</h4>
<p>Il Refresh Token è permanente finché non viene revocato dall'utente. Se la connessione smette di funzionare, potrebbe essere necessario rigenerarlo ripetendo il Passo 2.</p>
</div>
<div class="page-break"></div>
{{-- 4. Tipologie e Dati --}}
<h2 id="tipologie">4. Tipologie e Gestione Dati</h2>
<h3>Tipologie Documenti</h3> <h3>Tipologie Documenti</h3>
<p>Le tipologie classificano i documenti (avatar, galleria, documento, statuto, altro).</p> <p>Le tipologie classificano i documenti (avatar, galleria, documento, statuto, altro).</p>
@@ -208,10 +274,14 @@
<li><strong>Mailing</strong> visibile solo ai membri di una mailing list</li> <li><strong>Mailing</strong> visibile solo ai membri di una mailing list</li>
</ul> </ul>
<h3>Tag</h3>
<p>I <strong>tag</strong> affiancano le tipologie con un sistema trasversale di classificazione applicabile a individui, gruppi, eventi, documenti e mailing list. A differenza delle tipologie (specifiche per ogni entità), i tag sono condivisi tra tutte le entità e permettono ricerche incrociate.</p>
<p>Vedi la sezione <a href="#tag">Tag e Ricerca per Tag</a> per la gestione e l'assegnazione.</p>
<div class="page-break"></div> <div class="page-break"></div>
{{-- 4. Email / SMTP --}} {{-- 5. Email / SMTP --}}
<h2 id="email">4. Email / SMTP</h2> <h2 id="email">5. Email / SMTP</h2>
<h3>Configurazione Email</h3> <h3>Configurazione Email</h3>
<p>Vai in <strong>Impostazioni Email</strong> per configurare la posta elettronica.</p> <p>Vai in <strong>Impostazioni Email</strong> per configurare la posta elettronica.</p>
@@ -259,13 +329,18 @@
<li>Invia email alla lista da <strong>Email Nuova Email</strong>, selezionando la mailing list come destinatario</li> <li>Invia email alla lista da <strong>Email Nuova Email</strong>, selezionando la mailing list come destinatario</li>
</ul> </ul>
<div class="callout callout-warning">
<h4>Avviso contatti senza email</h4>
<p>Se nella mailing list sono presenti individui che non hanno un indirizzo email nei contatti, il sistema mostra un avviso con l'elenco dei nominativi esclusi dall'invio. Verifica sempre la presenza di email valide prima di procedere con l'invio.</p>
</div>
<h3>Test connessione</h3> <h3>Test connessione</h3>
<p>Dopo aver configurato i server, usa il pulsante <strong>Test Connessione</strong> per verificare che IMAP e SMTP funzionino correttamente.</p> <p>Dopo aver configurato i server, usa il pulsante <strong>Test Connessione</strong> per verificare che IMAP e SMTP funzionino correttamente.</p>
<div class="page-break"></div> <div class="page-break"></div>
{{-- 5. Installazione --}} {{-- 6. Installazione --}}
<h2 id="installazione">5. Guida all'Installazione</h2> <h2 id="installazione">6. Guida all'Installazione</h2>
<h3>Panoramica</h3> <h3>Panoramica</h3>
<p>L'installazione avviene tramite l'interattivo <code>install.php</code> che guida passo-passo. Supporta due modalità:</p> <p>L'installazione avviene tramite l'interattivo <code>install.php</code> che guida passo-passo. Supporta due modalità:</p>
@@ -411,8 +486,8 @@ sudo systemctl reload apache2</pre>
<div class="page-break"></div> <div class="page-break"></div>
{{-- 6. Backup e Migrazione --}} {{-- 7. Backup e Migrazione --}}
<h2 id="backup">6. Backup e Migrazione Server</h2> <h2 id="backup">7. Backup e Migrazione Server</h2>
<h3>Cos'è</h3> <h3>Cos'è</h3>
<p>Il sistema di backup crea un archivio ZIP contenente tutto il necessario per ripristinare l'applicazione su un nuovo server. Include database, file caricati e configurazione.</p> <p>Il sistema di backup crea un archivio ZIP contenente tutto il necessario per ripristinare l'applicazione su un nuovo server. Include database, file caricati e configurazione.</p>
@@ -591,8 +666,8 @@ sudo certbot --apache -d glastree.esempio.it</pre>
<div class="page-break"></div> <div class="page-break"></div>
{{-- 7. Docker --}} {{-- 8. Docker --}}
<h2 id="docker">7. Docker</h2> <h2 id="docker">8. Docker</h2>
<h3>Creare un'immagine Docker con dati di base</h3> <h3>Creare un'immagine Docker con dati di base</h3>
<p>Puoi creare un'immagine Docker pronta all'uso che include l'applicazione e i dati di base (tipologie documenti, tipologie eventi, ruoli). Scegli la configurazione in base alle tue esigenze:</p> <p>Puoi creare un'immagine Docker pronta all'uso che include l'applicazione e i dati di base (tipologie documenti, tipologie eventi, ruoli). Scegli la configurazione in base alle tue esigenze:</p>
@@ -864,5 +939,43 @@ echo 'Utente creato con successo!';
</ul> </ul>
</div> </div>
<div class="page-break"></div>
{{-- 9. Tag e Ricerca --}}
<h2 id="tag">9. Tag e Ricerca per Tag</h2>
<h3>Cos'è il sistema di Tag</h3>
<p>I tag permettono di classificare e raggruppare trasversalmente elementi di tipo diverso: individui, gruppi, eventi, documenti e mailing list. A differenza delle tipologie (specifiche per ogni entità), i tag sono <strong>condivisi</strong> tra tutte le entità.</p>
<h3>Gestione Tag</h3>
<p>Vai in <strong>Impostazioni Tag</strong> per creare, modificare, riordinare ed eliminare i tag. Ogni tag ha:</p>
<ul>
<li><strong>Nome</strong> il nome visualizzato</li>
<li><strong>Colore</strong> colore personalizzato per il badge (es. <code>#dc3545</code> per rosso)</li>
<li><strong>Slug</strong> identificatore univoco generato automaticamente dal nome</li>
</ul>
<h3>Assegnare Tag</h3>
<p>I tag si assegnano nelle pagine di creazione e modifica di ciascuna entità individui, gruppi, eventi, documenti, mailing list tramite il selettore a pillole colorate. Clicca su un tag per selezionarlo (il badge diventa opaco), clicca di nuovo per deselezionarlo. Usa la barra di ricerca per filtrare i tag per nome.</p>
<h3>Assegnazione Massiva di Tag</h3>
<p>Puoi assegnare o rimuovere tag a più elementi contemporaneamente:</p>
<ul>
<li><strong>Individui:</strong> seleziona gli individui nella tabella, apri <strong>Azioni Assegna Tag</strong>, scegli Assegna/Rimuovi e seleziona i tag</li>
<li><strong>Gruppi:</strong> stessa procedura seleziona i gruppi, <strong>Azioni Assegna Tag</strong></li>
<li><strong>Documenti:</strong> seleziona i documenti, pulsante <strong>Tag</strong> nella toolbar</li>
</ul>
<h3>Filtrare per Tag</h3>
<p>In ogni pagina elenco (Individui, Gruppi, Eventi, Documenti, Mailing List) trovi una barra con tutti i tag. Clicca su un tag per filtrare la tabella mostrando solo gli elementi con quel tag. Clicca <strong>Cancella filtri</strong> per rimuovere il filtro.</p>
<h3>Ricerca per Tag</h3>
<p>La sezione <strong>Ricerca per Tag</strong> (sidebar Ricerca per Tag) permette di trovare elementi di qualsiasi tipo in base ai tag:</p>
<ul>
<li><strong>Tag singolo:</strong> seleziona un tag per vedere tutti gli elementi associati</li>
<li><strong>Tag multipli (AND):</strong> seleziona più tag (Ctrl+click) per trovare elementi che hanno <strong>tutti</strong> i tag selezionati utile per ricerche precise (es. "animatore" + "giovane")</li>
</ul>
<p>I risultati sono raggruppati per tipo con info-box colorati e tabelle dettagliate.</p>
</body> </body>
</html> </html>
+181 -11
View File
@@ -44,6 +44,9 @@
<a href="#ruoli" class="list-group-item list-group-item-action" data-toggle="tab"> <a href="#ruoli" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-users-cog mr-2"></i> Tipi di Ruolo <i class="nav-icon fas fa-users-cog mr-2"></i> Tipi di Ruolo
</a> </a>
<a href="#tag" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-tags mr-2"></i> Tag
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -583,6 +586,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" class="custom-control-input" id="is_active" <input type="checkbox" class="custom-control-input" id="is_active"
name="is_active" value="1" {{ ($emailSettings->is_active ?? false) ? 'checked' : '' }}> name="is_active" value="1" {{ ($emailSettings->is_active ?? false) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">Account Email Attivo</label> <label class="custom-control-label" for="is_active">Account Email Attivo</label>
@@ -599,11 +603,12 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-group"> <div class="form-group">
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="signature_enabled" <input type="hidden" name="signature_enabled" value="0">
name="signature_enabled" value="1" {{ ($emailSettings->signature_enabled ?? true) ? 'checked' : '' }}> <input type="checkbox" class="custom-control-input" id="signature_enabled"
<label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label> name="signature_enabled" value="1" {{ ($emailSettings->signature_enabled ?? true) ? 'checked' : '' }}>
</div> <label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label>
</div>
<small class="text-muted">Quando attivo, la firma verrà aggiunta in fondo a ogni email inviata</small> <small class="text-muted">Quando attivo, la firma verrà aggiunta in fondo a ogni email inviata</small>
</div> </div>
<hr> <hr>
@@ -722,14 +727,17 @@
<a href="{{ route('impostazioni.email.sync') }}" class="btn btn-warning ml-2"> <a href="{{ route('impostazioni.email.sync') }}" class="btn btn-warning ml-2">
<i class="fas fa-sync mr-1"></i> Sincronizza Ora <i class="fas fa-sync mr-1"></i> Sincronizza Ora
</a> </a>
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" class="ml-auto" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div> </div>
</form> </form>
<div class="d-flex justify-content-end mt-2">
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div>
</div> </div>
{{-- === CALENDARIO === --}} {{-- === CALENDARIO === --}}
@@ -1063,6 +1071,136 @@
</div> </div>
</div> </div>
</div> </div>
{{-- === TAG === --}}
<div class="tab-pane" id="tag">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Tag</h3>
</div>
<div class="card-body">
<p class="text-muted">Gestisci i tag (etichette) per classificare individui, gruppi, eventi e documenti.</p>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<div class="mb-3">
<button type="button" class="btn btn-success btn-sm" data-toggle="modal" data-target="#addTagModal">
<i class="fas fa-plus"></i> Nuovo Tag
</button>
</div>
<table class="table table-bordered table-striped" id="tag-table">
<thead>
<tr>
<th style="width: 40px;">Ordine</th>
<th>Tag</th>
<th>Nome</th>
<th>Slug</th>
<th style="width: 100px;">Colore</th>
<th style="width: 150px;">Azioni</th>
</tr>
</thead>
<tbody id="tag-sortable">
@foreach($tags as $tag)
<tr data-id="{{ $tag->id }}">
<td class="text-center">
<i class="fas fa-arrows-alt handle" style="cursor: grab;"></i>
</td>
<td>
<span class="badge" style="background-color: {{ $tag->color ?? '#6c757d' }}; color: #fff;">
{{ $tag->name }}
</span>
</td>
<td><span class="tag-nome">{{ $tag->name }}</span></td>
<td><code>{{ $tag->slug }}</code></td>
<td>
<span class="tag-color-preview" style="display:inline-block;width:24px;height:24px;border-radius:4px;background:{{ $tag->color ?? '#6c757d' }};border:1px solid #dee2e6;vertical-align:middle;"></span>
<span class="tag-color-value">{{ $tag->color ?? '-' }}</span>
</td>
<td>
<button type="button" class="btn btn-xs btn-warning" onclick="editTag({{ $tag->id }}, '{{ addslashes($tag->name) }}', '{{ $tag->color ?? '' }}')">
<i class="fas fa-edit"></i>
</button>
<form method="POST" action="{{ route('impostazioni.tag.destroy', $tag->id) }}" style="display: inline;">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('Eliminare questo tag?')">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- ===== MODALI TAG ===== --}}
<div class="modal fade" id="addTagModal">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ route('impostazioni.tag.store') }}">
@csrf
<div class="modal-header">
<h4 class="modal-title">Nuovo Tag</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="tagName">Nome *</label>
<input type="text" name="name" id="tagName" class="form-control" required maxlength="100" placeholder="es. giovani">
</div>
<div class="form-group">
<label for="tagColor">Colore</label>
<input type="color" name="color" id="tagColor" class="form-control" style="height:38px;padding:4px;" value="#007bff">
<small class="text-muted">Scegli un colore per il badge del tag</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-primary">Salva</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="editTagModal">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="" id="editTagForm">
@csrf
@method('PUT')
<div class="modal-header">
<h4 class="modal-title">Modifica Tag</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="editTagName">Nome *</label>
<input type="text" name="name" id="editTagName" class="form-control" required maxlength="100">
</div>
<div class="form-group">
<label for="editTagColor">Colore</label>
<input type="color" name="color" id="editTagColor" class="form-control" style="height:38px;padding:4px;">
<small class="text-muted">Scegli un colore per il badge del tag</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-primary">Salva</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -1613,6 +1751,32 @@ var sortableTipologieEventi = Sortable.create(document.getElementById('tipologie
} }
}); });
function editTag(id, name, color) {
$('#editTagName').val(name);
$('#editTagColor').val(color || '#007bff');
$('#editTagForm').attr('action', '/impostazioni/tag/' + id);
$('#editTagModal').modal('show');
}
var sortableTag = Sortable.create(document.getElementById('tag-sortable'), {
handle: '.handle',
animation: 150,
onEnd: function(evt) {
var order = [];
$('#tag-sortable tr').each(function() {
order.push($(this).data('id'));
});
fetch('{{ route('impostazioni.tag.reorder') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ order: order })
});
}
});
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
var target = $(e.target).attr('href'); var target = $(e.target).attr('href');
if (target === '#ruoli') { if (target === '#ruoli') {
@@ -1621,11 +1785,17 @@ $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
if (target === '#tipologie-eventi') { if (target === '#tipologie-eventi') {
sortableTipologieEventi.refresh(); sortableTipologieEventi.refresh();
} }
if (target === '#tag') {
sortableTag.refresh();
}
if (target === '#calendario') { if (target === '#calendario') {
if (typeof sortableCalendario !== 'undefined') { if (typeof sortableCalendario !== 'undefined') {
sortableCalendario.refresh(); sortableCalendario.refresh();
} }
} }
if (target === '#email-firma') {
setTimeout(initSignatureEditor, 100);
}
}); });
// ===== MITTENTI AGGIUNTIVI ===== // ===== MITTENTI AGGIUNTIVI =====
@@ -108,6 +108,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-tags mr-2"></i>Tag</h3>
</div>
<div class="card-body">
@include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette'])
</div>
</div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><i class="fas fa-address-book mr-2"></i>Contatti</h3> <h3 class="card-title"><i class="fas fa-address-book mr-2"></i>Contatti</h3>

Some files were not shown because too many files have changed in this diff Show More