Compare commits

52 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
inext 82c54002e4 Merge branch 'dev'
versione finale 1.2
2026-06-06 23:20:49 +02:00
inext bc57d0dcd0 final 1.2 2026-06-06 23:19:55 +02:00
inext 139098bffc fix 1.1.1 2026-06-06 23:16:51 +02:00
inext a8c841bdbe - import gruppi ed eventi + docker 2026-06-06 23:00:07 +02:00
inext 00004d8b50 sistemazione installa e tabelle - ver 1.1 2026-06-06 20:43:56 +02:00
inext 2fd1ed8295 commit finale 662026 2026-06-06 18:13:11 +02:00
inext 8aa1d3d031 reset password user 2026-06-06 11:50:22 +02:00
inext d57431b10b fix -last 2026-06-05 12:23:13 +02:00
inext 05f1a4c396 git ignore image tart 2026-06-04 08:49:42 +02:00
inext 2f454ab749 fix install 1.0.11 2026-06-04 08:33:02 +02:00
inext da022bdf4a fix - install 1.0.10 2026-06-03 16:32:24 +02:00
inext 6b637fc12b fix - install 1.0.9 2026-06-03 16:23:27 +02:00
inext 72c48d301d fix - install 1.0.8 2026-06-03 16:15:17 +02:00
inext abb39e62f9 1.0.7 2026-06-03 14:21:20 +02:00
inext f36a9906a2 1.0.6 fix modifica install.php 2026-06-03 13:32:04 +02:00
inext fcbb6fb061 1.0.5 fix first login after install .php 2026-06-03 11:59:44 +02:00
inext 25168debc0 1.0.4 - fix instal.php 2026-06-03 11:18:20 +02:00
inext 7381d86362 1.0.3 - php cli install 2026-06-03 10:20:49 +02:00
inext 8404cda475 1.0.2 2026-06-03 09:15:51 +02:00
inext 75f6665252 Version 1.0.1 - add install.sh 2026-06-03 09:09:46 +02:00
inext 81541a3af5 versione 1.0.0 2026-06-03 08:21:04 +02:00
inext c6f8403d1a vesione staibile - debug fix 2026-06-03 08:06:09 +02:00
inext 6b913f330e fix email 2026-06-02 22:30:49 +02:00
inext 1e9eb29428 last 2026-06-02 22:18:29 +02:00
inext c9d66c2a80 aggiunta calendario 2026-06-02 22:07:16 +02:00
inext aa4e582925 fix error 2026-06-02 20:29:47 +02:00
2582 changed files with 9602 additions and 329302 deletions
+3
View File
@@ -0,0 +1,3 @@
glastree*.tar.gz
glastree*.tar
.env
+415 -38
View File
@@ -1,48 +1,425 @@
# 🧠 MEMORY.md — Stato Progetto # 🧠 MEMORY.md — Stato Progetto
## Goal ## Obiettivo
- Stabilizzare il modale "Carica Documento" e migliorare la gestione cartelle durante l'upload. App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
## Constraints & Preferences ## Funzionalità Implementate
- (none)
## Progress ### Page-length selector
### Done - **4 pagine**: Individui, Eventi, Email, Gruppi
- Aggiunto pulsante "Scarica Guida PDF" nella sidebar di `/help` con nuovo metodo `HelpController::downloadPdf()` e route `GET /help/pdf/download` - **Valori**: 10, 20, 25, 50, 100 (default 20)
- Aggiunta card "Backup" in dashboard (small-box bg-navy, icona fa-hdd) con conteggio backup, visibile solo a utenti con `canManage('settings')` - Dropdown nel card-footer con campi hidden per preservare query params
- Corretto `modal-body` nel modale `#uploadModal` che chiudeva prematuramente (pulsanti Annulla/Carica finivano fuori dal popup) - GruppoController: usa `LengthAwarePaginator` per paginare la collezione ordinata gerarchicamente
- Aggiunto campo `visibilita` (select Pubblico/Individuo/Gruppo) nel form di upload documento, mancante e richiesto dal controller
- Aggiunto selettore cartelle visibile (`<select name="cartella_id" id="uploadCartellaId">`) nel modale di upload (sostituisce il vecchio hidden input)
- Aggiunto pulsante "Nuova cartella" sia nel modale upload (`#uploadNewFolderBtn`) che nel modale "Sposta" (`#massMoveNewFolderBtn`)
- Sostituito SweetAlert2 per creazione cartella con il modale Bootstrap `#newFolderModal` già esistente (risolve focus trap Bootstrap 4 che bloccava l'input)
- Aggiornato `toggleUploadRepo()` per nascondere/mostrare il gruppo cartella in base alla selezione del repository remoto
- Aggiunto flag `window._uploadNewFolder` per comportamento differenziato: da upload aggiunge opzione al select senza ricaricare, da tooltip ricarica la pagina
- **Fix backdrop grigio persistente dopo creazione cartella**: ora `#uploadModal` viene prima nascosto (quando si clicca "Nuova cartella") e poi ri-mostrato dopo la creazione nel success handler, risolvendo il conflitto di backdrop tra modali annidati Bootstrap 4
- **Fix scroll modal upload**: ristrutturato `<form>` per non avvolgere `.modal-body` e `.modal-footer` (ora è dentro `.modal-body`), così `modal-dialog-scrollable` funziona correttamente. Pulsante submit ha `form="uploadForm"` per collegamento cross-DOM
### In Progress ### CSV Import Gruppi
- (none) - 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
- (none) - 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)
- Replicato il codice di creazione cartella con `SweetAlert2` → sostituito con modale Bootstrap `#newFolderModal` perché Bootstrap 4 impedisce il focus su elementi fuori dal `.modal` (focus trap) - Genera `glastree-YYYYMMDD_HHMM.tar.gz`
- Pulsante "Nuova cartella" nello stesso `<form>` del modal di upload riceve `e.preventDefault()` per sicurezza - Include: sorgenti, vendor (production)
- Per evitare conflitti di backdrop Bootstrap 4 con modali annidati, si nasconde `#uploadModal` prima di mostrare `#newFolderModal` e lo si ripristina dopo il success - Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker
- **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear
## Next Steps ## Tag System
- (none)
## Critical Context ### Implementazione Completa (Opzione B)
- Bootstrap 4.6.2 con focus trap: impedisce focus su elementi fuori dal `.modal` — SweetAlert2 aggiunge dialog al `<body>`, quindi il suo input non riceve focus - **Migration**: `2026_06_08_194155_create_tags_tables.php` — tabelle `tags` + `taggables`
- `#newFolderModal` già esisteva con submit handler AJAX a `/documenti/cartelle` e ricaricava pagina — ora con `window._uploadNewFolder` evita reload quando chiamato dall'upload - **Model `Tag.php`**: morphToMany a 5 entity types, auto-slug su creating, color badge
- Bug: backdrop grigio non spariva dopo creazione cartella perché Bootstrap 4 gestisce male i modali annidati (due backdrop concorrenti) - **Trait `HasTagsLight.php`**: `tags()` morphToMany, scopes `withAllTags`/`withAnyTags`
- **5 Models aggiornati**: Individuo, Gruppo, Evento, Documento, MailingList — usano `HasTagsLight`
- **ImpostazioniController**: CRUD completo per Tag (store, update, destroy, reorder) con tab nella pagina Impostazioni
- **Tag selector**: `_tag-selector.blade.php` — search + pill badges con colori, incluso in create/edit di Individui, Gruppi, Eventi, MailingList
- **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
- `app/Http/Controllers/HelpController.php`: metodo `downloadPdf()` + route `/help/pdf/download`
- `app/Http/Controllers/HomeController.php`: `$stats['backups']` via `BackupService::list()` ### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo
- `routes/web.php`: route `/help/pdf/download` - `descrizione_evento` e `is_incontro_gruppo` mancanti in install.sql → creata migration + ALTER TABLE
- `resources/views/help/index.blade.php`: card-footer con pulsante download PDF - `ruolo_nel_gruppo` mancante in install.sql per pivot `gruppo_individuo`
- `resources/views/home/dashboard.blade.php`: small-box Backup condizionale - `VistaReport.php`: aggiunto `is_default` a `$fillable`
- `resources/views/documenti/index.blade.php`: intero modale `#uploadModal` riscritto (visibilita, cartella select, nuova cartella via `#newFolderModal`, toggleUploadRepo); fix backdrop persistente nascondendo/rimostrando `#uploadModal`
### 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;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class ResetUserPassword extends Command
{
protected $signature = 'user:reset-password
{email? : Email dell\'utente}
{--password= : Nuova password (se omessa, richiesta interattiva)}';
protected $description = 'Cambia la password di un utente tramite email';
public function handle(): int
{
$email = $this->argument('email');
if (empty($email)) {
$email = $this->ask('Email dell\'utente');
}
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error("Nessun utente trovato con email: {$email}");
return self::FAILURE;
}
$this->line("Utente trovato: {$user->name} ({$user->email})");
$password = $this->option('password');
if (empty($password)) {
$password = $this->secret('Nuova password');
$confirm = $this->secret('Conferma nuova password');
if ($password !== $confirm) {
$this->error('Le password non corrispondono');
return self::FAILURE;
}
}
$validator = Validator::make(
['password' => $password],
['password' => ['required', 'string', 'min:12']]
);
if ($validator->fails()) {
$this->error('Password troppo corta: minimo 12 caratteri');
return self::FAILURE;
}
$user->password = Hash::make($password);
$user->save();
if ($user->wasChanged('password')) {
$this->info("Password aggiornata con successo per {$user->email}");
return self::SUCCESS;
}
$this->error('Errore durante l\'aggiornamento della password');
return self::FAILURE;
}
}
+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' => [
@@ -39,4 +39,35 @@ class ActivityLogController extends Controller
return view('admin.activity-logs.index', compact('logs', 'users', 'modules')); return view('admin.activity-logs.index', compact('logs', 'users', 'modules'));
} }
public function destroy(Request $request)
{
$query = ActivityLog::query();
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('module')) {
$query->where('module', $request->module);
}
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('from')) {
$query->whereDate('created_at', '>=', $request->from);
}
if ($request->filled('to')) {
$query->whereDate('created_at', '<=', $request->to);
}
$count = $query->count();
$query->delete();
return redirect()->route('admin.activity-logs.index')
->with('success', "Eliminati {$count} log.");
}
} }
@@ -3,10 +3,14 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting; use App\Models\EmailSetting;
use App\Models\SenderAccount; use App\Models\SenderAccount;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
class EmailSettingsController extends Controller class EmailSettingsController extends Controller
{ {
@@ -30,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'])) {
@@ -58,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.');
@@ -257,6 +283,30 @@ class EmailSettingsController extends Controller
return back()->with('success', 'Mittente eliminato.'); return back()->with('success', 'Mittente eliminato.');
} }
public function destroy(): RedirectResponse
{
$this->authorizeDelete('settings');
EmailAttachment::chunk(100, function ($attachments) {
foreach ($attachments as $attachment) {
if ($attachment->file_path && Storage::exists($attachment->file_path)) {
Storage::delete($attachment->file_path);
}
}
});
EmailFolder::whereIn('type', ['inbox', 'sent', 'drafts', 'trash', 'archive', 'starred'])->each(function ($folder) {
$folder->delete();
});
$settings = EmailSetting::first();
if ($settings) {
$settings->delete();
}
return redirect('/impostazioni/email')->with('success', 'Configurazione email eliminata. Messaggi, cartelle e allegati rimossi completamente.');
}
public function senderTestSmtp(Request $request, int $id) public function senderTestSmtp(Request $request, int $id)
{ {
$this->authorizeWrite('settings'); $this->authorizeWrite('settings');
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\CalendarioConnessione;
use App\Services\CalDavService;
use App\Services\GoogleCalendarSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CalendarioConnessioneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$maxOrdine = CalendarioConnessione::max('ordine') ?? 0;
$connessione = new CalendarioConnessione();
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
$connessione->stato = 'disconnesso';
$connessione->ordine = $maxOrdine + 1;
$connessione->encryptAndSetConfig($data['config'] ?? []);
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" creata.');
}
public function update(Request $request, int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
if (!empty($data['config'])) {
$connessione->encryptAndSetConfig($data['config']);
}
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" aggiornata.');
}
public function destroy(int $id): JsonResponse
{
$this->authorizeDelete('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$connessione->delete();
return response()->json(['success' => true, 'message' => 'Connessione calendario eliminata.']);
}
public function test(int $id): JsonResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->testConnection($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->testConnection($connessione),
default => ['success' => false, 'message' => 'Tipo sconosciuto'],
};
if ($result['success']) {
$connessione->update(['stato' => 'connesso', 'last_error_at' => null, 'last_error_message' => null]);
} else {
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $result['message'] ?? 'Errore sconosciuto']);
}
return response()->json($result);
}
public function sync(int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$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']) {
$msg = 'Sincronizzazione completata: ' . $result['imported'] . ' importati, ' . $result['exported'] . ' esportati.';
$connessione->update(['stato' => 'connesso']);
return redirect('/impostazioni#calendario')->with('success', $msg);
}
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $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
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
CalendarioConnessione::where('id', $id)->update(['ordine' => $index]);
}
return response()->json(['success' => true]);
}
}
+96 -6
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.');
} }
@@ -661,9 +712,13 @@ class DocumentoController extends Controller
'evento' => Evento::class, 'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class, 'mailing' => \App\Models\MailingList::class,
]; ];
$updateData['visibilita'] = $data['visibilita_target_type']; $targetType = $data['visibilita_target_type'];
if (!isset($typeMap[$targetType])) {
return back()->with('error', 'Tipo destinazione non valido.');
}
$updateData['visibilita'] = $targetType;
$updateData['visibilita_target_id'] = (int) $data['visibilita_target_id']; $updateData['visibilita_target_id'] = (int) $data['visibilita_target_id'];
$updateData['visibilita_target_type'] = $typeMap[$data['visibilita_target_type']] ?? $data['visibilita_target_type']; $updateData['visibilita_target_type'] = $typeMap[$targetType];
} }
Documento::whereIn('id', $ids)->update($updateData); Documento::whereIn('id', $ids)->update($updateData);
@@ -702,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.");
}
} }
@@ -3,10 +3,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\AppSetting; use App\Models\AppSetting;
use App\Models\CalendarioConnessione;
use App\Models\Documento;
use App\Models\EmailSetting; 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;
@@ -24,13 +27,15 @@ 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();
$repositories = StorageRepository::orderBy('ordine')->get(); $repositories = StorageRepository::orderBy('ordine')->get();
$googleDriveNewToken = session('google_drive_new_token'); $googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken')); 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)
@@ -268,10 +273,17 @@ class ImpostazioniController extends Controller
$ruolo = Ruolo::findOrFail($id); $ruolo = Ruolo::findOrFail($id);
$count = \DB::table('gruppo_individuo') $allRows = \DB::table('gruppo_individuo')
->whereNotNull('ruolo_ids') ->whereNotNull('ruolo_ids')
->whereRaw("JSON_CONTAINS(ruolo_ids, ?)", [json_encode($id)]) ->get(['id', 'ruolo_ids']);
->count();
$count = 0;
foreach ($allRows as $row) {
$ids = json_decode($row->ruolo_ids, true);
if (is_array($ids) && in_array((int) $id, $ids)) {
$count++;
}
}
if ($count > 0) { if ($count > 0) {
return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.'); return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.');
@@ -361,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');
@@ -388,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.');
} }
+143 -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,17 +253,29 @@ 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')) {
$contattiData = $request->input('contatti'); $contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]); \Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete(); $individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]); \Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
} else {
$contattiData = null;
}
if (!empty($contattiData) && is_array($contattiData)) { if (!empty($contattiData) && is_array($contattiData)) {
$created = 0; $created = 0;
@@ -303,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,
],
]; ];
} }
@@ -602,7 +641,7 @@ class ReportController extends Controller
'lista' => $lista->nome, 'lista' => $lista->nome,
'stato' => $lista->attiva ? 'Attiva' : 'Disattiva', 'stato' => $lista->attiva ? 'Attiva' : 'Disattiva',
'individuo' => $contatto->individuo?->nome_completo ?? 'N/D', 'individuo' => $contatto->individuo?->nome_completo ?? 'N/D',
'email' => $contatto->email ?? '-', 'email' => $contatto->valore ?? '-',
]; ];
} }
} }
@@ -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,13 +1089,34 @@ 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];
if ($relation !== 'tags') {
$query->with($relation); $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);
}
}
}
if (!empty($config['search'])) { if (!empty($config['search'])) {
$search = $config['search']; $search = $config['search'];
@@ -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.');
} }
+29 -30
View File
@@ -1,31 +1,25 @@
<?php <?php
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\Storage;
class AppSetting extends Model class AppSetting extends Model
{ {
protected $table = 'app_settings'; protected $table = 'app_settings';
protected $fillable = [ protected $fillable = [
'logo', 'logo', 'logo_small', 'logo_path', 'logo_small_path',
'logo_small', 'nome_applicazione', 'nome_organizzazione',
'logo_path', 'footer_text', 'app_version', 'dashboard_welcome', 'show_version',
'logo_small_path', 'backup_path', 'backup_retention_days', 'backup_include_files',
'nome_applicazione', 'backup_include_env', 'backup_auto_enabled', 'backup_auto_frequency', 'backup_auto_hour',
'nome_organizzazione', 'documenti_storage_disk', 'documenti_storage_path',
'footer_text',
'app_version',
'dashboard_welcome',
'show_version',
'backup_path',
'backup_retention_days',
'backup_include_files',
'backup_include_env',
'backup_auto_enabled',
'backup_auto_frequency',
'backup_auto_hour',
]; ];
protected function casts(): array protected function casts(): array
@@ -36,11 +30,16 @@ class AppSetting extends Model
'backup_include_env' => 'boolean', 'backup_include_env' => 'boolean',
'backup_auto_enabled' => 'boolean', 'backup_auto_enabled' => 'boolean',
'backup_auto_hour' => 'integer', 'backup_auto_hour' => 'integer',
'show_version' => 'boolean',
]; ];
} }
public static function getSetting(string $key, $default = null) public static function getSetting(string $key, mixed $default = null): mixed
{ {
if (!Schema::hasTable('app_settings')) {
return $default;
}
$setting = self::first(); $setting = self::first();
return $setting?->{$key} ?? $default; return $setting?->{$key} ?? $default;
} }
@@ -110,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;
} }
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class CalendarioConnessione extends Model
{
protected $table = 'calendario_connessioni';
protected $fillable = [
'nome',
'tipo',
'config',
'stato',
'sync_direction',
'sync_interval_minutes',
'last_sync_at',
'last_error_at',
'last_error_message',
'is_active',
'ordine',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'sync_interval_minutes' => 'integer',
'last_sync_at' => 'datetime',
'last_error_at' => 'datetime',
'ordine' => 'integer',
];
public function scopeAttivi($query)
{
return $query->where('is_active', true)->orderBy('ordine');
}
public function getDecryptedConfig(): array
{
$config = $this->config;
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field])) {
try {
$config[$field] = Crypt::decryptString($config[$field]);
} catch (\Exception) {
}
}
}
return $config;
}
public function encryptAndSetConfig(array $config): void
{
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field]) && $config[$field] !== '' && !str_starts_with($config[$field], 'eyJpdiI')) {
$config[$field] = Crypt::encryptString($config[$field]);
}
}
$this->config = $config;
}
public static function opzioniTipo(): array
{
return ['google_calendar', 'caldav'];
}
public static function etichettaTipo(string $tipo): string
{
return match ($tipo) {
'google_calendar' => 'Google Calendar',
'caldav' => 'CalDAV (Infomaniak/NextCloud)',
default => ucfirst($tipo),
};
}
}
+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
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CalDavService
{
private const CALDAV_NS = 'urn:ietf:params:xml:ns:caldav';
private const DAV_NS = 'DAV:';
public function testConnection(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '0'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<d:displayname/>
<cs:getctag/>
</d:prop>
</d:propfind>',
]);
if ($response->getStatusCode() < 300) {
return ['success' => true, 'message' => 'Connessione CalDAV riuscita.'];
}
return ['success' => false, 'message' => 'Errore HTTP: ' . $response->getStatusCode()];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/>
<d:displayname/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>',
]);
$body = (string) $response->getBody();
return $this->parseCalendarList($body);
} catch (\Exception $e) {
Log::error('CalDAV listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
$calendarUrl = $config['calendar_url'] ?? $config['url'];
$imported = 0;
$exported = 0;
$errors = [];
$start = Carbon::now()->subYear()->format('Ymd\THis\Z');
$end = Carbon::now()->addYear()->format('Ymd\THis\Z');
$direction = $connessione->sync_direction ?? 'bidirectional';
try {
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->fetchRemoteEvents($client, $calendarUrl, $start, $end);
$imported = $this->importEvents($result, $errors);
}
if ($direction === 'export' || $direction === 'bidirectional') {
$localEvents = Evento::where(function ($q) use ($connessione) {
$q->whereNotNull('uid_esterno')
->orWhereNull('uid_esterno');
})->get();
$exported = $this->exportEvents($client, $calendarUrl, $localEvents, $connessione, $errors);
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('CalDAV syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function fetchRemoteEvents($client, string $url, string $start, string $end): array
{
$body = '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="' . $start . '" end="' . $end . '"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>';
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1', 'Content-Type' => 'application/xml; charset=utf-8'],
'body' => $body,
]);
return $this->parseCalendarData((string) $response->getBody());
}
private function importEvents(array $remoteEvents, array &$errors): int
{
$count = 0;
foreach ($remoteEvents as $event) {
try {
$vcalendar = \Sabre\VObject\Reader::read($event['data']);
foreach ($vcalendar->VEVENT as $vevent) {
$uid = (string) ($vevent->UID ?? Str::uuid());
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = (string) ($vevent->SUMMARY ?? 'Evento importato');
$description = (string) ($vevent->DESCRIPTION ?? '');
$location = (string) ($vevent->LOCATION ?? '');
$dtstart = $vevent->DTSTART ? $this->parseDateTime($vevent->DTSTART) : null;
$dtend = $vevent->DTEND ? $this->parseDateTime($vevent->DTEND) : null;
$data = [
'nome_evento' => $summary,
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($dtstart) {
$data['data_specifica'] = $dtstart->format('Y-m-d');
$data['ora_inizio'] = $dtstart->format('H:i');
}
if ($dtend && $dtstart) {
$data['durata_minuti'] = (int) $dtstart->diffInMinutes($dtend);
}
$rrule = $vevent->RRULE ? (string) $vevent->RRULE : null;
if ($rrule) {
$parsed = $this->parseRRule($rrule);
$data = array_merge($data, $parsed);
}
Evento::create($data);
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore parsing evento: ' . $e->getMessage();
}
}
return $count;
}
private function exportEvents($client, string $url, iterable $localEvents, CalendarioConnessione $connessione, array &$errors): int
{
$count = 0;
$existingUids = $this->getExistingEventUids($client, $url);
foreach ($localEvents as $evento) {
try {
if ($evento->uid_esterno && !in_array($evento->uid_esterno, $existingUids)) {
continue;
}
$uid = $evento->uid_esterno ?? ('local-' . $evento->id . '-' . Str::uuid());
$icsData = $this->buildIcsForExport($evento, $uid);
$eventUrl = rtrim($url, '/') . '/' . $uid . '.ics';
$response = $client->request('PUT', $eventUrl, [
'headers' => ['Content-Type' => 'text/calendar; charset=utf-8'],
'body' => $icsData,
]);
if ($response->getStatusCode() < 300) {
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $uid]);
}
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
return $count;
}
private function getExistingEventUids($client, string $url): array
{
try {
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<d:resourcetype/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>',
]);
$body = (string) $response->getBody();
$xml = simplexml_load_string($body);
$xml->registerXPathNamespace('d', self::DAV_NS);
$uids = [];
foreach ($xml->xpath('//d:href') as $href) {
$path = (string) $href;
$name = basename($path);
if (str_ends_with($name, '.ics')) {
$uids[] = str_replace('.ics', '', $name);
}
}
return $uids;
} catch (\Exception) {
return [];
}
}
private function buildIcsForExport(Evento $evento, string $uid): string
{
$service = app(IcsExportService::class);
return $service->generateSingle($evento);
}
private function parseCalendarList(string $xml): array
{
$calendars = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$href = $xpath->query('d:href', $response)->item(0)?->textContent ?? '';
$displayName = $xpath->query('d:propstat/d:prop/d:displayname', $response)->item(0)?->textContent ?? $href;
$hasCalendar = false;
$resourcetypes = $xpath->query('d:propstat/d:prop/d:resourcetype/*', $response);
foreach ($resourcetypes as $type) {
if ($type->localName === 'calendar' && $type->namespaceURI === self::CALDAV_NS) {
$hasCalendar = true;
break;
}
}
if ($hasCalendar && $href) {
$calendars[] = [
'href' => $href,
'displayname' => $displayName,
];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarList: ' . $e->getMessage());
}
return $calendars;
}
private function parseCalendarData(string $xml): array
{
$events = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$dataNodes = $xpath->query('d:propstat/d:prop/c:calendar-data', $response);
foreach ($dataNodes as $node) {
$events[] = ['data' => $node->textContent];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarData: ' . $e->getMessage());
}
return $events;
}
private function parseDateTime($dt): ?Carbon
{
try {
if ($dt instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
$dtValue = $dt->getDateTime();
return Carbon::instance($dtValue);
}
} catch (\Exception) {
}
return null;
}
private function parseRRule(string $rrule): array
{
$result = [];
try {
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY' && isset($result['tipo_recorrenza'])) {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
$byDay = strtoupper($value);
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $byDay, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1]) && $result['tipo_recorrenza'] === 'mensile') {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
if ($result['tipo_recorrenza'] ?? '' === 'annuale') {
$result['mese_annuale'] = (int) $value;
} else {
$result['mesi_recorrenza'] = explode(',', $value);
}
}
}
} catch (\Exception) {
}
return $result;
}
private function buildClient(array $config): \GuzzleHttp\Client
{
$options = [
'verify' => false,
'timeout' => 30,
'http_errors' => false,
];
if (!empty($config['username']) && !empty($config['password'])) {
$options['auth'] = [$config['username'], $config['password'], 'basic'];
}
return new \GuzzleHttp\Client($options);
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Google\Client as GoogleClient;
use Google\Service\Calendar as GoogleCalendar;
use Google\Service\Calendar\Event as GoogleEvent;
use Illuminate\Support\Facades\Log;
class GoogleCalendarSyncService
{
private ?GoogleCalendar $calendarService = null;
private ?string $calendarId = null;
public function testConnection(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
return ['success' => true, 'message' => 'Connessione Google Calendar riuscita. Trovati ' . count($calendarList->getItems()) . ' calendari.'];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
$calendars = [];
foreach ($calendarList->getItems() as $cal) {
$calendars[] = [
'id' => $cal->getId(),
'summary' => $cal->getSummary(),
];
}
return $calendars;
} catch (\Exception $e) {
Log::error('GoogleCalendar listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$imported = 0;
$exported = 0;
$errors = [];
try {
$client = $this->buildClient($connessione);
$this->calendarService = new GoogleCalendar($client);
$this->calendarId = $config['calendar_id'] ?? 'primary';
$direction = $connessione->sync_direction ?? 'bidirectional';
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->importRemoteEvents($connessione);
$imported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
if ($direction === 'export' || $direction === 'bidirectional') {
$result = $this->exportLocalEvents($connessione);
$exported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('GoogleCalendar syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function importRemoteEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$timeMin = Carbon::now()->subYear()->toRfc3339String();
$timeMax = Carbon::now()->addYear()->toRfc3339String();
$optParams = [
'timeMin' => $timeMin,
'timeMax' => $timeMax,
'singleEvents' => true,
'orderBy' => 'startTime',
];
$events = $service->events->listEvents($this->calendarId, $optParams);
while (true) {
foreach ($events->getItems() as $event) {
try {
$uid = $event->getId();
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = $event->getSummary() ?? 'Evento importato';
$description = strip_tags($event->getDescription() ?? '');
$location = $event->getLocation() ?? '';
$start = $event->getStart();
$end = $event->getEnd();
$data = [
'nome_evento' => mb_substr($summary, 0, 255),
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($start && $start->getDateTime()) {
$dt = Carbon::parse($start->getDateTime());
$data['data_specifica'] = $dt->format('Y-m-d');
$data['ora_inizio'] = $dt->format('H:i');
} elseif ($start && $start->getDate()) {
$data['data_specifica'] = $start->getDate();
}
if ($end && $start && $end->getDateTime() && $start->getDateTime()) {
$data['durata_minuti'] = (int) Carbon::parse($start->getDateTime())->diffInMinutes(Carbon::parse($end->getDateTime()));
}
if ($event->getRecurrence()) {
foreach ($event->getRecurrence() as $rrule) {
if (str_starts_with($rrule, 'RRULE:')) {
$parsed = $this->parseRRule(substr($rrule, 6));
$data = array_merge($data, $parsed);
}
}
}
Evento::create($data);
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore import evento: ' . $e->getMessage();
}
}
$pageToken = $events->getNextPageToken();
if ($pageToken) {
$optParams['pageToken'] = $pageToken;
$events = $service->events->listEvents($this->calendarId, $optParams);
} else {
break;
}
}
} catch (\Exception $e) {
$errors[] = 'Errore import Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function exportLocalEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$localEvents = Evento::all();
$remoteEventMap = $this->getRemoteEventMap($service);
foreach ($localEvents as $evento) {
try {
$googleEvent = new GoogleEvent();
$googleEvent->setSummary($evento->nome_evento);
if ($evento->descrizione_evento) {
$googleEvent->setDescription($evento->descrizione_evento);
}
if ($evento->luogo_indirizzo) {
$googleEvent->setLocation($evento->luogo_indirizzo);
}
$startData = $this->buildGoogleDateTime($evento, 'start');
$endData = $this->buildGoogleDateTime($evento, 'end');
$googleEvent->setStart($startData);
$googleEvent->setEnd($endData);
if ($evento->tipo_recorrenza !== 'singolo') {
$rrule = $this->buildGoogleRRule($evento);
if ($rrule) {
$googleEvent->setRecurrence(['RRULE:' . $rrule]);
}
}
if ($evento->uid_esterno && isset($remoteEventMap[$evento->uid_esterno])) {
$service->events->update($this->calendarId, $evento->uid_esterno, $googleEvent);
} else {
$created = $service->events->insert($this->calendarId, $googleEvent);
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $created->getId()]);
}
}
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
} catch (\Exception $e) {
$errors[] = 'Errore export Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function getRemoteEventMap(GoogleCalendar $service): array
{
$map = [];
try {
$events = $service->events->listEvents($this->calendarId);
foreach ($events->getItems() as $event) {
$map[$event->getId()] = true;
}
} catch (\Exception) {
}
return $map;
}
private function buildGoogleDateTime(Evento $evento, string $type): \Google\Service\Calendar\EventDateTime
{
$dt = new \Google\Service\Calendar\EventDateTime();
if ($type === 'start') {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $time->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->format('Y-m-d'));
}
}
} else {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
$durata = (int) ($evento->durata_minuti ?? 60);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$endTime = $time->copy()->addMinutes($durata);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $endTime->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->copy()->addDay()->format('Y-m-d'));
}
}
}
return $dt;
}
private function buildGoogleRRule(Evento $evento): ?string
{
$dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
return match ($evento->tipo_recorrenza) {
'settimanale' => $evento->giorno_settimana !== null
? 'FREQ=WEEKLY;BYDAY=' . ($dayMap[(int) $evento->giorno_settimana] ?? 'MO')
: null,
'mensile' => $evento->occorrenza_mese
? 'FREQ=MONTHLY;BYSETPOS=' . $evento->occorrenza_mese
: null,
'annuale' => $evento->mese_annuale
? 'FREQ=YEARLY;BYMONTH=' . $evento->mese_annuale
: null,
default => null,
};
}
private function parseRRule(string $rrule): array
{
$result = [];
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY') {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $value, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1])) {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
$result['mese_annuale'] = (int) $value;
}
}
return $result;
}
private function buildClient(CalendarioConnessione $connessione): GoogleClient
{
$config = $connessione->getDecryptedConfig();
$client = new GoogleClient();
$client->setApplicationName(config('app.name'));
$client->setScopes([GoogleCalendar::CALENDAR]);
$client->setAuthConfig([
'web' => [
'client_id' => $config['client_id'] ?? '',
'client_secret' => $config['client_secret'] ?? '',
],
]);
$client->setAccessType('offline');
$client->setPrompt('consent');
if (!empty($config['refresh_token'])) {
$client->fetchAccessTokenWithRefreshToken($config['refresh_token']);
}
return $client;
}
}
+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"
+2
View File
@@ -9,9 +9,11 @@
"php": "^8.3", "php": "^8.3",
"as247/flysystem-google-drive": "^3.0", "as247/flysystem-google-drive": "^3.0",
"directorytree/imapengine-laravel": "^1.2", "directorytree/imapengine-laravel": "^1.2",
"google/apiclient": "^2.19",
"laravel/framework": "^13.7", "laravel/framework": "^13.7",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"league/flysystem-webdav": "^3.31", "league/flysystem-webdav": "^3.31",
"sabre/vobject": "^4.6",
"spatie/laravel-permission": "^7.4", "spatie/laravel-permission": "^7.4",
"webklex/php-imap": "^6.2" "webklex/php-imap": "^6.2"
}, },
Generated
+6 -6
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c6ab297804fe432e1792b166459e6469", "content-hash": "c594ad5c5f9ff96dc8644231135f694e",
"packages": [ "packages": [
{ {
"name": "as247/cloud-storages", "name": "as247/cloud-storages",
@@ -4271,16 +4271,16 @@
}, },
{ {
"name": "sabre/vobject", "name": "sabre/vobject",
"version": "4.5.8", "version": "4.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sabre-io/vobject.git", "url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1" "reference": "9432544fc369851fb8202c5d91159b2e669f0c88"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1", "url": "https://api.github.com/repos/sabre-io/vobject/zipball/9432544fc369851fb8202c5d91159b2e669f0c88",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1", "reference": "9432544fc369851fb8202c5d91159b2e669f0c88",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4371,7 +4371,7 @@
"issues": "https://github.com/sabre-io/vobject/issues", "issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject" "source": "https://github.com/fruux/sabre-vobject"
}, },
"time": "2026-01-12T10:45:19+00:00" "time": "2026-05-31T13:04:55+00:00"
}, },
{ {
"name": "sabre/xml", "name": "sabre/xml",
+1062
View File
File diff suppressed because it is too large Load Diff
@@ -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,13 +8,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('diocesi')) {
Schema::create('diocesi', function (Blueprint $table) { Schema::create('diocesi', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
$table->string('regione')->nullable(); $table->string('regione')->nullable();
$table->timestamps(); $table->timestamps();
}); });
}
if (!Schema::hasTable('comuni')) {
Schema::create('comuni', function (Blueprint $table) { Schema::create('comuni', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
@@ -29,6 +32,7 @@ return new class extends Migration
$table->index('sigla_provincia'); $table->index('sigla_provincia');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('individui')) {
Schema::create('individui', function (Blueprint $table) { Schema::create('individui', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -30,6 +31,7 @@ return new class extends Migration
$table->index('cognome'); $table->index('cognome');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('contatti')) {
Schema::create('contatti', function (Blueprint $table) { Schema::create('contatti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
@@ -19,6 +20,7 @@ return new class extends Migration
$table->index('individuo_id'); $table->index('individuo_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('gruppi')) {
Schema::create('gruppi', function (Blueprint $table) { Schema::create('gruppi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -27,6 +28,7 @@ return new class extends Migration
$table->index('parent_id'); $table->index('parent_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('gruppo_individuo')) {
Schema::create('gruppo_individuo', function (Blueprint $table) { Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
@@ -18,6 +19,7 @@ return new class extends Migration
$table->unique(['gruppo_id', 'individuo_id']); $table->unique(['gruppo_id', 'individuo_id']);
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('eventi')) {
Schema::create('eventi', function (Blueprint $table) { Schema::create('eventi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -23,14 +24,18 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->index('tenant_id'); $table->index('tenant_id');
}); });
}
if (!Schema::hasTable('eventi_gruppi')) {
Schema::create('eventi_gruppi', function (Blueprint $table) { Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps(); $table->timestamps();
}); });
}
if (!Schema::hasTable('eventi_responsabili')) {
Schema::create('eventi_responsabili', function (Blueprint $table) { Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
@@ -38,6 +43,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('documenti')) {
Schema::create('documenti', function (Blueprint $table) { Schema::create('documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -25,6 +26,7 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('mailing_lists')) {
Schema::create('mailing_lists', function (Blueprint $table) { Schema::create('mailing_lists', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -17,7 +18,9 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->index('tenant_id'); $table->index('tenant_id');
}); });
}
if (!Schema::hasTable('mailing_contacts')) {
Schema::create('mailing_contacts', function (Blueprint $table) { Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade'); $table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
@@ -28,7 +31,9 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']); $table->unique(['mailing_list_id', 'individuo_id']);
}); });
}
if (!Schema::hasTable('mailing_messaggi')) {
Schema::create('mailing_messaggi', function (Blueprint $table) { Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -46,6 +51,7 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('notifiche')) {
Schema::create('notifiche', function (Blueprint $table) { Schema::create('notifiche', function (Blueprint $table) {
$table->id(); $table->id();
$table->unsignedBigInteger('user_id')->nullable(); $table->unsignedBigInteger('user_id')->nullable();
@@ -21,6 +22,7 @@ return new class extends Migration
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) { Schema::create('users', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -21,13 +22,17 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
$table->index('email'); $table->index('email');
}); });
}
if (!Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table) { Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary(); $table->string('email')->primary();
$table->string('token'); $table->string('token');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
}); });
}
if (!Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table) { Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary(); $table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index(); $table->foreignId('user_id')->nullable()->index();
@@ -37,6 +42,7 @@ return new class extends Migration
$table->integer('last_activity')->index(); $table->integer('last_activity')->index();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,18 +8,23 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) { Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary(); $table->string('key')->primary();
$table->mediumText('value'); $table->mediumText('value');
$table->integer('expiration'); $table->integer('expiration');
}); });
}
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) { Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary(); $table->string('key')->primary();
$table->string('owner'); $table->string('owner');
$table->integer('expiration'); $table->integer('expiration');
}); });
}
if (!Schema::hasTable('jobs')) {
Schema::create('jobs', function (Blueprint $table) { Schema::create('jobs', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('queue')->index(); $table->string('queue')->index();
@@ -29,7 +34,9 @@ return new class extends Migration
$table->unsignedInteger('available_at'); $table->unsignedInteger('available_at');
$table->unsignedInteger('created_at'); $table->unsignedInteger('created_at');
}); });
}
if (!Schema::hasTable('job_batches')) {
Schema::create('job_batches', function (Blueprint $table) { Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary(); $table->string('id')->primary();
$table->string('name'); $table->string('name');
@@ -42,7 +49,9 @@ return new class extends Migration
$table->integer('created_at'); $table->integer('created_at');
$table->integer('finished_at')->nullable(); $table->integer('finished_at')->nullable();
}); });
}
if (!Schema::hasTable('failed_jobs')) {
Schema::create('failed_jobs', function (Blueprint $table) { Schema::create('failed_jobs', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('uuid')->unique(); $table->string('uuid')->unique();
@@ -53,6 +62,7 @@ return new class extends Migration
$table->timestamp('failed_at'); $table->timestamp('failed_at');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('individui', 'numero_documento')) {
Schema::table('individui', function (Blueprint $table) { Schema::table('individui', function (Blueprint $table) {
$table->string('numero_documento', 50)->nullable()->after('tipo_documento'); $table->string('numero_documento', 50)->nullable()->after('tipo_documento');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('eventi', 'giorno_mese')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->string('giorno_mese')->nullable()->after('giorno_settimana'); $table->string('giorno_mese')->nullable()->after('giorno_settimana');
$table->string('mesi_recorrenza')->nullable()->after('giorno_mese'); $table->string('mesi_recorrenza')->nullable()->after('giorno_mese');
$table->string('mese_annuale')->nullable()->after('mesi_recorrenza'); $table->string('mese_annuale')->nullable()->after('mesi_recorrenza');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('eventi', 'occorrenza_mese')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese'); $table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('eventi_documenti')) {
Schema::create('eventi_documenti', function (Blueprint $table) { Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
@@ -15,6 +16,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
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
{ {
if (!Schema::hasColumn('documenti', 'user_id')) {
Schema::table('documenti', function (Blueprint $table) { Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('viste_report')) {
Schema::create('viste_report', function (Blueprint $table) { Schema::create('viste_report', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
@@ -22,6 +23,7 @@ return new class extends Migration
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('viste_report', 'is_default')) {
Schema::table('viste_report', function (Blueprint $table) { Schema::table('viste_report', function (Blueprint $table) {
$table->boolean('is_default')->default(false)->after('nome'); $table->boolean('is_default')->default(false)->after('nome');
$table->index('is_default'); $table->index('is_default');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('mailing_lists', 'user_id')) {
Schema::table('mailing_lists', function (Blueprint $table) { Schema::table('mailing_lists', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('tipologie_documenti')) {
Schema::create('tipologie_documenti', function (Blueprint $table) { Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome', 50)->unique(); $table->string('nome', 50)->unique();
@@ -16,6 +17,7 @@ return new class extends Migration
$table->boolean('attiva')->default(true); $table->boolean('attiva')->default(true);
$table->timestamps(); $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,10 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('users', 'permissions')) {
Schema::table('users', function (Blueprint $table) { Schema::table('users', function (Blueprint $table) {
$table->json('permissions')->nullable()->after('is_admin'); $table->json('permissions')->nullable()->after('is_admin');
}); });
}
if (!Schema::hasTable('activity_logs')) {
Schema::create('activity_logs', function (Blueprint $table) { Schema::create('activity_logs', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->foreignId('user_id')->constrained()->onDelete('cascade');
@@ -26,7 +29,9 @@ return new class extends Migration
$table->index(['created_at']); $table->index(['created_at']);
$table->index(['action']); $table->index(['action']);
}); });
}
if (!Schema::hasTable('role_presets')) {
Schema::create('role_presets', function (Blueprint $table) { Schema::create('role_presets', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name', 50)->unique(); $table->string('name', 50)->unique();
@@ -35,6 +40,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -23,6 +23,7 @@ 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.
*/ */
if (!Schema::hasTable($tableNames['permissions'])) {
Schema::create($tableNames['permissions'], static function (Blueprint $table) { Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id $table->id(); // permission id
$table->string('name'); $table->string('name');
@@ -31,10 +32,12 @@ return new class extends Migration
$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.
*/ */
if (!Schema::hasTable($tableNames['roles'])) {
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id $table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
@@ -50,7 +53,9 @@ return new class extends Migration
$table->unique(['name', 'guard_name']); $table->unique(['name', 'guard_name']);
} }
}); });
}
if (!Schema::hasTable($tableNames['model_has_permissions'])) {
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission); $table->unsignedBigInteger($pivotPermission);
@@ -73,7 +78,9 @@ return new class extends Migration
'model_has_permissions_permission_model_type_primary'); 'model_has_permissions_permission_model_type_primary');
} }
}); });
}
if (!Schema::hasTable($tableNames['model_has_roles'])) {
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotRole);
@@ -96,7 +103,9 @@ return new class extends Migration
'model_has_roles_role_model_type_primary'); 'model_has_roles_role_model_type_primary');
} }
}); });
}
if (!Schema::hasTable($tableNames['role_has_permissions'])) {
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission); $table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotRole);
@@ -113,6 +122,7 @@ return new class extends Migration
$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,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_settings')) {
Schema::create('email_settings', function (Blueprint $table) { Schema::create('email_settings', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('imap_host')->nullable(); $table->string('imap_host')->nullable();
@@ -29,6 +30,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_folders')) {
Schema::create('email_folders', function (Blueprint $table) { Schema::create('email_folders', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
@@ -20,6 +21,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_messages')) {
Schema::create('email_messages', function (Blueprint $table) { Schema::create('email_messages', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade'); $table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
@@ -38,6 +39,7 @@ return new class extends Migration
$table->index('received_at'); $table->index('received_at');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_attachments')) {
Schema::create('email_attachments', function (Blueprint $table) { Schema::create('email_attachments', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade'); $table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
@@ -20,6 +21,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('ruoli')) {
Schema::create('ruoli', function (Blueprint $table) { Schema::create('ruoli', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome', 50)->unique(); $table->string('nome', 50)->unique();
@@ -16,6 +17,7 @@ return new class extends Migration
$table->boolean('attiva')->default(true); $table->boolean('attiva')->default(true);
$table->timestamps(); $table->timestamps();
}); });
}
$defaultRuoli = [ $defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'], ['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
@@ -38,6 +40,7 @@ return new class extends Migration
]); ]);
} }
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
@@ -60,6 +63,7 @@ return new class extends Migration
} }
} }
} }
}
public function down(): void public function down(): void
{ {
@@ -13,10 +13,12 @@ return new class extends Migration
$table->dropColumn('ruolo_id'); $table->dropColumn('ruolo_id');
}); });
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_ids')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->json('ruolo_ids')->nullable()->after('individuo_id'); $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');
}); });
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
}); });
} }
}
}; };
@@ -13,10 +13,12 @@ return new class extends Migration
$table->dropColumn('responsabile_id'); $table->dropColumn('responsabile_id');
}); });
if (!Schema::hasColumn('gruppi', 'responsabile_ids')) {
Schema::table('gruppi', function (Blueprint $table) { Schema::table('gruppi', function (Blueprint $table) {
$table->json('responsabile_ids')->nullable()->after('diocesi_id'); $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');
}); });
if (!Schema::hasColumn('gruppi', 'responsabile_id')) {
Schema::table('gruppi', function (Blueprint $table) { Schema::table('gruppi', function (Blueprint $table) {
$table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id'); $table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id');
$table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null'); $table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null');
}); });
} }
}
}; };
@@ -8,11 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('eventi', 'luogo_indirizzo')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->string('luogo_indirizzo', 500)->nullable()->after('note'); $table->string('luogo_indirizzo', 500)->nullable()->after('note');
$table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo'); $table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -0,0 +1,46 @@
<?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('app_settings')) {
return;
}
Schema::create('app_settings', function (Blueprint $table) {
$table->id();
$table->string('nome_applicazione')->nullable();
$table->string('nome_organizzazione')->nullable();
$table->string('logo')->nullable();
$table->string('logo_small')->nullable();
$table->string('logo_path')->nullable();
$table->string('logo_small_path')->nullable();
$table->string('footer_text')->nullable();
$table->string('app_version')->nullable();
$table->string('dashboard_welcome')->nullable();
$table->boolean('show_version')->default(false);
$table->string('documenti_storage_disk')->default('local');
$table->string('documenti_storage_path')->default('documenti');
$table->string('backup_path')->default('backups');
$table->integer('backup_retention_days')->default(30);
$table->boolean('backup_include_files')->default(true);
$table->boolean('backup_include_env')->default(true);
$table->boolean('backup_auto_enabled')->default(false);
$table->string('backup_auto_frequency', 20)->default('daily');
$table->integer('backup_auto_hour')->default(3);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('app_settings');
}
};
@@ -8,11 +8,22 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'logo_path')) {
Schema::table('app_settings', function (Blueprint $table) { Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo'); $table->string('logo_path')->nullable()->after('logo');
});
}
if (!Schema::hasColumn('app_settings', 'logo_small_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_small_path')->nullable()->after('logo_small'); $table->string('logo_small_path')->nullable()->after('logo_small');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,13 +8,25 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('app_settings', function (Blueprint $table) { if (!Schema::hasTable('app_settings')) {
$table->string('footer_text')->nullable()->after('nome_organizzazione'); return;
$table->string('app_version')->nullable()->after('footer_text'); }
$table->string('dashboard_welcome')->nullable()->after('app_version');
$table->boolean('show_version')->default(false)->after('dashboard_welcome'); $columns = ['footer_text', 'app_version', 'dashboard_welcome', 'show_version'];
foreach ($columns as $column) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column) {
match ($column) {
'footer_text' => $table->string('footer_text')->nullable()->after('nome_organizzazione'),
'app_version' => $table->string('app_version')->nullable()->after('footer_text'),
'dashboard_welcome' => $table->string('dashboard_welcome')->nullable()->after('app_version'),
'show_version' => $table->boolean('show_version')->default(false)->after('dashboard_welcome'),
};
}); });
} }
}
}
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
{ {
if (!Schema::hasColumn('email_settings', 'signature')) {
Schema::table('email_settings', function (Blueprint $table) { Schema::table('email_settings', function (Blueprint $table) {
$table->longText('signature')->nullable()->after('reply_to'); $table->longText('signature')->nullable()->after('reply_to');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('email_settings', 'signature_enabled')) {
Schema::table('email_settings', function (Blueprint $table) { Schema::table('email_settings', function (Blueprint $table) {
$table->boolean('signature_enabled')->default(true)->after('signature'); $table->boolean('signature_enabled')->default(true)->after('signature');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('report_custom')) {
Schema::create('report_custom', function (Blueprint $table) { Schema::create('report_custom', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
@@ -18,6 +19,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -10,6 +10,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('tipologie_eventi')) {
Schema::create('tipologie_eventi', function (Blueprint $table) { Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome', 50)->unique(); $table->string('nome', 50)->unique();
@@ -18,6 +19,7 @@ return new class extends Migration
$table->boolean('attiva')->default(true); $table->boolean('attiva')->default(true);
$table->timestamps(); $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,6 +10,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('sender_accounts')) {
Schema::create('sender_accounts', function (Blueprint $table) { Schema::create('sender_accounts', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('email_address')->unique(); $table->string('email_address')->unique();
@@ -26,6 +27,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -10,12 +10,14 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('mailing_messaggi', 'log_falliti')) {
Schema::table('mailing_messaggi', function (Blueprint $table) { Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->json('log_falliti')->nullable()->after('invii_falliti'); $table->json('log_falliti')->nullable()->after('invii_falliti');
$table->string('mittente_nome')->nullable()->after('log_falliti'); $table->string('mittente_nome')->nullable()->after('log_falliti');
$table->string('mittente_email')->nullable()->after('mittente_nome'); $table->string('mittente_email')->nullable()->after('mittente_nome');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('documenti_cartelle')) {
Schema::create('documenti_cartelle', function (Blueprint $table) { Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade'); $table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
@@ -15,6 +16,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
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
{ {
if (!Schema::hasColumn('documenti', 'cartella_id')) {
Schema::table('documenti', function (Blueprint $table) { Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null'); $table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,11 +8,22 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_disk')) {
Schema::table('app_settings', function (Blueprint $table) { Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_disk')->default('local')->after('show_version'); $table->string('documenti_storage_disk')->default('local')->after('show_version');
});
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_path')->default('documenti')->after('documenti_storage_disk'); $table->string('documenti_storage_path')->default('documenti')->after('documenti_storage_disk');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('storage_repositories')) {
Schema::create('storage_repositories', function (Blueprint $table) { Schema::create('storage_repositories', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
@@ -18,6 +19,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('documenti', 'repository_id')) {
Schema::table('documenti', function (Blueprint $table) { Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('repository_id') $table->foreignId('repository_id')
->nullable() ->nullable()
@@ -16,6 +17,7 @@ return new class extends Migration
->after('cartella_id'); ->after('cartella_id');
}); });
} }
}
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
{ {
if (!Schema::hasColumn('documenti', 'storage_disk')) {
Schema::table('documenti', function (Blueprint $table) { Schema::table('documenti', function (Blueprint $table) {
$table->string('storage_disk', 50)->nullable()->after('repository_id'); $table->string('storage_disk', 50)->nullable()->after('repository_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -10,16 +10,32 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('app_settings', function (Blueprint $table) { if (!Schema::hasTable('app_settings')) {
$table->string('backup_path')->default('backups')->after('show_version'); return;
$table->integer('backup_retention_days')->default(30)->after('backup_path'); }
$table->boolean('backup_include_files')->default(true)->after('backup_retention_days');
$table->boolean('backup_include_env')->default(true)->after('backup_include_files'); $columns = [
$table->boolean('backup_auto_enabled')->default(false)->after('backup_include_env'); 'backup_path' => ['type' => 'string', 'default' => 'backups'],
$table->string('backup_auto_frequency', 20)->default('daily')->after('backup_auto_enabled'); 'backup_retention_days' => ['type' => 'integer', 'default' => 30],
$table->integer('backup_auto_hour')->default(3)->after('backup_auto_frequency'); 'backup_include_files' => ['type' => 'boolean', 'default' => true],
'backup_include_env' => ['type' => 'boolean', 'default' => true],
'backup_auto_enabled' => ['type' => 'boolean', 'default' => false],
'backup_auto_frequency' => ['type' => 'string', 'length' => 20, 'default' => 'daily'],
'backup_auto_hour' => ['type' => 'integer', 'default' => 3],
];
foreach ($columns as $column => $config) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column, $config) {
match ($config['type']) {
'string' => $table->string($column, $config['length'] ?? 255)->default($config['default'])->after('show_version'),
'integer' => $table->integer($column)->default($config['default'])->after('show_version'),
'boolean' => $table->boolean($column)->default($config['default'])->after('show_version'),
};
}); });
} }
}
}
public function down(): void public function down(): void
{ {
@@ -0,0 +1,36 @@
<?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('calendario_connessioni')) {
Schema::create('calendario_connessioni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo'); // google_calendar, caldav
$table->text('config'); // JSON
$table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore
$table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional
$table->integer('sync_interval_minutes')->default(60);
$table->timestamp('last_sync_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->text('last_error_message')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('calendario_connessioni');
}
};
@@ -0,0 +1,28 @@
<?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('eventi', 'uid_esterno')) {
Schema::table('eventi', function (Blueprint $table) {
$table->string('uid_esterno', 500)->nullable()->after('luogo_url_maps');
$table->index('uid_esterno');
});
}
}
public function down(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->dropIndex(['uid_esterno']);
$table->dropColumn('uid_esterno');
});
}
};
@@ -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);
} }
} }
} }
+7 -3
View File
@@ -20,9 +20,10 @@ class DatabaseSeeder extends Seeder
DockerBaseDataSeeder::class, DockerBaseDataSeeder::class,
]); ]);
$admin = User::create([ $admin = User::firstOrCreate(
['email' => 'admin@glastree.local'],
[
'name' => 'Admin', 'name' => 'Admin',
'email' => 'admin@glastree.local',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'is_admin' => true, 'is_admin' => true,
'status' => 'active', 'status' => 'active',
@@ -34,9 +35,12 @@ class DatabaseSeeder extends Seeder
'mailing' => 2, 'mailing' => 2,
'viste' => 2, 'viste' => 2,
], ],
]); ]
);
if (!$admin->role_preset_id) {
$admin->role_preset_id = 1; $admin->role_preset_id = 1;
$admin->save(); $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);
} }
} }
} }
Binary file not shown.
+848
View File
@@ -0,0 +1,848 @@
#!/usr/bin/env php
<?php
/**
* Glastree — Installer interattivo per amministratori (PHP CLI)
*
* Uso:
* php install.php
*
* Richiede: PHP 8.3+, Composer, MySQL o SQLite
* Opzionale: npm (per asset frontend)
*
* Modalità:
* 1) Fresh Install su Apache (LAMP)
* 2) Fresh Install via Docker
* 3) Restore da Backup (Apache)
*/
// ── Safety ──────────────────────────────────────────
declare(strict_types=1);
// ── Constants ────────────────────────────────────────
const REQUIREMENTS = ['pdo_mysql', 'mbstring', 'xml', 'curl', 'zip', 'gd', 'fileinfo'];
const MIN_PHP = '8.3.0';
// ── Utilities ────────────────────────────────────────
function println(string $msg = ''): void { echo $msg . PHP_EOL; }
function color(string $text, string $code): string {
$map = [
'red' => '0;31',
'green' => '0;32',
'yellow' => '1;33',
'cyan' => '0;36',
'white' => '1;37',
];
$c = $map[$code] ?? '0';
return "\033[{$c}m{$text}\033[0m";
}
function info(string $msg): void { println(color('[INFO]', 'cyan') . ' ' . $msg); }
function ok(string $msg): void { println(color('[OK]', 'green') . ' ' . $msg); }
function warn(string $msg): void { println(color('[WARN]', 'yellow') . ' ' . $msg); }
function error(string $msg): void { println(color('[ERR]', 'red') . ' ' . $msg); }
function fail(string $msg): never { error($msg); exit(1); }
function ask(string $prompt, ?string $default = null): string {
$defaultStr = $default !== null ? " [{$default}]" : '';
echo color('?', 'cyan') . " {$prompt}{$defaultStr}: ";
$val = trim(fgets(STDIN) ?: '');
return $val !== '' ? $val : ($default ?? '');
}
function askRequired(string $prompt): string {
while (true) {
$val = ask($prompt);
if ($val !== '') break;
warn('Valore obbligatorio.');
}
return $val;
}
function confirm(string $prompt): bool {
$resp = strtolower(ask($prompt . ' [S/n]', 'S'));
return $resp === 's' || $resp === '';
}
function menu(string $prompt, array $options): string {
foreach ($options as $i => $opt) {
println(' ' . ($i + 1) . ') ' . $opt);
}
while (true) {
$val = ask($prompt, '1');
if (isset($options[(int)$val - 1])) return $options[(int)$val - 1];
warn('Scelta non valida.');
}
}
function checkCmd(string $cmd): bool {
$found = !(trim(shell_exec("command -v $cmd 2>/dev/null") ?: '') === '');
if (!$found) warn("Comando '$cmd' non trovato.");
return $found;
}
function run(string $cmd, ?string $label = null): bool {
if ($label) info($label);
println(" \$ {$cmd}");
passthru($cmd, $exitCode);
if ($exitCode !== 0) warn("Comando terminato con codice {$exitCode}");
return $exitCode === 0;
}
function runCapture(string $cmd): string {
return trim(shell_exec($cmd) ?: '');
}
/**
* Crea l'utente amministratore via PDO prepared statement.
* Nessun escaping PHP necessario (tutto via prepared statement).
*/
function createAdminUser(
string $dbHost, string $dbPort, string $dbName,
string $dbUser, string $dbPass,
string $adminName, string $adminEmail, string $adminPass
): bool {
try {
$pdo = new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$permissions = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
// Elimina eventuale utente con stessa email (seeder)
$stmt = $pdo->prepare('DELETE FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
// Inserisce admin con is_admin=1, status='active', permissions completi
$stmt = $pdo->prepare(
"INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at)
VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())"
);
$stmt->execute([$adminName, $adminEmail, $hash, $permissions]);
$userId = $pdo->lastInsertId();
// Assegna ruolo Spatie 'admin' (role_id = 1)
$stmt = $pdo->prepare(
"INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)"
);
$stmt->execute(['App\\Models\\User', $userId]);
return true;
} catch (\PDOException $e) {
error("Errore creazione admin: " . $e->getMessage());
return false;
}
}
// ── Banner ───────────────────────────────────────────
println(color(' ____ _ _ _', 'white'));
println(color(' / ___| | __ _ ___| |_ ___ _ __ ___ | |_ ___', 'white'));
println(color('| | _| |/ _` / __| __/ _ \'__/ _ \ | __/ _ \\', 'white'));
println(color('| |_| | | (_| \__ \ || __/ | | __/ | || __/', 'white'));
println(color(' \____|_|\__,_|___/\__\___|_| \___| \__\___|', 'white'));
println(color(' Installer interattivo per sysadmin', 'cyan'));
println();
// ── Preflight ────────────────────────────────────────
$scriptDir = dirname(__FILE__);
chdir($scriptDir);
if (!file_exists('.env.example')) {
fail("File .env.example non trovato in {$scriptDir}. Sei nella cartella dell'applicazione?");
}
// ── Select mode ──────────────────────────────────────
println('Seleziona la modalità di installazione:');
$mode = menu('Scegli', [
'Fresh Install su Apache (LAMP tradizionale)',
'Fresh Install via Docker',
'Restore da Backup (Apache)',
]);
// ── Common params ────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PARAMETRI GENERALI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$appName = askRequired('Nome del sito/app (es. Glastree MyChurch)');
$adminName = askRequired('Nome del primo utente amministratore');
$adminEmail = askRequired('Email dell\'amministratore');
$adminPass = askRequired('Password per l\'amministratore');
$appUrl = askRequired('URL pubblico del sito (es. https://glastree.esempio.it)');
// ── DB params (Apache / Restore) ─────────────────────
$dbType = 'mysql';
$dbHost = '127.0.0.1';
$dbPort = '3306';
$dbName = 'glastree';
$dbUser = 'glastree';
$dbPass = '';
if (in_array($mode, ['Fresh Install su Apache (LAMP tradizionale)', 'Restore da Backup (Apache)'])) {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dbType = menu('Tipo database', ['mysql', 'sqlite']);
if ($dbType === 'mysql') {
$dbHost = askRequired('Host database');
$dbPort = ask('Porta database', '3306');
$dbName = ask('Nome database (verrà creato se non esiste)', 'glastree');
$dbUser = askRequired('Utente database');
$dbPass = askRequired('Password database');
}
}
// ── Web server user detection ─────────────────────────
$wwwUser = 'www-data';
$sudo = '';
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
$detected = trim(shell_exec('ps aux | grep -E "apache|httpd" | grep -v grep | head -1 | awk \'{print $1}\'') ?: '');
if ($detected !== '' && $detected !== 'root') {
$wwwUser = $detected;
}
if (function_exists('posix_getuid') && posix_getuid() === 0) {
$sudo = "sudo -u {$wwwUser} ";
}
}
// ══════════════════════════════════════════════════════
// MODE 1: FRESH INSTALL APACHE
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// PHP version
$phpVer = PHP_VERSION;
info("PHP versione: {$phpVer}");
if (version_compare($phpVer, MIN_PHP, '<')) {
fail("PHP >= " . MIN_PHP . " richiesto (trovato {$phpVer})");
}
ok("PHP {$phpVer}");
// Extensions
foreach (REQUIREMENTS as $ext) {
if (!extension_loaded($ext)) {
warn("Estensione PHP '{$ext}' non trovata (alcune funzionalità potrebbero non funzionare)");
} else {
ok("Estensione PHP '{$ext}'");
}
}
// Composer
checkCmd('composer');
checkCmd('node');
checkCmd('npm');
// ── Step 1: Environment ─────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 1/7 — CONFIGURAZIONE AMBIENTE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!file_exists('.env')) {
copy('.env.example', '.env');
ok('.env creato da .env.example');
} else {
info('.env già esistente, lo aggiorno');
}
// APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY generata');
// Config
$dotenv = file_get_contents('.env');
$replacements = [
'/^APP_NAME=.*/m' => 'APP_NAME="' . addslashes($appName) . '"',
'/^APP_URL=.*/m' => "APP_URL={$appUrl}",
'/^APP_ENV=.*/m' => 'APP_ENV=production',
'/^APP_DEBUG=.*/m' => 'APP_DEBUG=false',
];
if ($dbType === 'mysql') {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=mysql';
$replacements['/^DB_HOST=.*/m'] = "DB_HOST={$dbHost}";
$replacements['/^DB_PORT=.*/m'] = "DB_PORT={$dbPort}";
$replacements['/^DB_DATABASE=.*/m'] = "DB_DATABASE={$dbName}";
$replacements['/^DB_USERNAME=.*/m'] = "DB_USERNAME={$dbUser}";
$replacements['/^DB_PASSWORD=.*/m'] = "DB_PASSWORD={$dbPass}";
} else {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=sqlite';
$replacements['/^DB_HOST=.*/m'] = '# DB_HOST=';
$replacements['/^DB_PORT=.*/m'] = '# DB_PORT=';
$replacements['/^DB_DATABASE=.*/m'] = 'DB_DATABASE=' . $scriptDir . '/database/database.sqlite';
$replacements['/^DB_USERNAME=.*/m'] = '# DB_USERNAME=';
$replacements['/^DB_PASSWORD=.*/m'] = '# DB_PASSWORD=';
}
foreach ($replacements as $pattern => $replacement) {
$dotenv = preg_replace($pattern, $replacement, $dotenv);
}
file_put_contents('.env', $dotenv);
ok('.env configurato');
// ── Step 2: Database ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 2/7 — DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
info("Connessione a MySQL {$dbHost}:{$dbPort}, database '{$dbName}'...");
try {
new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
ok("Accesso a '{$dbName}' come '{$dbUser}' riuscito");
} catch (\PDOException $e) {
// Diagnostica l'errore
$errMsg = $e->getMessage();
$diagnosi = match (true) {
(bool)preg_match('/1049|Unknown database/', $errMsg) => "Database '{$dbName}' non esiste",
(bool)preg_match('/1045|Access denied for user/', $errMsg) => "Utente '{$dbUser}' o password non validi",
(bool)preg_match('/1044/', $errMsg) => "Utente '{$dbUser}' non ha accesso al database '{$dbName}'",
default => $errMsg,
};
warn($diagnosi);
info("Connessione come root per creare/riparare utente e database...");
$rootPdo = null;
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException) {
$rootPass = askRequired('Password utente root MySQL (lascia vuoto se senza password)');
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', $rootPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException $r) {
fail("Connessione come root fallita: " . $r->getMessage());
}
}
$safeUser = str_replace(["'", '"', '`', "\0", '\\'], '', $dbUser);
$safePass = str_replace(["'", '"', '`', "\0", '\\'], '', $dbPass);
$safeName = str_replace(["'", '"', '`', "\0", '\\'], '', $dbName);
$rootPdo->exec("CREATE DATABASE IF NOT EXISTS `{$safeName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
foreach (['localhost', '%'] as $host) {
$rootPdo->exec("CREATE USER IF NOT EXISTS '{$safeUser}'@'{$host}' IDENTIFIED BY '{$safePass}'");
$rootPdo->exec("GRANT ALL PRIVILEGES ON `{$safeName}`.* TO '{$safeUser}'@'{$host}'");
}
$rootPdo->exec("FLUSH PRIVILEGES");
ok("Database '{$dbName}' e utente '{$dbUser}' configurati con permessi completi");
}
} else {
$sqlitePath = $scriptDir . '/database/database.sqlite';
if (!file_exists($sqlitePath)) {
touch($sqlitePath);
chmod($sqlitePath, 0664);
}
ok('SQLite pronto: ' . $sqlitePath);
}
// ── Step 3: Composer ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 3/7 — INSTALLAZIONE DIPENDENZE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
ok('Dipendenze PHP installate');
// ── Step 4: Package discovery ────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 4/7 — REGISTRAZIONE PACCHETTI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
ok('Pacchetti registrati');
// ── Step 5: Schema + Seed ────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 5/7 — SCHEMA DATABASE E DATI BASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$sqlFile = $scriptDir . '/database/install.sql';
if (!file_exists($sqlFile)) {
fail("File database/install.sql non trovato in {$scriptDir}");
}
if ($dbType === 'mysql') {
$importCmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (!run($importCmd, 'Importazione struttura database e dati base...')) {
fail("Importazione SQL fallita. Verifica il file database/install.sql e le credenziali.");
}
} else {
// SQLite fallback: usa le migration (install.sql è solo per MySQL)
if (!run("{$sudo}php artisan migrate --seed --force", 'Esecuzione migration e seed...')) {
fail('Migration fallita. Verifica la configurazione SQLite.');
}
}
ok('Database popolato con struttura e dati base');
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
// ── Step 6: Admin user (via PDO — no tinker) ─────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 6/7 — CREAZIONE AMMINISTRATORE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
if (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato con permessi completi");
} else {
fail("Creazione admin fallita. Verifica i dati inseriti.");
}
} else {
// SQLite: usa seeder (l'admin verrà creato da DatabaseSeeder)
// Poi aggiorna la password con quella scelta dall'utente
runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
ok("Amministratore {$adminEmail} configurato");
}
// Clear all caches after schema + admin creation
runCapture("{$sudo}php artisan optimize:clear 2>/dev/null");
ok('Cache ottimizzate');
// ── Step 7: Asset ────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 7/7 — ASSET FRONTEND', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$hasNpm = checkCmd('npm');
if ($hasNpm && confirm('Compilare gli asset frontend con npm?')) {
run("{$sudo}npm install", 'Installazione dipendenze npm...');
run("{$sudo}npm run build", 'Compilazione asset...');
ok('Asset compilati');
} else {
if (!$hasNpm) warn('npm non disponibile — asset non compilati (AdminLTE via CDN)');
else info('Asset non compilati (AdminLTE via CDN)');
}
// ── Permissions ──────────────────────────────────
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
info('Impostazione permessi...');
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// ── Summary ──────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Configura Apache con il VirtualHost (vedi Guida → Installazione, Passo 4).');
if ($dbType === 'mysql') {
warn('Se hai appena creato il database, assicurati che l\'utente abbia privilegi completi.');
}
}
// ══════════════════════════════════════════════════════
// MODE 2: DOCKER
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install via Docker') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Docker)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('docker');
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' CONFIGURAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dockerDb = menu('Tipo database per Docker', ['sqlite', 'mysql']);
$dockerPort = ask('Porta host (es. 8080)', '8080');
$composeFile = $dockerDb === 'mysql' ? 'docker-compose.mysql.yml' : 'docker-compose.yml';
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' INSTALLAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!run("docker compose -f {$composeFile} build", 'Build immagine Docker...')) {
fail('Build fallita');
}
// Override compose con variabili d'ambiente
$override = [
'services:',
' glastree:',
' environment:',
" - APP_NAME={$appName}",
" - APP_URL={$appUrl}",
' - APP_ENV=production',
' - APP_DEBUG=false',
];
file_put_contents('docker-compose.override.yml', implode(PHP_EOL, $override) . PHP_EOL);
if (!run(
"docker compose -f {$composeFile} -f docker-compose.override.yml up -d",
'Avvio container...'
)) {
@unlink('docker-compose.override.yml');
fail('Avvio container fallito');
}
// Attendi container pronto
info('Attendo che il container sia pronto...');
$containerId = runCapture("docker compose -f {$composeFile} ps -q glastree 2>/dev/null");
$adminCreated = false;
if ($containerId !== '') {
for ($i = 0; $i < 30; $i++) {
$status = runCapture("docker inspect -f '{{.State.Status}}' {$containerId} 2>/dev/null");
if ($status === 'running') {
$http = runCapture("docker exec {$containerId} sh -c 'wget -q -O- http://localhost/ 2>/dev/null && echo OK'");
if (str_contains($http, 'OK')) {
info('Container pronto');
break;
}
}
sleep(2);
}
// Import schema SQL nel container
$sqlFile = $scriptDir . '/database/install.sql';
if (file_exists($sqlFile) && $dockerDb === 'mysql') {
info('Importazione schema nel container...');
runCapture("docker cp {$sqlFile} {$containerId}:/tmp/install.sql 2>/dev/null");
$mysqlHost = 'mysql';
$mysqlUser = 'glastree';
$mysqlPass = 'secret';
$mysqlDb = 'glastree';
$importResult = runCapture(
"docker exec {$containerId} sh -c 'mysql -h {$mysqlHost} -u {$mysqlUser} -p{$mysqlPass} {$mysqlDb} < /tmp/install.sql' 2>&1"
);
if ($importResult !== '') {
warn("Import SQL: {$importResult}");
} else {
ok('Schema database importato');
}
} elseif ($dockerDb === 'sqlite') {
run("docker exec {$containerId} php artisan migrate --seed --force", 'Migration e seed...');
}
// Crea admin
if ($dockerDb === 'mysql') {
// Script PHP temporaneo con var_export (safe)
$adminScriptPath = sys_get_temp_dir() . '/glastree-create-admin-' . uniqid() . '.php';
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$perm = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
$nameExp = var_export($adminName, true);
$emailExp = var_export($adminEmail, true);
$hashExp = var_export($hash, true);
$userExp = var_export($mysqlUser, true);
$passExp = var_export($mysqlPass, true);
$dbExp = var_export($mysqlDb, true);
$hostExp = var_export($mysqlHost, true);
$adminPhp = <<<PHP
<?php
\$pdo = new PDO("mysql:host=" . {$hostExp} . ";dbname=" . {$dbExp}, {$userExp}, {$passExp}, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
\$pdo->prepare("DELETE FROM users WHERE email = ?")->execute([{$emailExp}]);
\$pdo->prepare("INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at) VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())")->execute([{$nameExp}, {$emailExp}, {$hashExp}, '{$perm}']);
\$uid = \$pdo->lastInsertId();
\$pdo->prepare("INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)")->execute(['App\\\\Models\\\\User', \$uid]);
echo "OK:" . \$uid;
PHP;
file_put_contents($adminScriptPath, $adminPhp);
runCapture("docker cp {$adminScriptPath} {$containerId}:/tmp/create_admin.php 2>/dev/null");
$adminResult = runCapture("docker exec {$containerId} php /tmp/create_admin.php 2>/dev/null");
@unlink($adminScriptPath);
} else {
// SQLite: usa tinker via docker exec
$adminResult = runCapture("docker exec {$containerId} php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
}
if (str_contains($adminResult ?? '', 'OK')) {
ok("Amministratore {$adminEmail} creato nel container");
$adminCreated = true;
} else {
warn("Creazione admin fallita — output: " . (($adminResult ?? '') ?: 'nessun output'));
}
}
@unlink('docker-compose.override.yml');
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE DOCKER COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: http://localhost:{$dockerPort}");
println(" Admin: {$adminEmail}");
println(" Container: {$containerId}");
println();
warn('Per URL pubblici, imposta APP_URL nelle environment del container.');
warn('Dati persistenti: volumi Docker (glastree_storage, glastree_db o mysql_data).');
}
// ══════════════════════════════════════════════════════
// MODE 3: RESTORE DA BACKUP
// ══════════════════════════════════════════════════════
if ($mode === 'Restore da Backup (Apache)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Restore)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('php');
checkCmd('composer');
checkCmd('unzip');
if ($dbType === 'mysql') {
checkCmd('mysql');
}
$backupZip = askRequired('Percorso del file ZIP di backup');
if (!file_exists($backupZip)) {
fail("File non trovato: {$backupZip}");
}
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' RESTORE DA BACKUP', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// Extract
$tmpDir = sys_get_temp_dir() . '/glastree-restore-' . uniqid();
mkdir($tmpDir, 0700, true);
info("Estrazione backup in {$tmpDir}...");
if (!run("unzip -qo {$backupZip} -d {$tmpDir}", null)) {
// pulisci tmp dir prima di fallire
runCapture("rm -rf {$tmpDir}");
fail('Estrazione backup fallita');
}
// Environment
if (!file_exists('.env')) {
copy('.env.example', '.env');
}
// Restore .env from backup preserving DB credentials
if (file_exists("{$tmpDir}/.env")) {
$currentEnv = file_get_contents('.env');
$backupEnv = file_get_contents("{$tmpDir}/.env");
// Save current DB_* values
$currentDb = [];
foreach (['DB_CONNECTION', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD'] as $key) {
if (preg_match("/^{$key}=(.*)$/m", $currentEnv, $m)) {
$currentDb[$key] = $m[1];
}
}
// Use backup .env as base
file_put_contents('.env', $backupEnv);
// Restore current DB credentials
foreach ($currentDb as $key => $val) {
$dotenv = file_get_contents('.env');
$dotenv = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $dotenv);
file_put_contents('.env', $dotenv);
}
ok('.env ripristinato dal backup (credenziali DB preservate)');
}
// Update APP_* config
$dotenv = file_get_contents('.env');
$dotenv = preg_replace('/^APP_NAME=.*/m', 'APP_NAME="' . addslashes($appName) . '"', $dotenv);
$dotenv = preg_replace('/^APP_URL=.*/m', "APP_URL={$appUrl}", $dotenv);
$dotenv = preg_replace('/^APP_ENV=.*/m', 'APP_ENV=production', $dotenv);
$dotenv = preg_replace('/^APP_DEBUG=.*/m', 'APP_DEBUG=false', $dotenv);
file_put_contents('.env', $dotenv);
// Generate APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY rigenerata');
// Import database
if ($dbType === 'mysql') {
$sqlFile = "{$tmpDir}/database.sql";
if (file_exists($sqlFile)) {
info("Importazione database MySQL: {$sqlFile}");
$cmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (run($cmd, null)) {
ok('Database importato');
} else {
warn('Import database fallito — importa manualmente');
}
} else {
warn('database.sql non trovato nel backup');
}
} else {
warn('Restore automatico supportato solo con MySQL. Per SQLite ripristina manualmente i file.');
}
// Restore storage
$storageBackup = "{$tmpDir}/storage";
if (is_dir($storageBackup)) {
info('Ripristino file storage...');
runCapture("cp -r {$storageBackup}/* {$scriptDir}/storage/ 2>/dev/null");
ok('Storage ripristinato');
}
// Composer
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
// Storage link + cache
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
runCapture("{$sudo}php artisan optimize:clear");
// Permissions
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// Create admin if not exists (via PDO)
if ($dbType === 'mysql') {
$exists = false;
try {
$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName}", $dbUser, $dbPass);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
$exists = (int) $stmt->fetchColumn() > 0;
} catch (\PDOException) {
// Ignora — tenteremo la creazione
}
if ($exists) {
ok("Amministratore {$adminEmail} già esistente");
} elseif (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato");
} else {
warn("Creazione admin fallita — crealo manualmente dal pannello admin");
}
} else {
// SQLite fallback: usa tinker
$result = runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$e = \App\Models\User::where('email', " . var_export($adminEmail, true) . ")->exists(); " .
"if (!\$e) { " .
"\$u = new \App\Models\User; " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"} else { echo 'EXISTS'; }"
) . " 2>/dev/null");
if (str_contains($result, 'OK')) {
ok("Amministratore {$adminEmail} creato");
} elseif (str_contains($result, 'EXISTS')) {
ok("Amministratore {$adminEmail} già esistente");
} else {
warn("Creazione admin fallita (output: " . ($result ?: 'vuoto') . ")");
}
}
// Cleanup
runCapture("rm -rf {$tmpDir}");
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' RESTORE COMPLETATO!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Verifica che APP_KEY non abbia invalidato dati criptati (token OAuth, password IMAP).');
warn('Se necessario, riconfigura SMTP e Google Drive dalle Impostazioni.');
}
println();
+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;
@@ -14,9 +14,14 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Attività di Sistema</h3> <h3 class="card-title">Attività di Sistema</h3>
<div class="card-tools">
<button type="button" class="btn btn-danger btn-sm" onclick="deleteLogs()">
<i class="fas fa-trash mr-1"></i> Cancella Log
</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="GET" class="mb-3"> <form method="GET" class="mb-3" id="filter-form">
<div class="row"> <div class="row">
<div class="col-md-2"> <div class="col-md-2">
<select name="user_id" class="form-control"> <select name="user_id" class="form-control">
@@ -95,3 +100,58 @@
</div> </div>
</div> </div>
@endsection @endsection
@section('scripts')
<script>
function deleteLogs() {
var filterForm = document.getElementById('filter-form');
var params = new URLSearchParams(new FormData(filterForm));
var filterDescription = [];
var userFilter = params.get('user_id');
var moduleFilter = params.get('module');
var actionFilter = params.get('action');
var fromFilter = params.get('from');
var toFilter = params.get('to');
if (userFilter) filterDescription.push('utente=' + userFilter);
if (moduleFilter) filterDescription.push('modulo=' + moduleFilter);
if (actionFilter) filterDescription.push('azione=' + actionFilter);
if (fromFilter) filterDescription.push('dal=' + fromFilter);
if (toFilter) filterDescription.push('al=' + toFilter);
var message = filterDescription.length > 0
? 'Eliminare i log filtrati? (' + filterDescription.join(', ') + ')'
: 'Eliminare TUTTI i log?';
if (!confirm(message)) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/activity-logs';
form.style.display = 'none';
var csrf = document.createElement('input');
csrf.name = '_token';
csrf.value = '{{ csrf_token() }}';
form.appendChild(csrf);
var method = document.createElement('input');
method.name = '_method';
method.value = 'DELETE';
form.appendChild(method);
filterForm.querySelectorAll('select, input').forEach(function(el) {
if (el.name && el.value) {
var input = document.createElement('input');
input.name = el.name;
input.value = el.value;
form.appendChild(input);
}
});
document.body.appendChild(form);
form.submit();
}
</script>
@endsection
@@ -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>
@@ -377,7 +379,7 @@
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer d-flex flex-wrap align-items-center gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-save mr-1"></i> Salva Impostazioni <i class="fas fa-save mr-1"></i> Salva Impostazioni
</button> </button>
@@ -392,6 +394,15 @@
</a> </a>
</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
@@ -521,7 +532,7 @@ function editSender(id) {
const sender = senderAccounts.find(s => s.id === id); const sender = senderAccounts.find(s => s.id === id);
if (!sender) return; if (!sender) return;
document.getElementById('senderForm').action = '{{ route('impostazioni.sender.update', '') }}/' + id; document.getElementById('senderForm').action = '{{ route('impostazioni.sender.update', '__ID__') }}'.replace('__ID__', id);
document.getElementById('senderMethod').value = 'PUT'; document.getElementById('senderMethod').value = 'PUT';
document.getElementById('senderModalTitle').textContent = 'Modifica Mittente - ' + sender.email_address; document.getElementById('senderModalTitle').textContent = 'Modifica Mittente - ' + sender.email_address;
document.getElementById('senderId').value = sender.id; document.getElementById('senderId').value = sender.id;
@@ -551,7 +562,7 @@ function testSenderSmtp(id) {
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
$.ajax({ $.ajax({
url: '{{ route('impostazioni.sender.test', '') }}/' + id, url: '{{ route('impostazioni.sender.test', '__ID__') }}'.replace('__ID__', id),
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
data: { email: email }, data: { email: email },
@@ -628,9 +639,11 @@ function testSmtp() {
}); });
} }
var quillEditorInstance = null;
function createEditor() { function createEditor() {
var container = document.querySelector('#signature-editor'); var container = document.querySelector('#signature-editor');
if (!container || container.querySelector('.ql-toolbar')) return; if (!container || quillEditorInstance) return;
var quill = new Quill('#signature-editor', { var quill = new Quill('#signature-editor', {
theme: 'snow', theme: 'snow',
@@ -679,10 +692,12 @@ function createEditor() {
signatureInput.value = quill.root.innerHTML; signatureInput.value = quill.root.innerHTML;
} }
}); });
quillEditorInstance = quill;
} }
function initSignatureEditor() { function initSignatureEditor() {
if (document.querySelector('#signature-editor .ql-toolbar')) return; if (quillEditorInstance) return;
if (typeof Quill === 'undefined') { if (typeof Quill === 'undefined') {
var script = document.createElement('script'); var script = document.createElement('script');
+1 -1
View File
@@ -59,7 +59,7 @@ $appOrgName = AppSetting::getOrgName() ?? '';
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-8">
<div class="icheck-primary"> <div class="icheck-primary">
<input type="checkbox" id="remember"> <input type="checkbox" id="remember" name="remember">
<label for="remember">Ricordami</label> <label for="remember">Ricordami</label>
</div> </div>
</div> </div>
+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
+107 -1
View File
@@ -87,11 +87,18 @@
<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">
<div class="mb-3">
<button type="button" class="btn btn-sm btn-primary" data-toggle="modal" data-target="#documentiModal">
<i class="fas fa-folder-open mr-1"></i> Seleziona da Documenti
</button>
<label class="btn btn-sm btn-success mb-0"> <label class="btn btn-sm btn-success mb-0">
<i class="fas fa-upload mr-1"></i> Carica file <i class="fas fa-upload mr-1"></i> Carica file
<input type="file" name="allegati[]" multiple class="d-none"> <input type="file" name="allegati[]" id="allegati" multiple class="d-none" onchange="handleFileUpload(this)">
</label> </label>
</div> </div>
<div id="allegati-list"></div>
<input type="hidden" name="documenti_selezionati" id="documenti_selezionati" value="">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -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);

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