diff --git a/MEMORY.md b/MEMORY.md
index fb680df9..6cf9d2e3 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -291,26 +291,92 @@ if (!empty($contatto['individuo_id'])) { create }
```
## Prossimi Passi
-- [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
+- [DONE] ... (existing items)
- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag
+- [DONE] Fix performance: spostata query `VistaReport::where(...)->get()` da `@php` nel partial `table-settings.blade.php` a tutti i 5 controller (prima veniva eseguita 1 query per ogni pagina load per ogni entity, anche senza mai aprire la modale)
+- [DONE] Rimosse vecchie modal legacy (`#saveVistaModal`, `#colonneModal`, `#vistaListModal`) da `individui` e `gruppi` — duplicate rispetto al nuovo modal unificato
+- [DONE] Rimosse vecchie funzioni JS (`saveVista()`, `toggleColumn()`, `showSaveVistaModal()`) da `individui` e `gruppi`
+- [DONE] Individui: `$allColumns` espanse da 6 a 17 colonne (aggiunte: data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at) — tutte con `
`/` ` condizionali nella vista
+- [DONE] Individui: `getColumnIndex()` introdotta per lookup dinamico dell'indice colonna (sostituisce hardcoded `{codice:1, cognome:2, ...}` in `sortTable()` e `applyColumnFilter()`)
+- [DONE] I controller per eventi, documenti, mailing-liste hanno già `$tableColumns` con flag `visible` — stesso meccanismo di gruppi. Nessuna modifica necessaria.
+- [DONE] Fix: pulsante edit vista usa `data-*` attributi invece di `onclick` con `addslashes(json_encode(...))` — previene rottura HTML per JSON contenente `"`
+- [DA FARE] Test browser: caricare ogni pagina entity, verificare default vista, toggle colonne, resize, drag-reorder, print
+- [DA FARE] Test browser: flusso edit vista (✏️ → popola form → modifica → Aggiorna → reload)
+- [DA FARE] Test browser: salvare nuova vista, switching tra viste, cancellazione vista
+
+## 2026-06-10 — Per-User Column Views (colonne visibili, larghezze, ordine)
+
+**Obiettivo**: Implementare viste colonne personalizzabili per utente su tutte le 5 entity list pages (individui, gruppi, eventi, documenti, mailing-liste), con persistenza tra login.
+
+### Cosa è stato fatto
+
+**Migration** (`2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php`):
+- Aggiunge `colonne_larghezze` (json) e `colonne_ordine` (json) a `viste_report`
+- Guardia `Schema::hasColumn` per compatibilità cross-DB (MySQL ↔ SQLite)
+- Già eseguita in batch 38
+
+**Backend**:
+- `VistaReport.php`: `$fillable` + `$casts` (array) per entrambi i nuovi campi
+- `VistaReportController@store`/`@update`: validazione estesa con `colonne_larghezze`, `colonne_ordine` (array)
+- Tutti i 5 controller passano alla vista: `$tableColumns` (array `['key','label','visible']`), `$entityType`, `$columnWidths`, `$visibleColumns`, `$allColumns` (varia nome in base al controller)
+- Tutti i 5 controller ora passano `$userVistas` (precaricate) invece di eseguire query nella view
+- `IndividuoController`: `$allColumns` espanse con tutte le colonne DB disponibili (da 6 a 17)
+- `IndividuoController`: aggiunto `use App\Models\VistaReport` per la query `$userVistas`
+
+**Frontend JS** (`public/js/column-manager.js`):
+- Classe `ColumnManager`: resize via mouse drag su handle, reorder via HTML5 DnD, persistenza larghezze in `sessionStorage`, fallback da `vista-data` DOM element, `printWithSettings()` che clona la tabella in finestra print-ready
+
+**Modal** (`resources/views/partials/table-settings.blade.php`):
+- Modal unificato con 3 tab: Colonne (checkbox visibilità + input larghezza + drag-reorder list), Viste (salva/carica/cancella), Stampa (pulsante print)
+- Definisce `initTableSettings()` (NON auto-esegue) per evitare race condition con ColumnManager
+- **RIMOSSA** query `VistaReport::where(...)->get()` dal `@php` block — ora usa `$userVistas` passato dal controller
+- Non usa più `Auth::id()` direttamente — riceve `$userVistas` già popolato
+
+**Views** — tutte e 5 aggiornate:
+- `individui/index.blade.php`:
+ - `$allColumns` nella vista ora ha 17 colonne (vs 6 prima)
+ - ` ` e ` ` condizionali per tutte le nuove colonne (data_nascita, genere, indirizzo, cap, citta, provincia, tipo_documento, numero_documento, scadenza_documento, note, created_at)
+ - RIMOSSE modal legacy: `#saveVistaModal`, `#colonneModal`
+ - RIMOSSE funzioni JS legacy: `saveVista()`, `toggleColumn()`
+ - RIMOSSO sync checkboxes nel `DOMContentLoaded` (non più necessari)
+ - NUOVA: `getColumnIndex(colKey)` — lookup dinamico dell'indice colonna basato su `data-column`
+ - `sortTable()` e `applyColumnFilter()` ora usano `getColumnIndex()` invece di hardcoded mappa
+- `gruppi/index.blade.php`:
+ - RIMOSSE modal legacy: `#saveVistaModal`, `#vistaListModal`
+ - RIMOSSO pulsante "Salva Vista" legacy
+ - RIMOSSE funzioni JS legacy: `showSaveVistaModal()`, `saveVista()`
+- `eventi/index.blade.php`: colonne dinamiche via `$visibleColumns`, `@section('scripts')` con ColumnManager + `initTableSettings()`
+- `mailing-liste/index.blade.php`: stessa struttura
+- `documenti/index.blade.php`: ColumnManager init (non reorderable), column-manager.js incluso
+- Tutti i ` ` hanno `data-column` attributo per mapping JS
+- Tutte chiamano `initTableSettings()` dopo `new ColumnManager()` nel `DOMContentLoaded`
+
+**Performance fix**:
+- Rimossa query `VistaReport::where(...)->get()` dal `@php` block in `table-settings.blade.php` (1 query extra per ogni pagina load su 5 entità)
+- Spostata nei 5 controller come `$userVistas` passata via `compact()`
+- Totale: -5 query per pagina load (una per ogni entity, anche su pagine non di index)
+
+**Verifica**:
+- JS brace balance: OK su tutti i 5 file (node check)
+- PHP lint: OK su tutti i 5 controller
+- Presenza `data-column`: individui=19, gruppi=14, eventi=9, mailing-liste=7, documenti=8
+- Fallback `$visibleColumns` quando `$vista === null`: presente e corretto in tutti i 5 controller
+- Nessun residuo di vecchie modal legacy in individui e gruppi
+- Rimosso bottone "Salva Vista" orfano da `individui/index.blade.php` (chiamava `showSaveVistaModal()` non più esistente)
+- Aggiunto pulsante modifica ✏️ nel tab Viste per ogni vista salvata: popola il form "Salva vista corrente" con nome, default flag e colonne visibili; cambia bottone in "Aggiorna" e fa PUT `/viste/{id}` invece di POST; zero query extra
+### 2026-06-10 — Fix: editVista onclick → data-* attributes
+
+**Problema**: Il pulsante modifica vista usava `onclick` con `addslashes(json_encode(...))`. In un attributo HTML delimitato da `"`, il backslash-escaping di `\"` non è gestito in modo standard dai browser — il JSON contenente `"` poteva rompere l'attributo HTML.
+
+**Fix**:
+1. Sostituito `onclick` con `data-*` attributi (`data-vista-id`, `data-vista-nome`, `data-vista-default`, `data-vista-colonne`) usando `{{ }}` di Blade (che applica `htmlspecialchars`, codificando `"` → `"` sicuro in HTML).
+2. `editVista()` ora accetta un array direttamente (non più JSON string da parsare).
+3. Click handler registrato in `initTableSettings()` via `document.querySelectorAll('.edit-vista-btn')` + `dataset` (browser decodifica automaticamente `"` → `"`).
+
+### 2026-06-10 — Fix: 405 Method Not Allowed su update vista (via AJAX fetch)
+
+**Problema**: Modificando una vista e cliccando "Aggiorna", `fetch()` inviava `PUT /viste/{id}` ma il controller `VistaReportController@update` restituiva `return back()` (302 redirect). `fetch()` seguiva il redirect — in alcuni browser il metodo PUT veniva preservato, ma la route `gruppi` accetta solo GET → 405 Method Not Allowed.
+
+**Fix**:
+1. `VistaReportController@update` (linea 101-103): aggiunto `if ($request->expectsJson()) { return response()->json([...]); }` — così il fetch riceve JSON 200, non un redirect 302.
+2. Fetch headers in `table-settings.blade.php`: aggiunto `'Accept': 'application/json'` — necessario perché `expectsJson()` controlla l'header `Accept`, non `Content-Type`.
diff --git a/app/Http/Controllers/DocumentoController.php b/app/Http/Controllers/DocumentoController.php
index 4fa7146b..00d7aa0d 100644
--- a/app/Http/Controllers/DocumentoController.php
+++ b/app/Http/Controllers/DocumentoController.php
@@ -92,11 +92,45 @@ class DocumentoController extends Controller
'children' => $f->children->map(fn($c) => ['id' => $c->id, 'nome' => $c->nome]),
]);
+ $vista = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'documenti')
+ ->where('is_default', true)
+ ->first();
+
+ $userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'documenti')
+ ->orderBy('is_default', 'desc')
+ ->orderBy('nome')
+ ->get();
+
+ $allColumnDefs = [
+ ['key' => 'nome_file', 'label' => 'Nome'],
+ ['key' => 'tipologia', 'label' => 'Tipologia'],
+ ['key' => 'dimensione', 'label' => 'Dimensione'],
+ ['key' => 'visibilita', 'label' => 'Contesto'],
+ ['key' => 'tag', 'label' => 'Tag'],
+ ['key' => 'created_at', 'label' => 'Data'],
+ ];
+ $defaultVisible = ['nome_file', 'tipologia', 'dimensione', 'visibilita', 'tag', 'created_at'];
+ $visibleColumns = $vista && !empty($vista->colonne_visibili)
+ ? $vista->colonne_visibili
+ : $defaultVisible;
+
+ $tableColumns = array_map(fn($col) => [
+ 'key' => $col['key'],
+ 'label' => $col['label'],
+ 'visible' => in_array($col['key'], $visibleColumns),
+ ], $allColumnDefs);
+ $entityType = 'documenti';
+ $columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
+
return view('documenti.index', compact(
'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle',
'currentRepo', 'repositories',
'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie',
- 'cartelleMoveOptions', 'allTags'
+ 'cartelleMoveOptions', 'allTags',
+ 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths',
+ 'userVistas'
));
}
diff --git a/app/Http/Controllers/EventoController.php b/app/Http/Controllers/EventoController.php
index fcf82d21..0897c52b 100644
--- a/app/Http/Controllers/EventoController.php
+++ b/app/Http/Controllers/EventoController.php
@@ -55,7 +55,42 @@ class EventoController extends Controller
$gruppi = Gruppo::orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
- return view('eventi.index', compact('eventi', 'gruppi', 'allTags'));
+ $vista = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'eventi')
+ ->where('is_default', true)
+ ->first();
+
+ $userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'eventi')
+ ->orderBy('is_default', 'desc')
+ ->orderBy('nome')
+ ->get();
+
+ $allColumnDefs = [
+ ['key' => 'nome_evento', 'label' => 'Nome Evento'],
+ ['key' => 'descrizione_evento', 'label' => 'Descrizione'],
+ ['key' => 'tipo_evento', 'label' => 'Tipologia'],
+ ['key' => 'tipo_recorrenza', 'label' => 'Ricorrenza'],
+ ['key' => 'gruppi', 'label' => 'Gruppi'],
+ ['key' => 'responsabili', 'label' => 'Responsabili'],
+ ['key' => 'tag', 'label' => 'Tag'],
+ ['key' => 'data_specifica', 'label' => 'Data'],
+ ['key' => 'created_at', 'label' => 'Creato'],
+ ];
+ $defaultVisible = ['nome_evento', 'descrizione_evento', 'tipo_evento', 'tipo_recorrenza', 'gruppi', 'responsabili', 'tag', 'created_at'];
+ $visibleColumns = $vista && !empty($vista->colonne_visibili)
+ ? $vista->colonne_visibili
+ : $defaultVisible;
+
+ $tableColumns = array_map(fn($col) => [
+ 'key' => $col['key'],
+ 'label' => $col['label'],
+ 'visible' => in_array($col['key'], $visibleColumns),
+ ], $allColumnDefs);
+ $entityType = 'eventi';
+ $columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
+
+ return view('eventi.index', compact('eventi', 'gruppi', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
}
public function create(Request $request)
diff --git a/app/Http/Controllers/GruppoController.php b/app/Http/Controllers/GruppoController.php
index 4a9ffcbd..30c3f4f7 100644
--- a/app/Http/Controllers/GruppoController.php
+++ b/app/Http/Controllers/GruppoController.php
@@ -28,7 +28,27 @@ class GruppoController extends Controller
->where('is_default', true)
->first();
- $allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email', 'tag'];
+ $userVistas = \App\Models\VistaReport::where('user_id', $user->id)
+ ->where('tipo', 'gruppi')
+ ->orderBy('is_default', 'desc')
+ ->orderBy('nome')
+ ->get();
+
+ $allColumnDefs = [
+ ['key' => 'nome', 'label' => 'Nome'],
+ ['key' => 'descrizione', 'label' => 'Descrizione'],
+ ['key' => 'diocesi', 'label' => 'Diocesi'],
+ ['key' => 'livello', 'label' => 'Livello'],
+ ['key' => 'padre', 'label' => 'Gruppo Padre'],
+ ['key' => 'membri', 'label' => 'Membri'],
+ ['key' => 'responsabili', 'label' => 'Responsabili'],
+ ['key' => 'indirizzo', 'label' => 'Indirizzo'],
+ ['key' => 'citta', 'label' => 'Città'],
+ ['key' => 'telefono', 'label' => 'Telefono'],
+ ['key' => 'email', 'label' => 'Email'],
+ ['key' => 'tag', 'label' => 'Tag'],
+ ];
+ $allColumns = array_column($allColumnDefs, 'key');
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili', 'tag'];
if ($vista && !empty($vista->colonne_visibili)) {
@@ -37,6 +57,14 @@ class GruppoController extends Controller
$visibleColumns = $defaultVisible;
}
+ $tableColumns = array_map(fn($col) => [
+ 'key' => $col['key'],
+ 'label' => $col['label'],
+ 'visible' => in_array($col['key'], $visibleColumns),
+ ], $allColumnDefs);
+ $entityType = 'gruppi';
+ $columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
+
$gruppiQuery = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar', 'tags']);
if (request()->filled('tag')) {
@@ -79,7 +107,11 @@ class GruppoController extends Controller
'visibleColumns',
'viewMode',
'vista',
- 'allTags'
+ 'allTags',
+ 'tableColumns',
+ 'entityType',
+ 'columnWidths',
+ 'userVistas'
));
}
diff --git a/app/Http/Controllers/IndividuoController.php b/app/Http/Controllers/IndividuoController.php
index 98f40f30..3008fadc 100644
--- a/app/Http/Controllers/IndividuoController.php
+++ b/app/Http/Controllers/IndividuoController.php
@@ -5,14 +5,15 @@ 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;
-use Symfony\Component\HttpFoundation\StreamedResponse;
+ use App\Models\Gruppo;
+ use App\Models\Comune;
+ use App\Models\Diocesi;
+ use App\Models\Tag;
+ use App\Models\VistaReport;
+ use Illuminate\Http\Request;
+ use Illuminate\Support\Facades\Storage;
+ use Illuminate\Validation\Rule;
+ use Symfony\Component\HttpFoundation\StreamedResponse;
class IndividuoController extends Controller
{
@@ -50,9 +51,20 @@ class IndividuoController extends Controller
['key' => 'codice', 'label' => 'Codice'],
['key' => 'cognome', 'label' => 'Cognome'],
['key' => 'nome', 'label' => 'Nome'],
+ ['key' => 'data_nascita', 'label' => 'Data Nascita'],
+ ['key' => 'genere', 'label' => 'Genere'],
+ ['key' => 'indirizzo', 'label' => 'Indirizzo'],
+ ['key' => 'cap', 'label' => 'CAP'],
+ ['key' => 'citta', 'label' => 'Città'],
+ ['key' => 'provincia', 'label' => 'Provincia'],
+ ['key' => 'tipo_documento', 'label' => 'Tipo Documento'],
+ ['key' => 'numero_documento', 'label' => 'Numero Documento'],
+ ['key' => 'scadenza_documento', 'label' => 'Scadenza Documento'],
['key' => 'email', 'label' => 'Email'],
['key' => 'telefono', 'label' => 'Telefono'],
+ ['key' => 'note', 'label' => 'Note'],
['key' => 'tag', 'label' => 'Tag'],
+ ['key' => 'created_at', 'label' => 'Creato il'],
];
$defaultVisible = ['codice', 'cognome', 'nome', 'email', 'telefono', 'tag'];
@@ -60,8 +72,22 @@ class IndividuoController extends Controller
? $vista->colonne_visibili
: $defaultVisible;
+ $entityType = 'individui';
+ $tableColumns = array_map(fn($col) => [
+ 'key' => $col['key'],
+ 'label' => $col['label'],
+ 'visible' => in_array($col['key'], $visibleColumns),
+ ], $allColumns);
+ $columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
+
+ $userVistas = VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'individui')
+ ->orderBy('is_default', 'desc')
+ ->orderBy('nome')
+ ->get();
+
$gruppi = Gruppo::orderBy('nome')->get(['id', 'nome']);
- return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns', 'allTags', 'gruppi'));
+ return view('individui.index', compact('individui', 'vista', 'allColumns', 'visibleColumns', 'allTags', 'gruppi', 'entityType', 'tableColumns', 'columnWidths', 'userVistas'));
}
public function exportCSV(Request $request)
diff --git a/app/Http/Controllers/MailingListController.php b/app/Http/Controllers/MailingListController.php
index 4cd77458..9efce682 100644
--- a/app/Http/Controllers/MailingListController.php
+++ b/app/Http/Controllers/MailingListController.php
@@ -20,7 +20,40 @@ class MailingListController extends Controller
$mailingLists = $query->orderBy('nome')->get();
$allTags = Tag::orderBy('name')->get();
- return view('mailing-liste.index', compact('mailingLists', 'allTags'));
+
+ $vista = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'mailing-liste')
+ ->where('is_default', true)
+ ->first();
+
+ $userVistas = \App\Models\VistaReport::where('user_id', auth()->id())
+ ->where('tipo', 'mailing-liste')
+ ->orderBy('is_default', 'desc')
+ ->orderBy('nome')
+ ->get();
+
+ $allColumnDefs = [
+ ['key' => 'nome', 'label' => 'Nome'],
+ ['key' => 'descrizione', 'label' => 'Descrizione'],
+ ['key' => 'contatti_count', 'label' => 'Contatti'],
+ ['key' => 'tag', 'label' => 'Tag'],
+ ['key' => 'attiva', 'label' => 'Stato'],
+ ['key' => 'created_at', 'label' => 'Creata il'],
+ ];
+ $defaultVisible = ['nome', 'descrizione', 'contatti_count', 'tag', 'attiva', 'created_at'];
+ $visibleColumns = $vista && !empty($vista->colonne_visibili)
+ ? $vista->colonne_visibili
+ : $defaultVisible;
+
+ $tableColumns = array_map(fn($col) => [
+ 'key' => $col['key'],
+ 'label' => $col['label'],
+ 'visible' => in_array($col['key'], $visibleColumns),
+ ], $allColumnDefs);
+ $entityType = 'mailing-liste';
+ $columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
+
+ return view('mailing-liste.index', compact('mailingLists', 'allTags', 'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths', 'userVistas'));
}
public function create()
diff --git a/app/Http/Controllers/VistaReportController.php b/app/Http/Controllers/VistaReportController.php
index 3e1672d1..50642766 100644
--- a/app/Http/Controllers/VistaReportController.php
+++ b/app/Http/Controllers/VistaReportController.php
@@ -33,9 +33,11 @@ class VistaReportController extends Controller
$this->authorizeWrite('viste');
$data = $request->validate([
'nome' => 'required|string|max:255',
- 'tipo' => 'required|in:individui,gruppi,documenti,eventi',
+ 'tipo' => 'required|in:individui,gruppi,documenti,eventi,mailing-liste',
'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array',
+ 'colonne_larghezze' => 'nullable|array',
+ 'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array',
'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean',
@@ -55,6 +57,8 @@ class VistaReportController extends Controller
'tipo' => $data['tipo'],
'colonne_visibili' => $data['colonne_visibili'] ?? null,
'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null,
+ 'colonne_larghezze' => $data['colonne_larghezze'] ?? null,
+ 'colonne_ordine' => $data['colonne_ordine'] ?? null,
'filtri' => $data['filtri'] ?? null,
'ricerca' => $data['ricerca'] ?? null,
'is_default' => $isDefault,
@@ -78,6 +82,8 @@ class VistaReportController extends Controller
'nome' => 'required|string|max:255',
'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array',
+ 'colonne_larghezze' => 'nullable|array',
+ 'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array',
'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean',
@@ -92,6 +98,10 @@ class VistaReportController extends Controller
$vistaReport->update($data);
+ if ($request->expectsJson()) {
+ return response()->json(['success' => true, 'message' => 'Vista aggiornata.']);
+ }
+
return back()->with('success', 'Vista aggiornata.');
}
diff --git a/app/Models/VistaReport.php b/app/Models/VistaReport.php
index 5b15fd7e..d2c3d167 100644
--- a/app/Models/VistaReport.php
+++ b/app/Models/VistaReport.php
@@ -10,13 +10,16 @@ class VistaReport extends Model
protected $table = 'viste_report';
protected $fillable = [
'user_id', 'nome', 'tipo', 'colonne_visibili',
- 'colonne_ordinamento', 'filtri', 'ricerca', 'is_default'
+ 'colonne_ordinamento', 'filtri', 'ricerca', 'is_default',
+ 'colonne_larghezze', 'colonne_ordine',
];
protected $casts = [
'colonne_visibili' => 'array',
'colonne_ordinamento' => 'array',
'filtri' => 'array',
+ 'colonne_larghezze' => 'array',
+ 'colonne_ordine' => 'array',
];
public function user(): BelongsTo
diff --git a/database/migrations/2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php b/database/migrations/2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php
new file mode 100644
index 00000000..aa969567
--- /dev/null
+++ b/database/migrations/2026_06_10_044946_add_column_widths_and_order_to_viste_report_table.php
@@ -0,0 +1,32 @@
+json('colonne_larghezze')->nullable()->after('colonne_visibili');
+ }
+ if (!Schema::hasColumn('viste_report', 'colonne_ordine')) {
+ $table->json('colonne_ordine')->nullable()->after('colonne_larghezze');
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('viste_report', function (Blueprint $table) {
+ if (Schema::hasColumn('viste_report', 'colonne_larghezze')) {
+ $table->dropColumn('colonne_larghezze');
+ }
+ if (Schema::hasColumn('viste_report', 'colonne_ordine')) {
+ $table->dropColumn('colonne_ordine');
+ }
+ });
+ }
+};
diff --git a/public/js/column-manager.js b/public/js/column-manager.js
new file mode 100644
index 00000000..749ae12d
--- /dev/null
+++ b/public/js/column-manager.js
@@ -0,0 +1,384 @@
+class ColumnManager {
+ constructor(tableId, options = {}) {
+ this.tableId = tableId;
+ this.table = document.getElementById(tableId);
+ if (!this.table) return;
+
+ this.options = Object.assign({
+ entityType: 'individui',
+ storageKey: null,
+ resizable: true,
+ reorderable: true,
+ persistWidths: true,
+ persistOrder: true,
+ }, options);
+
+ this.columns = [];
+ this.columnWidths = {};
+ this.columnOrder = [];
+ this.dragState = null;
+ this.resizeState = null;
+
+ this.init();
+ }
+
+ init() {
+ this.loadState();
+ this.initColumns();
+ if (this.options.resizable) this.initResize();
+ if (this.options.reorderable) this.initReorder();
+ this.applyState();
+ }
+
+ initColumns() {
+ const ths = this.table.querySelectorAll('thead th');
+ ths.forEach((th, idx) => {
+ const colKey = th.dataset.column || `col_${idx}`;
+ const col = {
+ index: idx,
+ key: colKey,
+ element: th,
+ width: th.style.width || '',
+ visible: th.style.display !== 'none',
+ label: th.textContent.trim(),
+ };
+ this.columns.push(col);
+ });
+ }
+
+ loadState() {
+ if (this.options.storageKey) {
+ try {
+ const saved = sessionStorage.getItem(this.options.storageKey);
+ if (saved) {
+ const data = JSON.parse(saved);
+ this.columnWidths = data.widths || {};
+ this.columnOrder = data.order || [];
+ return;
+ }
+ } catch (e) {}
+ }
+ const vistaEl = document.getElementById('vista-data');
+ if (vistaEl && vistaEl.textContent && vistaEl.textContent !== 'null') {
+ try {
+ const vista = JSON.parse(vistaEl.textContent);
+ this.columnWidths = vista.colonne_larghezze || {};
+ this.columnOrder = vista.colonne_ordine || [];
+ } catch (e) {}
+ }
+ }
+
+ persistState() {
+ if (!this.options.storageKey) return;
+ try {
+ sessionStorage.setItem(this.options.storageKey, JSON.stringify({
+ widths: this.columnWidths,
+ order: this.columnOrder,
+ }));
+ } catch (e) {}
+ }
+
+ applyState() {
+ if (this.columnOrder.length > 0) {
+ this.reorderColumns(this.columnOrder, false);
+ }
+ if (Object.keys(this.columnWidths).length > 0) {
+ this.applyWidths();
+ }
+ }
+
+ applyWidths() {
+ this.columns.forEach(col => {
+ const w = this.columnWidths[col.key];
+ if (w) {
+ col.element.style.width = w;
+ }
+ });
+ }
+
+ initResize() {
+ this.table.querySelectorAll('thead th').forEach(th => {
+ const handle = document.createElement('div');
+ handle.className = 'col-resize-handle';
+ handle.style.cssText = 'position:absolute;right:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:10;';
+ th.style.position = 'relative';
+ th.appendChild(handle);
+
+ handle.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const thEl = th;
+ const startX = e.clientX;
+ const startWidth = thEl.offsetWidth;
+
+ const onMouseMove = (ev) => {
+ const diff = ev.clientX - startX;
+ const newWidth = Math.max(30, startWidth + diff);
+ thEl.style.width = newWidth + 'px';
+ const colKey = thEl.dataset.column;
+ if (colKey) {
+ this.columnWidths[colKey] = newWidth + 'px';
+ this.persistState();
+ }
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ });
+ });
+ }
+
+ initReorder() {
+ const thead = this.table.querySelector('thead');
+ if (!thead) return;
+
+ let dragCol = null;
+ let dragIndex = -1;
+ let placeholder = null;
+
+ thead.querySelectorAll('th').forEach(th => {
+ if (th.querySelector('.col-resize-handle')) return;
+ th.draggable = true;
+
+ th.addEventListener('dragstart', (e) => {
+ dragCol = th;
+ dragIndex = Array.from(thead.querySelectorAll('th')).indexOf(th);
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', '');
+ th.classList.add('dragging');
+ });
+
+ th.addEventListener('dragend', () => {
+ th.classList.remove('dragging');
+ if (placeholder && placeholder.parentNode) {
+ placeholder.parentNode.removeChild(placeholder);
+ }
+ dragCol = null;
+ dragIndex = -1;
+ placeholder = null;
+ });
+
+ th.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (!dragCol || dragCol === th) return;
+
+ if (!placeholder) {
+ placeholder = document.createElement('th');
+ placeholder.style.cssText = 'border:2px dashed #007bff;background:rgba(0,123,255,0.1);width:0;padding:0;';
+ dragCol.parentNode.insertBefore(placeholder, dragCol);
+ }
+
+ const rect = th.getBoundingClientRect();
+ const midX = rect.left + rect.width / 2;
+ const parent = th.parentNode;
+
+ if (e.clientX < midX) {
+ parent.insertBefore(placeholder, th);
+ } else {
+ parent.insertBefore(placeholder, th.nextSibling);
+ }
+ });
+
+ th.addEventListener('drop', (e) => {
+ e.preventDefault();
+ if (!dragCol || dragCol === th) return;
+
+ const parent = th.parentNode;
+ const allTh = Array.from(parent.querySelectorAll('th')).filter(el => el !== placeholder);
+
+ const fromIdx = allTh.indexOf(dragCol);
+ let toIdx = allTh.indexOf(th);
+
+ if (placeholder) {
+ toIdx = Array.from(parent.children).indexOf(placeholder);
+ if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
+ placeholder = null;
+ }
+
+ if (fromIdx < 0) return;
+
+ const actualRows = this.table.querySelectorAll('tr');
+ actualRows.forEach(row => {
+ const cells = row.querySelectorAll('td, th');
+ if (cells[fromIdx] && cells[toIdx]) {
+ if (toIdx > fromIdx) {
+ row.insertBefore(cells[fromIdx], cells[toIdx].nextSibling);
+ } else {
+ row.insertBefore(cells[fromIdx], cells[toIdx]);
+ }
+ }
+ });
+
+ this.updateColumnOrder();
+ });
+ });
+ }
+
+ updateColumnOrder() {
+ const ths = this.table.querySelectorAll('thead th');
+ this.columnOrder = [];
+ const keysInOrder = [];
+ ths.forEach((th, idx) => {
+ const colKey = th.dataset.column || `col_${idx}`;
+ keysInOrder.push(colKey);
+ this.columnOrder.push(colKey);
+ });
+ this.columns.sort((a, b) => keysInOrder.indexOf(a.key) - keysInOrder.indexOf(b.key));
+ this.columns.forEach((col, idx) => col.index = idx);
+ this.persistState();
+ }
+
+ reorderColumns(keyOrder, persist = true) {
+ if (!keyOrder || keyOrder.length === 0) return;
+ const thead = this.table.querySelector('thead');
+ if (!thead) return;
+
+ const rows = this.table.querySelectorAll('tr');
+ const keyToThIndex = {};
+ const allTh = Array.from(thead.querySelectorAll('th'));
+
+ allTh.forEach((th, idx) => {
+ const key = th.dataset.column || `col_${idx}`;
+ keyToThIndex[key] = idx;
+ });
+
+ rows.forEach(row => {
+ const cells = row.querySelectorAll('td, th');
+ const reordered = [];
+ let lastRef = null;
+
+ keyOrder.forEach(key => {
+ const srcIdx = keyToThIndex[key];
+ if (srcIdx !== undefined && cells[srcIdx]) {
+ reordered.push(cells[srcIdx]);
+ }
+ });
+
+ reordered.forEach(cell => {
+ if (lastRef) {
+ row.insertBefore(cell, lastRef.nextSibling);
+ } else {
+ row.insertBefore(cell, row.firstChild);
+ }
+ lastRef = cell;
+ });
+ });
+
+ this.columnOrder = [...keyOrder];
+ this.columns.sort((a, b) => keyOrder.indexOf(a.key) - keyOrder.indexOf(b.key));
+ this.columns.forEach((col, idx) => col.index = idx);
+
+ if (persist) this.persistState();
+ }
+
+ setVisible(columnKey, visible) {
+ const th = this.table.querySelector(`th[data-column="${columnKey}"]`);
+ if (th) th.style.display = visible ? '' : 'none';
+
+ const colIdx = this.columns.findIndex(c => c.key === columnKey);
+ if (colIdx >= 0) {
+ this.columns[colIdx].visible = visible;
+ const rows = this.table.querySelectorAll('tbody tr, thead tr');
+ rows.forEach(row => {
+ const cells = row.querySelectorAll('td, th');
+ if (cells[colIdx]) {
+ cells[colIdx].style.display = visible ? '' : 'none';
+ }
+ });
+ }
+ }
+
+ getColumnWidths() {
+ return { ...this.columnWidths };
+ }
+
+ getColumnOrder() {
+ return [...this.columnOrder];
+ }
+
+ getVisibleColumns() {
+ return this.columns.filter(c => c.visible).map(c => c.key);
+ }
+
+ getColumnMetadata() {
+ return {
+ larghezze: this.getColumnWidths(),
+ ordine: this.getColumnOrder(),
+ visibili: this.getVisibleColumns(),
+ };
+ }
+
+ printWithSettings() {
+ const printWindow = window.open('', '_blank');
+ if (!printWindow) return;
+
+ const styles = Array.from(document.styleSheets)
+ .filter(sheet => {
+ try { return sheet.cssRules; } catch (e) { return false; }
+ })
+ .map(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText).join('\n'))
+ .join('\n');
+
+ const printStyles = `
+ @media print {
+ body { font-family: 'DejaVu Sans', Arial, sans-serif; font-size: 10pt; }
+ .no-print { display: none !important; }
+ .card { border: none !important; box-shadow: none !important; }
+ .table { border-collapse: collapse; width: 100%; }
+ .table th { background: #f5f5f5 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .table th, .table td { border: 1px solid #ddd; padding: 4px 6px; }
+ a { text-decoration: none; color: #000; }
+ .badge { border: 1px solid #999; padding: 1px 4px; font-size: 8pt; }
+ .pagination, .card-header .card-tools { display: none !important; }
+ .page-break { page-break-before: always; }
+ }
+ `;
+
+ const title = document.title || 'Stampa';
+ const pageTitle = document.querySelector('.page_title, .card-title h1, h1')?.textContent?.trim() || title;
+
+ let content = '';
+ const printArea = this.table?.cloneNode(true);
+ if (printArea) {
+ printArea.querySelectorAll('.col-resize-handle').forEach(h => h.remove());
+ content = printArea.outerHTML;
+ } else {
+ content = document.getElementById(this.tableId)?.outerHTML || '';
+ }
+
+ printWindow.document.write(`
+
+
+
+
+ ${title}
+
+
+
+
+ 🖨️ Stampa
+ ✖ Chiudi
+
+ ${pageTitle}
+ ${content}
+
+ Stampato il ${new Date().toLocaleDateString('it-IT')} — ${document.location.href}
+
+
+
+ `);
+ printWindow.document.close();
+ }
+}
+
+window.ColumnManager = ColumnManager;
diff --git a/resources/views/documenti/index.blade.php b/resources/views/documenti/index.blade.php
index 83268fce..37294264 100644
--- a/resources/views/documenti/index.blade.php
+++ b/resources/views/documenti/index.blade.php
@@ -5,6 +5,9 @@
@php
$canWriteDocumenti = Auth::user()->canManage('documenti');
$canDeleteDocumenti = Auth::user()->canDelete('documenti');
+$vistaDefaultJson = isset($vista) ? $vista->toJson() : 'null';
+$visibleColumnsJson = json_encode($visibleColumns ?? []);
+$tableColumnsJson = json_encode($tableColumns ?? []);
@endphp
@section('css')
@@ -293,21 +296,21 @@ $canDeleteDocumenti = Auth::user()->canDelete('documenti');
{{-- View: Lista --}}
@if($sottoCartelle->count() > 0 || $documenti->count() > 0)
-