From d82b123bfd4e1212ed99f4cc7a5eaf1ce761963d Mon Sep 17 00:00:00 2001 From: benzimariano Date: Tue, 9 Jun 2026 11:02:01 +0200 Subject: [PATCH] versione 2.0.0 --- MEMORY.md | 130 +++++++++++++- app/Helpers/ReportColumnRegistry.php | 4 + app/Http/Controllers/DocumentoController.php | 60 ++++++- app/Http/Controllers/EmailController.php | 2 +- app/Http/Controllers/EventoController.php | 31 +++- app/Http/Controllers/GruppoController.php | 101 +++++++++-- .../Controllers/ImpostazioniController.php | 68 +++++++- app/Http/Controllers/IndividuoController.php | 113 +++++++++++- app/Http/Controllers/MailingController.php | 18 +- .../Controllers/MailingListController.php | 46 +++-- app/Http/Controllers/ReportController.php | 160 ++++++++++++++++- app/Models/Documento.php | 2 + app/Models/Evento.php | 2 + app/Models/Gruppo.php | 2 + app/Models/Individuo.php | 2 + app/Models/MailingList.php | 3 + resources/views/documenti/edit.blade.php | 36 ++++ resources/views/documenti/index.blade.php | 67 ++++++++ resources/views/eventi/create.blade.php | 9 + resources/views/eventi/edit.blade.php | 9 + resources/views/eventi/index.blade.php | 10 +- resources/views/eventi/show.blade.php | 11 ++ resources/views/gruppi/create.blade.php | 9 + resources/views/gruppi/edit.blade.php | 9 + resources/views/gruppi/index.blade.php | 147 ++++++++++++++++ resources/views/gruppi/show.blade.php | 11 ++ resources/views/impostazioni/index.blade.php | 162 ++++++++++++++++++ resources/views/individui/create.blade.php | 9 + resources/views/individui/edit.blade.php | 10 ++ resources/views/individui/index.blade.php | 129 +++++++++++++- resources/views/individui/show.blade.php | 15 ++ resources/views/layouts/adminlte.blade.php | 9 + .../views/mailing-liste/create.blade.php | 3 + resources/views/mailing-liste/edit.blade.php | 143 ++++++++++++---- resources/views/mailing-liste/index.blade.php | 12 ++ resources/views/mailing-liste/show.blade.php | 17 ++ resources/views/report/index.blade.php | 50 ++++++ routes/web.php | 14 ++ 38 files changed, 1552 insertions(+), 83 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index c01b7c45..fb680df9 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -28,6 +28,25 @@ App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi. - Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker - **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear +## Tag System + +### Implementazione Completa (Opzione B) +- **Migration**: `2026_06_08_194155_create_tags_tables.php` — tabelle `tags` + `taggables` +- **Model `Tag.php`**: morphToMany a 5 entity types, auto-slug su creating, color badge +- **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` + ## Bug Fix Recenti ### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo @@ -184,5 +203,114 @@ php artisan config:clear 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 -- *(nessuno — in attesa di nuove richieste)* +- [DONE] Aggiungere filtro Tag nella vista Documenti — già presente (tag filter bar + colonna Tag in lista/griglia) +- [DONE] Gestire il caso `tag[]` vuoto — già gestito da `$request->filled('tag')` in tutti i 4 controller index +- [DONE] MailingList — tag support completato (controller, index/create/edit/show views, filter bar) +- [DONE] Report — tag columns in ReportColumnRegistry + tag filter in custom reports + UI multi-select +- [DONE] Ricerca per Tag — MailingList integrata nei risultati +- [DONE] `storeCustom()` ora salva correttamente `tag_filter` e `tag_filter_mode` +- [DONE] `MailingListController@store/update` — auto-fetch email da Individuo quando non fornita +- [DONE] Verificata presenza `@stack('scripts')` nel layout — presente a riga 305 (prima di `@yield('scripts')`) +- [DONE] Verificata mass tag action Documenti (`DocumentoController@massTag`) — funzionante +- [DONE] Verificato: tutti i 5 entity controllers passano `$allTags` alle viste index +- [DONE] La logica `$toRemove = array_diff($existingIds, $newIds)` in `update()` è corretta — rimuove solo contatti che l'utente ha esplicitamente deselezionato o rimosso dalla tabella. Non necessita modifiche. +- [DONE] SweetAlert2 conferma su salvataggio mailing list (edit.blade.php) quando ci sono contatti senza email +- [DONE] Normalizzata `invia()` da `flatMap`+`pluck` a `map`+`first()` (1 email per individuo, come `invioElabora()`) +- [DONE] Aggiunto logging contatti saltati in `invia()` e `invioElabora()` (quando senza email) +- [DONE] Fix: contattiCaricati initialization ora include codice_id/cognome/nome da righe DOM — contatti esistenti non più blank dopo Carica contatti +- [DONE] Fix: EmailController@destroy ora redirect a inbox invece che back() (non rimane su email cancellata) +- [DONE] Mass action Individui: Assegna Gruppo (route + controller + modal con select gruppi + Assign/Remove radio) +- [DONE] Mass action Individui: Assegna Tag (route + controller + modal con _tag-selector) +- [DONE] Mass action Gruppi: Assegna Tag (route + controller + modal con _tag-selector) +- [DONE] Mass action Gruppi: Elimina (route + controller + modal con conferma, detach membri + delete) +- [DONE] Fix: IndividuoController@massGruppo e @massTag ora convertono stringa ids in array (non funzionavano perché hidden input invia stringa, non array) +- [DONE] Ricerca per Tag multipli: controller accetta `?tag[]=slug1&tag[]=slug2`, usa `withAllTags` (AND logic) per 2+ tag. Vista aggiornata: checkbox cliccabili per multi-selezione, info boxes unificate, "Vedi tutti" passa tutti i tag +- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag diff --git a/app/Helpers/ReportColumnRegistry.php b/app/Helpers/ReportColumnRegistry.php index 744be113..222a6a2d 100644 --- a/app/Helpers/ReportColumnRegistry.php +++ b/app/Helpers/ReportColumnRegistry.php @@ -132,6 +132,7 @@ class ReportColumnRegistry 'contatti.telefono' => ['label' => 'Telefono (da contatti)', 'type' => 'relation'], 'contatti.cellulare' => ['label' => 'Cellulare (da contatti)', 'type' => 'relation'], 'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'], + 'tags.nome' => ['label' => 'Tag', 'type' => 'relation'], ], 'gruppi' => [ @@ -151,6 +152,7 @@ class ReportColumnRegistry 'parent.nome' => ['label' => 'Gruppo Padre', 'type' => 'relation'], 'diocesi.nome' => ['label' => 'Diocesi', 'type' => 'relation'], 'individui_count' => ['label' => 'Numero Membri', 'type' => 'relation'], + 'tags.nome' => ['label' => 'Tag', 'type' => 'relation'], ], 'eventi' => [ @@ -171,6 +173,7 @@ class ReportColumnRegistry 'info_ricorrenza' => ['label' => 'Info Ricorrenza', 'type' => 'accessor'], 'gruppi.nome' => ['label' => 'Gruppi', 'type' => 'relation'], 'responsabili.nome_completo' => ['label' => 'Responsabili', 'type' => 'relation'], + 'tags.nome' => ['label' => 'Tag', 'type' => 'relation'], ], 'documenti' => [ @@ -183,6 +186,7 @@ class ReportColumnRegistry 'note' => ['label' => 'Note', 'type' => 'direct'], 'created_at' => ['label' => 'Data Caricamento', 'type' => 'direct'], 'user.name' => ['label' => 'Caricato Da', 'type' => 'relation'], + 'tags.nome' => ['label' => 'Tag', 'type' => 'relation'], ], 'contatti' => [ diff --git a/app/Http/Controllers/DocumentoController.php b/app/Http/Controllers/DocumentoController.php index 9fa9f437..4fa7146b 100644 --- a/app/Http/Controllers/DocumentoController.php +++ b/app/Http/Controllers/DocumentoController.php @@ -11,6 +11,7 @@ use App\Models\Individuo; use App\Models\Gruppo; use App\Models\Evento; use App\Models\StorageRepository; +use App\Models\Tag; use App\Models\TipologiaDocumento; use App\Services\StorageRepositoryService; use Illuminate\Http\JsonResponse; @@ -42,9 +43,14 @@ class DocumentoController extends Controller $folderId = $request->get('folder_id'); $repoId = $request->get('repository_id'); - $query = Documento::with(['user', 'target', 'cartella', 'repository']) + $query = Documento::with(['user', 'target', 'cartella', 'repository', 'tags']) ->orderByDesc('created_at'); + if ($request->filled('tag')) { + $tagSlugs = (array) $request->input('tag'); + $query->withAnyTags($tagSlugs); + } + if ($repoId) { $query->where('repository_id', $repoId); } elseif ($folderId) { @@ -54,6 +60,7 @@ class DocumentoController extends Controller } $documenti = $query->paginate(20); + $allTags = Tag::orderBy('name')->get(); $cartelle = DocumentoCartella::with('children') ->whereNull('parent_id') ->orderBy('nome') @@ -89,7 +96,7 @@ class DocumentoController extends Controller 'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle', 'currentRepo', 'repositories', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie', - 'cartelleMoveOptions' + 'cartelleMoveOptions', 'allTags' )); } @@ -106,13 +113,15 @@ class DocumentoController extends Controller public function edit($documento) { $this->authorizeWrite('documenti'); - $documento = Documento::with('target')->findOrFail($documento); + $documento = Documento::with(['target', 'tags'])->findOrFail($documento); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get(); $eventi = Evento::orderBy('nome_evento')->get(); $mailingLists = \App\Models\MailingList::orderBy('nome')->get(); $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) @@ -165,6 +174,8 @@ class DocumentoController extends Controller 'visibilita_target_id' => 'nullable|integer', 'visibilita_target_type' => 'nullable|string', 'note' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $data['visibilita'] = $request->contesto_tipo ?: 'pubblico'; @@ -185,6 +196,12 @@ class DocumentoController extends Controller $documento->update($data); + if ($request->has('tags')) { + $documento->tags()->sync($request->tags); + } else { + $documento->tags()->detach(); + } + return redirect('/documenti')->with('success', 'Documento aggiornato.'); } @@ -706,6 +723,41 @@ class DocumentoController extends Controller 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 { $this->authorizeWrite('documenti'); diff --git a/app/Http/Controllers/EmailController.php b/app/Http/Controllers/EmailController.php index 5624ff22..59ae9601 100644 --- a/app/Http/Controllers/EmailController.php +++ b/app/Http/Controllers/EmailController.php @@ -341,7 +341,7 @@ class EmailController extends Controller $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) diff --git a/app/Http/Controllers/EventoController.php b/app/Http/Controllers/EventoController.php index e70f4adb..fcf82d21 100644 --- a/app/Http/Controllers/EventoController.php +++ b/app/Http/Controllers/EventoController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Evento; use App\Models\Gruppo; use App\Models\Individuo; +use App\Models\Tag; use App\Models\TipologiaEvento; use App\Services\IcsExportService; use Illuminate\Http\Request; @@ -16,7 +17,12 @@ class EventoController extends Controller public function index(Request $request) { $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')) { $query->where('nome_evento', 'like', '%' . $request->search . '%'); @@ -47,8 +53,9 @@ class EventoController extends Controller $perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20; $eventi = $query->paginate($perPage); $gruppi = Gruppo::orderBy('nome')->get(); + $allTags = Tag::orderBy('name')->get(); - return view('eventi.index', compact('eventi', 'gruppi')); + return view('eventi.index', compact('eventi', 'gruppi', 'allTags')); } public function create(Request $request) @@ -58,8 +65,9 @@ class EventoController extends Controller $individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null; $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) @@ -88,6 +96,8 @@ class EventoController extends Controller 'responsabili.*' => 'exists:individui,id', 'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_url_maps' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $evento = Evento::create([ @@ -110,6 +120,10 @@ class EventoController extends Controller 'luogo_url_maps' => $data['luogo_url_maps'] ?? null, ]); + if (!empty($data['tags'])) { + $evento->tags()->attach($data['tags']); + } + if (!empty($data['gruppi'])) { $evento->gruppi()->attach($data['gruppi']); } @@ -124,19 +138,21 @@ class EventoController extends Controller public function show($evento) { $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')); } public function edit($evento) { $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(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $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) @@ -167,6 +183,8 @@ class EventoController extends Controller 'responsabili.*' => 'exists:individui,id', 'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_url_maps' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $evento->update([ @@ -189,6 +207,7 @@ class EventoController extends Controller 'luogo_url_maps' => $data['luogo_url_maps'] ?? null, ]); + $evento->tags()->sync($data['tags'] ?? []); $evento->gruppi()->sync($data['gruppi'] ?? []); $evento->responsabili()->sync($data['responsabili'] ?? []); diff --git a/app/Http/Controllers/GruppoController.php b/app/Http/Controllers/GruppoController.php index c6af63ec..4a9ffcbd 100644 --- a/app/Http/Controllers/GruppoController.php +++ b/app/Http/Controllers/GruppoController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers; use App\Models\Gruppo; use App\Models\Individuo; use App\Models\Diocesi; +use App\Models\Tag; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; @@ -27,8 +28,8 @@ class GruppoController extends Controller ->where('is_default', true) ->first(); - $allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email']; - $defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili']; + $allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email', 'tag']; + $defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili', 'tag']; if ($vista && !empty($vista->colonne_visibili)) { $visibleColumns = $vista->colonne_visibili; @@ -36,9 +37,14 @@ class GruppoController extends Controller $visibleColumns = $defaultVisible; } - $allGruppi = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar']) - ->orderBy('nome') - ->get() + $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) { $depth = 0; $parent = $gruppo->parent; @@ -65,12 +71,15 @@ class GruppoController extends Controller $gruppi = $allGruppi; } + $allTags = Tag::orderBy('name')->get(); + return view('gruppi.index', compact( 'gruppi', 'allColumns', 'visibleColumns', 'viewMode', - 'vista' + 'vista', + 'allTags' )); } @@ -104,7 +113,8 @@ class GruppoController extends Controller $allGruppi = Gruppo::orderBy('nome')->get(); $selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null; $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) @@ -122,6 +132,8 @@ class GruppoController extends Controller 'città_incontro' => 'nullable|string|max:255', 'sigla_provincia_incontro' => 'nullable|string|max:2', 'mappa_posizione' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); if (!empty($data['responsabile_ids'])) { @@ -130,6 +142,10 @@ class GruppoController extends Controller $gruppo = Gruppo::create($data); + if ($request->has('tags')) { + $gruppo->tags()->sync($request->tags); + } + if ($request->has('individui')) { foreach ($request->individui as $individuoData) { if (!empty($individuoData['individuo_id'])) { @@ -149,7 +165,7 @@ class GruppoController extends Controller $this->authorizeRead('gruppi'); $gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) { $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); - }, 'avatar', 'eventi' => function ($q) { + }, 'avatar', 'tags', 'eventi' => function ($q) { $q->where('is_incontro_gruppo', true); }, 'eventi.responsabili.contatti'])->findOrFail($id); return view('gruppi.show', compact('gruppo')); @@ -158,11 +174,13 @@ class GruppoController extends Controller public function edit($id) { $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(); $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(); - 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) @@ -181,6 +199,8 @@ class GruppoController extends Controller 'città_incontro' => 'nullable|string|max:255', 'sigla_provincia_incontro' => 'nullable|string|max:2', 'mappa_posizione' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); if (!empty($data['responsabile_ids'])) { @@ -191,6 +211,12 @@ class GruppoController extends Controller $gruppo->update($data); + if ($request->has('tags')) { + $gruppo->tags()->sync($request->tags); + } else { + $gruppo->tags()->detach(); + } + if ($request->has('individui')) { $gruppo->individui()->detach(); foreach ($request->individui as $individuoData) { @@ -410,4 +436,59 @@ class GruppoController extends Controller '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."); + } } diff --git a/app/Http/Controllers/ImpostazioniController.php b/app/Http/Controllers/ImpostazioniController.php index 02f6f01c..3be5bf28 100644 --- a/app/Http/Controllers/ImpostazioniController.php +++ b/app/Http/Controllers/ImpostazioniController.php @@ -9,6 +9,7 @@ use App\Models\EmailSetting; use App\Models\Ruolo; use App\Models\SenderAccount; use App\Models\StorageRepository; +use App\Models\Tag; use App\Models\TipologiaDocumento; use App\Models\TipologiaEvento; use Illuminate\Http\RedirectResponse; @@ -26,6 +27,7 @@ class ImpostazioniController extends Controller $tipologie = TipologiaDocumento::orderBy('ordine')->get(); $tipologieEventi = TipologiaEvento::orderBy('ordine')->get(); $ruoli = Ruolo::orderBy('ordine')->get(); + $tags = Tag::orderBy('order_column')->get(); $appSettings = AppSetting::first(); $emailSettings = EmailSetting::first() ?? new EmailSetting(); $senderAccounts = SenderAccount::orderBy('email_address')->get(); @@ -33,7 +35,7 @@ class ImpostazioniController extends Controller $googleDriveNewToken = session('google_drive_new_token'); $calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get(); - return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni')); + return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni')); } public function saveAppSettings(Request $request) @@ -371,6 +373,70 @@ class ImpostazioniController extends Controller 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) { $this->authorizeWrite('settings'); diff --git a/app/Http/Controllers/IndividuoController.php b/app/Http/Controllers/IndividuoController.php index b0021589..98f40f30 100644 --- a/app/Http/Controllers/IndividuoController.php +++ b/app/Http/Controllers/IndividuoController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers; use App\Models\Individuo; use App\Models\Contatto; use App\Models\Documento; +use App\Models\Gruppo; use App\Models\Comune; use App\Models\Diocesi; +use App\Models\Tag; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; @@ -18,7 +20,16 @@ class IndividuoController extends Controller { $this->authorizeRead('individui'); $perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20; - $individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate($perPage); + + $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; @@ -41,14 +52,16 @@ class IndividuoController extends Controller ['key' => 'nome', 'label' => 'Nome'], ['key' => 'email', 'label' => 'Email'], ['key' => 'telefono', 'label' => 'Telefono'], + ['key' => 'tag', 'label' => 'Tag'], ]; - $defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono']; + $defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono', 'tag']; $visibleColumns = $vista && !empty($vista->colonne_visibili) ? $vista->colonne_visibili : $defaultVisible; - return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns')); + $gruppi = Gruppo::orderBy('nome')->get(['id', 'nome']); + return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns', 'allTags', 'gruppi')); } public function exportCSV(Request $request) @@ -110,7 +123,8 @@ public function create() $this->authorizeWrite('individui'); $comuni = Comune::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) @@ -129,10 +143,16 @@ public function create() 'numero_documento' => 'nullable|string|max:50', 'scadenza_documento' => 'nullable|date', 'note' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $individuo = Individuo::create($data); + if ($request->has('tags')) { + $individuo->tags()->sync($request->tags); + } + if ($request->has('contatti')) { foreach ($request->contatti as $contatto) { if (!empty($contatto['tipo']) && !empty($contatto['valore'])) { @@ -154,7 +174,7 @@ public function create() $this->authorizeRead('individui'); $individuo = Individuo::with(['contatti', 'gruppi' => function ($q) { $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); - }, 'avatar'])->findOrFail($individuo); + }, 'avatar', 'tags'])->findOrFail($individuo); return view('individui.show', compact('individuo')); } @@ -180,8 +200,10 @@ public function create() public function edit($individuo) { $this->authorizeWrite('individui'); - $individuo = Individuo::with(['contatti', 'gruppi', 'avatar'])->findOrFail($individuo); - return view('individui.edit', compact('individuo')); + $individuo = Individuo::with(['contatti', 'gruppi', 'avatar', 'tags'])->findOrFail($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) @@ -205,9 +227,17 @@ public function create() 'numero_documento' => 'nullable|string|max:50', 'scadenza_documento' => 'nullable|date', 'note' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $individuo->update($data); + + if ($request->has('tags')) { + $individuo->tags()->sync($request->tags); + } else { + $individuo->tags()->detach(); + } \Log::info('Individuo dati salvati', ['id' => $individuo->id]); @@ -308,6 +338,75 @@ public function create() 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) { $individuo = Individuo::findOrFail($individuo); diff --git a/app/Http/Controllers/MailingController.php b/app/Http/Controllers/MailingController.php index 4773e812..d9951aba 100644 --- a/app/Http/Controllers/MailingController.php +++ b/app/Http/Controllers/MailingController.php @@ -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(); + $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()) { return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput(); } @@ -229,9 +237,17 @@ class MailingController extends Controller ->with('individuo.contatti') ->get(); + $totaleContatti = $contatti->count(); $emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore) ->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()) { return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.'); } diff --git a/app/Http/Controllers/MailingListController.php b/app/Http/Controllers/MailingListController.php index 78ef469e..4cd77458 100644 --- a/app/Http/Controllers/MailingListController.php +++ b/app/Http/Controllers/MailingListController.php @@ -3,23 +3,31 @@ namespace App\Http\Controllers; use App\Models\MailingList; +use App\Models\Tag; use Illuminate\Http\Request; class MailingListController extends Controller { - public function index() + public function index(Request $request) { $this->authorizeRead('mailing'); - $mailingLists = MailingList::with(['user', 'contatti']) - ->orderBy('nome') - ->get(); - return view('mailing-liste.index', compact('mailingLists')); + $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(); + return view('mailing-liste.index', compact('mailingLists', 'allTags')); } public function create() { $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) @@ -30,6 +38,8 @@ class MailingListController extends Controller 'descrizione' => 'nullable|string', 'attiva' => 'boolean', 'contatti_json' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $data['user_id'] = auth()->id(); @@ -42,11 +52,15 @@ class MailingListController extends Controller 'user_id' => $data['user_id'], ]); + if ($request->has('tags')) { + $lista->tags()->sync($request->tags); + } + if (!empty($data['contatti_json'])) { $contatti = json_decode($data['contatti_json'], true); if (is_array($contatti)) { foreach ($contatti as $contatto) { - if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) { + if (!empty($contatto['individuo_id'])) { $lista->contatti()->create([ 'individuo_id' => $contatto['individuo_id'], 'opt_in' => true, @@ -63,15 +77,17 @@ class MailingListController extends Controller public function show($mailing_liste) { $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')); } public function edit($mailingList) { $this->authorizeWrite('mailing'); - $mailingList = MailingList::with('contatti.individuo.contatti')->findOrFail($mailingList); - return view('mailing-liste.edit', compact('mailingList')); + $mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($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) @@ -84,6 +100,8 @@ class MailingListController extends Controller 'descrizione' => 'nullable|string', 'attiva' => 'boolean', 'contatti_json' => 'nullable|string', + 'tags' => 'nullable|array', + 'tags.*' => 'exists:tags,id', ]); $data['attiva'] = $data['attiva'] ?? false; @@ -94,6 +112,12 @@ class MailingListController extends Controller 'attiva' => $data['attiva'], ]); + if ($request->has('tags')) { + $mailingList->tags()->sync($request->tags); + } else { + $mailingList->tags()->detach(); + } + if (!empty($data['contatti_json'])) { $newContatti = json_decode($data['contatti_json'], true); if (is_array($newContatti)) { @@ -107,7 +131,7 @@ class MailingListController extends Controller $toAdd = array_diff($newIds, $existingIds); foreach ($newContatti as $contatto) { - if (in_array($contatto['individuo_id'], $toAdd) && !empty($contatto['email'])) { + if (in_array($contatto['individuo_id'], $toAdd)) { $mailingList->contatti()->create([ 'individuo_id' => $contatto['individuo_id'], 'opt_in' => true, diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index 09a382ff..d163d39d 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -11,6 +11,7 @@ use App\Models\Evento; use App\Models\Documento; use App\Models\MailingList; use App\Models\Contatto; +use App\Models\Tag; use App\Models\TipologiaDocumento; use App\Models\ReportCustom; use Illuminate\Http\Request; @@ -47,12 +48,15 @@ class ReportController extends Controller $columnOptions[$type] = ReportColumnRegistry::getColumns($type); } + $allTags = Tag::orderBy('name')->get(); + return view('report.index', compact( 'stats', 'prebuiltReports', 'customReports', 'tipologieDocumento', - 'columnOptions' + 'columnOptions', + 'allTags' )); } @@ -136,6 +140,14 @@ class ReportController extends Controller $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([ 'user_id' => auth()->id(), 'nome' => $data['nome'], @@ -252,6 +264,9 @@ class ReportController extends Controller 'documenti_completo' => $this->reportDocumentiCompleto($tipologiaFilter), 'contatti_completo' => $this->reportContattiCompleto(), 'scadenze_documenti' => $this->reportScadenzeDocumenti($tipologiaFilter), + 'tag_statistiche' => $this->reportTagStatistiche(), + 'individui_per_tag' => $this->reportIndividuiPerTag(), + 'documenti_per_tag' => $this->reportDocumentiPerTag(), default => null, }; } @@ -403,6 +418,30 @@ class ReportController extends Controller 'colore' => 'teal', 'has_tipologia_filter' => false, ], + [ + 'id' => 'tag_statistiche', + 'nome' => 'Statistiche Tag', + 'descrizione' => 'Distribuzione degli elementi per tag (individui, gruppi, eventi, documenti, mailing list)', + 'icona' => 'fa-tags', + 'colore' => 'indigo', + 'has_tipologia_filter' => false, + ], + [ + 'id' => 'individui_per_tag', + 'nome' => 'Individui per Tag', + 'descrizione' => 'Elenco completo degli individui raggruppati per tag assegnato', + 'icona' => 'fa-user-tag', + 'colore' => 'info', + 'has_tipologia_filter' => false, + ], + [ + 'id' => 'documenti_per_tag', + 'nome' => 'Documenti per Tag', + 'descrizione' => 'Elenco completo dei documenti raggruppati per tag assegnato', + 'icona' => 'fa-tags', + 'colore' => 'secondary', + 'has_tipologia_filter' => false, + ], ]; } @@ -927,6 +966,102 @@ class ReportController extends Controller ]; } + protected function reportTagStatistiche(): array + { + $tags = Tag::orderBy('name')->get(); + + $rows = $tags->map(function ($tag) { + return [ + 'name' => $tag->name, + 'color' => $tag->color ?? '#6c757d', + 'individui' => $tag->individui()->count(), + 'gruppi' => $tag->gruppi()->count(), + 'eventi' => $tag->eventi()->count(), + 'documenti' => $tag->documenti()->count(), + 'mailing' => $tag->mailingLists()->count(), + 'totale' => $tag->count, + ]; + })->toArray(); + + $totalIndividui = array_sum(array_column($rows, 'individui')); + $totalGruppi = array_sum(array_column($rows, 'gruppi')); + $totalEventi = array_sum(array_column($rows, 'eventi')); + $totalDocumenti = array_sum(array_column($rows, 'documenti')); + $totalMailing = array_sum(array_column($rows, 'mailing')); + + return [ + 'title' => 'Statistiche Tag', + 'headers' => ['Tag', 'Individui', 'Gruppi', 'Eventi', 'Documenti', 'Mailing List', 'Totale Elementi'], + 'rows' => array_map(fn($r) => [$r['name'], $r['individui'], $r['gruppi'], $r['eventi'], $r['documenti'], $r['mailing'], $r['totale']], $rows), + 'summary' => 'Tag: ' . count($tags) . ' | Individui: ' . $totalIndividui . ' | Gruppi: ' . $totalGruppi . ' | Eventi: ' . $totalEventi . ' | Documenti: ' . $totalDocumenti . ' | Mailing List: ' . $totalMailing, + 'detail_rows' => $rows, + ]; + } + + protected function reportIndividuiPerTag(): array + { + $individui = Individuo::with('tags') + ->whereHas('tags') + ->orderBy('cognome') + ->orderBy('nome') + ->get(); + + $rows = $individui->flatMap(function ($ind) { + if ($ind->tags->isEmpty()) { + return collect(); + } + return $ind->tags->map(function ($tag) use ($ind) { + return [ + 'tag' => $tag->name, + 'tag_color' => $tag->color ?? '#6c757d', + 'codice' => $ind->codice_id, + 'cognome' => $ind->cognome, + 'nome' => $ind->nome, + 'email' => $ind->email_primaria ?? '-', + ]; + }); + })->toArray(); + + return [ + 'title' => 'Individui per Tag', + 'headers' => ['Tag', 'Codice', 'Cognome', 'Nome', 'Email'], + 'rows' => array_map(fn($r) => [$r['tag'], $r['codice'], $r['cognome'], $r['nome'], $r['email']], $rows), + 'summary' => 'Totale righe (individuo × tag): ' . count($rows), + 'detail_rows' => $rows, + ]; + } + + protected function reportDocumentiPerTag(): array + { + $documenti = Documento::with('tags') + ->whereHas('tags') + ->orderBy('nome_file') + ->get(); + + $rows = $documenti->flatMap(function ($doc) { + if ($doc->tags->isEmpty()) { + return collect(); + } + return $doc->tags->map(function ($tag) use ($doc) { + return [ + 'tag' => $tag->name, + 'tag_color' => $tag->color ?? '#6c757d', + 'nome_file' => $doc->nome_file, + 'tipologia' => ucfirst((string) $doc->tipologia), + 'data' => $doc->created_at->format('d/m/Y'), + ]; + }); + })->toArray(); + + return [ + 'title' => 'Documenti per Tag', + 'headers' => ['Tag', 'Nome File', 'Tipologia', 'Data Caricamento'], + 'rows' => array_map(fn($r) => [$r['tag'], $r['nome_file'], $r['tipologia'], $r['data']], $rows), + 'summary' => 'Totale righe (documento × tag): ' . count($rows), + 'detail_rows' => $rows, + ]; + } + protected function runCustomReport(ReportCustom $report): array { $tipo = $report->tipo_report; @@ -954,11 +1089,32 @@ class ReportController extends Controller $columns = $config['columns'] ?? []; $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) { if (str_contains($col, '.')) { $hasRelations = true; $relation = explode('.', $col)[0]; - $query->with($relation); + if ($relation !== 'tags') { + $query->with($relation); + } + } + } + + // Apply tag filter + if (!empty($config['tag_filter'])) { + $tagSlugs = Tag::whereIn('id', (array) $config['tag_filter'])->pluck('slug')->toArray(); + if (!empty($tagSlugs)) { + $mode = $config['tag_filter_mode'] ?? 'any'; + if ($mode === 'all') { + $query->withAllTags($tagSlugs); + } else { + $query->withAnyTags($tagSlugs); + } } } diff --git a/app/Models/Documento.php b/app/Models/Documento.php index 0c53d0d4..0863c820 100644 --- a/app/Models/Documento.php +++ b/app/Models/Documento.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasTagsLight; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Documento extends Model { + use HasTagsLight; protected $table = 'documenti'; protected $fillable = [ 'tenant_id', 'user_id', 'cartella_id', 'repository_id', 'storage_disk', diff --git a/app/Models/Evento.php b/app/Models/Evento.php index 21e64340..24e00cb6 100644 --- a/app/Models/Evento.php +++ b/app/Models/Evento.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasTagsLight; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Evento extends Model { + use HasTagsLight; protected $table = 'eventi'; protected $fillable = [ diff --git a/app/Models/Gruppo.php b/app/Models/Gruppo.php index 2470270d..8506c7a2 100644 --- a/app/Models/Gruppo.php +++ b/app/Models/Gruppo.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasTagsLight; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Gruppo extends Model { + use HasTagsLight; protected $table = 'gruppi'; protected $fillable = [ 'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids', diff --git a/app/Models/Individuo.php b/app/Models/Individuo.php index ace911a9..f83921aa 100644 --- a/app/Models/Individuo.php +++ b/app/Models/Individuo.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasTagsLight; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; class Individuo extends Model { + use HasTagsLight; protected $table = 'individui'; protected $fillable = [ 'tenant_id', 'codice_id', 'cognome', 'nome', 'data_nascita', diff --git a/app/Models/MailingList.php b/app/Models/MailingList.php index fc8ed0d9..b3fc6456 100644 --- a/app/Models/MailingList.php +++ b/app/Models/MailingList.php @@ -5,9 +5,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use App\Traits\HasTagsLight; class MailingList extends Model { + use HasTagsLight; + protected $table = 'mailing_lists'; protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva']; diff --git a/resources/views/documenti/edit.blade.php b/resources/views/documenti/edit.blade.php index 31d1da9c..5ee64d5d 100644 --- a/resources/views/documenti/edit.blade.php +++ b/resources/views/documenti/edit.blade.php @@ -103,6 +103,42 @@ @endif + + @include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette']) + +
+ + + + + + + + + + + + + + + + + + @if($documento->user) + + + + + @endif +
File:{{ $documento->file_path }}
Tipo MIME:{{ $documento->mime_type ?? '-' }}
Dimensione:{{ $documento->dimensione ? number_format($documento->dimensione / 1024, 1) . ' KB' : '-' }}
Creato:{{ $documento->created_at->format('d/m/Y H:i') }}
Utente:{{ $documento->user->name }}
+ @if($documento->file_path) +
+ + Scarica + + @endif +
+ diff --git a/resources/views/documenti/index.blade.php b/resources/views/documenti/index.blade.php index 935399cf..83268fce 100644 --- a/resources/views/documenti/index.blade.php +++ b/resources/views/documenti/index.blade.php @@ -123,6 +123,8 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); + @include('partials._tag-filter-bar', ['filterTags' => $allTags]) +
{{-- Toolbar azioni massive --}}
@@ -144,6 +146,9 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); + @@ -226,6 +231,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); @endif
+
+ @foreach($documento->tags as $tag) + {{ $tag->name }} + @endforeach +
@if($documento->user) {{ $documento->user->name }} @@ -295,6 +305,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); Tipologia Dimensione Contesto + Tag Data Azioni @@ -314,6 +325,7 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); - - - + - @@ -423,6 +435,11 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti'); - @endswitch + + @foreach($documento->tags as $tag) + {{ $tag->name }} + @endforeach + {{ $documento->created_at->format('d/m/Y') }} @if($documento->file_path) @@ -834,6 +851,50 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
+{{-- MODAL: Gestione Tag massiva --}} + + {{-- MODAL: Sposta documento --}} +
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette']) +
+
+
+
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette']) +
+
+
+ @include('partials._tag-filter-bar', ['filterTags' => $allTags]) +
+
+

Tag

+
+ @forelse($evento->tags as $tag) + {{ $tag->name }} + @empty +

Nessun tag assegnato.

+ @endforelse +
+
+ @if($evento->note)

Note

diff --git a/resources/views/gruppi/create.blade.php b/resources/views/gruppi/create.blade.php index 03fbead9..118908a7 100644 --- a/resources/views/gruppi/create.blade.php +++ b/resources/views/gruppi/create.blade.php @@ -117,6 +117,15 @@
+
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette']) +
+
+
+
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette']) +
+
+
+
+ + +
@@ -65,11 +82,19 @@ $visibleColumnsJson = json_encode($visibleColumns); + @include('partials._tag-filter-bar', ['filterTags' => $allTags]) + @if($viewMode === 'table')
+ @if(in_array('nome', $visibleColumns)) @endif @@ -103,12 +128,21 @@ $visibleColumnsJson = json_encode($visibleColumns); @if(in_array('email', $visibleColumns)) @endif + @if(in_array('tag', $visibleColumns)) + + @endif @foreach($gruppi as $gruppo) + @if(in_array('nome', $visibleColumns)) @endif + @if(in_array('tag', $visibleColumns)) + + @endif
+
+ + +
+
Nome EmailTagAzioni
+
+ + +
+
@@ -154,6 +188,13 @@ $visibleColumnsJson = json_encode($visibleColumns); @if(in_array('email', $visibleColumns)) - + @foreach($gruppo->tags as $tag) + {{ $tag->name }} + @endforeach + @@ -309,6 +350,68 @@ $visibleColumnsJson = json_encode($visibleColumns); + +{{-- MODAL: Gestione Tag massiva --}} + + +{{-- MODAL: Conferma Eliminazione massiva --}} + + @endsection @section('scripts') @@ -317,6 +420,50 @@ function switchView(mode) { window.location.href = '?view=' + mode; } +function toggleSelectAll(source) { + document.querySelectorAll('.row-checkbox').forEach(function(cb) { + cb.checked = source.checked; + }); +} + +function getSelectedIds() { + return Array.from(document.querySelectorAll('.row-checkbox:checked')).map(cb => cb.value); +} + +function showMassTagModal() { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Seleziona almeno un gruppo'); + return; + } + document.getElementById('massTagIds').value = ids.join(','); + document.getElementById('massTagCount').textContent = ids.length; + $('#massTagModal').modal('show'); +} + +function deleteSelected() { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Seleziona almeno un gruppo'); + return; + } + document.getElementById('delete-count').textContent = ids.length; + document.getElementById('confirm-delete-btn').onclick = function() { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/gruppi/mass-elimina'; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '{{ csrf_token() }}'; + let html = ''; + ids.forEach(function(id) { + html += ''; + }); + form.innerHTML = html; + document.body.appendChild(form); + form.submit(); + }; + $('#deleteModal').modal('show'); +} + function toggleItem(id) { const el = document.getElementById('group-' + id); const icon = document.getElementById('icon-' + id); diff --git a/resources/views/gruppi/show.blade.php b/resources/views/gruppi/show.blade.php index 339bde61..99221c8a 100644 --- a/resources/views/gruppi/show.blade.php +++ b/resources/views/gruppi/show.blade.php @@ -247,6 +247,17 @@ $canDeleteGruppi = Auth::user()->canDelete('gruppi'); @endif +
+

Tag

+
+ @forelse($gruppo->tags as $tag) + {{ $tag->name }} + @empty +

Nessun tag assegnato.

+ @endforelse +
+
+
@@ -1068,6 +1071,136 @@ + + {{-- === TAG === --}} +
+
+
+

Tag

+
+
+

Gestisci i tag (etichette) per classificare individui, gruppi, eventi e documenti.

+ + @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + +
+ +
+ + + + + + + + + + + + + + @foreach($tags as $tag) + + + + + + + + + @endforeach + +
OrdineTagNomeSlugColoreAzioni
+ + + + {{ $tag->name }} + + {{ $tag->name }}{{ $tag->slug }} + + {{ $tag->color ?? '-' }} + + +
+ @csrf + @method('DELETE') + +
+
+
+
+
+ + + + +{{-- ===== MODALI TAG ===== --}} + + + @@ -1618,6 +1751,32 @@ var sortableTipologieEventi = Sortable.create(document.getElementById('tipologie } }); +function editTag(id, name, color) { + $('#editTagName').val(name); + $('#editTagColor').val(color || '#007bff'); + $('#editTagForm').attr('action', '/impostazioni/tag/' + id); + $('#editTagModal').modal('show'); +} + +var sortableTag = Sortable.create(document.getElementById('tag-sortable'), { + handle: '.handle', + animation: 150, + onEnd: function(evt) { + var order = []; + $('#tag-sortable tr').each(function() { + order.push($(this).data('id')); + }); + fetch('{{ route('impostazioni.tag.reorder') }}', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ order: order }) + }); + } +}); + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { var target = $(e.target).attr('href'); if (target === '#ruoli') { @@ -1626,6 +1785,9 @@ $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { if (target === '#tipologie-eventi') { sortableTipologieEventi.refresh(); } + if (target === '#tag') { + sortableTag.refresh(); + } if (target === '#calendario') { if (typeof sortableCalendario !== 'undefined') { sortableCalendario.refresh(); diff --git a/resources/views/individui/create.blade.php b/resources/views/individui/create.blade.php index 5c0c898c..2d0fbdc5 100644 --- a/resources/views/individui/create.blade.php +++ b/resources/views/individui/create.blade.php @@ -108,6 +108,15 @@ +
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => [], 'label' => 'Etichette']) +
+
+

Contatti

diff --git a/resources/views/individui/edit.blade.php b/resources/views/individui/edit.blade.php index 4a3df555..7e0b8563 100644 --- a/resources/views/individui/edit.blade.php +++ b/resources/views/individui/edit.blade.php @@ -139,6 +139,16 @@
+ +
+
+

Tag

+
+
+ @include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette']) +
+
+

Contatti

diff --git a/resources/views/individui/index.blade.php b/resources/views/individui/index.blade.php index a789ff6e..c2f8781c 100644 --- a/resources/views/individui/index.blade.php +++ b/resources/views/individui/index.blade.php @@ -9,6 +9,7 @@ $columnLabels = [ 'nome' => 'Nome', 'email' => 'Email', 'telefono' => 'Telefono', + 'tag' => 'Tag', ]; $vistaDefaultJson = $vista ? $vista->toJson() : 'null'; $allColumnsJson = json_encode($allColumns); @@ -47,6 +48,12 @@ $canDeleteIndividui = Auth::user()->canDelete('individui'); Crea Mailing List + + Assegna Gruppo + + + Assegna Tag + Elimina Selezionati @@ -124,6 +131,8 @@ $canDeleteIndividui = Auth::user()->canDelete('individui');
+ @include('partials._tag-filter-bar', ['filterTags' => $allTags]) +
@if($individui->count() > 0) @@ -150,6 +159,9 @@ $canDeleteIndividui = Auth::user()->canDelete('individui'); @if(in_array('telefono', $visibleColumns)) @endif + @if(in_array('tag', $visibleColumns)) + + @endif @@ -181,6 +193,13 @@ $canDeleteIndividui = Auth::user()->canDelete('individui'); @if(in_array('telefono', $visibleColumns)) @endif + @if(in_array('tag', $visibleColumns)) + + @endif
Telefono TagAzioni
{{ $ind->getTelefonoPrimarioAttribute() ?: '-' }} + @foreach($ind->tags as $tag) + {{ $tag->name }} + @endforeach + @if($canWriteIndividui) @@ -415,6 +434,88 @@ $canDeleteIndividui = Auth::user()->canDelete('individui'); +{{-- MODAL: Assegna Gruppo massivo --}} + + +{{-- MODAL: Gestione Tag massiva --}} + + @endsection @section('scripts') @@ -444,7 +545,7 @@ function applyColumnFilter() { if (!col || !val) return; const rows = document.querySelectorAll('#table-body tr'); - const colIndex = { codice: 1, cognome: 2, nome: 3, email: 4, telefono: 5, azioni: 6 }[col]; + const colIndex = { codice: 1, cognome: 2, nome: 3, email: 4, telefono: 5, tag: 6, azioni: 7 }[col]; rows.forEach(row => { const cells = row.querySelectorAll('td'); @@ -476,7 +577,7 @@ function sortTable(column) { currentSort = { column: column, direction: 'asc' }; } - const colIndex = { codice: 0, cognome: 1, nome: 2, email: 3, telefono: 4, azioni: 5 }[column]; + const colIndex = { codice: 1, cognome: 2, nome: 3, email: 4, telefono: 5, tag: 6, azioni: 7 }[column]; const tbody = document.getElementById('table-body'); const rows = Array.from(tbody.querySelectorAll('tr')); @@ -500,7 +601,7 @@ function sortTable(column) { } function toggleColumn(column, visible) { - const colIndex = { codice: 1, cognome: 2, nome: 3, email: 4, telefono: 5, azioni: 6 }[column]; + const colIndex = { codice: 1, cognome: 2, nome: 3, email: 4, telefono: 5, tag: 6, azioni: 7 }[column]; const ths = document.querySelectorAll('#individui-table thead th'); const rows = document.querySelectorAll('#table-body tr'); @@ -890,6 +991,28 @@ function updateMlCheckboxEmail(id, email) { } } +function showMassGruppoModal() { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Seleziona almeno un individuo'); + return; + } + document.getElementById('massGruppoIds').value = ids.join(','); + document.getElementById('massGruppoCount').textContent = ids.length; + $('#massGruppoModal').modal('show'); +} + +function showMassTagModal() { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Seleziona almeno un individuo'); + return; + } + document.getElementById('massTagIds').value = ids.join(','); + document.getElementById('massTagCount').textContent = ids.length; + $('#massTagModal').modal('show'); +} + async function createMailingList() { const nome = document.getElementById('ml-nome').value.trim(); if (!nome) { diff --git a/resources/views/individui/show.blade.php b/resources/views/individui/show.blade.php index a6709114..be5ee425 100644 --- a/resources/views/individui/show.blade.php +++ b/resources/views/individui/show.blade.php @@ -88,6 +88,21 @@ +
+
+
+

Tag

+
+ @forelse($individuo->tags as $tag) + {{ $tag->name }} + @empty +

Nessun tag assegnato.

+ @endforelse +
+
+
+
+ @if($individuo->note)
+
+ @include('partials._tag-selector', ['allTags' => $tags ?? collect(), 'selectedTags' => old('tags', []), 'label' => 'Tag']) +
Come aggiungere contatti: vai su Individui, seleziona le righe desired, e usa il menu Azioni > Crea Mailing List per creare una lista dai contatti selezionati. diff --git a/resources/views/mailing-liste/edit.blade.php b/resources/views/mailing-liste/edit.blade.php index c11bc27e..9772817e 100644 --- a/resources/views/mailing-liste/edit.blade.php +++ b/resources/views/mailing-liste/edit.blade.php @@ -31,6 +31,8 @@
+ @include('partials._tag-selector', ['allTags' => $tags ?? collect(), 'selectedTags' => $selectedTags ?? [], 'label' => 'Tag']) +
@@ -64,6 +66,13 @@
+ +
@@ -185,11 +194,17 @@ document.addEventListener('DOMContentLoaded', function() { console.error('Errore individui:', err); }); - contattiCaricati = Array.from(document.querySelectorAll('.contatto-checkbox')).map(cb => ({ - individuo_id: parseInt(cb.value), - email: cb.dataset.email, - existing: true - })); + contattiCaricati = Array.from(document.querySelectorAll('.contatto-checkbox')).map(cb => { + const row = cb.closest('tr'); + return { + individuo_id: parseInt(cb.value), + email: cb.dataset.email || '', + existing: true, + codice_id: row ? (row.querySelector('td:nth-child(2)')?.textContent?.trim() || '') : '', + cognome: row ? (row.querySelector('td:nth-child(3)')?.textContent?.trim() || '') : '', + nome: row ? (row.querySelector('td:nth-child(4)')?.textContent?.trim() || '') : '' + }; + }); }); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; @@ -217,17 +232,15 @@ function caricaSelezionati() { }) .then(data => { data.forEach(function(ind) { - if (ind.emails && ind.emails.length > 0) { - if (!contattiCaricati.find(c => c.individuo_id === ind.id)) { - contattiCaricati.push({ - individuo_id: ind.id, - codice_id: ind.codice_id, - cognome: ind.cogname, - nome: ind.nome, - email: ind.emails[0], - origini: ['Gruppo'] - }); - } + if (!contattiCaricati.find(c => c.individuo_id === ind.id)) { + contattiCaricati.push({ + individuo_id: ind.id, + codice_id: ind.codice_id, + cognome: ind.cognome, + nome: ind.nome, + email: (ind.emails && ind.emails.length > 0) ? ind.emails[0] : '', + origini: ['Gruppo'] + }); } }); }) @@ -246,17 +259,15 @@ function caricaSelezionati() { }) .then(data => { data.forEach(function(ind) { - if (ind.emails && ind.emails.length > 0) { - if (!contattiCaricati.find(c => c.individuo_id === ind.id)) { - contattiCaricati.push({ - individuo_id: ind.id, - codice_id: ind.codice_id, - cognome: ind.cogname, - nome: ind.nome, - email: ind.emails[0], - origini: [' Individuo'] - }); - } + if (!contattiCaricati.find(c => c.individuo_id === ind.id)) { + contattiCaricati.push({ + individuo_id: ind.id, + codice_id: ind.codice_id, + cognome: ind.cognome, + nome: ind.nome, + email: (ind.emails && ind.emails.length > 0) ? ind.emails[0] : '', + origini: [' Individuo'] + }); } }); }) @@ -327,25 +338,87 @@ function deselezionaTutti() { } document.getElementById('ml-form').addEventListener('submit', function(e) { + e.preventDefault(); + + const form = this; const selected = []; + document.querySelectorAll('.contatto-checkbox:checked').forEach(function(cb) { + const row = cb.closest('tr'); selected.push({ individuo_id: parseInt(cb.value), - email: cb.dataset.email + email: cb.dataset.email || '', + _label: row + ? (row.querySelector('td:nth-child(2)')?.textContent?.trim() || '') + ' - ' + + (row.querySelector('td:nth-child(3)')?.textContent?.trim() || '') + ' ' + + (row.querySelector('td:nth-child(4)')?.textContent?.trim() || '') + : 'ID ' + cb.value }); }); + const indSelect = document.getElementById('individui-select'); + if (indSelect) { + Array.from(indSelect.selectedOptions).forEach(function(opt) { + const id = parseInt(opt.value); + if (!selected.find(function(s) { return s.individuo_id === id; })) { + selected.push({ + individuo_id: id, + email: '', + _label: opt.textContent + }); + } + }); + } + if (selected.length === 0) { - e.preventDefault(); - alert('Seleziona almeno un contatto con email'); + const gruppiSelect = document.getElementById('gruppi-select'); + if (gruppiSelect && gruppiSelect.selectedOptions.length > 0) { + alert('Devi cliccare "Carica contatti" per caricare i membri dei gruppi prima di salvare.'); + } else { + alert('Seleziona almeno un contatto.'); + } return; } - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = 'contatti_json'; - input.value = JSON.stringify(selected); - this.appendChild(input); + function completaInvio() { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'contatti_json'; + input.value = JSON.stringify(selected.map(function(s) { + return { individuo_id: s.individuo_id, email: s.email }; + })); + form.appendChild(input); + form.submit(); + } + + const senzaEmail = selected.filter(function(s) { return !s.email; }); + + if (senzaEmail.length > 0) { + const elencoHtml = '
    ' + + senzaEmail.map(function(s) { + return '
  • ' + s._label + '
  • '; + }).join('') + + '
'; + + Swal.fire({ + title: 'Contatti senza email', + html: '

I seguenti ' + senzaEmail.length + ' contatti non hanno un indirizzo email:

' + + elencoHtml + + '

La email verrà risolta automaticamente quando l\'individuo verrà aggiornato. Salvare comunque?

', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Salva lo stesso', + cancelButtonText: 'Annulla', + confirmButtonColor: '#28a745', + cancelButtonColor: '#6c757d' + }).then(function(result) { + if (result.isConfirmed) { + completaInvio(); + } + }); + } else { + completaInvio(); + } }); @endsection \ No newline at end of file diff --git a/resources/views/mailing-liste/index.blade.php b/resources/views/mailing-liste/index.blade.php index 18b297e8..2870664b 100644 --- a/resources/views/mailing-liste/index.blade.php +++ b/resources/views/mailing-liste/index.blade.php @@ -34,6 +34,7 @@ $canDeleteMailing = Auth::user()->canDelete('mailing'); @endif
+ @include('partials._tag-filter-bar', ['filterTags' => $allTags ?? collect()])
@if($mailingLists->count() > 0) @@ -50,6 +51,7 @@ $canDeleteMailing = Auth::user()->canDelete('mailing'); + @@ -73,6 +75,16 @@ $canDeleteMailing = Auth::user()->canDelete('mailing'); +
Nome Descrizione ContattiTag Stato Creata il Azioni {{ $lista->contatti->count() }} + @foreach($lista->tags as $tag) + + {{ $tag->name }} + + @endforeach + @if($lista->tags->isEmpty()) + - + @endif + @if($lista->attiva) Attiva diff --git a/resources/views/mailing-liste/show.blade.php b/resources/views/mailing-liste/show.blade.php index afdd7aed..af51aed5 100644 --- a/resources/views/mailing-liste/show.blade.php +++ b/resources/views/mailing-liste/show.blade.php @@ -49,6 +49,23 @@ + +
+
+

Tag

+
+
+ @if($mailingList->tags->isNotEmpty()) + @foreach($mailingList->tags as $tag) + + {{ $tag->name }} + + @endforeach + @else +

Nessun tag assegnato

+ @endif +
+
diff --git a/resources/views/report/index.blade.php b/resources/views/report/index.blade.php index 65b8c9ef..8e0deb39 100644 --- a/resources/views/report/index.blade.php +++ b/resources/views/report/index.blade.php @@ -295,6 +295,22 @@
+
+ + + Filtra i risultati per tag +
+
+ + +
@@ -346,6 +362,37 @@ document.addEventListener('DOMContentLoaded', function() { columnsSelectInitialized = false; } + let tagFilterSelect2Initialized = false; + + function initTagFilterSelect2() { + if (tagFilterSelect2Initialized) return; + $('#tag_filter').select2({ + theme: 'bootstrap4', + placeholder: 'Seleziona tag...', + allowClear: true, + width: '100%', + templateResult: function(opt) { + if (!opt.id) return opt.text; + var color = $(opt.element).css('background-color'); + var $span = $('' + opt.text + ''); + return $span; + }, + templateSelection: function(opt) { + if (!opt.id) return opt.text; + var color = $(opt.element).css('background-color'); + var $span = $('' + opt.text + ''); + return $span; + } + }); + tagFilterSelect2Initialized = true; + } + + function destroyTagFilterSelect2() { + if (!tagFilterSelect2Initialized) return; + $('#tag_filter').select2('destroy'); + tagFilterSelect2Initialized = false; + } + function getSortOptions(tipoReport) { const cols = columnOptions[tipoReport] || {}; const options = []; @@ -365,8 +412,10 @@ document.addEventListener('DOMContentLoaded', function() { $('#collapseThree').on('shown.bs.collapse', function () { initColumnsSelect2(); + initTagFilterSelect2(); }).on('hidden.bs.collapse', function () { destroyColumnsSelect2(); + destroyTagFilterSelect2(); }); if ($('#collapseTwo').hasClass('show')) { @@ -375,6 +424,7 @@ document.addEventListener('DOMContentLoaded', function() { if ($('#collapseThree').hasClass('show')) { initColumnsSelect2(); + initTagFilterSelect2(); } $('#tipo_report').on('change', function() { diff --git a/routes/web.php b/routes/web.php index 4eae3c43..f0442490 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\MailingController; use App\Http\Controllers\MailingListController; use App\Http\Controllers\ReportController; use App\Http\Controllers\ImpostazioniController; +use App\Http\Controllers\RicercaController; use App\Http\Controllers\Admin\UtenteController; use App\Http\Controllers\Admin\RuoloController; use App\Http\Controllers\Admin\ActivityLogController; @@ -75,6 +76,8 @@ Route::get('individui/{individuo}/report', [IndividuoController::class, 'report' Route::get('individui/{individuo}/collegati', [IndividuoController::class, 'elementiCollegati'])->middleware('auth'); Route::post('individui/{individuo}/elimina', [IndividuoController::class, 'elimina'])->middleware('auth'); Route::post('individui/mass-elimina', [IndividuoController::class, 'massElimina'])->middleware('auth'); +Route::post('individui/mass-gruppo', [IndividuoController::class, 'massGruppo'])->middleware('auth'); +Route::post('individui/mass-tag', [IndividuoController::class, 'massTag'])->middleware('auth'); Route::post('individui/{individuo}/avatar', [IndividuoController::class, 'uploadAvatar'])->middleware('auth'); Route::get('individui/{individuo}/avatar', [IndividuoController::class, 'getAvatar'])->middleware('auth'); Route::delete('individui/{individuo}/avatar', [IndividuoController::class, 'deleteAvatar'])->middleware('auth'); @@ -97,6 +100,8 @@ Route::post('gruppi/{gruppo}/avatar', [\App\Http\Controllers\GruppoAvatarControl Route::get('gruppi/{gruppo}/avatar-file', [\App\Http\Controllers\GruppoAvatarController::class, 'show'])->middleware('auth'); Route::delete('gruppi/{gruppo}/avatar', [\App\Http\Controllers\GruppoAvatarController::class, 'destroy'])->middleware('auth'); +Route::post('gruppi/mass-tag', [GruppoController::class, 'massTag'])->middleware('auth'); +Route::post('gruppi/mass-elimina', [GruppoController::class, 'massElimina'])->middleware('auth'); Route::resource('gruppi', GruppoController::class)->middleware('auth'); Route::get('eventi/calendar', [EventoController::class, 'calendar'])->name('eventi.calendar')->middleware('auth'); Route::get('eventi/calendar/events', [EventoController::class, 'calendarEvents'])->name('eventi.calendar.events')->middleware('auth'); @@ -133,6 +138,7 @@ Route::post('documenti/mass-update', [DocumentoController::class, 'massUpdate']) Route::post('documenti/mass-associate', [DocumentoController::class, 'massAssociate'])->middleware('auth'); Route::post('documenti/mass-move', [DocumentoController::class, 'massMove'])->middleware('auth'); Route::post('documenti/mass-download', [DocumentoController::class, 'massDownload'])->middleware('auth'); +Route::post('documenti/mass-tag', [DocumentoController::class, 'massTag'])->middleware('auth'); Route::post('documenti/check-links', [DocumentoController::class, 'checkMassLinks'])->middleware('auth'); Route::get('documenti/options', [DocumentoController::class, 'options'])->middleware('auth'); @@ -186,6 +192,11 @@ Route::post('/impostazioni/logo/remove', [ImpostazioniController::class, 'remove Route::post('/impostazioni/app-settings', [ImpostazioniController::class, 'saveAppSettings'])->middleware('auth')->name('impostazioni.app-settings.save'); Route::post('/impostazioni/migra-percorso', [ImpostazioniController::class, 'migrateStoragePath'])->middleware('auth')->name('impostazioni.migrate-percorso'); +Route::post('/impostazioni/tag', [ImpostazioniController::class, 'tagStore'])->middleware('auth')->name('impostazioni.tag.store'); +Route::put('/impostazioni/tag/{id}', [ImpostazioniController::class, 'tagUpdate'])->middleware('auth')->name('impostazioni.tag.update'); +Route::delete('/impostazioni/tag/{id}', [ImpostazioniController::class, 'tagDestroy'])->middleware('auth')->name('impostazioni.tag.destroy'); +Route::post('/impostazioni/tag/reorder', [ImpostazioniController::class, 'tagReorder'])->middleware('auth')->name('impostazioni.tag.reorder'); + Route::get('/impostazioni/email', [EmailSettingsController::class, 'index'])->middleware('auth')->name('impostazioni.email.index'); Route::post('/impostazioni/email', [EmailSettingsController::class, 'save'])->middleware('auth')->name('impostazioni.email.save'); Route::post('/impostazioni/email/test', [EmailSettingsController::class, 'testConnection'])->middleware('auth')->name('impostazioni.email.test'); @@ -206,6 +217,9 @@ Route::post('/calendario-connessioni/{id}/sync', [App\Http\Controllers\Calendari Route::post('/calendario-connessioni/sync-all', [App\Http\Controllers\CalendarioConnessioneController::class, 'syncAll'])->middleware('auth')->name('calendario-connessioni.sync-all'); Route::post('/calendario-connessioni/reorder', [App\Http\Controllers\CalendarioConnessioneController::class, 'reorder'])->middleware('auth')->name('calendario-connessioni.reorder'); +// Ricerca per Tag +Route::get('/ricerca', [RicercaController::class, 'index'])->middleware('auth')->name('ricerca.index'); + // Storage Repositories (Opzione B) Route::post('/storage-repositories', [StorageRepositoryController::class, 'store'])->middleware('auth')->name('storage-repositories.store'); Route::put('/storage-repositories/{storage_repository}', [StorageRepositoryController::class, 'update'])->middleware('auth')->name('storage-repositories.update');