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']) + +
| 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 }} | +
Nessun evento trovato
Nessun tag assegnato.
+ @endforelse +