浏览代码

Prenotazioni evento: conferma, mail in coda, piantina tavoli.

La conferma di massa passa da job Redis; le mail partono dall'observer senza bloccare l'admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
marcofalabretti 2 周前
父节点
当前提交
8ee478115f
共有 86 个文件被更改,包括 17582 次插入1492 次删除
  1. 68
    44
      app/DataTables/EventoDataTable.php
  2. 24
    4
      app/DataTables/EventoDataTableEditor.php
  3. 17
    9
      app/DataTables/EventoFormDataTableEditor.php
  4. 32
    4
      app/DataTables/OpzioneFormDataTableEditor.php
  5. 47
    33
      app/DataTables/PrenotazioneDataTable.php
  6. 47
    14
      app/DataTables/PrenotazioneDataTableEditor.php
  7. 30
    8
      app/DataTables/PrenotazioneTavoloDataTableEditor.php
  8. 380
    18
      app/Http/Controllers/ConsultaController.php
  9. 696
    13
      app/Http/Controllers/EventoController.php
  10. 834
    0
      app/Http/Controllers/EventoPiantinaController.php
  11. 1326
    49
      app/Http/Controllers/PrenotazioneController.php
  12. 15
    3
      app/Http/Controllers/PrenotazioneTavoloController.php
  13. 66
    0
      app/Jobs/ConfermaTuttePrenotazioniJob.php
  14. 18
    4
      app/Models/AbstractModels/AbstractEvento.php
  15. 4
    0
      app/Models/AbstractModels/AbstractEventoForm.php
  16. 51
    0
      app/Models/AbstractModels/AbstractEventoSala.php
  17. 62
    0
      app/Models/AbstractModels/AbstractEventoTavolo.php
  18. 2
    0
      app/Models/AbstractModels/AbstractPrenotazione.php
  19. 43
    0
      app/Models/AbstractModels/AbstractPrenotazioneAssegnazioneTavolo.php
  20. 458
    2
      app/Models/Evento.php
  21. 136
    10
      app/Models/EventoForm.php
  22. 93
    0
      app/Models/EventoSala.php
  23. 48
    0
      app/Models/EventoTavolo.php
  24. 88
    0
      app/Models/Prenotazione.php
  25. 8
    0
      app/Models/PrenotazioneAssegnazioneTavolo.php
  26. 64
    0
      app/Notifications/PrenotazioneAnnullata.php
  27. 71
    0
      app/Notifications/PrenotazioneConfermata.php
  28. 72
    0
      app/Notifications/PrenotazioneInviata.php
  29. 55
    0
      app/Notifications/PrenotazioneLinkModifica.php
  30. 36
    0
      app/Observers/EventoObserver.php
  31. 54
    0
      app/Observers/PrenotazioneObserver.php
  32. 103
    0
      app/Services/Calendario/CreaIcsPrenotazione.php
  33. 166
    0
      app/Services/Prenotazione/AccessoModificaCliente.php
  34. 327
    0
      app/Services/Prenotazione/AssegnaTavoliAutomatico.php
  35. 315
    0
      app/Services/Stampa/CreaSegnapostoFaccia.php
  36. 1
    1
      config/horizon.php
  37. 2
    2
      config/mail.php
  38. 29
    0
      database/migrations/2026_08_22_232525_fix__evento_form.php
  39. 24
    0
      database/migrations/2026_08_22_234242_fix__evento_form.php
  40. 26
    0
      database/migrations/2026_08_24_121900_add_numero_persone_to_prenotazione.php
  41. 22
    0
      database/migrations/2026_08_24_124800_make_prenotazione_telefono_nullable.php
  42. 28
    0
      database/migrations/2026_08_24_154039_conferma_prenotazione.php
  43. 36
    0
      database/migrations/2026_08_26_093626_evento_sala.php
  44. 43
    0
      database/migrations/2026_08_26_093635_evento_tavolo.php
  45. 37
    0
      database/migrations/2026_08_26_093656_prenotazione_assegnazione_tavolo.php
  46. 92
    0
      database/migrations/2026_08_26_220100_evento_sala_multiple.php
  47. 1
    1
      resources/views/consulta/index.blade.php
  48. 136
    0
      resources/views/consulta/prenotazione/_partials/flash-swal.blade.php
  49. 102
    0
      resources/views/consulta/prenotazione/annulla.blade.php
  50. 362
    0
      resources/views/consulta/prenotazione/modifica.blade.php
  51. 487
    152
      resources/views/consulta/prenotazione/show.blade.php
  52. 1
    1
      resources/views/consulta/scontrino/show.blade.php
  53. 305
    0
      resources/views/evento/_partials/domande-prenotazione.blade.php
  54. 97
    0
      resources/views/evento/_partials/email-anteprima.blade.php
  55. 764
    0
      resources/views/evento/_partials/public-styles.blade.php
  56. 572
    0
      resources/views/evento/admin_show.blade.php
  57. 1097
    213
      resources/views/evento/edit.blade.php
  58. 25
    3
      resources/views/evento/index.blade.php
  59. 6
    37
      resources/views/evento/menu.blade.php
  60. 35
    0
      resources/views/evento/modal_nuovo.blade.php
  61. 65
    0
      resources/views/evento/piantina/_partials/sale-tabs.blade.php
  62. 873
    0
      resources/views/evento/piantina/assegnazioni.blade.php
  63. 1734
    0
      resources/views/evento/piantina/edit.blade.php
  64. 301
    349
      resources/views/evento/show.blade.php
  65. 105
    0
      resources/views/prenotazione/_partials/campo-cella.blade.php
  66. 4
    0
      resources/views/prenotazione/_partials/modal.blade.php
  67. 564
    0
      resources/views/prenotazione/_partials/prenota-styles.blade.php
  68. 150
    0
      resources/views/prenotazione/cliente/_partials/campo-richiesta.blade.php
  69. 462
    463
      resources/views/prenotazione/cliente/prenota.blade.php
  70. 449
    0
      resources/views/prenotazione/edit.blade.php
  71. 40
    0
      resources/views/prenotazione/email/_partials/header.blade.php
  72. 131
    0
      resources/views/prenotazione/email/annullata.blade.php
  73. 133
    0
      resources/views/prenotazione/email/confermata.blade.php
  74. 141
    0
      resources/views/prenotazione/email/inviata.blade.php
  75. 87
    0
      resources/views/prenotazione/email/link-modifica.blade.php
  76. 70
    11
      resources/views/prenotazione/index.blade.php
  77. 21
    2
      resources/views/prenotazione/menu.blade.php
  78. 335
    0
      resources/views/prenotazione/nuova.blade.php
  79. 12
    0
      resources/views/prenotazione/report/_segnaposto-faccia.blade.php
  80. 403
    42
      resources/views/prenotazione/report/index.blade.php
  81. 29
    0
      resources/views/prenotazione/report/lista.blade.php
  82. 440
    0
      resources/views/prenotazione/report/pdf.blade.php
  83. 110
    0
      resources/views/prenotazione/report/piantina.blade.php
  84. 54
    0
      resources/views/prenotazione/report/segnaposto.blade.php
  85. 551
    0
      resources/views/prenotazione/report/show.blade.php
  86. 132
    0
      resources/views/prenotazione/report/tavoli.blade.php

+ 68
- 44
app/DataTables/EventoDataTable.php 查看文件

@@ -34,29 +34,38 @@ class EventoDataTable extends DataTable
34 34
             ->addColumn('stato_display', function($entity){
35 35
               return view('_partials.available', ['available' => $entity->is_attivo]);
36 36
             })
37
-            ->addColumn('data_inizio_display', function($entity){
38
-              return Carbon::parse($entity->data_inizio)->format('d/m/Y');
37
+            // ->addColumn('data_inizio_display', function($entity){
38
+            //   return Carbon::parse($entity->data_inizio)->format('d/m/Y');
39
+            // })
40
+            // ->addColumn('data_fine_display', function($entity){
41
+            //   return Carbon::parse($entity->data_fine)->format('d/m/Y');
42
+            // })
43
+            // ->addColumn('prezzo_display', function($entity){
44
+            //   return '€ ' . number_format($entity->prezzo, 2, ',', '.');
45
+            // })
46
+            // ->addColumn('apertura_prenotazione_display', function($entity){
47
+            //   return Carbon::parse($entity->apertura_prenotazione)->format('d/m/Y H:i');
48
+            // })
49
+            // ->addColumn('chiusura_prenotazione_display', function($entity){
50
+            //   return Carbon::parse($entity->chiusura_prenotazione)->format('d/m/Y H:i');
51
+            // })
52
+            // ->addColumn('periodo_evento_display', function($entity){
53
+            //   return Carbon::parse($entity->data_inizio)->format('d/m/Y') . ' - ' . Carbon::parse($entity->data_fine)->format('d/m/Y');
54
+            // })
55
+            // ->addColumn('periodo_prenotazioni_display', function($entity){
56
+            //   return Carbon::parse($entity->apertura_prenotazione)->format('d/m/Y') . ' - ' . Carbon::parse($entity->chiusura_prenotazione)->format('d/m/Y');
57
+            // })
58
+            // ->addColumn('prezzo_fisso_display', function($entity){
59
+            //   return view('_partials.available', ['available' => $entity->prezzo_fisso]);
60
+            // })
61
+            ->addColumn('posti_prenotati_confermati', function($entity){
62
+              return $entity->getCopertiConfermati();
39 63
             })
40
-            ->addColumn('data_fine_display', function($entity){
41
-              return Carbon::parse($entity->data_fine)->format('d/m/Y');
64
+            ->addColumn('posti_prenotati_non_confermati', function($entity){
65
+              return $entity->getPostiNonConfermati();
42 66
             })
43
-            ->addColumn('prezzo_display', function($entity){
44
-              return '€ ' . number_format($entity->prezzo, 2, ',', '.');
45
-            })
46
-            ->addColumn('apertura_prenotazione_display', function($entity){
47
-              return Carbon::parse($entity->apertura_prenotazione)->format('d/m/Y H:i');
48
-            })
49
-            ->addColumn('chiusura_prenotazione_display', function($entity){
50
-              return Carbon::parse($entity->chiusura_prenotazione)->format('d/m/Y H:i');
51
-            })
52
-            ->addColumn('periodo_evento_display', function($entity){
53
-              return Carbon::parse($entity->data_inizio)->format('d/m/Y') . ' - ' . Carbon::parse($entity->data_fine)->format('d/m/Y');
54
-            })
55
-            ->addColumn('periodo_prenotazioni_display', function($entity){
56
-              return Carbon::parse($entity->apertura_prenotazione)->format('d/m/Y') . ' - ' . Carbon::parse($entity->chiusura_prenotazione)->format('d/m/Y');
57
-            })
58
-            ->addColumn('prezzo_fisso_display', function($entity){
59
-              return view('_partials.available', ['available' => $entity->prezzo_fisso]);
67
+            ->addColumn('posti_rimasti', function($entity){
68
+              return $entity->posti - $entity->getCopertiConfermati() - $entity->getPostiNonConfermati();
60 69
             })
61 70
             ->setRowId('id');
62 71
     }
@@ -68,7 +77,12 @@ class EventoDataTable extends DataTable
68 77
      */
69 78
     public function query(Evento $model): QueryBuilder
70 79
     {
71
-        return $model->newQuery();
80
+        $attivitaId = (int) $this->attivita_id;
81
+        if ($attivitaId <= 0) {
82
+            return $model->newQuery()->whereRaw('1 = 0');
83
+        }
84
+
85
+        return $model->newQuery()->where('attivita_id', $attivitaId);
72 86
     }
73 87
 
74 88
     /**
@@ -78,19 +92,25 @@ class EventoDataTable extends DataTable
78 92
     {
79 93
         $buttons = [];
80 94
         if(Auth::user()->can('create-evento')){
81
-            array_push($buttons, Button::make('create')
82
-                ->editor('editor')
95
+
96
+            // array_push($buttons, Button::make('create')
97
+            //     ->editor('editor')
98
+            //     ->className('btn btn-sm btn-primary mb-4')
99
+            //     ->formTitle('Crea nuovo evento')
100
+            //     ->formButtons([
101
+            //         Button::raw('Annulla')
102
+            //             ->className('btn btn-secondary ml-2')
103
+            //             ->actionClose(),
104
+            //         Button::raw('Salva')
105
+            //             ->className('btn btn-success ml-2')
106
+            //             ->actionHandler('create')
107
+            //     ])
108
+            //     ->text('<i class="fas fa-plus"></i> Nuovo evento'));
109
+
110
+            array_push($buttons, Button::raw('nuovoEvento')
83 111
                 ->className('btn btn-sm btn-primary mb-4')
84
-                ->formTitle('Crea nuovo evento')
85
-                ->formButtons([
86
-                    Button::raw('Annulla')
87
-                        ->className('btn btn-secondary ml-2')
88
-                        ->actionClose(),
89
-                    Button::raw('Salva')
90
-                        ->className('btn btn-success ml-2')
91
-                        ->actionHandler('create')
92
-                ])
93
-                ->text('<i class="fas fa-plus"></i> Nuovo evento'));
112
+                ->text('<i class="fas fa-plus"></i> Nuovo evento')
113
+                ->action("nuovoEvento()"));
94 114
         }
95 115
         return $this->builder()
96 116
                     ->setTableId($this->dataTableVariable)
@@ -135,17 +155,21 @@ class EventoDataTable extends DataTable
135 155
         return [
136 156
             Column::make('id')->title('ID')->width('10%')->visible(false),
137 157
             Column::make('stato')->data('stato_display')->title('Stato')->addClass('text-center')->responsivePriority(1)->width('10%'),
138
-            Column::make('nome')->title('Nome')->responsivePriority(1),
158
+            Column::make('nome')->title('Titolo')->responsivePriority(1),
159
+            Column::make('posti')->title('Posti')->responsivePriority(2),
160
+            Column::computed('posti_prenotati_non_confermati')->title('NON confermati')->responsivePriority(2)->addClass('text-center text-warning'),
161
+            Column::computed('posti_prenotati_confermati')->title('Confermati')->responsivePriority(2)->addClass('text-center text-success'),
162
+            Column::computed('posti_rimasti')->title('Rimasti')->responsivePriority(2)->addClass('text-center'),
139 163
             // Column::make('descrizione')->title('Descrizione'),
140
-            Column::computed('periodo_evento_display')->title('Periodo evento')->responsivePriority(2),
141
-            Column::computed('periodo_prenotazioni_display')->title('Periodo prenotazioni')->responsivePriority(2),
142
-            Column::make('data_inizio')->title('Data inizio')->data('data_inizio_display')->visible(false)->responsivePriority(2),
143
-            Column::make('data_fine')->title('Data fine')->data('data_fine_display')->visible(false)->responsivePriority(2),
144
-            Column::make('apertura_prenotazione')->title('Apertura prenotazione')->data('apertura_prenotazione_display')->visible(false)->responsivePriority(2),
145
-            Column::make('chiusura_prenotazione')->title('Chiusura prenotazione')->data('chiusura_prenotazione_display')->visible(false)->responsivePriority(2),
146
-            Column::make('prezzo_fisso')->title('Prezzo fisso')->data('prezzo_fisso_display')->addClass('text-center')->responsivePriority(2),
147
-            Column::make('prezzo')->title('Prezzo')->data('prezzo_display')->addClass('text-center')->responsivePriority(2),
148
-            Column::make('note')->title('Note')->visible(false)->responsivePriority(2),
164
+            // Column::computed('periodo_evento_display')->title('Periodo evento')->responsivePriority(2),
165
+            // Column::computed('periodo_prenotazioni_display')->title('Periodo prenotazioni')->responsivePriority(2),
166
+            // Column::make('data_inizio')->title('Data inizio')->data('data_inizio_display')->visible(false)->responsivePriority(2),
167
+            // Column::make('data_fine')->title('Data fine')->data('data_fine_display')->visible(false)->responsivePriority(2),
168
+            // Column::make('apertura_prenotazione')->title('Apertura prenotazione')->data('apertura_prenotazione_display')->visible(false)->responsivePriority(2),
169
+            // Column::make('chiusura_prenotazione')->title('Chiusura prenotazione')->data('chiusura_prenotazione_display')->visible(false)->responsivePriority(2),
170
+            // Column::make('prezzo_fisso')->title('Prezzo fisso')->data('prezzo_fisso_display')->addClass('text-center')->responsivePriority(2),
171
+            // Column::make('prezzo')->title('Prezzo')->data('prezzo_display')->addClass('text-center')->responsivePriority(2),
172
+            // Column::make('note')->title('Note')->visible(false)->responsivePriority(2),
149 173
             Column::computed('action')
150 174
                   ->title('')
151 175
                   ->exportable(false)

+ 24
- 4
app/DataTables/EventoDataTableEditor.php 查看文件

@@ -3,12 +3,14 @@
3 3
 namespace App\DataTables;
4 4
 
5 5
 use App\Models\Evento;
6
+use App\Services\Attivita\AttivitaService;
6 7
 use Illuminate\Database\Eloquent\Model;
7 8
 use Illuminate\Http\JsonResponse;
8 9
 use Illuminate\Http\UploadedFile;
9 10
 use Yajra\DataTables\DataTablesEditor;
10 11
 use Illuminate\Http\Request;
11 12
 use Illuminate\Validation\ValidationException;
13
+use Illuminate\Support\Facades\Session;
12 14
 use Illuminate\Support\Facades\Storage;
13 15
 use Illuminate\Support\Str;
14 16
 
@@ -110,14 +112,24 @@ class EventoDataTableEditor extends DataTablesEditor
110 112
 
111 113
   public function creating(Model $model, array $data): array
112 114
   {
113
-    // $model->roles()->sync([$data['ruolo_id']]);
115
+    $data['attivita_id'] = Session::get('attivita_attuale');
116
+    AttivitaService::garantisciAttivita($data['attivita_id']);
117
+
114 118
     return $data;
115 119
   }
116 120
 
117 121
   public function updating(Model $model, array $data): array
118 122
   {
119
-    // dd($data['ruolo']);
120
-    // $model->roles()->sync([$data['ruolo_id']]);
123
+    AttivitaService::garantisciAttivita($model->attivita_id);
124
+    unset($data['attivita_id']);
125
+
126
+    return $data;
127
+  }
128
+
129
+  public function deleting(Model $model, array $data): array
130
+  {
131
+    AttivitaService::garantisciAttivita($model->attivita_id);
132
+
121 133
     return $data;
122 134
   }
123 135
 
@@ -246,10 +258,18 @@ class EventoDataTableEditor extends DataTablesEditor
246 258
   {
247 259
     $id = $request->input('id');
248 260
     if ($id !== null && $id !== '' && is_numeric($id)) {
261
+      $evento = Evento::find((int) $id);
262
+      if (! $evento) {
263
+        abort(404);
264
+      }
265
+      AttivitaService::garantisciAttivita($evento->attivita_id);
266
+
249 267
       return $this->uploadDir.'/'.(int) $id;
250 268
     }
251 269
 
252
-    return $this->uploadDir; //$this->uploadDir.'/tmp';
270
+    AttivitaService::garantisciAttivita(Session::get('attivita_attuale'));
271
+
272
+    return $this->uploadDir;
253 273
   }
254 274
 
255 275
   protected function getUploadedFilename(string $field, UploadedFile $uploadedFile): string

+ 17
- 9
app/DataTables/EventoFormDataTableEditor.php 查看文件

@@ -2,14 +2,11 @@
2 2
 
3 3
 namespace App\DataTables;
4 4
 
5
+use App\Models\Evento;
5 6
 use App\Models\EventoForm;
7
+use App\Services\Attivita\AttivitaService;
6 8
 use Illuminate\Database\Eloquent\Model;
7
-use Illuminate\Validation\Rule;
8 9
 use Yajra\DataTables\DataTablesEditor;
9
-use Illuminate\Http\Request;
10
-use Illuminate\Validation\ValidationException;
11
-use Storage;
12
-use Illuminate\Support\Str;
13 10
 
14 11
 class EventoFormDataTableEditor extends DataTablesEditor
15 12
 {
@@ -94,17 +91,28 @@ class EventoFormDataTableEditor extends DataTablesEditor
94 91
   public function creating(Model $model, array $data): array
95 92
   {
96 93
     $eventoId = request()->query('evento_id') ?? request()->input('evento_id');
97
-    if ($eventoId) {
98
-      $data['evento_id'] = (int) $eventoId;
94
+    $evento = Evento::find($eventoId);
95
+    if (! $evento) {
96
+      abort(404);
99 97
     }
98
+    AttivitaService::garantisciEventoDiQuestaAttivita($evento);
99
+    $data['evento_id'] = (int) $evento->id;
100 100
 
101 101
     return $data;
102 102
   }
103 103
 
104 104
   public function updating(Model $model, array $data): array
105 105
   {
106
-    // dd($data['ruolo']);
107
-    // $model->roles()->sync([$data['ruolo_id']]);
106
+    AttivitaService::garantisciEventoDiQuestaAttivita($model->evento);
107
+    unset($data['evento_id']);
108
+
109
+    return $data;
110
+  }
111
+
112
+  public function deleting(Model $model, array $data): array
113
+  {
114
+    AttivitaService::garantisciEventoDiQuestaAttivita($model->evento);
115
+
108 116
     return $data;
109 117
   }
110 118
   public function messages(): array

+ 32
- 4
app/DataTables/OpzioneFormDataTableEditor.php 查看文件

@@ -2,7 +2,9 @@
2 2
 
3 3
 namespace App\DataTables;
4 4
 
5
+use App\Models\EventoForm;
5 6
 use App\Models\OpzioneForm;
7
+use App\Services\Attivita\AttivitaService;
6 8
 use Illuminate\Database\Eloquent\Model;
7 9
 use Yajra\DataTables\DataTablesEditor;
8 10
 
@@ -40,11 +42,37 @@ class OpzioneFormDataTableEditor extends DataTablesEditor
40 42
 
41 43
     public function creating(Model $model, array $data): array
42 44
     {
43
-        $efId = request()->query('evento_form_id') ?? request()->input('evento_form_id');
44
-        if ($efId) {
45
-            $data['evento_form_id'] = (int) $efId;
46
-        }
45
+        $form = $this->garantisciFormDiQuestaAttivita(
46
+            request()->query('evento_form_id') ?? request()->input('evento_form_id') ?? ($data['evento_form_id'] ?? null)
47
+        );
48
+        $data['evento_form_id'] = (int) $form->id;
49
+
50
+        return $data;
51
+    }
52
+
53
+    public function updating(Model $model, array $data): array
54
+    {
55
+        $this->garantisciFormDiQuestaAttivita($model->evento_form_id);
56
+        unset($data['evento_form_id']);
57
+
58
+        return $data;
59
+    }
60
+
61
+    public function deleting(Model $model, array $data): array
62
+    {
63
+        $this->garantisciFormDiQuestaAttivita($model->evento_form_id);
47 64
 
48 65
         return $data;
49 66
     }
67
+
68
+    private function garantisciFormDiQuestaAttivita($formId): EventoForm
69
+    {
70
+        $form = EventoForm::find($formId);
71
+        if (! $form) {
72
+            abort(404);
73
+        }
74
+        AttivitaService::garantisciEventoDiQuestaAttivita($form->evento);
75
+
76
+        return $form;
77
+    }
50 78
 }

+ 47
- 33
app/DataTables/PrenotazioneDataTable.php 查看文件

@@ -42,6 +42,9 @@ class PrenotazioneDataTable extends DataTable
42 42
             ->addColumn('stato_display', function($entity){
43 43
               return view('prenotazione._partials.stato-prenotazione', ['stato' => $entity->stato??'']);
44 44
             })
45
+            ->addColumn('confermata', function($entity){
46
+              return view('_partials.available', ['available' => $entity->confermata]);
47
+            })
45 48
             ->setRowId('id');
46 49
     }
47 50
 
@@ -52,10 +55,16 @@ class PrenotazioneDataTable extends DataTable
52 55
      */
53 56
     public function query(Prenotazione $model): QueryBuilder
54 57
     {
55
-        if(isset($this->evento_id)){
56
-            return $model->newQuery()->where('evento_id', $this->evento_id);
58
+        $attivitaId = (int) session('attivita_attuale');
59
+        $query = $model->newQuery()->whereHas('evento', function ($q) use ($attivitaId) {
60
+            $q->where('attivita_id', $attivitaId);
61
+        });
62
+
63
+        if (isset($this->evento_id)) {
64
+            $query->where('evento_id', $this->evento_id);
57 65
         }
58
-        return $model->newQuery();
66
+
67
+        return $query;
59 68
     }
60 69
 
61 70
     /**
@@ -64,39 +73,42 @@ class PrenotazioneDataTable extends DataTable
64 73
     public function html(): HtmlBuilder
65 74
     {
66 75
         $titoloPrenotazioni = 'Prenotazione';
67
-        if(isset($this->evento_id)){
68
-            $titoloPrenotazioni = 'Prenota per ' . Evento::find($this->evento_id)->nome;
69
-        }
76
+
70 77
         $buttons = [];
71 78
         if(Auth::user()->can('create-prenotazione')){
72
-            array_push($buttons, Button::make('create')
73
-                ->editor('editor')
74
-                ->className('btn btn-sm btn-primary mb-4')
75
-                ->formTitle('Crea nuovo prenotazione')
76
-                ->formButtons([
77
-                    Button::raw('Annulla')
78
-                        ->className('btn btn-secondary ml-2')
79
-                        ->actionClose(),
80
-                    Button::raw('Salva')
81
-                        ->className('btn btn-success ml-2')
82
-                        ->actionHandler('create')
83
-                ])
84
-                ->text('<i class="fas fa-plus"></i> ' . $titoloPrenotazioni));
79
+            $nuovaUrl = isset($this->evento_id)
80
+                ? route('prenotazione.nuova', ['evento_id' => $this->evento_id])
81
+                : route('prenotazione.nuova');
82
+
83
+            array_push($buttons, Button::raw()
84
+                ->className('btn btn-sm btn-primary mb-4 text-white')
85
+                ->action('window.location.href = '.json_encode($nuovaUrl))
86
+                ->text('<i class="fas fa-plus"></i> Prenotazione'));
87
+            if($this->evento_id){
88
+                array_push($buttons, Button::raw('Conferma tutte le prenotazioni')
89
+                    ->className('btn btn-sm btn-primary mb-4 text-white')
90
+                        ->action('confermaTuttePrenotazioni()')
91
+                        ->text('<i class="fas fa-check"></i> Conferma tutte le prenotazioni')
92
+                    );
93
+            }
94
+
85 95
         };
86 96
 
87
-        if(Auth::user()->can('edit-prenotazione') || Auth::user()->can('segna-presenza-prenotazione')){
88
-            array_push($buttons, Button::raw('Appello')
89
-                ->className('btn btn-sm btn-warning mb-4')
90
-                ->action('appelloPrenotazioni')
91
-            );
92
-        }
97
+        // if(Auth::user()->can('edit-prenotazione') || Auth::user()->can('segna-presenza-prenotazione')){
98
+        //     array_push($buttons, Button::raw('Appello')
99
+        //         ->className('btn btn-sm btn-warning mb-4')
100
+        //         ->action('appelloPrenotazioni')
101
+        //     );
102
+        // }
93 103
 
94 104
         if(Auth::user()->can('report-prenotazione') || Auth::user()->can('segna-presenza-prenotazione')){
105
+            if($this->evento_id){
95 106
             array_push($buttons, Button::raw('Report')
96
-                ->className('btn btn-sm btn-success mb-4')
97
-                ->action('reportPrenotazioni()')
98
-                ->text('<i class="fas fa-file-alt"></i> Report')
99
-            );
107
+                ->className('btn btn-sm btn-primary mb-4 text-white')
108
+                    ->action('reportPrenotazioni()')
109
+                    ->text('<i class="fas fa-file-alt"></i> Report')
110
+                );
111
+            }
100 112
         }
101 113
 
102 114
 
@@ -108,10 +120,11 @@ $fields = [
108 120
         Fields\Text::make('email')->label('Email'),
109 121
         Fields\Text::make('telefono')->label('Telefono'),
110 122
         Fields\Select2::make('evento_id')->label('Evento')
111
-        ->options(Evento::where('is_attivo', true)->pluck('id', 'nome'))
123
+        ->options(Evento::where('attivita_id', session('attivita_attuale'))->orderBy('nome')->pluck('id', 'nome'))
112 124
         ->default(isset($this->evento_id) ? $this->evento_id : ''),
113
-        Fields\Select2::make('cucina_id')->label('Cucina')->options(Cucina::all()->pluck('id', 'nome')),
125
+        Fields\Select2::make('cucina_id')->label('Cucina')->options(Cucina::where('attivita_id', session('attivita_attuale'))->orderBy('nome')->pluck('id', 'nome')),
114 126
         Fields\Select2::make('stato')->label('Stato')->options(Prenotazione::getStati()->pluck('value', 'label'))->default(Prenotazione::STATO_INVIATA) ,
127
+        Fields\Boolean::make('confermata')->label('Confermata')->default(false),
115 128
         Fields\Text::make('note')->label('Note'),
116 129
         Fields\Text::make('codice')->label('Codice'),
117 130
         Fields\Text::make('stato_pagamento')->label('Stato pagamento'),
@@ -150,14 +163,15 @@ $fields = [
150 163
         return [
151 164
             Column::make('id')->title('ID')->width('10%')->visible(false)->responsivePriority(1)->width('10%'),
152 165
             Column::make('stato')->data('stato_display')->title('Stato')->searchable(true)->filter(true)->responsivePriority(1),
166
+            Column::make('confermata')->addClass('text-center')->title('Confermata')->responsivePriority(1),
153 167
             Column::make('codice')->title('Codice')->responsivePriority(1),
154 168
             Column::make('cognome')->title('Cognome')->responsivePriority(2),
155 169
             Column::make('nome')->title('Nome'),
156 170
             Column::make('email')->title('Email'),
157 171
             Column::make('telefono')->title('Telefono'),
158 172
             Column::make('evento_id_display')->title('Evento'),
159
-            Column::make('cucina_id_display')->title('Cucina'),
160
-            Column::make('note')->title('Note'),
173
+            // Column::make('cucina_id_display')->title('Cucina'),
174
+            // Column::make('note')->title('Note'),
161 175
             Column::computed('action')
162 176
                   ->title('')
163 177
                   ->exportable(false)

+ 47
- 14
app/DataTables/PrenotazioneDataTableEditor.php 查看文件

@@ -2,20 +2,16 @@
2 2
 
3 3
 namespace App\DataTables;
4 4
 
5
+use App\Models\Cucina;
6
+use App\Models\Evento;
5 7
 use App\Models\Prenotazione;
8
+use App\Services\Attivita\AttivitaService;
6 9
 use Illuminate\Database\Eloquent\Model;
7
-use Illuminate\Validation\Rule;
8 10
 use Yajra\DataTables\DataTablesEditor;
9
-use Illuminate\Http\Request;
10
-use Illuminate\Validation\ValidationException;
11
-use Storage;
12
-use Illuminate\Support\Str;
13 11
 
14 12
 class PrenotazioneDataTableEditor extends DataTablesEditor
15 13
 {
16 14
   protected $model = Prenotazione::class;
17
-  // protected $uploadDir = '';
18
-  // protected $disk = 'modelliRicevute';
19 15
 
20 16
   protected $messages = [
21 17
     'nome.required' => 'Il nome è richiesto',
@@ -24,7 +20,7 @@ class PrenotazioneDataTableEditor extends DataTablesEditor
24 20
     'telefono.required' => 'Il telefono è richiesto',
25 21
     'evento_id.required' => 'L\'evento è richiesto',
26 22
     'cucina_id.required' => 'La cucina è richiesta',
27
-    'stato.required' => 'Il stato è richiesto',
23
+    'stato.required' => 'Lo stato è richiesto',
28 24
     'note.required' => 'La note è richiesta',
29 25
   ];
30 26
 
@@ -41,8 +37,9 @@ class PrenotazioneDataTableEditor extends DataTablesEditor
41 37
       'email' => 'nullable|email',
42 38
       'telefono' => 'nullable',
43 39
       'evento_id' => 'required|exists:evento,id',
44
-      'cucina_id' => 'required|exists:cucina,id',
40
+      'cucina_id' => 'nullable|exists:cucina,id',
45 41
       'stato' => 'nullable|string',
42
+      'confermata' => 'nullable|boolean',
46 43
       'note' => 'nullable',
47 44
       'codice' => 'required|unique:prenotazione,codice',
48 45
       'stato_pagamento' => 'nullable|string',
@@ -68,10 +65,11 @@ class PrenotazioneDataTableEditor extends DataTablesEditor
68 65
       'email' => 'nullable|email',
69 66
       'telefono' => 'nullable',
70 67
       'evento_id' => 'required|exists:evento,id',
71
-      'cucina_id' => 'required|exists:cucina,id',
68
+      'cucina_id' => 'nullable|exists:cucina,id',
72 69
       'stato' => 'nullable|string',
70
+      'confermata' => 'nullable|boolean',
73 71
       'note' => 'nullable',
74
-      'codice' => 'required|unique:prenotazione,codice',
72
+      'codice' => 'required|unique:prenotazione,codice,'.$model->getKey(),
75 73
     ];
76 74
   }
77 75
 
@@ -92,16 +90,51 @@ class PrenotazioneDataTableEditor extends DataTablesEditor
92 90
 
93 91
   public function creating(Model $model, array $data): array
94 92
   {
95
-    // $model->roles()->sync([$data['ruolo_id']]);
93
+    $evento = $this->eventoDiQuestaAttivita($data['evento_id'] ?? null);
94
+    $this->garantisciCucinaDiQuestaAttivita($data['cucina_id'] ?? null, $evento->attivita_id);
95
+
96 96
     return $data;
97 97
   }
98 98
 
99 99
   public function updating(Model $model, array $data): array
100 100
   {
101
-    // dd($data['ruolo']);
102
-    // $model->roles()->sync([$data['ruolo_id']]);
101
+    $this->eventoDiQuestaAttivita($model->evento_id);
102
+    $evento = $this->eventoDiQuestaAttivita($data['evento_id'] ?? $model->evento_id);
103
+    $this->garantisciCucinaDiQuestaAttivita($data['cucina_id'] ?? $model->cucina_id, $evento->attivita_id);
104
+
103 105
     return $data;
104 106
   }
107
+
108
+  public function deleting(Model $model, array $data): array
109
+  {
110
+    $this->eventoDiQuestaAttivita($model->evento_id);
111
+
112
+    return $data;
113
+  }
114
+
115
+  private function eventoDiQuestaAttivita($eventoId): Evento
116
+  {
117
+    $evento = Evento::find($eventoId);
118
+    if (! $evento) {
119
+      abort(404);
120
+    }
121
+    AttivitaService::garantisciEventoDiQuestaAttivita($evento);
122
+
123
+    return $evento;
124
+  }
125
+
126
+  private function garantisciCucinaDiQuestaAttivita($cucinaId, $attivitaId): void
127
+  {
128
+    if (! $cucinaId) {
129
+      return;
130
+    }
131
+
132
+    $cucina = Cucina::find($cucinaId);
133
+    if (! $cucina || (int) $cucina->attivita_id !== (int) $attivitaId) {
134
+      abort(404);
135
+    }
136
+  }
137
+
105 138
   public function messages(): array
106 139
   {
107 140
     return $this->messages;

+ 30
- 8
app/DataTables/PrenotazioneTavoloDataTableEditor.php 查看文件

@@ -3,13 +3,11 @@
3 3
 namespace App\DataTables;
4 4
 
5 5
 use App\Models\PrenotazioneTavolo;
6
+use App\Models\Tavolo;
7
+use App\Services\Attivita\AttivitaService;
6 8
 use Illuminate\Database\Eloquent\Model;
7
-use Illuminate\Validation\Rule;
8 9
 use Yajra\DataTables\DataTablesEditor;
9
-use Illuminate\Http\Request;
10
-use Illuminate\Validation\ValidationException;
11
-use Storage;
12
-use Illuminate\Support\Str;
10
+use Illuminate\Support\Facades\Session;
13 11
 
14 12
 class PrenotazioneTavoloDataTableEditor extends DataTablesEditor
15 13
 {
@@ -90,16 +88,40 @@ class PrenotazioneTavoloDataTableEditor extends DataTablesEditor
90 88
 
91 89
   public function creating(Model $model, array $data): array
92 90
   {
93
-    // $model->roles()->sync([$data['ruolo_id']]);
91
+    $data['attivita_id'] = Session::get('attivita_attuale');
92
+    AttivitaService::garantisciAttivita($data['attivita_id']);
93
+    $this->garantisciTavoloDiQuestaAttivita($data['tavolo_id'] ?? null, $data['attivita_id']);
94
+
94 95
     return $data;
95 96
   }
96 97
 
97 98
   public function updating(Model $model, array $data): array
98 99
   {
99
-    // dd($data['ruolo']);
100
-    // $model->roles()->sync([$data['ruolo_id']]);
100
+    AttivitaService::garantisciAttivita($model->attivita_id);
101
+    unset($data['attivita_id']);
102
+    $this->garantisciTavoloDiQuestaAttivita($data['tavolo_id'] ?? $model->tavolo_id, $model->attivita_id);
103
+
101 104
     return $data;
102 105
   }
106
+
107
+  public function deleting(Model $model, array $data): array
108
+  {
109
+    AttivitaService::garantisciAttivita($model->attivita_id);
110
+
111
+    return $data;
112
+  }
113
+
114
+  private function garantisciTavoloDiQuestaAttivita($tavoloId, $attivitaId): void
115
+  {
116
+    if (! $tavoloId) {
117
+      return;
118
+    }
119
+
120
+    $tavolo = Tavolo::find($tavoloId);
121
+    if (! $tavolo || (int) $tavolo->attivita_id !== (int) $attivitaId) {
122
+      abort(404);
123
+    }
124
+  }
103 125
   public function messages(): array
104 126
   {
105 127
     return $this->messages;

+ 380
- 18
app/Http/Controllers/ConsultaController.php 查看文件

@@ -2,12 +2,18 @@
2 2
 
3 3
 namespace App\Http\Controllers;
4 4
 
5
+use App\Models\EventoForm;
5 6
 use App\Models\Ordine;
6 7
 use App\Models\Prenotazione;
8
+use App\Models\PrenotazioneRisposta;
9
+use App\Notifications\PrenotazioneLinkModifica;
10
+use App\Services\Prenotazione\AccessoModificaCliente;
7 11
 use Endroid\QrCode\Builder\Builder;
8 12
 use Endroid\QrCode\Color\Color;
9 13
 use Endroid\QrCode\Writer\SvgWriter;
10 14
 use Illuminate\Http\Request;
15
+use Illuminate\Support\Facades\Notification;
16
+use Illuminate\Support\Facades\Validator;
11 17
 
12 18
 class ConsultaController extends Controller
13 19
 {
@@ -18,37 +24,400 @@ class ConsultaController extends Controller
18 24
 
19 25
     public function scontrino_show(Request $request)
20 26
     {
21
-        $ordine = Ordine::find($request->route('ordine_id'));
22
-        // dd($ordine);
23
-        if (!$ordine) {
27
+        $ordine = Ordine::where('codice', $request->route('ordine_codice'))->first();
28
+        if (! $ordine) {
24 29
             return redirect()->route('consulta.index')->with('error', 'Ordine non trovato');
25 30
         }
31
+
26 32
         return view('consulta.scontrino.show', compact('ordine'));
27 33
     }
28
-    
34
+
29 35
     public function comanda_show(Request $request)
30 36
     {
31
-        $ordine = Ordine::find($request->route('ordine_id'));
32
-        if (!$ordine) {
37
+        $ordine = Ordine::where('codice', $request->route('ordine_codice'))->first();
38
+        if (! $ordine) {
33 39
             return redirect()->route('consulta.index')->with('error', 'Ordine non trovato');
34 40
         }
41
+
35 42
         return view('consulta.comanda.show', compact('ordine'));
36 43
     }
44
+
37 45
     public function prenotazione_show(Request $request)
38 46
     {
47
+        $prenotazione = $this->trovaPrenotazionePerCodice($request->route('codice'));
48
+        if (! $prenotazione) {
49
+            return redirect()->route('welcome')->with('error', 'Prenotazione non trovata');
50
+        }
51
+
52
+        return view('consulta.prenotazione.show', [
53
+            'prenotazione' => $prenotazione,
54
+            'codiceQrSvg' => $this->qrSvg($prenotazione),
55
+            'puoModificare' => (bool) $prenotazione->evento?->clientePuoModificareQuestaPrenotazione($prenotazione),
56
+            'puoAnnullare' => (bool) $prenotazione->evento?->clientePuoAnnullareQuestaPrenotazione($prenotazione),
57
+        ]);
58
+    }
59
+
60
+    public function prenotazione_calendario(Request $request, \App\Services\Calendario\CreaIcsPrenotazione $ics)
61
+    {
62
+        $request->validate([
63
+            'codice' => 'required|string',
64
+        ]);
65
+
39 66
         $prenotazione = Prenotazione::query()
67
+            ->with(['evento.attivita'])
68
+            ->where('codice', $request->get('codice'))
69
+            ->first();
70
+
71
+        if (! $prenotazione) {
72
+            return redirect()->route('consulta.index')->with('error', 'Prenotazione non trovata');
73
+        }
74
+
75
+        $contenuto = $ics->contenuto($prenotazione);
76
+        if (! $contenuto) {
77
+            return redirect()
78
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
79
+                ->with('error', 'Calendario non disponibile per questa prenotazione');
80
+        }
81
+
82
+        return response($contenuto, 200, [
83
+            'Content-Type' => 'text/calendar; charset=utf-8',
84
+            'Content-Disposition' => 'attachment; filename="'.$ics->nomeFile($prenotazione).'"',
85
+        ]);
86
+    }
87
+
88
+    /**
89
+     * Da consulta: invia email con magic link (modifica o annulla).
90
+     */
91
+    public function prenotazione_richiedi_modifica(Request $request, AccessoModificaCliente $accesso)
92
+    {
93
+        $data = $request->validate([
94
+            'codice' => 'required|string',
95
+            'azione' => 'required|in:modifica,annulla',
96
+        ]);
97
+
98
+        $prenotazione = $this->trovaPrenotazionePerCodice($data['codice']);
99
+        if (! $prenotazione) {
100
+            return redirect()->route('consulta.index')->with('error', 'Prenotazione non trovata');
101
+        }
102
+
103
+        $azione = $data['azione'];
104
+        if (! $accesso->puoRichiedereLink($prenotazione, $azione)) {
105
+            return redirect()
106
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
107
+                ->with('error', 'Questa operazione non è più disponibile per la prenotazione.');
108
+        }
109
+
110
+        if (! filled($prenotazione->email)) {
111
+            return redirect()
112
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
113
+                ->with('error', 'Nessuna email associata a questa prenotazione.');
114
+        }
115
+
116
+        if (! $accesso->puoInviareOra($prenotazione, $azione)) {
117
+            return redirect()
118
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
119
+                ->with('error', 'Abbiamo già inviato un link. Controlla la posta o riprova tra un minuto.');
120
+        }
121
+
122
+        $url = $accesso->urlFirmato($prenotazione, $azione);
123
+        $emailMask = $accesso->mascheraEmail($prenotazione->email);
124
+
125
+        Notification::route('mail', $prenotazione->email)
126
+            ->notify(new PrenotazioneLinkModifica($prenotazione, $azione, $url));
127
+
128
+        $msg = $azione === 'annulla'
129
+            ? 'Ti abbiamo inviato un’email a '.$emailMask.' con il link per annullare (valido '.AccessoModificaCliente::MINUTI_LINK.' minuti). Controlla anche lo spam.'
130
+            : 'Ti abbiamo inviato un’email a '.$emailMask.' con il link per modificare (valido '.AccessoModificaCliente::MINUTI_LINK.' minuti). Controlla anche lo spam.';
131
+
132
+        return redirect()
133
+            ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
134
+            ->with('success', $msg);
135
+    }
136
+
137
+    /**
138
+     * Entrypoint magic link firmato (monouso) → avvia sessione 15 min.
139
+     */
140
+    public function prenotazione_modifica_apri(Request $request, AccessoModificaCliente $accesso)
141
+    {
142
+        if (! $request->hasValidSignature()) {
143
+            return redirect()
144
+                ->route('consulta.index')
145
+                ->with('error', 'Link non valido o scaduto. Richiedine uno nuovo dalla scheda prenotazione.');
146
+        }
147
+
148
+        $codice = (string) $request->query('codice', '');
149
+        $azione = (string) $request->query('azione', 'modifica');
150
+
151
+        $prenotazione = $this->trovaPrenotazionePerCodice($codice);
152
+        if (! $prenotazione) {
153
+            return redirect()->route('consulta.index')->with('error', 'Prenotazione non trovata');
154
+        }
155
+
156
+        if (! $accesso->puoRichiedereLink($prenotazione, $azione)) {
157
+            return redirect()
158
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
159
+                ->with('error', 'Questa operazione non è più disponibile.');
160
+        }
161
+
162
+        if (! $accesso->consumaLink($request)) {
163
+            return redirect()
164
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
165
+                ->with('error', 'Questo link è già stato usato. Richiedine uno nuovo se serve.');
166
+        }
167
+
168
+        $accesso->avviaSessione($prenotazione, $azione);
169
+
170
+        return $azione === 'annulla'
171
+            ? redirect()->route('consulta.prenotazione.annulla.form')
172
+            : redirect()->route('consulta.prenotazione.modifica.form');
173
+    }
174
+
175
+    public function prenotazione_modifica_form(AccessoModificaCliente $accesso)
176
+    {
177
+        $prenotazione = $this->prenotazioneDaSessione($accesso, 'modifica');
178
+        if (! $prenotazione) {
179
+            return redirect()->route('consulta.index')->with('error', 'Sessione di modifica scaduta. Richiedi un nuovo link.');
180
+        }
181
+
182
+        if (! $accesso->puoRichiedereLink($prenotazione, 'modifica')) {
183
+            $accesso->chiudiSessione();
184
+
185
+            return redirect()
186
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
187
+                ->with('error', 'La modifica non è più disponibile.');
188
+        }
189
+
190
+        $risposte = $this->rispostePerForm($prenotazione);
191
+
192
+        return view('consulta.prenotazione.modifica', [
193
+            'prenotazione' => $prenotazione,
194
+            'evento' => $prenotazione->evento,
195
+            'risposte' => $risposte,
196
+            'minutiSessione' => AccessoModificaCliente::MINUTI_LINK,
197
+        ]);
198
+    }
199
+
200
+    public function prenotazione_modifica_salva(Request $request, AccessoModificaCliente $accesso)
201
+    {
202
+        $prenotazione = $this->prenotazioneDaSessione($accesso, 'modifica');
203
+        if (! $prenotazione || ! $prenotazione->evento) {
204
+            return redirect()->route('consulta.index')->with('error', 'Sessione di modifica scaduta.');
205
+        }
206
+
207
+        $evento = $prenotazione->evento;
208
+        if (! $accesso->puoRichiedereLink($prenotazione, 'modifica')) {
209
+            $accesso->chiudiSessione();
210
+
211
+            return redirect()
212
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
213
+                ->with('error', 'La modifica non è più disponibile.');
214
+        }
215
+
216
+        $nPersone = max(1, (int) $request->input('numero_persone', 1));
217
+        $user = null;
218
+
219
+        $rules = [
220
+            'nome' => 'required|string|max:255',
221
+            'cognome' => 'required|string|max:255',
222
+            'telefono' => 'nullable|string|max:50',
223
+            'note' => 'nullable|string|max:2000',
224
+            'numero_persone' => 'required|integer|min:1|max:100',
225
+        ];
226
+
227
+        $campiVisibili = $evento->form->filter(fn ($campo) => $campo->isVisibileAlCliente($user));
228
+
229
+        foreach ($campiVisibili as $campo) {
230
+            $key = 'prenotazione_risposta_'.$campo->id;
231
+            $obbligatoria = (bool) $campo->obbligatoria;
232
+
233
+            if ($campo->per_coperto) {
234
+                $rules[$key] = $obbligatoria ? 'required|array|size:'.$nPersone : 'nullable|array';
235
+                for ($i = 0; $i < $nPersone; $i++) {
236
+                    if ($campo->tipo === EventoForm::MULTISELEZIONE) {
237
+                        $rules[$key.'.'.$i] = $obbligatoria ? 'required|array|min:1' : 'nullable|array';
238
+                    } elseif ($campo->tipo === EventoForm::CHECKBOX) {
239
+                        $rules[$key.'.'.$i] = $obbligatoria ? 'required|in:1' : 'nullable';
240
+                    } else {
241
+                        $rules[$key.'.'.$i] = $obbligatoria ? 'required' : 'nullable';
242
+                    }
243
+                }
244
+            } elseif ($campo->tipo === EventoForm::MULTISELEZIONE) {
245
+                $rules[$key] = $obbligatoria ? 'required|array|min:1' : 'nullable|array';
246
+            } elseif ($campo->tipo === EventoForm::CHECKBOX) {
247
+                $rules[$key] = $obbligatoria ? 'required|in:1' : 'nullable';
248
+            } else {
249
+                $rules[$key] = $obbligatoria ? 'required' : 'nullable';
250
+            }
251
+        }
252
+
253
+        $validator = Validator::make($request->all(), $rules);
254
+        if ($validator->fails()) {
255
+            return redirect()
256
+                ->route('consulta.prenotazione.modifica.form')
257
+                ->withErrors($validator)
258
+                ->withInput()
259
+                ->with('error', $validator->errors()->first());
260
+        }
261
+
262
+        $eraConfermata = $prenotazione->stato === Prenotazione::STATO_CONFERMATA
263
+            || (bool) $prenotazione->confermata;
264
+
265
+        $prenotazione->fill($request->only(['nome', 'cognome', 'telefono', 'note']));
266
+        $prenotazione->numero_persone = $nPersone;
267
+        // Email non modificabile dal cliente.
268
+
269
+        if ($eraConfermata && $evento->clienteModificaSeConfermata()) {
270
+            $prenotazione->stato = Prenotazione::STATO_INVIATA;
271
+            $prenotazione->confermata = false;
272
+        }
273
+
274
+        $prenotazione->save();
275
+
276
+        $seenFormIds = [];
277
+        foreach ($campiVisibili as $campo) {
278
+            $value = $request->input('prenotazione_risposta_'.$campo->id);
279
+            if (is_array($value)) {
280
+                $value = json_encode(array_values($value), JSON_UNESCAPED_UNICODE);
281
+            }
282
+
283
+            if ($value === null || $value === '') {
284
+                continue;
285
+            }
286
+
287
+            PrenotazioneRisposta::updateOrCreate(
288
+                [
289
+                    'prenotazione_id' => $prenotazione->id,
290
+                    'evento_form_id' => $campo->id,
291
+                ],
292
+                ['risposta' => (string) $value]
293
+            );
294
+            $seenFormIds[] = $campo->id;
295
+        }
296
+
297
+        $visibleIds = $campiVisibili->pluck('id')->all();
298
+        if ($visibleIds !== []) {
299
+            $prenotazione->risposte()
300
+                ->whereIn('evento_form_id', $visibleIds)
301
+                ->when(
302
+                    $seenFormIds !== [],
303
+                    fn ($q) => $q->whereNotIn('evento_form_id', $seenFormIds),
304
+                    fn ($q) => $q
305
+                )
306
+                ->delete();
307
+        }
308
+
309
+        $accesso->chiudiSessione();
310
+
311
+        $msg = 'Prenotazione aggiornata.';
312
+        if ($eraConfermata && $prenotazione->stato === Prenotazione::STATO_INVIATA) {
313
+            $msg .= ' È tornata in attesa di conferma da parte degli organizzatori.';
314
+        }
315
+
316
+        return redirect()
317
+            ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
318
+            ->with('success', $msg);
319
+    }
320
+
321
+    public function prenotazione_annulla_form(AccessoModificaCliente $accesso)
322
+    {
323
+        $prenotazione = $this->prenotazioneDaSessione($accesso, 'annulla');
324
+        if (! $prenotazione) {
325
+            return redirect()->route('consulta.index')->with('error', 'Sessione scaduta. Richiedi un nuovo link.');
326
+        }
327
+
328
+        if (! $accesso->puoRichiedereLink($prenotazione, 'annulla')) {
329
+            $accesso->chiudiSessione();
330
+
331
+            return redirect()
332
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
333
+                ->with('error', 'L’annullamento non è più disponibile.');
334
+        }
335
+
336
+        return view('consulta.prenotazione.annulla', [
337
+            'prenotazione' => $prenotazione,
338
+            'evento' => $prenotazione->evento,
339
+            'minutiSessione' => AccessoModificaCliente::MINUTI_LINK,
340
+        ]);
341
+    }
342
+
343
+    public function prenotazione_annulla_conferma(Request $request, AccessoModificaCliente $accesso)
344
+    {
345
+        $prenotazione = $this->prenotazioneDaSessione($accesso, 'annulla');
346
+        if (! $prenotazione) {
347
+            return redirect()->route('consulta.index')->with('error', 'Sessione scaduta.');
348
+        }
349
+
350
+        if (! $accesso->puoRichiedereLink($prenotazione, 'annulla')) {
351
+            $accesso->chiudiSessione();
352
+
353
+            return redirect()
354
+                ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
355
+                ->with('error', 'L’annullamento non è più disponibile.');
356
+        }
357
+
358
+        $request->validate([
359
+            'conferma' => 'accepted',
360
+        ], [
361
+            'conferma.accepted' => 'Conferma di voler annullare la prenotazione.',
362
+        ]);
363
+
364
+        $prenotazione->stato = Prenotazione::STATO_CANCELLATA;
365
+        $prenotazione->confermata = false;
366
+        $prenotazione->save();
367
+
368
+        $accesso->chiudiSessione();
369
+
370
+        return redirect()
371
+            ->route('consulta.prenotazione.show', ['codice' => $prenotazione->codice])
372
+            ->with('success', 'Prenotazione annullata.');
373
+    }
374
+
375
+    private function trovaPrenotazionePerCodice(?string $codice): ?Prenotazione
376
+    {
377
+        if (! filled($codice)) {
378
+            return null;
379
+        }
380
+
381
+        return Prenotazione::query()
40 382
             ->with([
41 383
                 'risposte',
384
+                'evento.attivita',
42 385
                 'evento.form' => function ($q) {
43
-                    $q->orderBy('ordine_visualizzazione')->with('opzioni_form');
386
+                    $q->orderBy('ordine_visualizzazione')->with(['opzioni_form' => function ($oq) {
387
+                        $oq->orderBy('ordine')->orderBy('id');
388
+                    }]);
44 389
                 },
45 390
             ])
46
-            ->where('codice', $request->get('codice'))
391
+            ->where('codice', $codice)
47 392
             ->first();
48
-        if (!$prenotazione) {
49
-            return redirect()->route('consulta.index')->with('error', 'Prenotazione non trovata');
393
+    }
394
+
395
+    private function prenotazioneDaSessione(AccessoModificaCliente $accesso, string $azione): ?Prenotazione
396
+    {
397
+        if (! $accesso->sessioneValida(azione: $azione)) {
398
+            return null;
50 399
         }
51 400
 
401
+        $codice = $accesso->codiceSessione();
402
+
403
+        return $this->trovaPrenotazionePerCodice($codice);
404
+    }
405
+
406
+    private function rispostePerForm(Prenotazione $prenotazione): array
407
+    {
408
+        $risposte = [];
409
+        foreach ($prenotazione->risposte as $r) {
410
+            $decoded = json_decode((string) $r->risposta, true);
411
+            $risposte[$r->evento_form_id] = (json_last_error() === JSON_ERROR_NONE && is_array($decoded))
412
+                ? $decoded
413
+                : $r->risposta;
414
+        }
415
+
416
+        return $risposte;
417
+    }
418
+
419
+    private function qrSvg(Prenotazione $prenotazione): string
420
+    {
52 421
         $qrBuilder = new Builder(
53 422
             writer: new SvgWriter(),
54 423
             writerOptions: [
@@ -56,18 +425,11 @@ class ConsultaController extends Controller
56 425
             ],
57 426
         );
58 427
 
59
-        $codiceQrSvg = $qrBuilder->build(
428
+        return $qrBuilder->build(
60 429
             data: (string) $prenotazione->codice,
61 430
             size: 168,
62 431
             margin: 4,
63 432
             foregroundColor: new Color(21, 38, 92),
64
-            // class: 'bg-primary',
65
-            // backgroundColor: new Color(245, 130, 32),
66 433
         )->getString();
67
-
68
-        return view('consulta.prenotazione.show', [
69
-            'prenotazione' => $prenotazione,
70
-            'codiceQrSvg' => $codiceQrSvg,
71
-        ]);
72 434
     }
73 435
 }

+ 696
- 13
app/Http/Controllers/EventoController.php 查看文件

@@ -4,10 +4,19 @@ namespace App\Http\Controllers;
4 4
 
5 5
 use Illuminate\Http\Request;
6 6
 use App\Models\Evento;
7
+use App\Models\EventoForm;
8
+use App\Models\OpzioneForm;
9
+use App\Models\Prenotazione;
7 10
 use App\DataTables\EventoDataTable;
8 11
 use App\DataTables\EventoDataTableEditor;
9 12
 use Illuminate\Support\Facades\Auth;
10
-use App\DataTables\EventoFormDataTable;
13
+use Illuminate\Support\Facades\DB;
14
+use Illuminate\Support\Facades\Http;
15
+use Illuminate\Support\Facades\Storage;
16
+use Illuminate\Routing\Controllers\Middleware;
17
+use Illuminate\Support\Facades\Session;
18
+use Illuminate\Support\Str;
19
+use App\Services\Attivita\AttivitaService;
11 20
 
12 21
 
13 22
 class EventoController extends Controller
@@ -20,18 +29,31 @@ class EventoController extends Controller
20 29
         'delete-evento' => 'Elimina',
21 30
     ];
22 31
 
32
+    // ADMIN
23 33
     public static function middleware(): array
24 34
     {
25 35
         return [
26
-            new Middleware('permission:view-evento', only: ['index']),
27
-            new Middleware('permission:create-evento|edit-evento|delete-evento', only: ['store', 'update', 'destroy']),
36
+            new Middleware('permission:view-evento', only: ['index', 'admin_show']),
37
+            new Middleware('permission:create-evento|edit-evento|delete-evento', only: [
38
+                'store', 'update', 'destroy', 'crea', 'edit', 'geocodeLuogo',
39
+                'gallery_edit', 'gallery_upload',
40
+                'applicaPresetDomande',
41
+                'storeDomanda', 'updateDomanda', 'destroyDomanda', 'reorderDomande',
42
+                'uploadLocandina', 'destroyLocandina', 'uploadCopertina', 'destroyCopertina',
43
+            ]),
28 44
         ];
29 45
     }
30 46
 
47
+    // ADMIN
31 48
     public function index(EventoDatatable $dataTable)
32 49
     {
33
-        return $dataTable->render('evento.index');
50
+      $dataTable->attivita_id = Session::get('attivita_attuale');
51
+      return $dataTable->render('evento.index', [
52
+        'attivita_id' => Session::get('attivita_attuale'),
53
+      ]);
34 54
     }
55
+
56
+    // ADMIN
35 57
     public function store(EventoDataTableEditor $editor)
36 58
     {
37 59
         $request = request();
@@ -52,18 +74,106 @@ class EventoController extends Controller
52 74
         return $editor->process($request);
53 75
     }
54 76
 
55
-    public function show(Request $request)
77
+    // ADMIN — cruscotto evento
78
+    public function admin_show(Request $request)
56 79
     {
57
-      // dd($request->all());
58
-        $evento = Evento::find($request->get('evento_id'));
80
+        $evento = Evento::with([
81
+            'attivita',
82
+            'sale.tavoli' => fn ($q) => $q->where('is_attivo', true),
83
+        ])->find($request->get('evento_id'));
84
+
85
+        if (! $evento) {
86
+            return redirect()->route('evento.index')->with('error', 'Evento non trovato');
87
+        }
88
+        
89
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
90
+
91
+        $prenotazioni = $evento->prenotazioni()
92
+            ->whereNotIn('stato', [
93
+                Prenotazione::STATO_CANCELLATA,
94
+                Prenotazione::STATO_ANNULLATA,
95
+                Prenotazione::STATO_RIFIUTATA,
96
+            ]);
97
+
98
+        $galleria = is_array(json_decode($evento->galleria, true))
99
+            ? json_decode($evento->galleria, true)
100
+            : [];
101
+
102
+        $disk = Storage::disk('eventi');
103
+        $locandinaUrl = null;
104
+        if (filled($evento->immagine) && $disk->exists($evento->immagine)) {
105
+            $locandinaUrl = $disk->url($evento->immagine).'?v='.$disk->lastModified($evento->immagine);
106
+        }
107
+
108
+        $postiEvento = $evento->posti !== null ? (int) $evento->posti : null;
109
+        $postiPiantina = (int) $evento->sale->sum(fn ($sala) => $sala->tavoli->sum('posti'));
110
+        $tavoliCount = (int) $evento->sale->sum(fn ($sala) => $sala->tavoli->count());
111
+        $alertPosti = null;
112
+        if ($postiEvento !== null && $postiPiantina !== $postiEvento) {
113
+            $alertPosti = [
114
+                'tipo' => $postiPiantina < $postiEvento ? 'warning' : 'info',
115
+                'posti_evento' => $postiEvento,
116
+                'posti_piantina' => $postiPiantina,
117
+            ];
118
+        }
119
+
120
+        $stats = [
121
+            'prenotazioni' => (clone $prenotazioni)->count(),
122
+            'confermate' => (clone $prenotazioni)->where('confermata', true)->count(),
123
+            'coperti_confermati' => (int) $evento->getCopertiConfermati(),
124
+            'posti' => $postiEvento,
125
+            'posti_piantina' => $postiPiantina,
126
+            'tavoli' => $tavoliCount,
127
+            'galleria' => count($galleria),
128
+            'prenotabile' => $evento->isPrenotabileOra(),
129
+            'messaggio_prenotazione' => $evento->messaggioStatoPrenotazione(),
130
+        ];
131
+
132
+        return view('evento.admin_show', [
133
+            'evento' => $evento,
134
+            'stats' => $stats,
135
+            'alertPosti' => $alertPosti,
136
+            'locandinaUrl' => $locandinaUrl,
137
+            'stati' => Evento::stati(),
138
+        ]);
139
+    }
140
+
141
+    // CLIENTE — scheda pubblica evento
142
+    public function show(string $codice)
143
+    {
144
+        $evento = Evento::with('attivita')->where('codice', $codice)->first();
145
+
146
+        if (! $evento || ! $evento->isVisibilePublicamente()) {
147
+            return redirect()->route('welcome')->with('error', 'Evento non trovato o non disponibile.');
148
+        }
149
+
150
+        $galleriaItems = is_array(json_decode($evento->galleria, true)) ? json_decode($evento->galleria, true) : [];
151
+
59 152
         return view('evento.show', [
60
-          'evento' => $evento , 
61
-          'galleriaItems' => is_array(json_decode($evento->galleria, true)) ? json_decode($evento->galleria, true) : []]);
153
+            'evento' => $evento,
154
+            'galleriaItems' => $galleriaItems,
155
+            'prenotabileOra' => $evento->isPrenotabileOra(),
156
+            'messaggioPrenotazione' => $evento->messaggioStatoPrenotazione(),
157
+        ]);
158
+    }
159
+
160
+    // CLIENTE — redirect da ?evento_id=… alla scheda pubblica con codice
161
+    public function showLegacy(Request $request)
162
+    {
163
+        $evento = Evento::find($request->get('evento_id'));
164
+        if (! $evento) {
165
+            return redirect()->route('welcome')->with('error', 'Evento non trovato o non disponibile.');
166
+        }
167
+
168
+        return redirect()->route('evento.show', ['codice' => $evento->publicCodice()]);
62 169
     }
63 170
 
171
+    // ADMIN
64 172
     public function gallery_edit(Request $request)
65 173
     {
66 174
       $evento = Evento::find($request->get('evento_id'));
175
+      if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
176
+      AttivitaService::garantisciEventoDiQuestaAttivita($evento);
67 177
 
68 178
       return view('evento.gallery.edit', [
69 179
           'evento' => $evento,
@@ -71,9 +181,13 @@ class EventoController extends Controller
71 181
       ]);
72 182
     }
73 183
 
184
+    // ADMIN
74 185
     public function gallery_upload(Request $request)
75 186
     {
76 187
       $evento = Evento::find($request->get('evento_id'));
188
+      if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
189
+      AttivitaService::garantisciEventoDiQuestaAttivita($evento);
190
+
77 191
       $galleria = json_decode($evento->galleria);
78 192
       if($galleria == null) $galleria = [];
79 193
       $nome = $request->file('file')->getClientOriginalName();
@@ -86,11 +200,580 @@ class EventoController extends Controller
86 200
       return redirect()->route('evento.gallery.edit', ['evento_id' => $evento->id])->with('success', 'Foto aggiunta con successo');
87 201
     }
88 202
 
89
-    public function edit(Request $request , EventoFormDataTable $dataTable)
203
+    // ADMIN
204
+    public function edit(Request $request)
90 205
     {
91
-      $evento = Evento::find($request->get('evento_id'));
92
-      $dataTable->evento_id = $evento->id;
93
-      return $dataTable->render('evento.edit', compact('evento'));
206
+      $evento = Evento::with(['attivita', 'form' => function ($q) {
207
+        $q->orderBy('ordine_visualizzazione')->orderBy('id')->with(['opzioni_form' => function ($oq) {
208
+          $oq->orderBy('ordine')->orderBy('id');
209
+        }]);
210
+      }])->find($request->get('evento_id'));
211
+
212
+      if (! $evento) {
213
+        return redirect()->route('evento.index')->with('error', 'Evento non trovato');
214
+      }
215
+
216
+      AttivitaService::garantisciEventoDiQuestaAttivita($evento);
217
+
218
+      // Se non ci sono domande, applica subito il set "pasto" così sono visibili
219
+      if ($evento->form->isEmpty()) {
220
+        $this->applyDomandePreset($evento, 'pasto');
221
+        $evento->load(['form' => function ($q) {
222
+          $q->orderBy('ordine_visualizzazione')->orderBy('id')->with(['opzioni_form' => function ($oq) {
223
+            $oq->orderBy('ordine')->orderBy('id');
224
+          }]);
225
+        }]);
226
+      }
227
+
228
+      return view('evento.edit', compact('evento'));
94 229
     }
95 230
 
231
+    // ADMIN
232
+    public function crea(Request $request)
233
+    {
234
+      $attivitaId = Session::get('attivita_attuale');
235
+      if (! (int) $attivitaId) {
236
+        abort(404);
237
+      }
238
+      AttivitaService::garantisciAttivita($attivitaId);
239
+
240
+      $nome = $request->get('nome');
241
+      $evento = Evento::create([
242
+        'nome' => $nome,
243
+        'attivita_id' => $attivitaId,
244
+        'is_attivo' => false,
245
+        'codice' => Str::random(30),
246
+      ]);
247
+
248
+      $this->applyDomandePreset($evento, 'pasto');
249
+
250
+      return redirect()->route('evento.edit', ['evento_id' => $evento->id, 'tab' => 'prenotazione']);
251
+    }
252
+
253
+    // ADMIN
254
+    public function applicaPresetDomande(Request $request)
255
+    {
256
+        $request->validate([
257
+            'evento_id' => 'required|exists:evento,id',
258
+            'preset' => 'required|string|in:allergie,orario,bambini,pasto',
259
+        ]);
260
+
261
+        if (! Auth::user()->can('edit-evento') && ! Auth::user()->can('create-evento')) {
262
+            abort(403);
263
+        }
264
+
265
+        $evento = Evento::findOrFail($request->evento_id);
266
+        if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
267
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
268
+
269
+        $result = $this->applyDomandePreset($evento, $request->input('preset'));
270
+
271
+        $message = $result['aggiunte'] > 0
272
+            ? "Aggiunte {$result['aggiunte']} domande".($result['saltate'] > 0 ? " ({$result['saltate']} già presenti, saltate)" : '')
273
+            : 'Nessuna domanda aggiunta: risultano già presenti.';
274
+
275
+        return redirect()
276
+            ->route('evento.edit', ['evento_id' => $evento->id, 'tab' => 'prenotazione'])
277
+            ->with('success', $message);
278
+    }
279
+
280
+    // ADMIN
281
+    public function storeDomanda(Request $request)
282
+    {
283
+        $data = $this->validateDomanda($request);
284
+        $evento = Evento::findOrFail($data['evento_id']);
285
+        if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
286
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
287
+
288
+        if (! Auth::user()->can('edit-evento') && ! Auth::user()->can('create-evento')) {
289
+            abort(403);
290
+        }
291
+
292
+        DB::transaction(function () use ($evento, $data) {
293
+            $ordine = (int) EventoForm::where('evento_id', $evento->id)->max('ordine_visualizzazione') + 1;
294
+            $form = EventoForm::create([
295
+                'evento_id' => $evento->id,
296
+                'label' => $data['label'],
297
+                'descrizione' => $data['descrizione'] ?? null,
298
+                'tipo' => $data['tipo'],
299
+                'obbligatoria' => (bool) ($data['obbligatoria'] ?? false),
300
+                'per_coperto' => (bool) ($data['per_coperto'] ?? false),
301
+                'nascosta' => false,
302
+                'nascondi_se_auth' => false,
303
+                'ordine_visualizzazione' => $ordine,
304
+            ]);
305
+            $this->syncOpzioniDomanda($form, $data);
306
+        });
307
+
308
+        return redirect()
309
+            ->route('evento.edit', ['evento_id' => $evento->id, 'tab' => 'prenotazione'])
310
+            ->with('success', 'Domanda aggiunta.');
311
+    }
312
+
313
+    // ADMIN
314
+    public function updateDomanda(Request $request, $domanda_id)
315
+    {
316
+        $form = EventoForm::findOrFail($domanda_id);
317
+        if(!$form) return redirect()->route('evento.index')->with('error', 'Domanda non trovata');
318
+        AttivitaService::garantisciEventoDiQuestaAttivita($form->evento);
319
+        
320
+        $data = $this->validateDomanda($request, $form);
321
+
322
+        if (! Auth::user()->can('edit-evento')) {
323
+            abort(403);
324
+        }
325
+
326
+        if ((int) $form->evento_id !== (int) $data['evento_id']) {
327
+            abort(422, 'Domanda non valida per questo evento.');
328
+        }
329
+
330
+        DB::transaction(function () use ($form, $data) {
331
+            $form->update([
332
+                'label' => $data['label'],
333
+                'descrizione' => $data['descrizione'] ?? null,
334
+                'tipo' => $data['tipo'],
335
+                'obbligatoria' => (bool) ($data['obbligatoria'] ?? false),
336
+                'per_coperto' => (bool) ($data['per_coperto'] ?? false),
337
+            ]);
338
+            $this->syncOpzioniDomanda($form, $data);
339
+        });
340
+
341
+        return redirect()
342
+            ->route('evento.edit', ['evento_id' => $form->evento_id, 'tab' => 'prenotazione'])
343
+            ->with('success', 'Domanda aggiornata.');
344
+    }
345
+
346
+    // ADMIN
347
+    public function destroyDomanda(Request $request, $domanda_id)
348
+    {
349
+        $form = EventoForm::findOrFail($domanda_id);
350
+        if(!$form) return redirect()->route('evento.index')->with('error', 'Domanda non trovata');
351
+        AttivitaService::garantisciEventoDiQuestaAttivita($form->evento);
352
+
353
+        if (! Auth::user()->can('edit-evento') && ! Auth::user()->can('delete-evento')) {
354
+            abort(403);
355
+        }
356
+
357
+        $eventoId = $form->evento_id;
358
+        $form->delete(); // cascade su opzioni e risposte
359
+
360
+        return redirect()
361
+            ->route('evento.edit', ['evento_id' => $eventoId, 'tab' => 'prenotazione'])
362
+            ->with('success', 'Domanda eliminata.');
363
+    }
364
+
365
+    // ADMIN
366
+    public function reorderDomande(Request $request)
367
+    {
368
+        $data = $request->validate([
369
+            'evento_id' => 'required|exists:evento,id',
370
+            'ordine' => 'required|array|min:1',
371
+            'ordine.*' => 'integer|exists:evento_form,id',
372
+        ]);
373
+
374
+        if (! Auth::user()->can('edit-evento')) {
375
+            abort(403);
376
+        }
377
+
378
+        $eventoId = (int) $data['evento_id'];
379
+        if(!$eventoId) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
380
+        AttivitaService::garantisciEventoDiQuestaAttivita(Evento::find($eventoId));
381
+        
382
+        $ids = array_map('intval', $data['ordine']);
383
+
384
+        $validIds = EventoForm::where('evento_id', $eventoId)
385
+            ->whereIn('id', $ids)
386
+            ->pluck('id')
387
+            ->map(fn ($id) => (int) $id)
388
+            ->all();
389
+
390
+        if (count($validIds) !== count($ids) || count(array_diff($ids, $validIds)) > 0) {
391
+            return response()->json(['message' => 'Ordine non valido per questo evento.'], 422);
392
+        }
393
+
394
+        DB::transaction(function () use ($ids) {
395
+            foreach ($ids as $index => $id) {
396
+                EventoForm::where('id', $id)->update([
397
+                    'ordine_visualizzazione' => $index + 1,
398
+                ]);
399
+            }
400
+        });
401
+
402
+        return response()->json(['ok' => true]);
403
+    }
404
+
405
+    // ADMIN (helper)
406
+    private function validateDomanda(Request $request, ?EventoForm $existing = null): array
407
+    {
408
+        $tipi = [
409
+            EventoForm::TESTO,
410
+            EventoForm::NUMERO,
411
+            EventoForm::CHECKBOX,
412
+            EventoForm::SELEZIONE,
413
+            EventoForm::MULTISELEZIONE,
414
+        ];
415
+
416
+        return $request->validate([
417
+            'evento_id' => 'required|exists:evento,id',
418
+            'label' => 'required|string|max:255',
419
+            'descrizione' => 'nullable|string|max:1000',
420
+            'tipo' => 'required|in:'.implode(',', $tipi),
421
+            'obbligatoria' => 'nullable|boolean',
422
+            'per_coperto' => 'nullable|boolean',
423
+            'opzioni_testo' => 'nullable|string',
424
+        ]);
425
+    }
426
+
427
+    // ADMIN (helper)
428
+    private function syncOpzioniDomanda(EventoForm $form, array $data): void
429
+    {
430
+        $needsOpzioni = in_array($form->tipo, [EventoForm::SELEZIONE, EventoForm::MULTISELEZIONE], true);
431
+
432
+        if (! $needsOpzioni) {
433
+            OpzioneForm::where('evento_form_id', $form->id)->delete();
434
+            return;
435
+        }
436
+
437
+        $lines = preg_split('/\r\n|\r|\n/', (string) ($data['opzioni_testo'] ?? '')) ?: [];
438
+        $labels = array_values(array_filter(array_map('trim', $lines), fn ($l) => $l !== ''));
439
+
440
+        OpzioneForm::where('evento_form_id', $form->id)->delete();
441
+        foreach ($labels as $i => $label) {
442
+            OpzioneForm::create([
443
+                'evento_form_id' => $form->id,
444
+                'label' => $label,
445
+                'ordine' => $i + 1,
446
+            ]);
447
+        }
448
+    }
449
+
450
+    // ADMIN (helper)
451
+    /**
452
+     * @return array{aggiunte:int,saltate:int}
453
+     */
454
+    private function applyDomandePreset(Evento $evento, string $preset): array
455
+    {
456
+        $definitions = $this->domandePresetDefinitions($preset);
457
+        $aggiunte = 0;
458
+        $saltate = 0;
459
+
460
+        DB::transaction(function () use ($evento, $definitions, &$aggiunte, &$saltate) {
461
+            $ordine = (int) EventoForm::where('evento_id', $evento->id)->max('ordine_visualizzazione');
462
+
463
+            foreach ($definitions as $def) {
464
+                $exists = EventoForm::where('evento_id', $evento->id)
465
+                    ->where('label', $def['label'])
466
+                    ->exists();
467
+
468
+                if ($exists) {
469
+                    $saltate++;
470
+                    continue;
471
+                }
472
+
473
+                $ordine++;
474
+                $form = EventoForm::create([
475
+                    'evento_id' => $evento->id,
476
+                    'label' => $def['label'],
477
+                    'descrizione' => $def['descrizione'] ?? null,
478
+                    'tipo' => $def['tipo'],
479
+                    'obbligatoria' => (bool) ($def['obbligatoria'] ?? false),
480
+                    'nascosta' => false,
481
+                    'nascondi_se_auth' => false,
482
+                    'ordine_visualizzazione' => $ordine,
483
+                ]);
484
+
485
+                foreach ($def['opzioni'] ?? [] as $i => $opzioneLabel) {
486
+                    OpzioneForm::create([
487
+                        'evento_form_id' => $form->id,
488
+                        'label' => $opzioneLabel,
489
+                        'ordine' => $i + 1,
490
+                    ]);
491
+                }
492
+
493
+                $aggiunte++;
494
+            }
495
+        });
496
+
497
+        return ['aggiunte' => $aggiunte, 'saltate' => $saltate];
498
+    }
499
+
500
+    // ADMIN (helper)
501
+    private function domandePresetDefinitions(string $preset): array
502
+    {
503
+        return match ($preset) {
504
+            'allergie' => [
505
+                [
506
+                    'label' => 'Allergie o intolleranze',
507
+                    'descrizione' => 'Indica eventuali allergie o intolleranze alimentari',
508
+                    'tipo' => EventoForm::TESTO,
509
+                    'obbligatoria' => false,
510
+                ],
511
+            ],
512
+            'orario' => [
513
+                [
514
+                    'label' => 'Preferenza orario',
515
+                    'descrizione' => 'Scegli la fascia preferita',
516
+                    'tipo' => EventoForm::SELEZIONE,
517
+                    'obbligatoria' => true,
518
+                    'opzioni' => ['Pranzo', 'Cena'],
519
+                ],
520
+            ],
521
+            'bambini' => [
522
+                [
523
+                    'label' => 'Quanti bambini?',
524
+                    'descrizione' => 'Numero di bambini nel gruppo (0 se nessuno)',
525
+                    'tipo' => EventoForm::NUMERO,
526
+                    'obbligatoria' => false,
527
+                ],
528
+            ],
529
+            'pasto' => [
530
+                [
531
+                    'label' => 'Preferenza orario',
532
+                    'descrizione' => 'Scegli la fascia preferita',
533
+                    'tipo' => EventoForm::SELEZIONE,
534
+                    'obbligatoria' => true,
535
+                    'opzioni' => ['Pranzo', 'Cena'],
536
+                ],
537
+                [
538
+                    'label' => 'Allergie o intolleranze',
539
+                    'descrizione' => 'Indica eventuali allergie o intolleranze alimentari',
540
+                    'tipo' => EventoForm::TESTO,
541
+                    'obbligatoria' => false,
542
+                ],
543
+                [
544
+                    'label' => 'Quanti bambini?',
545
+                    'descrizione' => 'Numero di bambini nel gruppo (0 se nessuno)',
546
+                    'tipo' => EventoForm::NUMERO,
547
+                    'obbligatoria' => false,
548
+                ],
549
+            ],
550
+            default => [],
551
+        };
552
+    }
553
+
554
+    // ADMIN
555
+    public function update(Request $request, $evento_id)
556
+    {
557
+        $evento = Evento::findOrFail($evento_id);
558
+
559
+        if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
560
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
561
+
562
+        if (! Auth::user()->can('edit-evento')) {
563
+            abort(403);
564
+        }
565
+
566
+        foreach (['descrizione', 'data_inizio', 'data_fine', 'posti', 'prezzo', 'note', 'apertura_prenotazione', 'chiusura_prenotazione', 'tempo_cortesia_ore', 'luogo', 'luogo_lat', 'luogo_lng'] as $field) {
567
+            if ($request->input($field) === '') {
568
+                $request->merge([$field => null]);
569
+            }
570
+        }
571
+
572
+        $stati = implode(',', array_keys(Evento::stati()));
573
+
574
+        $data = $request->validate([
575
+            'nome' => 'required|string|max:255',
576
+            'descrizione' => 'nullable|string',
577
+            'stato' => 'required|in:'.$stati,
578
+            'is_attivo' => 'nullable|boolean',
579
+            'data_inizio' => 'nullable|date',
580
+            'data_fine' => 'nullable|date|after_or_equal:data_inizio',
581
+            'posti' => 'nullable|integer|min:0',
582
+            'prezzo' => 'nullable|numeric|min:0',
583
+            'prezzo_fisso' => 'nullable|boolean',
584
+            'note' => 'nullable|string',
585
+            'apertura_prenotazione' => 'nullable|date',
586
+            'chiusura_prenotazione' => 'nullable|date|after_or_equal:apertura_prenotazione',
587
+            'tempo_cortesia_ore' => 'nullable|integer|min:0|max:8760',
588
+            'conferma_automatica' => 'nullable|boolean',
589
+            'nascondi_posti_disponibili' => 'nullable|boolean',
590
+            'cliente_modifica_se_confermata' => 'nullable|boolean',
591
+            'cliente_annulla_se_confermata' => 'nullable|boolean',
592
+            'luogo' => 'nullable|string|max:255',
593
+            'luogo_lat' => 'nullable|numeric|between:-90,90',
594
+            'luogo_lng' => 'nullable|numeric|between:-180,180',
595
+            'email_testo_inviata' => 'nullable|string|max:2000',
596
+            'email_testo_confermata' => 'nullable|string|max:2000',
597
+        ]);
598
+
599
+        $data['prezzo_fisso'] = $request->boolean('prezzo_fisso');
600
+        $data['is_attivo'] = $request->boolean('is_attivo');
601
+
602
+        if (! $request->filled('apertura_prenotazione') && ! $request->filled('chiusura_prenotazione')) {
603
+            $data['apertura_prenotazione'] = null;
604
+            $data['chiusura_prenotazione'] = null;
605
+        }
606
+
607
+        $info = is_array($evento->info) ? $evento->info : [];
608
+        $info['tempo_cortesia_ore'] = $request->filled('tempo_cortesia_ore')
609
+            ? (int) $request->input('tempo_cortesia_ore')
610
+            : null;
611
+        $info['conferma_automatica'] = $request->boolean('conferma_automatica');
612
+        $info['nascondi_posti_disponibili'] = $request->boolean('nascondi_posti_disponibili');
613
+        $info['cliente_modifica_se_confermata'] = $request->boolean('cliente_modifica_se_confermata');
614
+        $info['cliente_annulla_se_confermata'] = $request->boolean('cliente_annulla_se_confermata');
615
+        $info['luogo'] = $request->filled('luogo') ? trim((string) $request->input('luogo')) : null;
616
+        if (! $request->filled('luogo')) {
617
+            $info['luogo_lat'] = null;
618
+            $info['luogo_lng'] = null;
619
+        } else {
620
+            $info['luogo_lat'] = $request->filled('luogo_lat') ? (float) $request->input('luogo_lat') : null;
621
+            $info['luogo_lng'] = $request->filled('luogo_lng') ? (float) $request->input('luogo_lng') : null;
622
+        }
623
+
624
+        $testoInviata = trim((string) $request->input('email_testo_inviata', ''));
625
+        $testoConfermata = trim((string) $request->input('email_testo_confermata', ''));
626
+        $info['email_testo_inviata'] = $testoInviata !== '' ? $testoInviata : null;
627
+        $info['email_testo_confermata'] = $testoConfermata !== '' ? $testoConfermata : null;
628
+
629
+        $data['info'] = $info;
630
+
631
+        unset(
632
+            $data['tempo_cortesia_ore'],
633
+            $data['conferma_automatica'],
634
+            $data['nascondi_posti_disponibili'],
635
+            $data['cliente_modifica_se_confermata'],
636
+            $data['cliente_annulla_se_confermata'],
637
+            $data['luogo'],
638
+            $data['luogo_lat'],
639
+            $data['luogo_lng'],
640
+            $data['email_testo_inviata'],
641
+            $data['email_testo_confermata']
642
+        );
643
+
644
+        $evento->update($data);
645
+
646
+        $tab = $request->input('tab', 'generali');
647
+        if (! in_array($tab, ['generali', 'kit', 'prenotazione', 'avanzate', 'email'], true)) {
648
+            $tab = 'generali';
649
+        }
650
+
651
+        return redirect()
652
+            ->route('evento.edit', ['evento_id' => $evento->id, 'tab' => $tab])
653
+            ->with('success', 'Evento aggiornato.');
654
+    }
655
+
656
+    // ADMIN
657
+    public function geocodeLuogo(Request $request)
658
+    {
659
+        if (! Auth::user()->can('edit-evento')) {
660
+            abort(403);
661
+        }
662
+
663
+        $query = trim((string) $request->query('q', ''));
664
+        if (strlen($query) < 3) {
665
+            return response()->json([]);
666
+        }
667
+
668
+        $response = Http::withHeaders([
669
+            'User-Agent' => config('app.name').' ('.config('app.url').')',
670
+        ])->get('https://nominatim.openstreetmap.org/search', [
671
+            'format' => 'json',
672
+            'q' => $query,
673
+            'limit' => 6,
674
+            'addressdetails' => 0,
675
+            'countrycodes' => 'it',
676
+        ]);
677
+
678
+        if (! $response->successful()) {
679
+            return response()->json([]);
680
+        }
681
+
682
+        $results = collect($response->json())
683
+            ->map(fn (array $item) => [
684
+                'label' => $item['display_name'] ?? '',
685
+                'lat' => isset($item['lat']) ? (float) $item['lat'] : null,
686
+                'lng' => isset($item['lon']) ? (float) $item['lon'] : null,
687
+            ])
688
+            ->filter(fn (array $item) => filled($item['label']))
689
+            ->values();
690
+
691
+        return response()->json($results);
692
+    }
693
+
694
+    // ADMIN
695
+    public function uploadLocandina(Request $request, $evento_id)
696
+    {
697
+        return $this->uploadImmagineEvento($request, $evento_id, 'locandina', 'immagine', 'Locandina aggiornata.');
698
+    }
699
+
700
+    // ADMIN
701
+    public function destroyLocandina(Request $request, $evento_id)
702
+    {
703
+        return $this->destroyImmagineEvento($evento_id, 'immagine', 'Locandina rimossa.');
704
+    }
705
+
706
+    // ADMIN
707
+    public function uploadCopertina(Request $request, $evento_id)
708
+    {
709
+        return $this->uploadImmagineEvento($request, $evento_id, 'copertina', 'copertina', 'Copertina aggiornata.');
710
+    }
711
+
712
+    // ADMIN
713
+    public function destroyCopertina(Request $request, $evento_id)
714
+    {
715
+        return $this->destroyImmagineEvento($evento_id, 'copertina', 'Copertina rimossa.');
716
+    }
717
+
718
+    // ADMIN (helper)
719
+    private function uploadImmagineEvento(Request $request, $evento_id, string $inputName, string $column, string $message)
720
+    {
721
+        $evento = Evento::findOrFail($evento_id);
722
+        if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
723
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
724
+        
725
+        if (! Auth::user()->can('edit-evento')) {
726
+            abort(403);
727
+        }
728
+
729
+        $request->validate([
730
+            $inputName => 'required|file|image|mimes:jpeg,png,jpg,gif,webp|max:5120',
731
+        ]);
732
+
733
+        $disk = Storage::disk('eventi');
734
+        $dir = (string) $evento->id;
735
+        $ext = strtolower($request->file($inputName)->getClientOriginalExtension() ?: 'jpg');
736
+        // Nome univoco: evita cache browser sulla stessa URL dopo sostituzione
737
+        $filename = $column.'_'.time().'_'.Str::random(4).'.'.$ext;
738
+        $path = $dir.'/'.$filename;
739
+
740
+        $oldPath = $evento->{$column};
741
+        if (filled($oldPath) && $disk->exists($oldPath)) {
742
+            $disk->delete($oldPath);
743
+        }
744
+
745
+        $disk->makeDirectory($dir);
746
+        $disk->putFileAs($dir, $request->file($inputName), $filename);
747
+
748
+        $evento->{$column} = $path;
749
+        $evento->save();
750
+
751
+        return redirect()
752
+            ->route('evento.edit', ['evento_id' => $evento->id, 'tab' => 'kit'])
753
+            ->with('success', $message);
754
+    }
755
+
756
+    // ADMIN (helper)
757
+    private function destroyImmagineEvento($evento_id, string $column, string $message)
758
+    {
759
+        $evento = Evento::findOrFail($evento_id);
760
+        if(!$evento) return redirect()->route('evento.index')->with('error', 'Evento non trovato');
761
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
762
+        
763
+        if (! Auth::user()->can('edit-evento') && ! Auth::user()->can('delete-evento')) {
764
+            abort(403);
765
+        }
766
+
767
+        $disk = Storage::disk('eventi');
768
+        if (filled($evento->{$column}) && $disk->exists($evento->{$column})) {
769
+            $disk->delete($evento->{$column});
770
+        }
771
+
772
+        $evento->{$column} = null;
773
+        $evento->save();
774
+
775
+        return redirect()
776
+            ->route('evento.edit', ['evento_id' => $evento->id, 'tab' => 'kit'])
777
+            ->with('success', $message);
778
+    }
96 779
 }

+ 834
- 0
app/Http/Controllers/EventoPiantinaController.php 查看文件

@@ -0,0 +1,834 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\Evento;
6
+use App\Models\EventoSala;
7
+use App\Models\EventoTavolo;
8
+use App\Models\Prenotazione;
9
+use App\Models\PrenotazioneAssegnazioneTavolo;
10
+use App\Services\Attivita\AttivitaService;
11
+use App\Services\Prenotazione\AssegnaTavoliAutomatico;
12
+use Illuminate\Http\Request;
13
+use Illuminate\Routing\Controllers\Middleware;
14
+use Illuminate\Support\Facades\Storage;
15
+use Illuminate\Support\Str;
16
+
17
+class EventoPiantinaController extends Controller
18
+{
19
+    public static function middleware(): array
20
+    {
21
+        return [
22
+            new Middleware('permission:view-evento', only: ['edit', 'assegnazioni']),
23
+            new Middleware('permission:edit-evento', only: [
24
+                'storeSala',
25
+                'updateSala',
26
+                'destroySala',
27
+                'uploadPiantina',
28
+                'destroyPiantina',
29
+                'storeTavolo',
30
+                'updateTavolo',
31
+                'destroyTavolo',
32
+                'batchTavoli',
33
+                'assegna',
34
+                'assegnaAutomatico',
35
+                'rimuoviAssegnazione',
36
+            ]),
37
+        ];
38
+    }
39
+
40
+    /**
41
+     * Editor piantina (sala + tavoli).
42
+     */
43
+    public function edit(Request $request)
44
+    {
45
+        $evento = $this->findEventoOrFail($request->get('evento_id'));
46
+        $sale = $this->saleList($evento);
47
+        $sala = $this->resolveSala($request, $evento, $sale);
48
+        $sala->load(['tavoli' => fn ($q) => $q->orderBy('ordine_in_gruppo')->orderBy('label')]);
49
+
50
+        return view('evento.piantina.edit', [
51
+            'evento' => $evento,
52
+            'sala' => $sala,
53
+            'sale' => $sale,
54
+            'piantinaUrl' => $this->piantinaUrl($sala),
55
+            'griglia' => $sala->grigliaConfig(),
56
+        ]);
57
+    }
58
+
59
+    /**
60
+     * Crea una nuova sala/zona per l'evento.
61
+     */
62
+    public function storeSala(Request $request, int $evento_id)
63
+    {
64
+        $evento = $this->findEventoOrFail($evento_id);
65
+        $data = $request->validate([
66
+            'nome' => 'nullable|string|max:255',
67
+        ]);
68
+
69
+        $ordine = (int) $evento->sale()->max('ordine');
70
+        $sala = EventoSala::create([
71
+            'evento_id' => $evento->id,
72
+            'nome' => filled($data['nome'] ?? null) ? trim($data['nome']) : 'Nuova sala',
73
+            'ordine' => $ordine + 1,
74
+        ]);
75
+
76
+        return redirect()
77
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
78
+            ->with('success', 'Sala creata.');
79
+    }
80
+
81
+    /**
82
+     * Aggiorna metadati sala (nome, dimensioni).
83
+     */
84
+    public function updateSala(Request $request, int $evento_id)
85
+    {
86
+        $evento = $this->findEventoOrFail($evento_id);
87
+        $sala = $this->resolveSala($request, $evento);
88
+
89
+        $data = $request->validate([
90
+            'nome' => 'sometimes|nullable|string|max:255',
91
+            'larghezza' => 'sometimes|nullable|integer|min:1|max:20000',
92
+            'altezza' => 'sometimes|nullable|integer|min:1|max:20000',
93
+            'griglia_attiva' => 'nullable|boolean',
94
+            'griglia_modalita' => 'nullable|string|in:celle,libera',
95
+            'griglia_cols' => 'nullable|integer|min:1|max:40',
96
+            'griglia_rows' => 'nullable|integer|min:1|max:40',
97
+            'griglia_margin_top' => 'nullable|numeric|between:0,40',
98
+            'griglia_margin_right' => 'nullable|numeric|between:0,40',
99
+            'griglia_margin_bottom' => 'nullable|numeric|between:0,40',
100
+            'griglia_margin_left' => 'nullable|numeric|between:0,40',
101
+            'griglia_inset' => 'nullable|numeric|between:0,40',
102
+            'griglia_inset_v' => 'nullable|numeric|between:0,40',
103
+            'griglia_colonne' => 'nullable|string|max:255',
104
+        ]);
105
+
106
+        if ($request->exists('nome')) {
107
+            $sala->nome = filled($data['nome'] ?? null) ? trim($data['nome']) : null;
108
+        }
109
+        if ($request->exists('larghezza')) {
110
+            $sala->larghezza = $data['larghezza'] ?? null;
111
+        }
112
+        if ($request->exists('altezza')) {
113
+            $sala->altezza = $data['altezza'] ?? null;
114
+        }
115
+
116
+        $info = is_array($sala->info) ? $sala->info : [];
117
+        $prev = is_array($info['griglia'] ?? null) ? $info['griglia'] : [];
118
+        $modalita = $data['griglia_modalita'] ?? ($prev['modalita'] ?? 'libera');
119
+        if (! in_array($modalita, ['celle', 'libera'], true)) {
120
+            $modalita = 'libera';
121
+        }
122
+
123
+        $attiva = $request->exists('griglia_attiva')
124
+            ? $request->boolean('griglia_attiva')
125
+            : (bool) ($prev['attiva'] ?? ($modalita === 'celle'));
126
+
127
+        $info['griglia'] = [
128
+            'attiva' => $attiva,
129
+            'modalita' => $modalita,
130
+            'cols' => (int) ($data['griglia_cols'] ?? ($prev['cols'] ?? 10)),
131
+            'rows' => (int) ($data['griglia_rows'] ?? ($prev['rows'] ?? 4)),
132
+            'margin_top' => (float) ($data['griglia_margin_top'] ?? ($prev['margin_top'] ?? 14)),
133
+            'margin_right' => (float) ($data['griglia_margin_right'] ?? ($prev['margin_right'] ?? 2)),
134
+            'margin_bottom' => (float) ($data['griglia_margin_bottom'] ?? ($prev['margin_bottom'] ?? 2)),
135
+            'margin_left' => (float) ($data['griglia_margin_left'] ?? ($prev['margin_left'] ?? 8)),
136
+            'inset' => (float) ($data['griglia_inset'] ?? ($prev['inset'] ?? 6)),
137
+            'inset_v' => (float) ($data['griglia_inset_v'] ?? ($prev['inset_v'] ?? 1.5)),
138
+            'colonne' => trim((string) ($data['griglia_colonne'] ?? ($prev['colonne'] ?? 'A,B,C,D,E,F,H,K,P,R'))),
139
+        ];
140
+        $sala->info = $info;
141
+        $sala->save();
142
+
143
+        if ($request->expectsJson() || $request->wantsJson()) {
144
+            return response()->json([
145
+                'ok' => true,
146
+                'griglia' => $sala->grigliaConfig(),
147
+            ]);
148
+        }
149
+
150
+        return redirect()
151
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
152
+            ->with('success', 'Sala e griglia aggiornate.');
153
+    }
154
+
155
+    /**
156
+     * Elimina una sala (non l'ultima).
157
+     */
158
+    public function destroySala(Request $request, int $evento_id, int $sala_id)
159
+    {
160
+        $evento = $this->findEventoOrFail($evento_id);
161
+        $sala = $evento->sale()->where('id', $sala_id)->firstOrFail();
162
+
163
+        if ($evento->sale()->count() <= 1) {
164
+            return redirect()
165
+                ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
166
+                ->with('error', 'Non puoi eliminare l’unica sala dell’evento.');
167
+        }
168
+
169
+        $disk = Storage::disk('eventi');
170
+        if (filled($sala->immagine) && $disk->exists($sala->immagine)) {
171
+            $disk->delete($sala->immagine);
172
+        }
173
+        $sala->delete();
174
+
175
+        $next = $this->ensureSala($evento);
176
+
177
+        return redirect()
178
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $next))
179
+            ->with('success', 'Sala eliminata.');
180
+    }
181
+
182
+    /**
183
+     * Upload immagine piantina.
184
+     */
185
+    public function uploadPiantina(Request $request, int $evento_id)
186
+    {
187
+        $evento = $this->findEventoOrFail($evento_id);
188
+        $sala = $this->resolveSala($request, $evento);
189
+
190
+        $request->validate([
191
+            'immagine' => 'required|file|image|mimes:jpeg,png,jpg,gif,webp|max:8192',
192
+        ]);
193
+
194
+        $disk = Storage::disk('eventi');
195
+        $dir = $evento->id.'/piantina';
196
+        $ext = strtolower($request->file('immagine')->getClientOriginalExtension() ?: 'jpg');
197
+        $filename = 'piantina_'.time().'_'.Str::random(4).'.'.$ext;
198
+        $path = $dir.'/'.$filename;
199
+
200
+        if (filled($sala->immagine) && $disk->exists($sala->immagine)) {
201
+            $disk->delete($sala->immagine);
202
+        }
203
+
204
+        $disk->makeDirectory($dir);
205
+        $disk->putFileAs($dir, $request->file('immagine'), $filename);
206
+
207
+        $sala->immagine = $path;
208
+
209
+        // Dimensioni naturali se non impostate
210
+        $full = $disk->path($path);
211
+        if (is_file($full) && (! $sala->larghezza || ! $sala->altezza)) {
212
+            $size = @getimagesize($full);
213
+            if (is_array($size)) {
214
+                $sala->larghezza = $sala->larghezza ?: (int) $size[0];
215
+                $sala->altezza = $sala->altezza ?: (int) $size[1];
216
+            }
217
+        }
218
+
219
+        $sala->save();
220
+
221
+        return redirect()
222
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
223
+            ->with('success', 'Piantina caricata.');
224
+    }
225
+
226
+    public function destroyPiantina(Request $request, int $evento_id)
227
+    {
228
+        $evento = $this->findEventoOrFail($evento_id);
229
+        $sala = $this->resolveSala($request, $evento);
230
+
231
+        $disk = Storage::disk('eventi');
232
+        if (filled($sala->immagine) && $disk->exists($sala->immagine)) {
233
+            $disk->delete($sala->immagine);
234
+        }
235
+
236
+        $sala->immagine = null;
237
+        $sala->save();
238
+
239
+        return redirect()
240
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
241
+            ->with('success', 'Piantina rimossa.');
242
+    }
243
+
244
+    /**
245
+     * Crea un tavolo sulla piantina.
246
+     */
247
+    public function storeTavolo(Request $request, int $evento_id)
248
+    {
249
+        $evento = $this->findEventoOrFail($evento_id);
250
+        $sala = $this->resolveSala($request, $evento);
251
+
252
+        $data = $request->validate([
253
+            'label' => 'required|string|max:50',
254
+            'posti' => 'required|integer|min:1|max:200',
255
+            'x' => 'nullable|numeric|between:0,100',
256
+            'y' => 'nullable|numeric|between:0,100',
257
+            'w' => 'nullable|numeric|between:0.5,100',
258
+            'h' => 'nullable|numeric|between:0.5,100',
259
+            'rotazione' => 'nullable|integer|between:-360,360',
260
+            'forma' => 'nullable|string|in:rettangolo,cerchio',
261
+            'gruppo_id' => 'nullable|string|max:100',
262
+            'ordine_in_gruppo' => 'nullable|integer|min:0|max:9999',
263
+            'is_attivo' => 'nullable|boolean',
264
+        ]);
265
+
266
+        $tavolo = EventoTavolo::create([
267
+            'evento_sala_id' => $sala->id,
268
+            'label' => trim($data['label']),
269
+            'posti' => (int) $data['posti'],
270
+            'x' => (float) ($data['x'] ?? 10),
271
+            'y' => (float) ($data['y'] ?? 10),
272
+            'w' => (float) ($data['w'] ?? 8),
273
+            'h' => (float) ($data['h'] ?? 8),
274
+            'rotazione' => (int) ($data['rotazione'] ?? 0),
275
+            'forma' => $data['forma'] ?? EventoTavolo::FORMA_RETTANGOLO,
276
+            'gruppo_id' => filled($data['gruppo_id'] ?? null) ? trim($data['gruppo_id']) : null,
277
+            'ordine_in_gruppo' => (int) ($data['ordine_in_gruppo'] ?? 0),
278
+            'is_attivo' => $request->boolean('is_attivo', true),
279
+        ]);
280
+
281
+        if ($request->expectsJson() || $request->wantsJson()) {
282
+            return response()->json(['ok' => true, 'tavolo' => $tavolo]);
283
+        }
284
+
285
+        return redirect()
286
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
287
+            ->with('success', 'Tavolo '.$tavolo->label.' aggiunto.');
288
+    }
289
+
290
+    /**
291
+     * Aggiorna tavolo (posizione, posti, gruppo…).
292
+     */
293
+    public function updateTavolo(Request $request, int $evento_id, int $tavolo_id)
294
+    {
295
+        $evento = $this->findEventoOrFail($evento_id);
296
+        $sala = $this->resolveSala($request, $evento);
297
+        $tavolo = EventoTavolo::where('evento_sala_id', $sala->id)->findOrFail($tavolo_id);
298
+
299
+        $data = $request->validate([
300
+            'label' => 'sometimes|required|string|max:50',
301
+            'posti' => 'sometimes|required|integer|min:1|max:200',
302
+            'x' => 'nullable|numeric|between:0,100',
303
+            'y' => 'nullable|numeric|between:0,100',
304
+            'w' => 'nullable|numeric|between:0.5,100',
305
+            'h' => 'nullable|numeric|between:0.5,100',
306
+            'rotazione' => 'nullable|integer|between:-360,360',
307
+            'forma' => 'nullable|string|in:rettangolo,cerchio',
308
+            'gruppo_id' => 'nullable|string|max:100',
309
+            'ordine_in_gruppo' => 'nullable|integer|min:0|max:9999',
310
+            'is_attivo' => 'nullable|boolean',
311
+        ]);
312
+
313
+        if (array_key_exists('label', $data)) {
314
+            $tavolo->label = trim($data['label']);
315
+        }
316
+        if (array_key_exists('posti', $data)) {
317
+            $tavolo->posti = (int) $data['posti'];
318
+        }
319
+        foreach (['x', 'y', 'w', 'h'] as $coord) {
320
+            if (array_key_exists($coord, $data) && $data[$coord] !== null) {
321
+                $tavolo->{$coord} = (float) $data[$coord];
322
+            }
323
+        }
324
+        if (array_key_exists('rotazione', $data) && $data['rotazione'] !== null) {
325
+            $tavolo->rotazione = (int) $data['rotazione'];
326
+        }
327
+        if (array_key_exists('forma', $data) && $data['forma'] !== null) {
328
+            $tavolo->forma = $data['forma'];
329
+        }
330
+        if (array_key_exists('gruppo_id', $data)) {
331
+            $tavolo->gruppo_id = filled($data['gruppo_id']) ? trim($data['gruppo_id']) : null;
332
+        }
333
+        if (array_key_exists('ordine_in_gruppo', $data) && $data['ordine_in_gruppo'] !== null) {
334
+            $tavolo->ordine_in_gruppo = (int) $data['ordine_in_gruppo'];
335
+        }
336
+        if ($request->has('is_attivo')) {
337
+            $tavolo->is_attivo = $request->boolean('is_attivo');
338
+        }
339
+
340
+        $tavolo->save();
341
+
342
+        if ($request->expectsJson() || $request->wantsJson()) {
343
+            return response()->json(['ok' => true, 'tavolo' => $tavolo->fresh()]);
344
+        }
345
+
346
+        return redirect()
347
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
348
+            ->with('success', 'Tavolo aggiornato.');
349
+    }
350
+
351
+    public function destroyTavolo(Request $request, int $evento_id, int $tavolo_id)
352
+    {
353
+        $evento = $this->findEventoOrFail($evento_id);
354
+        $sala = $this->resolveSala($request, $evento);
355
+        $tavolo = EventoTavolo::where('evento_sala_id', $sala->id)->findOrFail($tavolo_id);
356
+        $label = $tavolo->label;
357
+        $tavolo->delete();
358
+
359
+        if ($request->expectsJson() || $request->wantsJson()) {
360
+            return response()->json(['ok' => true]);
361
+        }
362
+
363
+        return redirect()
364
+            ->route('evento.piantina.edit', $this->piantinaParams($evento, $sala))
365
+            ->with('success', 'Tavolo '.$label.' eliminato.');
366
+    }
367
+
368
+    /**
369
+     * Aggiorna geometria/label di più tavoli in un colpo (allinea griglia).
370
+     */
371
+    public function batchTavoli(Request $request, int $evento_id)
372
+    {
373
+        $evento = $this->findEventoOrFail($evento_id);
374
+        $sala = $this->resolveSala($request, $evento);
375
+
376
+        $data = $request->validate([
377
+            'tavoli' => 'required|array|min:1|max:200',
378
+            'tavoli.*.id' => 'required|integer',
379
+            'tavoli.*.x' => 'nullable|numeric|between:0,100',
380
+            'tavoli.*.y' => 'nullable|numeric|between:0,100',
381
+            'tavoli.*.w' => 'nullable|numeric|between:0.5,100',
382
+            'tavoli.*.h' => 'nullable|numeric|between:0.5,100',
383
+            'tavoli.*.label' => 'nullable|string|max:50',
384
+            'tavoli.*.posti' => 'nullable|integer|min:1|max:200',
385
+            'tavoli.*.gruppo_id' => 'nullable|string|max:100',
386
+            'tavoli.*.ordine_in_gruppo' => 'nullable|integer|min:0|max:9999',
387
+        ]);
388
+
389
+        $ids = collect($data['tavoli'])->pluck('id')->map(fn ($id) => (int) $id)->all();
390
+        $existing = EventoTavolo::query()
391
+            ->where('evento_sala_id', $sala->id)
392
+            ->whereIn('id', $ids)
393
+            ->get()
394
+            ->keyBy('id');
395
+
396
+        $updated = [];
397
+        foreach ($data['tavoli'] as $row) {
398
+            $tavolo = $existing->get((int) $row['id']);
399
+            if (! $tavolo) {
400
+                continue;
401
+            }
402
+            foreach (['x', 'y', 'w', 'h'] as $coord) {
403
+                if (array_key_exists($coord, $row) && $row[$coord] !== null) {
404
+                    $tavolo->{$coord} = (float) $row[$coord];
405
+                }
406
+            }
407
+            if (array_key_exists('label', $row) && filled($row['label'])) {
408
+                $tavolo->label = trim($row['label']);
409
+            }
410
+            if (array_key_exists('posti', $row) && $row['posti'] !== null) {
411
+                $tavolo->posti = (int) $row['posti'];
412
+            }
413
+            if (array_key_exists('gruppo_id', $row)) {
414
+                $tavolo->gruppo_id = filled($row['gruppo_id']) ? trim($row['gruppo_id']) : null;
415
+            }
416
+            if (array_key_exists('ordine_in_gruppo', $row) && $row['ordine_in_gruppo'] !== null) {
417
+                $tavolo->ordine_in_gruppo = (int) $row['ordine_in_gruppo'];
418
+            }
419
+            $tavolo->save();
420
+            $updated[] = $tavolo->fresh();
421
+        }
422
+
423
+        return response()->json(['ok' => true, 'tavoli' => $updated]);
424
+    }
425
+
426
+    /**
427
+     * Vista assegnazione prenotazioni → tavoli.
428
+     */
429
+    public function assegnazioni(Request $request)
430
+    {
431
+        $evento = $this->findEventoOrFail($request->get('evento_id'));
432
+        $sale = $this->saleList($evento);
433
+        $sala = $this->resolveSala($request, $evento, $sale);
434
+        $sala->load([
435
+            'tavoli' => fn ($q) => $q->where('is_attivo', true)
436
+                ->with(['assegnazioni.prenotazione'])
437
+                ->orderBy('ordine_in_gruppo')
438
+                ->orderBy('label'),
439
+        ]);
440
+
441
+        $prenotazioni = Prenotazione::query()
442
+            ->where('evento_id', $evento->id)
443
+            ->whereNotIn('stato', [
444
+                Prenotazione::STATO_CANCELLATA,
445
+                Prenotazione::STATO_ANNULLATA,
446
+                Prenotazione::STATO_RIFIUTATA,
447
+            ])
448
+            ->with(['assegnazioniTavolo.eventoTavolo'])
449
+            ->orderByDesc('confermata')
450
+            ->orderBy('cognome')
451
+            ->orderBy('nome')
452
+            ->get();
453
+
454
+        $tavoliJs = $sala->tavoli->map(function (EventoTavolo $t) {
455
+            $occupati = $t->postiOccupati();
456
+
457
+            return [
458
+                'id' => $t->id,
459
+                'label' => $t->label,
460
+                'posti' => (int) $t->posti,
461
+                'occupati' => $occupati,
462
+                'liberi' => max(0, (int) $t->posti - $occupati),
463
+                'x' => (float) $t->x,
464
+                'y' => (float) $t->y,
465
+                'w' => (float) $t->w,
466
+                'h' => (float) $t->h,
467
+                'forma' => $t->forma ?: 'rettangolo',
468
+                'gruppo_id' => $t->gruppo_id,
469
+                'assegnazioni' => $t->assegnazioni->map(fn ($a) => [
470
+                    'id' => $a->id,
471
+                    'posti' => (int) $a->posti_assegnati,
472
+                    'prenotazione_id' => (int) $a->prenotazione_id,
473
+                    'nome' => trim(($a->prenotazione?->cognome ?? '').' '.($a->prenotazione?->nome ?? '')),
474
+                    'codice' => $a->prenotazione?->codice,
475
+                ])->values(),
476
+            ];
477
+        })->values();
478
+
479
+        $prenotazioniJs = $prenotazioni->map(function (Prenotazione $p) {
480
+            $coperti = max(1, (int) ($p->numero_persone ?? 1));
481
+            $assegnati = (int) $p->assegnazioniTavolo->sum('posti_assegnati');
482
+
483
+            return [
484
+                'id' => $p->id,
485
+                'nome' => trim(($p->cognome ?? '').' '.($p->nome ?? '')),
486
+                'codice' => $p->codice,
487
+                'stato' => $p->stato,
488
+                'confermata' => (bool) $p->confermata,
489
+                'coperti' => $coperti,
490
+                'assegnati' => $assegnati,
491
+                'rimasti' => max(0, $coperti - $assegnati),
492
+                'assegnazioni' => $p->assegnazioniTavolo->map(fn ($a) => [
493
+                    'id' => $a->id,
494
+                    'posti' => (int) $a->posti_assegnati,
495
+                    'tavolo_id' => (int) $a->evento_tavolo_id,
496
+                    'label' => $a->eventoTavolo?->label,
497
+                    'gruppo_id' => $a->eventoTavolo?->gruppo_id,
498
+                ])->values(),
499
+            ];
500
+        })->values();
501
+
502
+        return view('evento.piantina.assegnazioni', [
503
+            'evento' => $evento,
504
+            'sala' => $sala,
505
+            'sale' => $sale,
506
+            'prenotazioni' => $prenotazioni,
507
+            'piantinaUrl' => $this->piantinaUrl($sala),
508
+            'tavoliJs' => $tavoliJs,
509
+            'prenotazioniJs' => $prenotazioniJs,
510
+            'griglia' => $sala->grigliaConfig(),
511
+        ]);
512
+    }
513
+
514
+    /**
515
+     * Assegna automaticamente le prenotazioni ai tavoli (intere + buchi + spezza colonna).
516
+     */
517
+    public function assegnaAutomatico(Request $request, int $evento_id, AssegnaTavoliAutomatico $service)
518
+    {
519
+        $evento = $this->findEventoOrFail($evento_id);
520
+        $sala = $this->resolveSala($request, $evento);
521
+
522
+        $data = $request->validate([
523
+            'solo_confermate' => 'nullable|boolean',
524
+        ]);
525
+
526
+        $stats = $service->esegui($evento, $sala, [
527
+            'solo_confermate' => $request->boolean('solo_confermate'),
528
+        ]);
529
+
530
+        $msg = sprintf(
531
+            'Assegnazione automatica: %d posti su %d prenotazioni (%d intere, %d nei buchi, %d spezzate).',
532
+            $stats['posti_assegnati'],
533
+            $stats['prenotazioni_toccate'],
534
+            $stats['intere'],
535
+            $stats['buchi'],
536
+            $stats['spezzate']
537
+        );
538
+        if ($stats['non_collocate'] > 0) {
539
+            $msg .= ' '.$stats['non_collocate'].' ancora senza posto completo.';
540
+        }
541
+
542
+        if ($request->expectsJson() || $request->wantsJson()) {
543
+            return response()->json([
544
+                'ok' => true,
545
+                'message' => $msg,
546
+                'stats' => $stats,
547
+            ]);
548
+        }
549
+
550
+        return redirect()
551
+            ->route('evento.piantina.assegnazioni', $this->piantinaParams($evento, $sala))
552
+            ->with('success', $msg);
553
+    }
554
+
555
+    /**
556
+     * Assegna posti di una prenotazione a un tavolo (eventualmente spezzati).
557
+     */
558
+    public function assegna(Request $request, int $evento_id)
559
+    {
560
+        $evento = $this->findEventoOrFail($evento_id);
561
+        $sala = $this->resolveSala($request, $evento);
562
+
563
+        $data = $request->validate([
564
+            'prenotazione_id' => 'required|integer|exists:prenotazione,id',
565
+            'evento_tavolo_id' => 'required|integer|exists:evento_tavolo,id',
566
+            'posti_assegnati' => 'nullable|integer|min:1|max:200',
567
+        ]);
568
+
569
+        $prenotazione = Prenotazione::where('evento_id', $evento->id)->findOrFail($data['prenotazione_id']);
570
+        $tavolo = EventoTavolo::where('evento_sala_id', $sala->id)->findOrFail($data['evento_tavolo_id']);
571
+
572
+        if (in_array($prenotazione->stato, [
573
+            Prenotazione::STATO_CANCELLATA,
574
+            Prenotazione::STATO_ANNULLATA,
575
+            Prenotazione::STATO_RIFIUTATA,
576
+        ], true)) {
577
+            return $this->assegnazioneFail($request, $evento, 'Prenotazione non assegnabile in questo stato.', $sala);
578
+        }
579
+
580
+        $coperti = max(1, (int) ($prenotazione->numero_persone ?? 1));
581
+        $giaAssegnati = (int) $prenotazione->assegnazioniTavolo()->sum('posti_assegnati');
582
+        $rimanenti = max(0, $coperti - $giaAssegnati);
583
+        if ($rimanenti < 1) {
584
+            return $this->assegnazioneFail($request, $evento, 'Tutti i coperti di questa prenotazione sono già assegnati.', $sala);
585
+        }
586
+
587
+        $postiRichiesti = (int) ($data['posti_assegnati'] ?? $rimanenti);
588
+        $postiRichiesti = min(max(1, $postiRichiesti), $rimanenti);
589
+
590
+        // Se spezza su un secondo tavolo, deve essere dello stesso gruppo (tavoli attaccati)
591
+        $esistenti = $prenotazione->assegnazioniTavolo()->with('eventoTavolo')->get();
592
+        if ($esistenti->isNotEmpty()) {
593
+            $okVicino = $esistenti->contains(function (PrenotazioneAssegnazioneTavolo $a) use ($tavolo) {
594
+                $t = $a->eventoTavolo;
595
+                if (! $t) {
596
+                    return false;
597
+                }
598
+                if ((int) $t->id === (int) $tavolo->id) {
599
+                    return true;
600
+                }
601
+                if (! filled($t->gruppo_id) || ! filled($tavolo->gruppo_id)) {
602
+                    return false;
603
+                }
604
+
605
+                return $t->gruppo_id === $tavolo->gruppo_id
606
+                    && (int) $t->evento_sala_id === (int) $tavolo->evento_sala_id;
607
+            });
608
+
609
+            if (! $okVicino) {
610
+                return $this->assegnazioneFail(
611
+                    $request,
612
+                    $evento,
613
+                    'Per spezzare i coperti usa un tavolo dello stesso gruppo (tavoli attaccati).',
614
+                    $sala
615
+                );
616
+            }
617
+        }
618
+
619
+        $assegnazione = PrenotazioneAssegnazioneTavolo::firstOrNew([
620
+            'prenotazione_id' => $prenotazione->id,
621
+            'evento_tavolo_id' => $tavolo->id,
622
+        ]);
623
+
624
+        $giaSuQuesto = $assegnazione->exists ? (int) $assegnazione->posti_assegnati : 0;
625
+        $altriSuQuesto = (int) $tavolo->assegnazioni()
626
+            ->when($assegnazione->exists, fn ($q) => $q->where('id', '!=', $assegnazione->id))
627
+            ->sum('posti_assegnati');
628
+        $liberiPerQuesta = max(0, (int) $tavolo->posti - $altriSuQuesto - $giaSuQuesto);
629
+        $aggiungi = min($postiRichiesti, $liberiPerQuesta);
630
+
631
+        if ($aggiungi < 1) {
632
+            return $this->assegnazioneFail($request, $evento, 'Il tavolo non ha posti liberi sufficienti.', $sala);
633
+        }
634
+
635
+        $assegnazione->posti_assegnati = $giaSuQuesto + $aggiungi;
636
+        if (! $assegnazione->exists) {
637
+            $assegnazione->ordine = (int) ($esistenti->max('ordine') ?? -1) + 1;
638
+        }
639
+        $assegnazione->save();
640
+
641
+        if ($request->expectsJson() || $request->wantsJson()) {
642
+            return response()->json([
643
+                'ok' => true,
644
+                'aggiunti' => $aggiungi,
645
+                'assegnazione' => $assegnazione->fresh(['eventoTavolo', 'prenotazione']),
646
+                'tavolo' => $this->tavoloPayload($tavolo->fresh(['assegnazioni.prenotazione'])),
647
+                'prenotazione' => $this->prenotazionePayload($prenotazione->fresh(['assegnazioniTavolo.eventoTavolo'])),
648
+            ]);
649
+        }
650
+
651
+        return redirect()
652
+            ->route('evento.piantina.assegnazioni', $this->piantinaParams($evento, $sala))
653
+            ->with('success', 'Assegnati '.$aggiungi.' posti a '.$tavolo->label.'.');
654
+    }
655
+
656
+    public function rimuoviAssegnazione(Request $request, int $evento_id, int $assegnazione_id)
657
+    {
658
+        $evento = $this->findEventoOrFail($evento_id);
659
+        $sala = $this->resolveSala($request, $evento);
660
+
661
+        $assegnazione = PrenotazioneAssegnazioneTavolo::query()
662
+            ->whereKey($assegnazione_id)
663
+            ->whereHas('prenotazione', fn ($q) => $q->where('evento_id', $evento->id))
664
+            ->firstOrFail();
665
+
666
+        $prenotazione = $assegnazione->prenotazione;
667
+        $tavolo = $assegnazione->eventoTavolo;
668
+        $assegnazione->delete();
669
+
670
+        if ($tavolo && ! $tavolo->relationLoaded('assegnazioni')) {
671
+            $tavolo->load(['assegnazioni.prenotazione']);
672
+        }
673
+        if ($prenotazione && ! $prenotazione->relationLoaded('assegnazioniTavolo')) {
674
+            $prenotazione->load(['assegnazioniTavolo.eventoTavolo']);
675
+        }
676
+
677
+        if ($request->expectsJson() || $request->wantsJson()) {
678
+            return response()->json([
679
+                'ok' => true,
680
+                'tavolo' => $tavolo ? $this->tavoloPayload($tavolo->fresh(['assegnazioni.prenotazione'])) : null,
681
+                'prenotazione' => $prenotazione ? $this->prenotazionePayload($prenotazione->fresh(['assegnazioniTavolo.eventoTavolo'])) : null,
682
+            ]);
683
+        }
684
+
685
+        return redirect()
686
+            ->route('evento.piantina.assegnazioni', $this->piantinaParams($evento, $sala))
687
+            ->with('success', 'Assegnazione rimossa.');
688
+    }
689
+
690
+    private function tavoloPayload(EventoTavolo $t): array
691
+    {
692
+        $occupati = $t->postiOccupati();
693
+
694
+        return [
695
+            'id' => $t->id,
696
+            'label' => $t->label,
697
+            'posti' => (int) $t->posti,
698
+            'occupati' => $occupati,
699
+            'liberi' => max(0, (int) $t->posti - $occupati),
700
+            'x' => (float) $t->x,
701
+            'y' => (float) $t->y,
702
+            'w' => (float) $t->w,
703
+            'h' => (float) $t->h,
704
+            'forma' => $t->forma ?: 'rettangolo',
705
+            'gruppo_id' => $t->gruppo_id,
706
+            'assegnazioni' => $t->assegnazioni->map(fn ($a) => [
707
+                'id' => $a->id,
708
+                'posti' => (int) $a->posti_assegnati,
709
+                'prenotazione_id' => (int) $a->prenotazione_id,
710
+                'nome' => trim(($a->prenotazione?->cognome ?? '').' '.($a->prenotazione?->nome ?? '')),
711
+                'codice' => $a->prenotazione?->codice,
712
+            ])->values(),
713
+        ];
714
+    }
715
+
716
+    private function prenotazionePayload(Prenotazione $p): array
717
+    {
718
+        $coperti = max(1, (int) ($p->numero_persone ?? 1));
719
+        $assegnati = (int) $p->assegnazioniTavolo->sum('posti_assegnati');
720
+
721
+        return [
722
+            'id' => $p->id,
723
+            'nome' => trim(($p->cognome ?? '').' '.($p->nome ?? '')),
724
+            'codice' => $p->codice,
725
+            'stato' => $p->stato,
726
+            'confermata' => (bool) $p->confermata,
727
+            'coperti' => $coperti,
728
+            'assegnati' => $assegnati,
729
+            'rimasti' => max(0, $coperti - $assegnati),
730
+            'assegnazioni' => $p->assegnazioniTavolo->map(fn ($a) => [
731
+                'id' => $a->id,
732
+                'posti' => (int) $a->posti_assegnati,
733
+                'tavolo_id' => (int) $a->evento_tavolo_id,
734
+                'label' => $a->eventoTavolo?->label,
735
+                'gruppo_id' => $a->eventoTavolo?->gruppo_id,
736
+            ])->values(),
737
+        ];
738
+    }
739
+
740
+    private function findEventoOrFail(mixed $eventoId): Evento
741
+    {
742
+        $evento = Evento::find($eventoId);
743
+        if (! $evento) {
744
+            abort(404, 'Evento non trovato');
745
+        }
746
+
747
+        AttivitaService::garantisciEventoDiQuestaAttivita($evento);
748
+
749
+        return $evento;
750
+    }
751
+
752
+    /**
753
+     * @return \Illuminate\Support\Collection<int, EventoSala>
754
+     */
755
+    private function saleList(Evento $evento)
756
+    {
757
+        $sale = $evento->sale()->get();
758
+        if ($sale->isEmpty()) {
759
+            $sale = collect([$this->ensureSala($evento)]);
760
+        }
761
+
762
+        return $sale;
763
+    }
764
+
765
+    private function resolveSala(Request $request, Evento $evento, $sale = null): EventoSala
766
+    {
767
+        $sale = $sale ?? $this->saleList($evento);
768
+        $salaId = (int) ($request->input('sala_id') ?: $request->query('sala_id') ?: 0);
769
+
770
+        if ($salaId > 0) {
771
+            $found = $sale->firstWhere('id', $salaId);
772
+            if ($found) {
773
+                return $found;
774
+            }
775
+            abort(404, 'Sala non trovata');
776
+        }
777
+
778
+        return $sale->first() ?? $this->ensureSala($evento);
779
+    }
780
+
781
+    private function ensureSala(Evento $evento): EventoSala
782
+    {
783
+        $sala = $evento->sale()->orderBy('ordine')->orderBy('id')->first();
784
+        if ($sala) {
785
+            return $sala;
786
+        }
787
+
788
+        return EventoSala::create([
789
+            'evento_id' => $evento->id,
790
+            'nome' => 'Sala principale',
791
+            'ordine' => 0,
792
+        ]);
793
+    }
794
+
795
+    private function piantinaParams(Evento $evento, EventoSala $sala): array
796
+    {
797
+        return [
798
+            'evento_id' => $evento->id,
799
+            'sala_id' => $sala->id,
800
+        ];
801
+    }
802
+
803
+    private function piantinaUrl(?EventoSala $sala): ?string
804
+    {
805
+        if (! $sala || ! filled($sala->immagine)) {
806
+            return null;
807
+        }
808
+
809
+        $disk = Storage::disk('eventi');
810
+        if (! $disk->exists($sala->immagine)) {
811
+            return null;
812
+        }
813
+
814
+        return $disk->url($sala->immagine).'?v='.$disk->lastModified($sala->immagine);
815
+    }
816
+
817
+    private function assegnazioneFail(Request $request, Evento $evento, string $message, ?EventoSala $sala = null)
818
+    {
819
+        if ($request->expectsJson() || $request->wantsJson()) {
820
+            return response()->json(['ok' => false, 'error' => $message], 422);
821
+        }
822
+
823
+        $params = ['evento_id' => $evento->id];
824
+        if ($sala) {
825
+            $params['sala_id'] = $sala->id;
826
+        } elseif ($request->input('sala_id') || $request->query('sala_id')) {
827
+            $params['sala_id'] = (int) ($request->input('sala_id') ?: $request->query('sala_id'));
828
+        }
829
+
830
+        return redirect()
831
+            ->route('evento.piantina.assegnazioni', $params)
832
+            ->with('error', $message);
833
+    }
834
+}

+ 1326
- 49
app/Http/Controllers/PrenotazioneController.php
文件差异内容过多而无法显示
查看文件


+ 15
- 3
app/Http/Controllers/PrenotazioneTavoloController.php 查看文件

@@ -2,6 +2,8 @@
2 2
 
3 3
 namespace App\Http\Controllers;
4 4
 
5
+
6
+use Illuminate\Routing\Controllers\Middleware;
5 7
 use Illuminate\Http\Request;
6 8
 use App\Models\PrenotazioneTavolo;
7 9
 use App\DataTables\PrenotazioneTavoloDataTable;
@@ -11,6 +13,7 @@ use App\Models\Attivita;
11 13
 use Illuminate\Support\Facades\Validator;
12 14
 use Illuminate\Support\Str;
13 15
 use Illuminate\Support\Facades\Session;
16
+use App\Models\Tavolo;
14 17
 
15 18
 class PrenotazioneTavoloController extends Controller
16 19
 {
@@ -26,7 +29,7 @@ class PrenotazioneTavoloController extends Controller
26 29
     public static function middleware(): array
27 30
     {
28 31
         return [
29
-            new Middleware('permission:view-prenotazione_tavolo', only: ['index']),
32
+            new Middleware('permission:view-prenotazione_tavolo', only: ['index', 'oggi', 'appelloPrenotazioniTavoli']),
30 33
             new Middleware('permission:create-prenotazione_tavolo|edit-prenotazione_tavolo|delete-prenotazione_tavolo', only: ['store', 'update', 'destroy']),
31 34
         ];
32 35
     }
@@ -102,8 +105,16 @@ class PrenotazioneTavoloController extends Controller
102 105
             'data_prenotazione' => 'required|date',
103 106
             'ora_prenotazione' => 'required',
104 107
         ]);
108
+        $tavolo = Tavolo::find($request->tavolo_id);
109
+        if (! $tavolo || (int) $tavolo->attivita_id !== (int) $attivita->id) {
110
+            return redirect()->back()->with('error', 'Tavolo non valido');
111
+        }
105 112
         $prenotazione = new PrenotazioneTavolo();
106
-        $prenotazione->fill($request->all());
113
+        $prenotazione->fill($request->only([
114
+            'nome', 'email', 'telefono', 'data_prenotazione', 'ora_prenotazione',
115
+        ]));
116
+        $prenotazione->attivita_id = $attivita->id;
117
+        $prenotazione->tavolo_id = $tavolo->id;
107 118
         $prenotazione->stato = PrenotazioneTavolo::STATO_INVIATA;
108 119
         // dd($prenotazione);
109 120
         $prenotazione->save();
@@ -112,7 +123,8 @@ class PrenotazioneTavoloController extends Controller
112 123
     }
113 124
 
114 125
     public function cliente_show(Request $request, $prenotazione_tavoli_id){
115
-        $prenotazione = PrenotazioneTavolo::find($request->get('prenotazione_tavolo_id')|| $prenotazione_tavoli_id);
126
+        $id = $prenotazione_tavoli_id ?: $request->get('prenotazione_tavolo_id');
127
+        $prenotazione = PrenotazioneTavolo::find($id);
116 128
         if(!$prenotazione) return redirect()->route('cliente.attivita.show')->with('error', 'Prenotazione non trovata');
117 129
         return view('prenotazione_tavolo.cliente.show')->with(['prenotazione' => $prenotazione]);
118 130
     }

+ 66
- 0
app/Jobs/ConfermaTuttePrenotazioniJob.php 查看文件

@@ -0,0 +1,66 @@
1
+<?php
2
+
3
+namespace App\Jobs;
4
+
5
+use App\Models\Evento;
6
+use App\Models\Prenotazione;
7
+use Illuminate\Contracts\Queue\ShouldBeUnique;
8
+use Illuminate\Contracts\Queue\ShouldQueue;
9
+use Illuminate\Foundation\Queue\Queueable;
10
+use Illuminate\Support\Facades\Log;
11
+
12
+class ConfermaTuttePrenotazioniJob implements ShouldQueue, ShouldBeUnique
13
+{
14
+    use Queueable;
15
+
16
+    public int $tries = 3;
17
+
18
+    public int $timeout = 120;
19
+
20
+    public function __construct(
21
+        public int $eventoId,
22
+        public bool $soloNonConfermate = false,
23
+    ) {
24
+        $this->onConnection('redis');
25
+        $this->onQueue('prenotazioni');
26
+    }
27
+
28
+    public function uniqueId(): string
29
+    {
30
+        return 'conferma-prenotazioni-evento-'.$this->eventoId;
31
+    }
32
+
33
+    public function handle(): void
34
+    {
35
+        $evento = Evento::query()->find($this->eventoId);
36
+        if ($evento === null) {
37
+            Log::warning('Conferma prenotazioni: evento non trovato', [
38
+                'evento_id' => $this->eventoId,
39
+            ]);
40
+
41
+            return;
42
+        }
43
+
44
+        $query = $evento->prenotazioni()
45
+            ->whereNotIn('stato', [
46
+                Prenotazione::STATO_CANCELLATA,
47
+                Prenotazione::STATO_RIFIUTATA,
48
+                Prenotazione::STATO_ANNULLATA,
49
+            ]);
50
+
51
+        if ($this->soloNonConfermate) {
52
+            $query->where('confermata', false);
53
+        }
54
+
55
+        $query->each(function (Prenotazione $prenotazione) {
56
+            if ($prenotazione->stato === Prenotazione::STATO_CONFERMATA) {
57
+                return;
58
+            }
59
+
60
+            $prenotazione->update([
61
+                'confermata' => true,
62
+                'stato' => Prenotazione::STATO_CONFERMATA,
63
+            ]);
64
+        });
65
+    }
66
+}

+ 18
- 4
app/Models/AbstractModels/AbstractEvento.php 查看文件

@@ -35,20 +35,24 @@ abstract class AbstractEvento extends \Illuminate\Foundation\Auth\User
35 35
      * @var array
36 36
      */
37 37
     protected $casts = [
38
-        'id' => 'integer',  
38
+        'id' => 'integer',
39
+        'attivita_id' => 'integer',
39 40
         'nome' => 'string',
40 41
         'descrizione' => 'string',
41 42
         'data_inizio' => 'date',
42 43
         'data_fine' => 'date',
43
-        'apertura_prenotazione' => 'date',
44
-        'chiusura_prenotazione' => 'date',
44
+        'apertura_prenotazione' => 'datetime',
45
+        'chiusura_prenotazione' => 'datetime',
45 46
         'prezzo_fisso' => 'boolean',
46 47
         'prezzo' => 'decimal:2',
47 48
         'stato' => 'string',
49
+        'note' => 'string',
50
+        'is_attivo' => 'boolean',
51
+        'codice' => 'string',
48 52
         'immagine' => 'string',
49 53
         'copertina' => 'string',
50 54
         'galleria' => 'json',
51
-        'note' => 'string',
55
+        'info' => 'array',
52 56
         'posti' => 'integer',
53 57
         'created_at' => 'datetime',
54 58
         'updated_at' => 'datetime'
@@ -61,6 +65,7 @@ abstract class AbstractEvento extends \Illuminate\Foundation\Auth\User
61 65
      */
62 66
     protected $fillable = [
63 67
         'id',
68
+        'attivita_id',
64 69
         'nome',
65 70
         'descrizione',
66 71
         'data_inizio',
@@ -71,13 +76,22 @@ abstract class AbstractEvento extends \Illuminate\Foundation\Auth\User
71 76
         'prezzo',
72 77
         'stato',
73 78
         'note',
79
+        'is_attivo',
80
+        'codice',
74 81
         'immagine',
75 82
         'copertina',
76 83
         'galleria',
84
+        'info',
77 85
         'posti',
78 86
         'created_at',
79 87
         'updated_at'
88
+   
80 89
     ];
90
+
91
+    public function attivita()
92
+    {
93
+        return $this->belongsTo('\App\Models\Attivita', 'attivita_id', 'id');
94
+    }
81 95
     
82 96
     public function prenotazioni()
83 97
     {

+ 4
- 0
app/Models/AbstractModels/AbstractEventoForm.php 查看文件

@@ -49,6 +49,8 @@ abstract class AbstractEventoForm extends \Illuminate\Foundation\Auth\User
49 49
         'opzioni' => 'string',
50 50
         'remote_id' => 'integer',
51 51
         'posti_definiti_opzioni' => 'boolean',
52
+        'per_coperto' => 'boolean',
53
+        'impostazioni' => 'json',
52 54
         'created_at' => 'datetime',
53 55
         'updated_at' => 'datetime'
54 56
     ];
@@ -74,6 +76,8 @@ abstract class AbstractEventoForm extends \Illuminate\Foundation\Auth\User
74 76
         'opzioni',
75 77
         'remote_id',
76 78
         'posti_definiti_opzioni',
79
+        'per_coperto',
80
+        'impostazioni',
77 81
         'created_at',
78 82
         'updated_at'
79 83
     ];

+ 51
- 0
app/Models/AbstractModels/AbstractEventoSala.php 查看文件

@@ -0,0 +1,51 @@
1
+<?php
2
+/**
3
+ * Model object for evento_sala (piantina per evento).
4
+ */
5
+
6
+namespace App\Models\AbstractModels;
7
+
8
+abstract class AbstractEventoSala extends \Illuminate\Database\Eloquent\Model
9
+{
10
+    protected $table = 'evento_sala';
11
+
12
+    protected $keyType = 'bigInteger';
13
+
14
+    protected $casts = [
15
+        'id' => 'integer',
16
+        'evento_id' => 'integer',
17
+        'ordine' => 'integer',
18
+        'nome' => 'string',
19
+        'immagine' => 'string',
20
+        'larghezza' => 'integer',
21
+        'altezza' => 'integer',
22
+        'info' => 'array',
23
+        'created_at' => 'datetime',
24
+        'updated_at' => 'datetime',
25
+    ];
26
+
27
+    protected $fillable = [
28
+        'id',
29
+        'evento_id',
30
+        'ordine',
31
+        'nome',
32
+        'immagine',
33
+        'larghezza',
34
+        'altezza',
35
+        'info',
36
+        'created_at',
37
+        'updated_at',
38
+    ];
39
+
40
+    public function evento()
41
+    {
42
+        return $this->belongsTo(\App\Models\Evento::class, 'evento_id', 'id');
43
+    }
44
+
45
+    public function tavoli()
46
+    {
47
+        return $this->hasMany(\App\Models\EventoTavolo::class, 'evento_sala_id', 'id')
48
+            ->orderBy('ordine_in_gruppo')
49
+            ->orderBy('label');
50
+    }
51
+}

+ 62
- 0
app/Models/AbstractModels/AbstractEventoTavolo.php 查看文件

@@ -0,0 +1,62 @@
1
+<?php
2
+/**
3
+ * Model object for evento_tavolo (tavolo sulla piantina evento).
4
+ */
5
+
6
+namespace App\Models\AbstractModels;
7
+
8
+abstract class AbstractEventoTavolo extends \Illuminate\Database\Eloquent\Model
9
+{
10
+    protected $table = 'evento_tavolo';
11
+
12
+    protected $keyType = 'bigInteger';
13
+
14
+    protected $casts = [
15
+        'id' => 'integer',
16
+        'evento_sala_id' => 'integer',
17
+        'label' => 'string',
18
+        'posti' => 'integer',
19
+        'x' => 'float',
20
+        'y' => 'float',
21
+        'w' => 'float',
22
+        'h' => 'float',
23
+        'rotazione' => 'integer',
24
+        'forma' => 'string',
25
+        'gruppo_id' => 'string',
26
+        'ordine_in_gruppo' => 'integer',
27
+        'is_attivo' => 'boolean',
28
+        'info' => 'array',
29
+        'created_at' => 'datetime',
30
+        'updated_at' => 'datetime',
31
+    ];
32
+
33
+    protected $fillable = [
34
+        'id',
35
+        'evento_sala_id',
36
+        'label',
37
+        'posti',
38
+        'x',
39
+        'y',
40
+        'w',
41
+        'h',
42
+        'rotazione',
43
+        'forma',
44
+        'gruppo_id',
45
+        'ordine_in_gruppo',
46
+        'is_attivo',
47
+        'info',
48
+        'created_at',
49
+        'updated_at',
50
+    ];
51
+
52
+    public function sala()
53
+    {
54
+        return $this->belongsTo(\App\Models\EventoSala::class, 'evento_sala_id', 'id');
55
+    }
56
+
57
+    public function assegnazioni()
58
+    {
59
+        return $this->hasMany(\App\Models\PrenotazioneAssegnazioneTavolo::class, 'evento_tavolo_id', 'id')
60
+            ->orderBy('ordine');
61
+    }
62
+}

+ 2
- 0
app/Models/AbstractModels/AbstractPrenotazione.php 查看文件

@@ -44,6 +44,7 @@ abstract class AbstractPrenotazione extends \Illuminate\Foundation\Auth\User
44 44
         'cucina_id' => 'integer',
45 45
         'stato' => 'string',
46 46
         'note' => 'string',
47
+        'confermata' => 'boolean',
47 48
         'codice' => 'string',
48 49
         'stato_pagamento' => 'string',
49 50
         'metodo_pagamento' => 'string',
@@ -65,6 +66,7 @@ abstract class AbstractPrenotazione extends \Illuminate\Foundation\Auth\User
65 66
         'telefono',
66 67
         'evento_id',
67 68
         'cucina_id',
69
+        'confermata',
68 70
         'stato',
69 71
         'note',
70 72
         'codice',

+ 43
- 0
app/Models/AbstractModels/AbstractPrenotazioneAssegnazioneTavolo.php 查看文件

@@ -0,0 +1,43 @@
1
+<?php
2
+/**
3
+ * Model object for prenotazione_assegnazione_tavolo.
4
+ */
5
+
6
+namespace App\Models\AbstractModels;
7
+
8
+abstract class AbstractPrenotazioneAssegnazioneTavolo extends \Illuminate\Database\Eloquent\Model
9
+{
10
+    protected $table = 'prenotazione_assegnazione_tavolo';
11
+
12
+    protected $keyType = 'bigInteger';
13
+
14
+    protected $casts = [
15
+        'id' => 'integer',
16
+        'prenotazione_id' => 'integer',
17
+        'evento_tavolo_id' => 'integer',
18
+        'posti_assegnati' => 'integer',
19
+        'ordine' => 'integer',
20
+        'created_at' => 'datetime',
21
+        'updated_at' => 'datetime',
22
+    ];
23
+
24
+    protected $fillable = [
25
+        'id',
26
+        'prenotazione_id',
27
+        'evento_tavolo_id',
28
+        'posti_assegnati',
29
+        'ordine',
30
+        'created_at',
31
+        'updated_at',
32
+    ];
33
+
34
+    public function prenotazione()
35
+    {
36
+        return $this->belongsTo(\App\Models\Prenotazione::class, 'prenotazione_id', 'id');
37
+    }
38
+
39
+    public function eventoTavolo()
40
+    {
41
+        return $this->belongsTo(\App\Models\EventoTavolo::class, 'evento_tavolo_id', 'id');
42
+    }
43
+}

+ 458
- 2
app/Models/Evento.php 查看文件

@@ -2,9 +2,465 @@
2 2
 
3 3
 namespace App\Models;
4 4
 
5
-use Illuminate\Database\Eloquent\Model;
5
+use Carbon\Carbon;
6
+use Illuminate\Database\Eloquent\Builder;
6 7
 
7 8
 class Evento extends \App\Models\AbstractModels\AbstractEvento
8 9
 {
9
-    //
10
+    //STATI
11
+    const ATTIVO = 'attivo';
12
+    const ARCHIVIATO = 'archiviato';
13
+
14
+    public static function stati()
15
+    {
16
+        return [
17
+            self::ATTIVO => [
18
+                'nome' => 'Attivo',
19
+                'color' => 'success',
20
+                'icon' => 'bx bx-check-circle',
21
+                'description' => 'L\'evento è attivo',
22
+            ],
23
+            self::ARCHIVIATO => [
24
+                'nome' => 'Archiviato',
25
+                'color' => 'secondary',
26
+                'icon' => 'bx bx-archive',
27
+                'description' => 'L\'evento è archiviato',
28
+            ]
29
+        ];
30
+    }
31
+
32
+    /**
33
+     * Assicura un codice pubblico unico (persistito se mancante).
34
+     */
35
+    public function ensureCodice(): string
36
+    {
37
+        if (filled($this->codice)) {
38
+            return (string) $this->codice;
39
+        }
40
+
41
+        do {
42
+            $codice = \Illuminate\Support\Str::random(30);
43
+        } while (static::where('codice', $codice)->exists());
44
+
45
+        $this->codice = $codice;
46
+        $this->saveQuietly();
47
+
48
+        return $codice;
49
+    }
50
+
51
+    public function publicCodice(): string
52
+    {
53
+        return $this->ensureCodice();
54
+    }
55
+
56
+    public function publicUrl(): string
57
+    {
58
+        return route('evento.show', ['codice' => $this->publicCodice()]);
59
+    }
60
+
61
+    public function prenotaUrl(): string
62
+    {
63
+        return route('prenotazone.prenota_form', ['codice' => $this->publicCodice()]);
64
+    }
65
+
66
+    /**
67
+     * Legge una chiave dal JSON info.
68
+     */
69
+    public function infoGet(string $key, mixed $default = null): mixed
70
+    {
71
+        $info = $this->info;
72
+        if (! is_array($info)) {
73
+            return $default;
74
+        }
75
+
76
+        return array_key_exists($key, $info) ? $info[$key] : $default;
77
+    }
78
+
79
+    /**
80
+     * Ore prima di data_inizio entro cui il cliente può ancora modificare/cancellare.
81
+     * null = nessuna limitazione (cortesia disattivata).
82
+     */
83
+    public function tempoCortesiaOre(): ?int
84
+    {
85
+        $ore = $this->infoGet('tempo_cortesia_ore');
86
+        if ($ore === null || $ore === '') {
87
+            return null;
88
+        }
89
+
90
+        return max(0, (int) $ore);
91
+    }
92
+
93
+    /**
94
+     * Se true, le prenotazioni nuove partono già confermate.
95
+     */
96
+    public function confermaAutomatica(): bool
97
+    {
98
+        return (bool) $this->infoGet('conferma_automatica', false);
99
+    }
100
+
101
+    /**
102
+     * Testo extra email "prenotazione ricevuta" (info JSON). Vuoto = non mostrare.
103
+     */
104
+    public function emailTestoInviata(): ?string
105
+    {
106
+        $testo = $this->infoGet('email_testo_inviata');
107
+        $testo = is_string($testo) ? trim($testo) : '';
108
+
109
+        return $testo !== '' ? $testo : null;
110
+    }
111
+
112
+    /**
113
+     * Testo extra email "prenotazione confermata" (info JSON). Vuoto = non mostrare.
114
+     */
115
+    public function emailTestoConfermata(): ?string
116
+    {
117
+        $testo = $this->infoGet('email_testo_confermata');
118
+        $testo = is_string($testo) ? trim($testo) : '';
119
+
120
+        return $testo !== '' ? $testo : null;
121
+    }
122
+
123
+    /**
124
+     * URL assoluta copertina evento per email (null se assente).
125
+     */
126
+    public function emailCopertinaUrl(): ?string
127
+    {
128
+        if (! filled($this->copertina)) {
129
+            return null;
130
+        }
131
+
132
+        $disk = \Illuminate\Support\Facades\Storage::disk('eventi');
133
+        if (! $disk->exists($this->copertina)) {
134
+            return null;
135
+        }
136
+
137
+        $url = $disk->url($this->copertina);
138
+        if (str_starts_with($url, '/')) {
139
+            $url = rtrim((string) config('app.url'), '/').$url;
140
+        }
141
+
142
+        return $url;
143
+    }
144
+
145
+    /**
146
+     * Se true, i posti non vengono mostrati nella scheda pubblica.
147
+     */
148
+    public function nascondiPostiDisponibili(): bool
149
+    {
150
+        return (bool) $this->infoGet('nascondi_posti_disponibili', false);
151
+    }
152
+
153
+    /**
154
+     * Cliente può modificare anche una prenotazione già confermata
155
+     * (dopo salvataggio torna inviata da riconfermare).
156
+     */
157
+    public function clienteModificaSeConfermata(): bool
158
+    {
159
+        return (bool) $this->infoGet('cliente_modifica_se_confermata', false);
160
+    }
161
+
162
+    /**
163
+     * Cliente può annullare anche una prenotazione già confermata.
164
+     */
165
+    public function clienteAnnullaSeConfermata(): bool
166
+    {
167
+        return (bool) $this->infoGet('cliente_annulla_se_confermata', false);
168
+    }
169
+
170
+    /**
171
+     * Luogo dell'evento (es. sala, stand, indirizzo breve).
172
+     */
173
+    public function luogo(): ?string
174
+    {
175
+        $luogo = $this->infoGet('luogo');
176
+
177
+        return filled($luogo) ? trim((string) $luogo) : null;
178
+    }
179
+
180
+    public function getLuogoAttribute(): ?string
181
+    {
182
+        return $this->luogo();
183
+    }
184
+
185
+    /**
186
+     * Indirizzo compatto per il pubblico: via + civico, città.
187
+     */
188
+    public function luogoBreve(): ?string
189
+    {
190
+        $full = $this->luogo();
191
+        if ($full === null) {
192
+            return null;
193
+        }
194
+
195
+        if (! str_contains($full, ',') || mb_strlen($full) <= 48) {
196
+            return $full;
197
+        }
198
+
199
+        $parts = array_values(array_filter(
200
+            array_map('trim', explode(',', $full)),
201
+            fn (string $p) => $p !== ''
202
+        ));
203
+
204
+        $noise = [
205
+            'italia', 'italy',
206
+            'lombardia', 'piemonte', 'veneto', 'lazio', 'emilia-romagna', 'emilia romagna',
207
+            'toscana', 'campania', 'sicilia', 'sardegna', 'puglia', 'calabria', 'liguria',
208
+            'marche', 'abruzzo', 'umbria', 'friuli-venezia giulia', 'friuli venezia giulia',
209
+            'trentino-alto adige', 'trentino alto adige', "valle d'aosta", 'valle d’aosta',
210
+            'basilicata', 'molise',
211
+        ];
212
+
213
+        $clean = [];
214
+        foreach ($parts as $part) {
215
+            $lower = mb_strtolower($part);
216
+            if (preg_match('/^\d{5}$/', $part)) {
217
+                continue;
218
+            }
219
+            if (in_array($lower, $noise, true)) {
220
+                continue;
221
+            }
222
+            if (str_starts_with($lower, 'città metropolitana')
223
+                || str_starts_with($lower, 'citta metropolitana')
224
+                || str_starts_with($lower, 'provincia di')
225
+                || str_starts_with($lower, 'provincia ')) {
226
+                continue;
227
+            }
228
+            $clean[] = $part;
229
+        }
230
+
231
+        if ($clean === []) {
232
+            return $full;
233
+        }
234
+
235
+        $street = $clean[0];
236
+        $civico = null;
237
+        $city = null;
238
+
239
+        if (isset($clean[1]) && preg_match('/^\d+[a-zA-Z]?$/u', $clean[1])) {
240
+            $civico = $clean[1];
241
+            $city = count($clean) > 2 ? $clean[count($clean) - 1] : null;
242
+        } elseif (preg_match('/^\d+[a-zA-Z]?$/u', $clean[0]) && isset($clean[1])) {
243
+            $civico = $clean[0];
244
+            $street = $clean[1];
245
+            $city = count($clean) > 2 ? $clean[count($clean) - 1] : null;
246
+        } else {
247
+            $city = count($clean) > 1 ? $clean[count($clean) - 1] : null;
248
+        }
249
+
250
+        $line = $street;
251
+        if ($civico) {
252
+            $line .= ' '.$civico;
253
+        }
254
+        if ($city && mb_strtolower($city) !== mb_strtolower($street)) {
255
+            $line .= ', '.$city;
256
+        }
257
+
258
+        return $line;
259
+    }
260
+
261
+    public function luogoLat(): ?float
262
+    {
263
+        $lat = $this->infoGet('luogo_lat');
264
+
265
+        return is_numeric($lat) ? (float) $lat : null;
266
+    }
267
+
268
+    public function luogoLng(): ?float
269
+    {
270
+        $lng = $this->infoGet('luogo_lng');
271
+
272
+        return is_numeric($lng) ? (float) $lng : null;
273
+    }
274
+
275
+    /**
276
+     * Link per aprire mappe / navigazione verso il luogo.
277
+     */
278
+    public function navigaUrl(): ?string
279
+    {
280
+        if ($this->luogoLat() !== null && $this->luogoLng() !== null) {
281
+            return 'https://www.google.com/maps/dir/?api=1&destination='.$this->luogoLat().','.$this->luogoLng();
282
+        }
283
+
284
+        if ($this->luogo()) {
285
+            return 'https://www.google.com/maps/search/?api=1&query='.rawurlencode($this->luogo());
286
+        }
287
+
288
+        return null;
289
+    }
290
+
291
+    /**
292
+     * Finestra temporale: modificabile/cancellabile finché manca più di N ore all'inizio.
293
+     */
294
+    public function clientePuoModificarePrenotazione(?Carbon $now = null): bool
295
+    {
296
+        $ore = $this->tempoCortesiaOre();
297
+        if ($ore === null) {
298
+            return true;
299
+        }
300
+
301
+        if (! $this->data_inizio) {
302
+            return true;
303
+        }
304
+
305
+        $now = $now ?? now();
306
+        $inizio = Carbon::parse($this->data_inizio)->startOfDay();
307
+        $limite = $inizio->copy()->subHours($ore);
308
+
309
+        return $now->lt($limite);
310
+    }
311
+
312
+    /**
313
+     * Il cliente può modificare questa prenotazione (cortesia + stato + opzioni avanzate).
314
+     * Email non è mai modificabile dal cliente (regola fissa).
315
+     */
316
+    public function clientePuoModificareQuestaPrenotazione(Prenotazione $prenotazione, ?Carbon $now = null): bool
317
+    {
318
+        if (! $this->clientePuoModificarePrenotazione($now)) {
319
+            return false;
320
+        }
321
+
322
+        $stato = (string) $prenotazione->stato;
323
+
324
+        if (in_array($stato, [
325
+            Prenotazione::STATO_CANCELLATA,
326
+            Prenotazione::STATO_ANNULLATA,
327
+            Prenotazione::STATO_RIFIUTATA,
328
+        ], true)) {
329
+            return false;
330
+        }
331
+
332
+        if ($stato === Prenotazione::STATO_CONFERMATA) {
333
+            return $this->clienteModificaSeConfermata();
334
+        }
335
+
336
+        return in_array($stato, [
337
+            Prenotazione::STATO_INVIATA,
338
+            Prenotazione::STATO_LISTA_ATTESA,
339
+        ], true);
340
+    }
341
+
342
+    /**
343
+     * Il cliente può annullare questa prenotazione (cortesia + stato + opzioni avanzate).
344
+     */
345
+    public function clientePuoAnnullareQuestaPrenotazione(Prenotazione $prenotazione, ?Carbon $now = null): bool
346
+    {
347
+        if (! $this->clientePuoModificarePrenotazione($now)) {
348
+            return false;
349
+        }
350
+
351
+        $stato = (string) $prenotazione->stato;
352
+
353
+        if (in_array($stato, [
354
+            Prenotazione::STATO_CANCELLATA,
355
+            Prenotazione::STATO_ANNULLATA,
356
+            Prenotazione::STATO_RIFIUTATA,
357
+        ], true)) {
358
+            return false;
359
+        }
360
+
361
+        if ($stato === Prenotazione::STATO_CONFERMATA) {
362
+            return $this->clienteAnnullaSeConfermata();
363
+        }
364
+
365
+        return in_array($stato, [
366
+            Prenotazione::STATO_INVIATA,
367
+            Prenotazione::STATO_LISTA_ATTESA,
368
+        ], true);
369
+    }
370
+
371
+    /**
372
+     * Eventi visibili in scheda attività pubblica (non archiviati, non conclusi).
373
+     */
374
+    public function scopePubblici(Builder $query, ?int $attivitaId = null): Builder
375
+    {
376
+        $query->where('is_attivo', true)
377
+            ->where('stato', self::ATTIVO)
378
+            ->where(function (Builder $q) {
379
+                $q->whereNull('data_fine')
380
+                    ->orWhereDate('data_fine', '>=', now()->toDateString());
381
+            });
382
+
383
+        if ($attivitaId !== null) {
384
+            $query->where('attivita_id', $attivitaId);
385
+        }
386
+
387
+        return $query;
388
+    }
389
+
390
+    public function isVisibilePublicamente(): bool
391
+    {
392
+        if (! $this->is_attivo || $this->stato !== self::ATTIVO) {
393
+            return false;
394
+        }
395
+
396
+        if ($this->data_fine && Carbon::parse($this->data_fine)->endOfDay()->isPast()) {
397
+            return false;
398
+        }
399
+
400
+        return true;
401
+    }
402
+
403
+    /**
404
+     * Prenotabile adesso (visibile + finestra prenotazioni aperta).
405
+     */
406
+    public function isPrenotabileOra(?Carbon $now = null): bool
407
+    {
408
+        if (! $this->isVisibilePublicamente()) {
409
+            return false;
410
+        }
411
+
412
+        $now = $now ?? now();
413
+
414
+        if ($this->apertura_prenotazione && $now->lt(Carbon::parse($this->apertura_prenotazione))) {
415
+            return false;
416
+        }
417
+
418
+        if ($this->chiusura_prenotazione && $now->gt(Carbon::parse($this->chiusura_prenotazione))) {
419
+            return false;
420
+        }
421
+
422
+        return true;
423
+    }
424
+
425
+    public function messaggioStatoPrenotazione(?Carbon $now = null): ?string
426
+    {
427
+        $now = $now ?? now();
428
+
429
+        if (! $this->isVisibilePublicamente()) {
430
+            return 'Questo evento non è al momento disponibile.';
431
+        }
432
+
433
+        if ($this->apertura_prenotazione && $now->lt(Carbon::parse($this->apertura_prenotazione))) {
434
+            return 'Le prenotazioni apriranno il '.Carbon::parse($this->apertura_prenotazione)->format('d/m/Y H:i').'.';
435
+        }
436
+
437
+        if ($this->chiusura_prenotazione && $now->gt(Carbon::parse($this->chiusura_prenotazione))) {
438
+            return 'Le prenotazioni per questo evento sono chiuse.';
439
+        }
440
+
441
+        return null;
442
+    }
443
+
444
+    public function getCopertiConfermati(): int
445
+    {
446
+        return $this->prenotazioni()->where('confermata', true)->sum('numero_persone');
447
+    }
448
+    public function getPostiNonConfermati(): int
449
+    {
450
+        return $this->prenotazioni()->where('confermata', false)->sum('numero_persone');
451
+    }
452
+
453
+    public function sala()
454
+    {
455
+        return $this->hasOne(EventoSala::class, 'evento_id', 'id')
456
+            ->orderBy('ordine')
457
+            ->orderBy('id');
458
+    }
459
+
460
+    public function sale()
461
+    {
462
+        return $this->hasMany(EventoSala::class, 'evento_id', 'id')
463
+            ->orderBy('ordine')
464
+            ->orderBy('id');
465
+    }
10 466
 }

+ 136
- 10
app/Models/EventoForm.php 查看文件

@@ -16,6 +16,34 @@ class EventoForm extends \App\Models\AbstractModels\AbstractEventoForm
16 16
     const RADIO = 'radio';
17 17
     const ELENCO = 'elenco';
18 18
 
19
+    /**
20
+     * Legge una chiave da impostazioni JSON (es. placeholder, min, max).
21
+     */
22
+    public function impostazione(string $key, mixed $default = null): mixed
23
+    {
24
+        $impostazioni = $this->impostazioni;
25
+        if (! is_array($impostazioni)) {
26
+            return $default;
27
+        }
28
+
29
+        return $impostazioni[$key] ?? $default;
30
+    }
31
+
32
+    /**
33
+     * Visibile nel modulo prenotazione pubblico.
34
+     */
35
+    public function isVisibileAlCliente(?\Illuminate\Contracts\Auth\Authenticatable $user = null): bool
36
+    {
37
+        if ($this->nascosta) {
38
+            return false;
39
+        }
40
+
41
+        if ($this->nascondi_se_auth && $user) {
42
+            return false;
43
+        }
44
+
45
+        return true;
46
+    }
19 47
     public static function getTipi()
20 48
     {
21 49
         return collect([
@@ -43,18 +71,116 @@ class EventoForm extends \App\Models\AbstractModels\AbstractEventoForm
43 71
 
44 72
     public function getRisposta($prenotazione_id)
45 73
     {
46
-        $x = \App\Models\PrenotazioneRisposta::where('prenotazione_id', $prenotazione_id)
47
-        ->where('evento_form_id', $this->id);
74
+        $record = \App\Models\PrenotazioneRisposta::where('prenotazione_id', $prenotazione_id)
75
+            ->where('evento_form_id', $this->id)
76
+            ->first();
77
+
78
+        return $this->formatRisposta($record?->risposta);
79
+    }
80
+
81
+    public function formatRisposta(?string $raw, string $separator = ' · '): string
82
+    {
83
+        if ($raw === null || $raw === '') {
84
+            return '—';
85
+        }
86
+
87
+        $decoded = json_decode($raw, true);
88
+        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
89
+            return collect($decoded)->map(function ($item, $i) {
90
+                $txt = $this->formatValoreRisposta($item);
91
+                return $this->per_coperto
92
+                    ? ('👤 '.$txt)
93
+                    : $txt;
94
+            })->implode($separator);
95
+        }
96
+
97
+        return $this->formatValoreRisposta($raw);
98
+    }
99
+
100
+    /**
101
+     * Risposta formattata per report HTML/PDF (icona utente per ogni coperto).
102
+     */
103
+    public function formatRispostaReport(?string $raw): string
104
+    {
105
+        if ($raw === null || $raw === '') {
106
+            return '—';
107
+        }
108
+
109
+        $decoded = json_decode($raw, true);
110
+        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
111
+            if ($this->per_coperto) {
112
+                $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#64748b">'
113
+                    .'<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2h19.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>'
114
+                    .'</svg>';
115
+                $src = 'data:image/svg+xml,'.rawurlencode($svg);
116
+
117
+                return collect($decoded)->map(function ($item) use ($src) {
118
+                    $txt = e($this->formatValoreRisposta($item));
119
+
120
+                    return '<span class="coperto-line">'
121
+                        .'<img class="coperto-ico" src="'.$src.'" width="11" height="11" alt="">'
122
+                        .$txt
123
+                        .'</span>';
124
+                })->implode('');
125
+            }
48 126
 
49
-        switch($this->tipo){
50
-            case EventoForm::ELENCO:
51
-                return $this->opzioni_form->where('id', $x->first()->risposta)->first()->label;
52
-                break;
127
+            return e(collect($decoded)->map(fn ($item) => $this->formatValoreRisposta($item))->implode(' · '));
128
+        }
129
+
130
+        return e($this->formatValoreRisposta($raw));
131
+    }
132
+
133
+    public function formatSingoloValore(mixed $item): string
134
+    {
135
+        return $this->formatValoreRisposta($item);
136
+    }
137
+
138
+    /**
139
+     * Decodifica raw risposta (array JSON o scalare).
140
+     */
141
+    public function decodeRispostaRaw(?string $raw): mixed
142
+    {
143
+        if ($raw === null || $raw === '') {
144
+            return null;
145
+        }
146
+
147
+        $decoded = json_decode($raw, true);
148
+        if (json_last_error() === JSON_ERROR_NONE) {
149
+            return $decoded;
150
+        }
151
+
152
+        return $raw;
153
+    }
154
+
155
+    protected function formatValoreRisposta(mixed $item): string
156
+    {
157
+        if (is_array($item)) {
158
+            $labels = collect($item)->map(fn ($id) => $this->labelOpzione($id))->filter();
159
+
160
+            return $labels->isEmpty() ? '—' : $labels->implode(', ');
161
+        }
162
+
163
+        if ($this->tipo === self::CHECKBOX) {
164
+            return ((string) $item === '1') ? 'Sì' : 'No';
165
+        }
166
+
167
+        if (in_array($this->tipo, [self::SELEZIONE, self::ELENCO, self::RADIO, self::MULTISELEZIONE], true)) {
168
+            return $this->labelOpzione($item);
169
+        }
170
+
171
+        return (string) $item;
172
+    }
173
+
174
+    protected function labelOpzione(mixed $id): string
175
+    {
176
+        if ($id === null || $id === '') {
177
+            return '—';
178
+        }
53 179
 
54
-            default:
55
-                return $x->first()->risposta;
56
-                break;
57
-        };
180
+        $opt = $this->opzioni_form
181
+            ->firstWhere('id', $id)
182
+            ?? $this->opzioni_form->firstWhere('id', (int) $id);
58 183
 
184
+        return $opt?->label ?? (string) $id;
59 185
     }
60 186
 }

+ 93
- 0
app/Models/EventoSala.php 查看文件

@@ -0,0 +1,93 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+class EventoSala extends \App\Models\AbstractModels\AbstractEventoSala
6
+{
7
+    /**
8
+     * Configurazione griglia piantina (celle = posti tavolo).
9
+     */
10
+    public function grigliaConfig(): array
11
+    {
12
+        $g = is_array($this->info['griglia'] ?? null) ? $this->info['griglia'] : [];
13
+
14
+        $cols = max(1, min(40, (int) ($g['cols'] ?? 10)));
15
+        $rows = max(1, min(40, (int) ($g['rows'] ?? 4)));
16
+        $colonneRaw = trim((string) ($g['colonne'] ?? 'A,B,C,D,E,F,H,K,P,R'));
17
+        $colonne = array_values(array_filter(array_map('trim', explode(',', $colonneRaw))));
18
+        if (count($colonne) < $cols) {
19
+            for ($i = count($colonne); $i < $cols; $i++) {
20
+                $colonne[] = chr(65 + ($i % 26));
21
+            }
22
+        }
23
+        $colonne = array_slice($colonne, 0, $cols);
24
+
25
+        // inset = orizzontale (fra colonne); inset_v = verticale (fra righe dello stesso gruppo)
26
+        $inset = (float) ($g['inset'] ?? 6);
27
+        $insetV = array_key_exists('inset_v', $g) ? (float) $g['inset_v'] : min(2, $inset * 0.2);
28
+
29
+        $modalita = (string) ($g['modalita'] ?? 'libera');
30
+        if (! in_array($modalita, ['celle', 'libera'], true)) {
31
+            $modalita = 'libera';
32
+        }
33
+
34
+        return [
35
+            'attiva' => (bool) ($g['attiva'] ?? true),
36
+            'modalita' => $modalita,
37
+            'cols' => $cols,
38
+            'rows' => $rows,
39
+            'margin_top' => (float) ($g['margin_top'] ?? 14),
40
+            'margin_right' => (float) ($g['margin_right'] ?? 2),
41
+            'margin_bottom' => (float) ($g['margin_bottom'] ?? 2),
42
+            'margin_left' => (float) ($g['margin_left'] ?? 8),
43
+            'inset' => $inset,
44
+            'inset_v' => $insetV,
45
+            'colonne' => $colonne,
46
+            'colonne_str' => implode(',', $colonne),
47
+        ];
48
+    }
49
+
50
+    public function modalitaCelle(): bool
51
+    {
52
+        return ($this->grigliaConfig()['modalita'] ?? 'libera') === 'celle';
53
+    }
54
+
55
+    /**
56
+     * Rettangolo % del tavolo dentro la cella (col/row 0-based).
57
+     * Gap verticale ridotto → colonna A1–A4 sembra un blocco continuo.
58
+     */
59
+    public function cellaTavolo(int $col, int $row): ?array
60
+    {
61
+        $g = $this->grigliaConfig();
62
+        if ($col < 0 || $row < 0 || $col >= $g['cols'] || $row >= $g['rows']) {
63
+            return null;
64
+        }
65
+
66
+        $innerW = 100 - $g['margin_left'] - $g['margin_right'];
67
+        $innerH = 100 - $g['margin_top'] - $g['margin_bottom'];
68
+        $cellW = $innerW / $g['cols'];
69
+        $cellH = $innerH / $g['rows'];
70
+        $insetX = $cellW * ($g['inset'] / 100);
71
+        $insetY = $cellH * ($g['inset_v'] / 100);
72
+
73
+        $x = $g['margin_left'] + ($col * $cellW) + $insetX;
74
+        $y = $g['margin_top'] + ($row * $cellH) + $insetY;
75
+        $w = max(0.5, $cellW - (2 * $insetX));
76
+        $h = max(0.5, $cellH - (2 * $insetY));
77
+
78
+        $label = ($g['colonne'][$col] ?? ('C'.($col + 1))).($row + 1);
79
+        $lettera = $g['colonne'][$col] ?? ('C'.($col + 1));
80
+
81
+        return [
82
+            'col' => $col,
83
+            'row' => $row,
84
+            'x' => round($x, 2),
85
+            'y' => round($y, 2),
86
+            'w' => round($w, 2),
87
+            'h' => round($h, 2),
88
+            'label' => $label,
89
+            'gruppo_id' => $lettera,
90
+            'ordine_in_gruppo' => $row + 1,
91
+        ];
92
+    }
93
+}

+ 48
- 0
app/Models/EventoTavolo.php 查看文件

@@ -0,0 +1,48 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+class EventoTavolo extends \App\Models\AbstractModels\AbstractEventoTavolo
6
+{
7
+    public const FORMA_RETTANGOLO = 'rettangolo';
8
+
9
+    public const FORMA_CERCHIO = 'cerchio';
10
+
11
+    /**
12
+     * Posti già assegnati su questo tavolo.
13
+     */
14
+    public function postiOccupati(): int
15
+    {
16
+        if ($this->relationLoaded('assegnazioni')) {
17
+            return (int) $this->assegnazioni->sum('posti_assegnati');
18
+        }
19
+
20
+        return (int) $this->assegnazioni()->sum('posti_assegnati');
21
+    }
22
+
23
+    /**
24
+     * Posti ancora liberi.
25
+     */
26
+    public function postiLiberi(): int
27
+    {
28
+        return max(0, (int) $this->posti - $this->postiOccupati());
29
+    }
30
+
31
+    /**
32
+     * Altri tavoli dello stesso gruppo (catena "attaccati"), escluso sé stesso.
33
+     */
34
+    public function tavoliStessoGruppo()
35
+    {
36
+        if (! filled($this->gruppo_id) || ! $this->evento_sala_id) {
37
+            return static::query()->whereRaw('1 = 0');
38
+        }
39
+
40
+        return static::query()
41
+            ->where('evento_sala_id', $this->evento_sala_id)
42
+            ->where('gruppo_id', $this->gruppo_id)
43
+            ->where('id', '!=', $this->id)
44
+            ->where('is_attivo', true)
45
+            ->orderBy('ordine_in_gruppo')
46
+            ->orderBy('label');
47
+    }
48
+}

+ 88
- 0
app/Models/Prenotazione.php 查看文件

@@ -63,5 +63,93 @@ class Prenotazione extends \App\Models\AbstractModels\AbstractPrenotazione
63 63
         return '#'.$this->id.', '.$this->nome . ' ' . $this->cognome;
64 64
     }
65 65
 
66
+    public function isConfermata(): bool
67
+    {
68
+        return (bool) $this->confermata;
69
+    }
70
+
71
+    protected static function booted(): void
72
+    {
73
+        static::saving(function (Prenotazione $prenotazione) {
74
+            $statiChiuse = [
75
+                self::STATO_CANCELLATA,
76
+                self::STATO_RIFIUTATA,
77
+                self::STATO_ANNULLATA,
78
+            ];
79
+
80
+            if (in_array($prenotazione->stato, $statiChiuse, true)) {
81
+                $prenotazione->confermata = false;
82
+
83
+                return;
84
+            }
85
+
86
+            if ($prenotazione->isDirty('confermata')) {
87
+                if ($prenotazione->confermata) {
88
+                    $prenotazione->stato = self::STATO_CONFERMATA;
89
+                } elseif ($prenotazione->stato === self::STATO_CONFERMATA) {
90
+                    $prenotazione->stato = self::STATO_INVIATA;
91
+                }
92
+
93
+                return;
94
+            }
95
+
96
+            if ($prenotazione->isDirty('stato') || $prenotazione->stato === self::STATO_CONFERMATA) {
97
+                $prenotazione->confermata = $prenotazione->stato === self::STATO_CONFERMATA;
98
+            }
99
+        });
100
+    }
101
+
102
+    public function assegnazioniTavolo()
103
+    {
104
+        return $this->hasMany(PrenotazioneAssegnazioneTavolo::class, 'prenotazione_id', 'id')
105
+            ->orderBy('ordine');
106
+    }
107
+
108
+    /**
109
+     * Etichetta tavoli assegnati per report/lista.
110
+     * Con più sale: "Sala - A1" o "Sala - A1 (3) + Altra - A2 (2)".
111
+     */
112
+    public function labelTavoliAssegnati(): string
113
+    {
114
+        $this->loadMissing(['assegnazioniTavolo.eventoTavolo.sala', 'evento.sale']);
115
+        $assegnazioni = $this->assegnazioniTavolo;
116
+        if ($assegnazioni->isEmpty()) {
117
+            return '—';
118
+        }
119
+
120
+        $multiAssegnazioni = $assegnazioni->count() > 1;
121
+        $multiSala = ($this->evento?->sale?->count() ?? 0) > 1
122
+            || $assegnazioni
123
+                ->map(fn ($a) => $a->eventoTavolo?->evento_sala_id)
124
+                ->filter()
125
+                ->unique()
126
+                ->count() > 1;
127
+
128
+        return $assegnazioni->map(function ($a) use ($multiAssegnazioni, $multiSala) {
129
+            $tavolo = $a->eventoTavolo;
130
+            $label = $tavolo?->label ?: '?';
131
+            if ($multiSala) {
132
+                $salaNome = filled($tavolo?->sala?->nome)
133
+                    ? (string) $tavolo->sala->nome
134
+                    : ('Sala #'.($tavolo?->evento_sala_id ?: '?'));
135
+                $label = $salaNome.' - '.$label;
136
+            }
137
+            if (! $multiAssegnazioni) {
138
+                return $label;
139
+            }
66 140
 
141
+            return $label.' ('.(int) $a->posti_assegnati.')';
142
+        })->implode(' + ');
143
+    }
144
+
145
+    /**
146
+     * Prima etichetta tavolo (utile per ordinamento report).
147
+     */
148
+    public function primoLabelTavolo(): ?string
149
+    {
150
+        $this->loadMissing('assegnazioniTavolo.eventoTavolo');
151
+        $first = $this->assegnazioniTavolo->first();
152
+
153
+        return $first?->eventoTavolo?->label;
154
+    }
67 155
 }

+ 8
- 0
app/Models/PrenotazioneAssegnazioneTavolo.php 查看文件

@@ -0,0 +1,8 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+class PrenotazioneAssegnazioneTavolo extends \App\Models\AbstractModels\AbstractPrenotazioneAssegnazioneTavolo
6
+{
7
+    //
8
+}

+ 64
- 0
app/Notifications/PrenotazioneAnnullata.php 查看文件

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use Illuminate\Bus\Queueable;
6
+use Illuminate\Contracts\Queue\ShouldQueue;
7
+use Illuminate\Notifications\Messages\MailMessage;
8
+use Illuminate\Notifications\Notification;
9
+use App\Models\Prenotazione;
10
+use App\Services\Calendario\CreaIcsPrenotazione;
11
+
12
+
13
+class PrenotazioneAnnullata extends Notification implements ShouldQueue
14
+{
15
+    use Queueable;
16
+
17
+    /**
18
+     * Create a new notification instance.
19
+     */
20
+    public function __construct(public Prenotazione $prenotazione)
21
+    {
22
+        //
23
+    }
24
+
25
+    /**
26
+     * Get the notification's delivery channels.
27
+     *
28
+     * @return array<int, string>
29
+     */
30
+    public function via(object $notifiable): array
31
+    {
32
+        return ['mail'];
33
+    }
34
+
35
+    /**
36
+     * Get the mail representation of the notification.
37
+     */
38
+    public function toMail(object $notifiable): MailMessage
39
+    {
40
+        $this->prenotazione->loadMissing(['evento.attivita']);
41
+
42
+        $attivitaNome = $this->prenotazione->evento?->attivita?->nome ?? 'Prenotazione';
43
+        $codice = strtoupper((string) ($this->prenotazione->codice ?? ''));
44
+
45
+        $mail = (new MailMessage)
46
+            ->subject('Prenotazione annullata · '.$codice)
47
+            ->view('prenotazione.email.annullata', ['prenotazione' => $this->prenotazione])
48
+            ->from(config('mail.from.address'), $attivitaNome);
49
+
50
+        return $mail;
51
+    }
52
+
53
+    /**
54
+     * Get the array representation of the notification.
55
+     *
56
+     * @return array<string, mixed>
57
+     */
58
+    public function toArray(object $notifiable): array
59
+    {
60
+        return [
61
+            //
62
+        ];
63
+    }
64
+}

+ 71
- 0
app/Notifications/PrenotazioneConfermata.php 查看文件

@@ -0,0 +1,71 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use Illuminate\Bus\Queueable;
6
+use Illuminate\Contracts\Queue\ShouldQueue;
7
+use Illuminate\Notifications\Messages\MailMessage;
8
+use Illuminate\Notifications\Notification;
9
+use App\Models\Prenotazione;
10
+use App\Services\Calendario\CreaIcsPrenotazione;
11
+
12
+class PrenotazioneConfermata extends Notification implements ShouldQueue
13
+{
14
+    use Queueable;
15
+
16
+    /**
17
+     * Create a new notification instance.
18
+     */
19
+    public function __construct(public Prenotazione $prenotazione)
20
+    {
21
+        //
22
+    }
23
+
24
+    /**
25
+     * Get the notification's delivery channels.
26
+     *
27
+     * @return array<int, string>
28
+     */
29
+    public function via(object $notifiable): array
30
+    {
31
+        return ['mail'];
32
+    }
33
+
34
+    /**
35
+     * Get the mail representation of the notification.
36
+     */
37
+    public function toMail(object $notifiable): MailMessage
38
+    {
39
+        $this->prenotazione->loadMissing(['evento.attivita']);
40
+
41
+        $attivitaNome = $this->prenotazione->evento?->attivita?->nome ?? 'Prenotazione';
42
+        $codice = strtoupper((string) ($this->prenotazione->codice ?? ''));
43
+
44
+        $mail = (new MailMessage)
45
+            ->subject('Prenotazione confermata · '.$codice)
46
+            ->view('prenotazione.email.confermata', ['prenotazione' => $this->prenotazione])
47
+            ->from(config('mail.from.address'), $attivitaNome);
48
+
49
+        $ics = app(CreaIcsPrenotazione::class);
50
+        $contenuto = $ics->contenuto($this->prenotazione);
51
+        if ($contenuto) {
52
+            $mail->attachData($contenuto, $ics->nomeFile($this->prenotazione), [
53
+                'mime' => 'text/calendar; charset=utf-8',
54
+            ]);
55
+        }
56
+
57
+        return $mail;
58
+    }
59
+
60
+    /**
61
+     * Get the array representation of the notification.
62
+     *
63
+     * @return array<string, mixed>
64
+     */
65
+    public function toArray(object $notifiable): array
66
+    {
67
+        return [
68
+            //
69
+        ];
70
+    }
71
+}

+ 72
- 0
app/Notifications/PrenotazioneInviata.php 查看文件

@@ -0,0 +1,72 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use Illuminate\Bus\Queueable;
6
+use Illuminate\Contracts\Queue\ShouldQueue;
7
+use Illuminate\Notifications\Messages\MailMessage;
8
+use Illuminate\Notifications\Notification;
9
+use App\Models\Prenotazione;
10
+use App\Services\Calendario\CreaIcsPrenotazione;
11
+
12
+
13
+class PrenotazioneInviata extends Notification implements ShouldQueue
14
+{
15
+    use Queueable;
16
+
17
+    /**
18
+     * Create a new notification instance.
19
+     */
20
+    public function __construct(public Prenotazione $prenotazione)
21
+    {
22
+        //
23
+    }
24
+
25
+    /**
26
+     * Get the notification's delivery channels.
27
+     *
28
+     * @return array<int, string>
29
+     */
30
+    public function via(object $notifiable): array
31
+    {
32
+        return ['mail'];
33
+    }
34
+
35
+    /**
36
+     * Get the mail representation of the notification.
37
+     */
38
+    public function toMail(object $notifiable): MailMessage
39
+    {
40
+        $this->prenotazione->loadMissing(['evento.attivita']);
41
+
42
+        $attivitaNome = $this->prenotazione->evento?->attivita?->nome ?? 'Prenotazione';
43
+        $codice = strtoupper((string) ($this->prenotazione->codice ?? ''));
44
+
45
+        $mail = (new MailMessage)
46
+            ->subject('Prenotazione ricevuta · '.$codice)
47
+            ->view('prenotazione.email.inviata', ['prenotazione' => $this->prenotazione])
48
+            ->from(config('mail.from.address'), $attivitaNome);
49
+
50
+        $ics = app(CreaIcsPrenotazione::class);
51
+        $contenuto = $ics->contenuto($this->prenotazione);
52
+        if ($contenuto) {
53
+            $mail->attachData($contenuto, $ics->nomeFile($this->prenotazione), [
54
+                'mime' => 'text/calendar; charset=utf-8',
55
+            ]);
56
+        }
57
+
58
+        return $mail;
59
+    }
60
+
61
+    /**
62
+     * Get the array representation of the notification.
63
+     *
64
+     * @return array<string, mixed>
65
+     */
66
+    public function toArray(object $notifiable): array
67
+    {
68
+        return [
69
+            //
70
+        ];
71
+    }
72
+}

+ 55
- 0
app/Notifications/PrenotazioneLinkModifica.php 查看文件

@@ -0,0 +1,55 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Prenotazione;
6
+use App\Services\Prenotazione\AccessoModificaCliente;
7
+use Illuminate\Bus\Queueable;
8
+use Illuminate\Contracts\Queue\ShouldQueue;
9
+use Illuminate\Notifications\Messages\MailMessage;
10
+use Illuminate\Notifications\Notification;
11
+
12
+class PrenotazioneLinkModifica extends Notification implements ShouldQueue
13
+{
14
+    use Queueable;
15
+
16
+    public function __construct(
17
+        public Prenotazione $prenotazione,
18
+        public string $azione = 'modifica',
19
+        public string $urlFirmato = '',
20
+    ) {}
21
+
22
+    public function via(object $notifiable): array
23
+    {
24
+        return ['mail'];
25
+    }
26
+
27
+    public function toMail(object $notifiable): MailMessage
28
+    {
29
+        $this->prenotazione->loadMissing(['evento.attivita']);
30
+
31
+        $attivitaNome = $this->prenotazione->evento?->attivita?->nome ?? 'Prenotazione';
32
+        $codice = strtoupper((string) ($this->prenotazione->codice ?? ''));
33
+        $isAnnulla = $this->azione === 'annulla';
34
+        $minuti = AccessoModificaCliente::MINUTI_LINK;
35
+
36
+        $subject = $isAnnulla
37
+            ? 'Annulla prenotazione · '.$codice
38
+            : 'Modifica prenotazione · '.$codice;
39
+
40
+        return (new MailMessage)
41
+            ->subject($subject)
42
+            ->view('prenotazione.email.link-modifica', [
43
+                'prenotazione' => $this->prenotazione,
44
+                'azione' => $this->azione,
45
+                'urlFirmato' => $this->urlFirmato,
46
+                'minuti' => $minuti,
47
+            ])
48
+            ->from(config('mail.from.address'), $attivitaNome);
49
+    }
50
+
51
+    public function toArray(object $notifiable): array
52
+    {
53
+        return [];
54
+    }
55
+}

+ 36
- 0
app/Observers/EventoObserver.php 查看文件

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+namespace App\Observers;
4
+
5
+use App\Models\Evento;
6
+use Illuminate\Support\Str;
7
+
8
+class EventoObserver
9
+{
10
+    /**
11
+     * Default impostazioni avanzate (JSON info) + codice pubblico alla creazione.
12
+     */
13
+    public function creating(Evento $evento): void
14
+    {
15
+        if (! filled($evento->codice)) {
16
+            do {
17
+                $codice = Str::random(30);
18
+            } while (Evento::where('codice', $codice)->exists());
19
+
20
+            $evento->codice = $codice;
21
+        }
22
+
23
+        $info = is_array($evento->info) ? $evento->info : [];
24
+
25
+        $evento->info = array_merge([
26
+            'tempo_cortesia_ore' => null,
27
+            'conferma_automatica' => false,
28
+            'nascondi_posti_disponibili' => false,
29
+            'cliente_modifica_se_confermata' => false,
30
+            'cliente_annulla_se_confermata' => false,
31
+            'luogo' => null,
32
+            'luogo_lat' => null,
33
+            'luogo_lng' => null,
34
+        ], $info);
35
+    }
36
+}

+ 54
- 0
app/Observers/PrenotazioneObserver.php 查看文件

@@ -0,0 +1,54 @@
1
+<?php
2
+
3
+namespace App\Observers;
4
+
5
+use App\Models\Prenotazione;
6
+use Illuminate\Support\Facades\Log;
7
+use Illuminate\Support\Facades\Notification;
8
+use App\Notifications\PrenotazioneInviata;
9
+use App\Notifications\PrenotazioneConfermata;
10
+use App\Notifications\PrenotazioneAnnullata;
11
+use Illuminate\Notifications\Notification as BaseNotification;
12
+
13
+class PrenotazioneObserver
14
+{
15
+    public function created(Prenotazione $prenotazione)
16
+    {
17
+        if($prenotazione->stato == Prenotazione::STATO_INVIATA){
18
+            $this->notificaMail($prenotazione, new PrenotazioneInviata($prenotazione));
19
+        }elseif($prenotazione->stato == Prenotazione::STATO_CONFERMATA){
20
+            $this->notificaMail($prenotazione, new PrenotazioneConfermata($prenotazione));
21
+        }
22
+
23
+    }
24
+
25
+    public function updated(Prenotazione $prenotazione)
26
+    {
27
+        if($prenotazione->wasChanged('stato') && $prenotazione->stato == Prenotazione::STATO_CONFERMATA){
28
+            $this->notificaMail($prenotazione, new PrenotazioneConfermata($prenotazione));
29
+        }
30
+
31
+        if($prenotazione->wasChanged('stato') && $prenotazione->stato == Prenotazione::STATO_ANNULLATA){
32
+            $this->notificaMail($prenotazione, new PrenotazioneAnnullata($prenotazione));
33
+        }
34
+    }
35
+
36
+    public function deleted(Prenotazione $prenotazione)
37
+    {
38
+
39
+    }
40
+
41
+    private function notificaMail(Prenotazione $prenotazione, BaseNotification $notification): void
42
+    {
43
+        if (! filled($prenotazione->email)) {
44
+            Log::warning('Prenotazione senza email, notifica saltata', [
45
+                'prenotazione_id' => $prenotazione->id,
46
+                'stato' => $prenotazione->stato,
47
+            ]);
48
+
49
+            return;
50
+        }
51
+
52
+        Notification::route('mail', $prenotazione->email)->notify($notification);
53
+    }
54
+}

+ 103
- 0
app/Services/Calendario/CreaIcsPrenotazione.php 查看文件

@@ -0,0 +1,103 @@
1
+<?php
2
+
3
+namespace App\Services\Calendario;
4
+
5
+use App\Models\Prenotazione;
6
+use Carbon\Carbon;
7
+
8
+class CreaIcsPrenotazione
9
+{
10
+    public function contenuto(Prenotazione $prenotazione): ?string
11
+    {
12
+        $prenotazione->loadMissing(['evento.attivita']);
13
+        $evento = $prenotazione->evento;
14
+        if (! $evento || ! $evento->data_inizio) {
15
+            return null;
16
+        }
17
+
18
+        $tz = config('app.timezone', 'Europe/Rome');
19
+        $inizio = Carbon::parse($evento->data_inizio)->timezone($tz);
20
+        $fine = $evento->data_fine
21
+            ? Carbon::parse($evento->data_fine)->timezone($tz)
22
+            : $inizio->copy()->addHours(2);
23
+
24
+        if ($fine->lessThanOrEqualTo($inizio)) {
25
+            $fine = $inizio->copy()->addHours(2);
26
+        }
27
+
28
+        $allDay = $inizio->format('H:i:s') === '00:00:00'
29
+            && (! $evento->data_fine || Carbon::parse($evento->data_fine)->format('H:i:s') === '00:00:00');
30
+
31
+        $summary = $evento->nome ?: 'Evento';
32
+        $attivita = $evento->attivita?->nome;
33
+        if ($attivita) {
34
+            $summary = $attivita.' · '.$summary;
35
+        }
36
+
37
+        $descrizione = [
38
+            'Prenotazione '.$prenotazione->codice,
39
+            trim(($prenotazione->cognome ?? '').' '.($prenotazione->nome ?? '')),
40
+            'Coperti: '.max(1, (int) ($prenotazione->numero_persone ?? 1)),
41
+            route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]),
42
+        ];
43
+        $descrizione = implode('\\n', array_map(
44
+            fn ($line) => $this->escape((string) $line),
45
+            array_values(array_filter($descrizione, fn ($l) => filled($l)))
46
+        ));
47
+
48
+        $location = $this->escape((string) ($evento->luogo() ?? ''));
49
+        $host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'segrestafest.local';
50
+        $uid = 'prenotazione-'.$prenotazione->id.'-'.strtolower((string) $prenotazione->codice).'@'.$host;
51
+        $now = now()->utc()->format('Ymd\THis\Z');
52
+        $url = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
53
+
54
+        if ($allDay) {
55
+            $dtStart = 'DTSTART;VALUE=DATE:'.$inizio->format('Ymd');
56
+            $dtEnd = 'DTEND;VALUE=DATE:'.$fine->copy()->addDay()->format('Ymd');
57
+        } else {
58
+            $dtStart = 'DTSTART;TZID='.$tz.':'.$inizio->format('Ymd\THis');
59
+            $dtEnd = 'DTEND;TZID='.$tz.':'.$fine->format('Ymd\THis');
60
+        }
61
+
62
+        $lines = [
63
+            'BEGIN:VCALENDAR',
64
+            'VERSION:2.0',
65
+            'PRODID:-//SegrestaFest//Prenotazione//IT',
66
+            'CALSCALE:GREGORIAN',
67
+            'METHOD:PUBLISH',
68
+            'BEGIN:VEVENT',
69
+            'UID:'.$uid,
70
+            'DTSTAMP:'.$now,
71
+            $dtStart,
72
+            $dtEnd,
73
+            'SUMMARY:'.$this->escape($summary),
74
+            'DESCRIPTION:'.$descrizione,
75
+            'URL:'.$url,
76
+        ];
77
+
78
+        if ($location !== '') {
79
+            $lines[] = 'LOCATION:'.$location;
80
+        }
81
+
82
+        $lines[] = 'END:VEVENT';
83
+        $lines[] = 'END:VCALENDAR';
84
+
85
+        return implode("\r\n", $lines)."\r\n";
86
+    }
87
+
88
+    public function nomeFile(Prenotazione $prenotazione): string
89
+    {
90
+        $codice = strtoupper((string) ($prenotazione->codice ?: 'evento'));
91
+
92
+        return 'prenotazione-'.$codice.'.ics';
93
+    }
94
+
95
+    protected function escape(string $value): string
96
+    {
97
+        return str_replace(
98
+            ['\\', ';', ',', "\r\n", "\n", "\r"],
99
+            ['\\\\', '\;', '\,', '\n', '\n', '\n'],
100
+            $value
101
+        );
102
+    }
103
+}

+ 166
- 0
app/Services/Prenotazione/AccessoModificaCliente.php 查看文件

@@ -0,0 +1,166 @@
1
+<?php
2
+
3
+namespace App\Services\Prenotazione;
4
+
5
+use App\Models\Prenotazione;
6
+use Illuminate\Http\Request;
7
+use Illuminate\Support\Facades\Cache;
8
+use Illuminate\Support\Facades\URL;
9
+
10
+class AccessoModificaCliente
11
+{
12
+    public const MINUTI_LINK = 15;
13
+
14
+    public const SESSION_KEY = 'cliente_prenotazione_edit';
15
+
16
+    public function urlFirmato(Prenotazione $prenotazione, string $azione = 'modifica'): string
17
+    {
18
+        $azione = $this->normalizzaAzione($azione);
19
+
20
+        return URL::temporarySignedRoute(
21
+            'consulta.prenotazione.modifica.apri',
22
+            now()->addMinutes(self::MINUTI_LINK),
23
+            [
24
+                'codice' => $prenotazione->codice,
25
+                'azione' => $azione,
26
+            ]
27
+        );
28
+    }
29
+
30
+    public function mascheraEmail(?string $email): string
31
+    {
32
+        $email = trim((string) $email);
33
+        if ($email === '' || ! str_contains($email, '@')) {
34
+            return '***';
35
+        }
36
+
37
+        [$local, $domain] = explode('@', $email, 2);
38
+        $localMask = mb_substr($local, 0, 1).'***';
39
+        $domainParts = explode('.', $domain);
40
+        $domainMask = mb_substr($domainParts[0] ?? '', 0, 1).'***';
41
+        if (count($domainParts) > 1) {
42
+            $domainMask .= '.'.implode('.', array_slice($domainParts, 1));
43
+        }
44
+
45
+        return $localMask.'@'.$domainMask;
46
+    }
47
+
48
+    /**
49
+     * Marca il link firmato come usato (monouso). False se già consumato.
50
+     */
51
+    public function consumaLink(Request $request): bool
52
+    {
53
+        $signature = (string) $request->query('signature', '');
54
+        if ($signature === '') {
55
+            return false;
56
+        }
57
+
58
+        $key = $this->cacheKeyUsato($signature);
59
+        if (Cache::has($key)) {
60
+            return false;
61
+        }
62
+
63
+        Cache::put($key, 1, now()->addMinutes(self::MINUTI_LINK + 5));
64
+
65
+        return true;
66
+    }
67
+
68
+    public function avviaSessione(Prenotazione $prenotazione, string $azione = 'modifica'): void
69
+    {
70
+        session([
71
+            self::SESSION_KEY => [
72
+                'prenotazione_id' => (int) $prenotazione->id,
73
+                'codice' => (string) $prenotazione->codice,
74
+                'azione' => $this->normalizzaAzione($azione),
75
+                'expires_at' => now()->addMinutes(self::MINUTI_LINK)->timestamp,
76
+            ],
77
+        ]);
78
+    }
79
+
80
+    public function sessioneValida(?int $prenotazioneId = null, ?string $azione = null): bool
81
+    {
82
+        $data = session(self::SESSION_KEY);
83
+        if (! is_array($data)) {
84
+            return false;
85
+        }
86
+
87
+        $expiresAt = (int) ($data['expires_at'] ?? 0);
88
+        if ($expiresAt < now()->timestamp) {
89
+            $this->chiudiSessione();
90
+
91
+            return false;
92
+        }
93
+
94
+        if ($prenotazioneId !== null && (int) ($data['prenotazione_id'] ?? 0) !== $prenotazioneId) {
95
+            return false;
96
+        }
97
+
98
+        if ($azione !== null && ($data['azione'] ?? '') !== $this->normalizzaAzione($azione)) {
99
+            return false;
100
+        }
101
+
102
+        return true;
103
+    }
104
+
105
+    public function azioneSessione(): ?string
106
+    {
107
+        if (! $this->sessioneValida()) {
108
+            return null;
109
+        }
110
+
111
+        return session(self::SESSION_KEY.'.azione');
112
+    }
113
+
114
+    public function codiceSessione(): ?string
115
+    {
116
+        if (! $this->sessioneValida()) {
117
+            return null;
118
+        }
119
+
120
+        return session(self::SESSION_KEY.'.codice');
121
+    }
122
+
123
+    public function chiudiSessione(): void
124
+    {
125
+        session()->forget(self::SESSION_KEY);
126
+    }
127
+
128
+    public function puoRichiedereLink(Prenotazione $prenotazione, string $azione = 'modifica'): bool
129
+    {
130
+        $evento = $prenotazione->evento;
131
+        if (! $evento) {
132
+            return false;
133
+        }
134
+
135
+        $azione = $this->normalizzaAzione($azione);
136
+
137
+        return $azione === 'annulla'
138
+            ? $evento->clientePuoAnnullareQuestaPrenotazione($prenotazione)
139
+            : $evento->clientePuoModificareQuestaPrenotazione($prenotazione);
140
+    }
141
+
142
+    /**
143
+     * Throttle richieste email: max 1 al minuto per codice+azione.
144
+     */
145
+    public function puoInviareOra(Prenotazione $prenotazione, string $azione = 'modifica'): bool
146
+    {
147
+        $key = 'prenotazione_edit_mail:'.$prenotazione->codice.':'.$this->normalizzaAzione($azione);
148
+        if (Cache::has($key)) {
149
+            return false;
150
+        }
151
+
152
+        Cache::put($key, 1, now()->addMinute());
153
+
154
+        return true;
155
+    }
156
+
157
+    private function cacheKeyUsato(string $signature): string
158
+    {
159
+        return 'prenotazione_edit_used:'.hash('sha256', $signature);
160
+    }
161
+
162
+    private function normalizzaAzione(string $azione): string
163
+    {
164
+        return $azione === 'annulla' ? 'annulla' : 'modifica';
165
+    }
166
+}

+ 327
- 0
app/Services/Prenotazione/AssegnaTavoliAutomatico.php 查看文件

@@ -0,0 +1,327 @@
1
+<?php
2
+
3
+namespace App\Services\Prenotazione;
4
+
5
+use App\Models\Evento;
6
+use App\Models\EventoSala;
7
+use App\Models\EventoTavolo;
8
+use App\Models\Prenotazione;
9
+use App\Models\PrenotazioneAssegnazioneTavolo;
10
+use Illuminate\Support\Collection;
11
+use Illuminate\Support\Facades\DB;
12
+
13
+/**
14
+ * Assegna automaticamente le prenotazioni ai tavoli dell'evento.
15
+ *
16
+ * 1) Intere: gruppo che sta tutto su un solo tavolo (best fit).
17
+ * 2) Buchi: posti rimasti che stanno tutti insieme su un tavolo con buco.
18
+ * 3) Spezza: eventuali resti su tavoli dello stesso gruppo (colonna).
19
+ */
20
+class AssegnaTavoliAutomatico
21
+{
22
+    /**
23
+     * @param  array{solo_confermate?: bool}  $options
24
+     * @return array{
25
+     *   prenotazioni_toccate: int,
26
+     *   posti_assegnati: int,
27
+     *   intere: int,
28
+     *   buchi: int,
29
+     *   spezzate: int,
30
+     *   non_collocate: int,
31
+     * }
32
+     */
33
+    public function esegui(Evento $evento, EventoSala $sala, array $options = []): array
34
+    {
35
+        $soloConfermate = (bool) ($options['solo_confermate'] ?? false);
36
+
37
+        return DB::transaction(function () use ($evento, $sala, $soloConfermate) {
38
+            $tavoli = EventoTavolo::query()
39
+                ->where('evento_sala_id', $sala->id)
40
+                ->where('is_attivo', true)
41
+                ->with(['assegnazioni'])
42
+                ->orderBy('ordine_in_gruppo')
43
+                ->orderBy('label')
44
+                ->get();
45
+
46
+            $query = Prenotazione::query()
47
+                ->where('evento_id', $evento->id)
48
+                ->whereNotIn('stato', [
49
+                    Prenotazione::STATO_CANCELLATA,
50
+                    Prenotazione::STATO_ANNULLATA,
51
+                    Prenotazione::STATO_RIFIUTATA,
52
+                ])
53
+                ->with(['assegnazioniTavolo.eventoTavolo']);
54
+
55
+            if ($soloConfermate) {
56
+                $query->where('confermata', true);
57
+            }
58
+
59
+            $prenotazioni = $query->get();
60
+
61
+            $liberi = [];
62
+            foreach ($tavoli as $t) {
63
+                $liberi[(int) $t->id] = $t->postiLiberi();
64
+            }
65
+
66
+            $rimasti = [];
67
+            $gruppoVincolo = []; // prenotazione_id => gruppo_id|null se già assegnata
68
+            foreach ($prenotazioni as $p) {
69
+                $coperti = max(1, (int) ($p->numero_persone ?? 1));
70
+                $assegnati = (int) $p->assegnazioniTavolo->sum('posti_assegnati');
71
+                $rimasti[(int) $p->id] = max(0, $coperti - $assegnati);
72
+
73
+                $gruppi = $p->assegnazioniTavolo
74
+                    ->map(fn ($a) => $a->eventoTavolo?->gruppo_id)
75
+                    ->filter(fn ($g) => filled($g))
76
+                    ->unique()
77
+                    ->values();
78
+                $gruppoVincolo[(int) $p->id] = $gruppi->count() === 1 ? $gruppi->first() : ($gruppi->isEmpty() ? null : '__locked__');
79
+            }
80
+
81
+            $stats = [
82
+                'prenotazioni_toccate' => 0,
83
+                'posti_assegnati' => 0,
84
+                'intere' => 0,
85
+                'buchi' => 0,
86
+                'spezzate' => 0,
87
+                'non_collocate' => 0,
88
+            ];
89
+            $toccate = [];
90
+
91
+            $daSedere = fn () => $prenotazioni
92
+                ->filter(fn (Prenotazione $p) => ($rimasti[(int) $p->id] ?? 0) > 0)
93
+                ->sort(function (Prenotazione $a, Prenotazione $b) use ($rimasti) {
94
+                    $ra = $rimasti[(int) $a->id];
95
+                    $rb = $rimasti[(int) $b->id];
96
+                    if ($ra !== $rb) {
97
+                        return $rb <=> $ra;
98
+                    }
99
+                    if ((bool) $a->confermata !== (bool) $b->confermata) {
100
+                        return $b->confermata <=> $a->confermata;
101
+                    }
102
+
103
+                    return $a->id <=> $b->id;
104
+                })
105
+                ->values();
106
+
107
+            // Passo 1 — intere su un solo tavolo (best fit)
108
+            foreach ($daSedere() as $p) {
109
+                $need = $rimasti[(int) $p->id];
110
+                $tavolo = $this->scegliTavoloIntero($tavoli, $liberi, $need, $gruppoVincolo[(int) $p->id], preferBuco: false);
111
+                if (! $tavolo) {
112
+                    continue;
113
+                }
114
+                $n = $this->metti($p, $tavolo, $need, $liberi, $rimasti, $gruppoVincolo);
115
+                if ($n > 0) {
116
+                    $stats['intere']++;
117
+                    $stats['posti_assegnati'] += $n;
118
+                    $toccate[(int) $p->id] = true;
119
+                }
120
+            }
121
+
122
+            // Passo 2 — riempie i buchi: tutti i posti rimasti insieme, preferendo tavoli già parziali
123
+            foreach ($daSedere() as $p) {
124
+                $need = $rimasti[(int) $p->id];
125
+                $tavolo = $this->scegliTavoloIntero($tavoli, $liberi, $need, $gruppoVincolo[(int) $p->id], preferBuco: true);
126
+                if (! $tavolo) {
127
+                    continue;
128
+                }
129
+                $n = $this->metti($p, $tavolo, $need, $liberi, $rimasti, $gruppoVincolo);
130
+                if ($n > 0) {
131
+                    $stats['buchi']++;
132
+                    $stats['posti_assegnati'] += $n;
133
+                    $toccate[(int) $p->id] = true;
134
+                }
135
+            }
136
+
137
+            // Passo 3 — spezza sui tavoli della stessa colonna/gruppo
138
+            foreach ($daSedere() as $p) {
139
+                $need = $rimasti[(int) $p->id];
140
+                $assegnatiQui = $this->spezzaSuGruppo($p, $tavoli, $liberi, $rimasti, $gruppoVincolo, $need);
141
+                if ($assegnatiQui > 0) {
142
+                    $stats['spezzate']++;
143
+                    $stats['posti_assegnati'] += $assegnatiQui;
144
+                    $toccate[(int) $p->id] = true;
145
+                }
146
+            }
147
+
148
+            $stats['prenotazioni_toccate'] = count($toccate);
149
+            $stats['non_collocate'] = $daSedere()->count();
150
+
151
+            return $stats;
152
+        });
153
+    }
154
+
155
+    /**
156
+     * @param  Collection<int, EventoTavolo>  $tavoli
157
+     * @param  array<int, int>  $liberi
158
+     */
159
+    private function scegliTavoloIntero(
160
+        Collection $tavoli,
161
+        array $liberi,
162
+        int $need,
163
+        mixed $vincoloGruppo,
164
+        bool $preferBuco
165
+    ): ?EventoTavolo {
166
+        if ($need < 1 || $vincoloGruppo === '__locked__') {
167
+            return null;
168
+        }
169
+
170
+        $candidati = $tavoli->filter(function (EventoTavolo $t) use ($liberi, $need, $vincoloGruppo, $preferBuco) {
171
+            $lib = $liberi[(int) $t->id] ?? 0;
172
+            if ($lib < $need) {
173
+                return false;
174
+            }
175
+            $occupati = max(0, (int) $t->posti - $lib);
176
+            if ($preferBuco) {
177
+                if ($occupati < 1) {
178
+                    return false; // solo buchi
179
+                }
180
+            } elseif ($occupati > 0) {
181
+                return false; // passo 1: solo tavoli ancora vuoti
182
+            }
183
+            if ($vincoloGruppo === null) {
184
+                return true;
185
+            }
186
+
187
+            return filled($t->gruppo_id) && $t->gruppo_id === $vincoloGruppo;
188
+        });
189
+
190
+        if ($candidati->isEmpty()) {
191
+            return null;
192
+        }
193
+
194
+        return $candidati->sort(function (EventoTavolo $a, EventoTavolo $b) use ($liberi) {
195
+            $la = $liberi[(int) $a->id];
196
+            $lb = $liberi[(int) $b->id];
197
+            if ($la !== $lb) {
198
+                return $la <=> $lb; // best fit
199
+            }
200
+            if ((int) $a->posti !== (int) $b->posti) {
201
+                return (int) $a->posti <=> (int) $b->posti;
202
+            }
203
+
204
+            return strcmp((string) $a->label, (string) $b->label);
205
+        })->first();
206
+    }
207
+
208
+    /**
209
+     * @param  Collection<int, EventoTavolo>  $tavoli
210
+     * @param  array<int, int>  $liberi
211
+     * @param  array<int, int>  $rimasti
212
+     * @param  array<int, mixed>  $gruppoVincolo
213
+     */
214
+    private function spezzaSuGruppo(
215
+        Prenotazione $p,
216
+        Collection $tavoli,
217
+        array &$liberi,
218
+        array &$rimasti,
219
+        array &$gruppoVincolo,
220
+        int $need
221
+    ): int {
222
+        if ($need < 1 || $gruppoVincolo[(int) $p->id] === '__locked__') {
223
+            return 0;
224
+        }
225
+
226
+        $vincolo = $gruppoVincolo[(int) $p->id];
227
+        $gruppi = $tavoli
228
+            ->filter(fn (EventoTavolo $t) => filled($t->gruppo_id) && ($liberi[(int) $t->id] ?? 0) > 0)
229
+            ->groupBy('gruppo_id');
230
+
231
+        $scelto = null;
232
+        $capacitaScelta = 0;
233
+        foreach ($gruppi as $gid => $set) {
234
+            if ($vincolo !== null && $gid !== $vincolo) {
235
+                continue;
236
+            }
237
+            $cap = $set->sum(fn (EventoTavolo $t) => $liberi[(int) $t->id] ?? 0);
238
+            if ($cap < $need) {
239
+                continue;
240
+            }
241
+            // Preferisci il gruppo con capacità più stretta
242
+            if ($scelto === null || $cap < $capacitaScelta) {
243
+                $scelto = $set->sortBy([
244
+                    ['ordine_in_gruppo', 'asc'],
245
+                    ['label', 'asc'],
246
+                ])->values();
247
+                $capacitaScelta = $cap;
248
+            }
249
+        }
250
+
251
+        if (! $scelto) {
252
+            return 0;
253
+        }
254
+
255
+        $totale = 0;
256
+        foreach ($scelto as $tavolo) {
257
+            $need = $rimasti[(int) $p->id];
258
+            if ($need < 1) {
259
+                break;
260
+            }
261
+            $lib = $liberi[(int) $tavolo->id] ?? 0;
262
+            if ($lib < 1) {
263
+                continue;
264
+            }
265
+            $n = $this->metti($p, $tavolo, min($need, $lib), $liberi, $rimasti, $gruppoVincolo);
266
+            $totale += $n;
267
+        }
268
+
269
+        return $totale;
270
+    }
271
+
272
+    /**
273
+     * @param  array<int, int>  $liberi
274
+     * @param  array<int, int>  $rimasti
275
+     * @param  array<int, mixed>  $gruppoVincolo
276
+     */
277
+    private function metti(
278
+        Prenotazione $p,
279
+        EventoTavolo $tavolo,
280
+        int $posti,
281
+        array &$liberi,
282
+        array &$rimasti,
283
+        array &$gruppoVincolo
284
+    ): int {
285
+        $posti = min($posti, $rimasti[(int) $p->id] ?? 0, $liberi[(int) $tavolo->id] ?? 0);
286
+        if ($posti < 1) {
287
+            return 0;
288
+        }
289
+
290
+        $vincolo = $gruppoVincolo[(int) $p->id] ?? null;
291
+        if ($vincolo === '__locked__') {
292
+            return 0;
293
+        }
294
+        if ($vincolo !== null && (! filled($tavolo->gruppo_id) || $tavolo->gruppo_id !== $vincolo)) {
295
+            return 0;
296
+        }
297
+
298
+        $assegnazione = PrenotazioneAssegnazioneTavolo::firstOrNew([
299
+            'prenotazione_id' => $p->id,
300
+            'evento_tavolo_id' => $tavolo->id,
301
+        ]);
302
+        $gia = $assegnazione->exists ? (int) $assegnazione->posti_assegnati : 0;
303
+        if (! $assegnazione->exists) {
304
+            $maxOrdine = (int) PrenotazioneAssegnazioneTavolo::query()
305
+                ->where('prenotazione_id', $p->id)
306
+                ->max('ordine');
307
+            $assegnazione->ordine = $maxOrdine + 1;
308
+        }
309
+        $assegnazione->posti_assegnati = $gia + $posti;
310
+        $assegnazione->save();
311
+
312
+        $liberi[(int) $tavolo->id] = max(0, ($liberi[(int) $tavolo->id] ?? 0) - $posti);
313
+        $rimasti[(int) $p->id] = max(0, ($rimasti[(int) $p->id] ?? 0) - $posti);
314
+
315
+        if ($vincolo === null && filled($tavolo->gruppo_id)) {
316
+            $gruppoVincolo[(int) $p->id] = $tavolo->gruppo_id;
317
+        } elseif ($vincolo === null && ! filled($tavolo->gruppo_id)) {
318
+            // Tavolo senza gruppo: non si potrà più spezzare altrove
319
+            $gruppoVincolo[(int) $p->id] = '__locked__';
320
+        }
321
+
322
+        // Mantieni relation in sync per payload successivi se servono
323
+        $p->unsetRelation('assegnazioniTavolo');
324
+
325
+        return $posti;
326
+    }
327
+}

+ 315
- 0
app/Services/Stampa/CreaSegnapostoFaccia.php 查看文件

@@ -0,0 +1,315 @@
1
+<?php
2
+
3
+namespace App\Services\Stampa;
4
+
5
+use Illuminate\Support\Facades\File;
6
+use Illuminate\Support\Str;
7
+
8
+class CreaSegnapostoFaccia
9
+{
10
+    /**
11
+     * Genera PNG di un foglio A5 completo (due facce + piega al centro).
12
+     * Una sola immagine = una pagina PDF, senza spezzature DomPDF.
13
+     *
14
+     * @param  'verticale'|'orizzontale'  $formato
15
+     */
16
+    public function generaFoglio(
17
+        string $nomeOspite,
18
+        string $eventoNome,
19
+        ?string $logoPath,
20
+        string $accentHex,
21
+        string $formato = 'verticale',
22
+        string $codice = ''
23
+    ): string {
24
+        $dir = storage_path('app/temp/segnaposto');
25
+        File::ensureDirectoryExists($dir);
26
+
27
+        // 2× dimensioni A5 in pt (~72dpi × 2)
28
+        if ($formato === 'orizzontale') {
29
+            $pageW = 840;  // 419.53 × 2
30
+            $pageH = 1190; // 595.28 × 2
31
+            $halfW = $pageW;
32
+            $halfH = (int) ($pageH / 2); // 595 — piega a metà esatta
33
+        } else {
34
+            $pageW = 1190;
35
+            $pageH = 840;
36
+            $halfW = (int) ($pageW / 2); // 595
37
+            $halfH = $pageH;
38
+        }
39
+
40
+        $faccia = $this->creaFacciaGd($nomeOspite, $eventoNome, $logoPath, $accentHex, $halfW, $halfH, false, $codice);
41
+        $page = imagecreatetruecolor($pageW, $pageH);
42
+        $white = imagecolorallocate($page, 255, 255, 255);
43
+        imagefilledrectangle($page, 0, 0, $pageW, $pageH, $white);
44
+
45
+        if ($formato === 'orizzontale') {
46
+            $facciaTop = $this->creaFacciaGd($nomeOspite, $eventoNome, $logoPath, $accentHex, $halfW, $halfH, true, $codice);
47
+            imagecopy($page, $facciaTop, 0, 0, 0, 0, $halfW, $halfH);
48
+            imagecopy($page, $faccia, 0, $halfH, 0, 0, $halfW, $halfH);
49
+            imagedestroy($facciaTop);
50
+            $this->drawDashedLineH($page, $halfH, $pageW);
51
+        } else {
52
+            imagecopy($page, $faccia, 0, 0, 0, 0, $halfW, $halfH);
53
+            imagecopy($page, $faccia, $halfW, 0, 0, 0, $halfW, $halfH);
54
+            $this->drawDashedLineV($page, $halfW, $pageH);
55
+        }
56
+
57
+        imagedestroy($faccia);
58
+
59
+        $filename = 'foglio_'.Str::uuid()->toString().'.png';
60
+        $path = $dir.DIRECTORY_SEPARATOR.$filename;
61
+        imagepng($page, $path, 6);
62
+        imagedestroy($page);
63
+
64
+        return str_replace('\\', '/', $path);
65
+    }
66
+
67
+    /**
68
+     * @return \GdImage
69
+     */
70
+    protected function creaFacciaGd(
71
+        string $nomeOspite,
72
+        string $eventoNome,
73
+        ?string $logoPath,
74
+        string $accentHex,
75
+        int $width,
76
+        int $height,
77
+        bool $ruota180 = false,
78
+        string $codice = ''
79
+    ) {
80
+        $img = imagecreatetruecolor($width, $height);
81
+        imagealphablending($img, true);
82
+        imagesavealpha($img, true);
83
+
84
+        $white = imagecolorallocate($img, 255, 255, 255);
85
+        imagefilledrectangle($img, 0, 0, $width, $height, $white);
86
+
87
+        [$ar, $ag, $ab] = $this->hexToRgb($accentHex);
88
+        $accent = imagecolorallocate($img, $ar, $ag, $ab);
89
+        $barH = max(6, (int) round($height * 0.028));
90
+        imagefilledrectangle($img, 0, 0, $width, $barH, $accent);
91
+
92
+        $slate = imagecolorallocate($img, 51, 65, 85);
93
+        $muted = imagecolorallocate($img, 100, 116, 139);
94
+        $ink = imagecolorallocate($img, 15, 23, 42);
95
+
96
+        $fontRegular = base_path('vendor/dompdf/dompdf/lib/fonts/DejaVuSans.ttf');
97
+        $fontBold = base_path('vendor/dompdf/dompdf/lib/fonts/DejaVuSans-Bold.ttf');
98
+        if (! is_file($fontBold)) {
99
+            $fontBold = $fontRegular;
100
+        }
101
+
102
+        $padX = (int) round($width * 0.08);
103
+        $y = $barH + (int) round($height * 0.08);
104
+
105
+        if ($logoPath && is_file($logoPath)) {
106
+            $logo = $this->loadImage($logoPath);
107
+            if ($logo) {
108
+                $maxW = (int) round($width * 0.34);
109
+                $maxH = (int) round($height * 0.18);
110
+                $lw = imagesx($logo);
111
+                $lh = imagesy($logo);
112
+                $scale = min($maxW / max(1, $lw), $maxH / max(1, $lh), 1.0);
113
+                $dw = max(1, (int) round($lw * $scale));
114
+                $dh = max(1, (int) round($lh * $scale));
115
+                $dx = (int) round(($width - $dw) / 2);
116
+                imagecopyresampled($img, $logo, $dx, $y, 0, 0, $dw, $dh, $lw, $lh);
117
+                imagedestroy($logo);
118
+                $y += $dh + (int) round($height * 0.035);
119
+            }
120
+        }
121
+
122
+        $evento = mb_strtoupper(trim($eventoNome));
123
+        if ($evento !== '') {
124
+            $eventoSize = max(10, (int) round($width * 0.032));
125
+            for ($try = 0; $try < 6; $try++) {
126
+                $lines = $this->wrapText($fontBold, $eventoSize, $evento, $width - 2 * $padX);
127
+                if (count($lines) <= 2) {
128
+                    break;
129
+                }
130
+                $eventoSize = max(9, $eventoSize - 1);
131
+            }
132
+            $y = $this->drawCenteredText($img, $fontBold, $eventoSize, $evento, $slate, $padX, $y, $width - 2 * $padX, true);
133
+            $y += (int) round($height * 0.05);
134
+        } else {
135
+            $y += (int) round($height * 0.08);
136
+        }
137
+
138
+        $nomeSize = max(22, (int) round($width * 0.085));
139
+        $nomeMaxW = $width - 2 * $padX;
140
+        $nomeY = max($y, (int) round($height * 0.48));
141
+        $afterNomeY = $this->drawCenteredText($img, $fontBold, $nomeSize, trim($nomeOspite) ?: '—', $ink, $padX, $nomeY, $nomeMaxW, true);
142
+
143
+        $codice = strtoupper(trim($codice));
144
+        if ($codice !== '') {
145
+            $codiceSize = max(11, (int) round($width * 0.038));
146
+            $this->drawCenteredText(
147
+                $img,
148
+                $fontRegular,
149
+                $codiceSize,
150
+                $codice,
151
+                $muted,
152
+                $padX,
153
+                $afterNomeY + (int) round($height * 0.02),
154
+                $nomeMaxW,
155
+                false
156
+            );
157
+        }
158
+
159
+        if ($ruota180) {
160
+            $rotated = imagerotate($img, 180, $white);
161
+            imagedestroy($img);
162
+
163
+            return $rotated;
164
+        }
165
+
166
+        return $img;
167
+    }
168
+
169
+    /**
170
+     * @param  \GdImage  $img
171
+     */
172
+    protected function drawDashedLineV($img, int $x, int $height): void
173
+    {
174
+        $color = imagecolorallocate($img, 148, 163, 184);
175
+        $dash = 10;
176
+        $gap = 8;
177
+        for ($y = 0; $y < $height; $y += $dash + $gap) {
178
+            imageline($img, $x, $y, $x, min($height - 1, $y + $dash - 1), $color);
179
+        }
180
+    }
181
+
182
+    /**
183
+     * @param  \GdImage  $img
184
+     */
185
+    protected function drawDashedLineH($img, int $y, int $width): void
186
+    {
187
+        $color = imagecolorallocate($img, 148, 163, 184);
188
+        $dash = 10;
189
+        $gap = 8;
190
+        for ($x = 0; $x < $width; $x += $dash + $gap) {
191
+            imageline($img, $x, $y, min($width - 1, $x + $dash - 1), $y, $color);
192
+        }
193
+    }
194
+
195
+    /**
196
+     * @return array{0:int,1:int,2:int}
197
+     */
198
+    protected function hexToRgb(string $hex): array
199
+    {
200
+        $hex = ltrim(trim($hex), '#');
201
+        if (strlen($hex) === 3) {
202
+            $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
203
+        }
204
+        if (! preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
205
+            return [37, 99, 235];
206
+        }
207
+
208
+        return [
209
+            hexdec(substr($hex, 0, 2)),
210
+            hexdec(substr($hex, 2, 2)),
211
+            hexdec(substr($hex, 4, 2)),
212
+        ];
213
+    }
214
+
215
+    /**
216
+     * @return \GdImage|false
217
+     */
218
+    protected function loadImage(string $path)
219
+    {
220
+        $info = @getimagesize($path);
221
+        if (! $info) {
222
+            return false;
223
+        }
224
+
225
+        return match ($info[2] ?? null) {
226
+            IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
227
+            IMAGETYPE_PNG => @imagecreatefrompng($path),
228
+            IMAGETYPE_GIF => @imagecreatefromgif($path),
229
+            IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false,
230
+            default => false,
231
+        };
232
+    }
233
+
234
+    protected function drawCenteredText(
235
+        $img,
236
+        string $font,
237
+        int $size,
238
+        string $text,
239
+        int $color,
240
+        int $x,
241
+        int $y,
242
+        int $maxWidth,
243
+        bool $wrap = false
244
+    ): int {
245
+        if (! is_file($font) || $text === '') {
246
+            return $y;
247
+        }
248
+
249
+        $lines = $wrap
250
+            ? $this->wrapText($font, $size, $text, $maxWidth)
251
+            : [$this->fitSingleLine($font, $size, $text, $maxWidth)];
252
+
253
+        $lineHeight = (int) round($size * 1.28);
254
+        $imgW = imagesx($img);
255
+
256
+        foreach ($lines as $line) {
257
+            $bbox = imagettfbbox($size, 0, $font, $line);
258
+            $tw = abs($bbox[2] - $bbox[0]);
259
+            $tx = (int) round(($imgW - $tw) / 2);
260
+            imagettftext($img, $size, 0, $tx, $y + $size, $color, $font, $line);
261
+            $y += $lineHeight;
262
+        }
263
+
264
+        return $y;
265
+    }
266
+
267
+    /**
268
+     * @return list<string>
269
+     */
270
+    protected function wrapText(string $font, int $size, string $text, int $maxWidth): array
271
+    {
272
+        $words = preg_split('/\s+/u', trim($text)) ?: [];
273
+        $lines = [];
274
+        $current = '';
275
+
276
+        foreach ($words as $word) {
277
+            $trial = $current === '' ? $word : $current.' '.$word;
278
+            $bbox = imagettfbbox($size, 0, $font, $trial);
279
+            $tw = abs($bbox[2] - $bbox[0]);
280
+            if ($tw <= $maxWidth || $current === '') {
281
+                $current = $trial;
282
+            } else {
283
+                $lines[] = $current;
284
+                $current = $word;
285
+            }
286
+        }
287
+        if ($current !== '') {
288
+            $lines[] = $current;
289
+        }
290
+
291
+        return $lines ?: ['—'];
292
+    }
293
+
294
+    protected function fitSingleLine(string $font, int $size, string $text, int $maxWidth): string
295
+    {
296
+        $bbox = imagettfbbox($size, 0, $font, $text);
297
+        $tw = abs($bbox[2] - $bbox[0]);
298
+        if ($tw <= $maxWidth) {
299
+            return $text;
300
+        }
301
+
302
+        $ellipsis = '…';
303
+        $cut = mb_strlen($text);
304
+        while ($cut > 1) {
305
+            $cut--;
306
+            $candidate = rtrim(mb_substr($text, 0, $cut)).$ellipsis;
307
+            $bbox = imagettfbbox($size, 0, $font, $candidate);
308
+            if (abs($bbox[2] - $bbox[0]) <= $maxWidth) {
309
+                return $candidate;
310
+            }
311
+        }
312
+
313
+        return $ellipsis;
314
+    }
315
+}

+ 1
- 1
config/horizon.php 查看文件

@@ -167,7 +167,7 @@ return [
167 167
   'defaults' => [
168 168
     'supervisor-'.env('CACHE_PREFIX','1') => [
169 169
       'connection' => 'redis',
170
-      'queue' => ['high', 'default', 'pdfIscrizione'],
170
+      'queue' => ['high', 'default', 'pdfIscrizione', 'prenotazioni'],
171 171
       'balance' => 'auto',
172 172
       'maxProcesses' => 2,
173 173
       'minProcesses' => 1,

+ 2
- 2
config/mail.php 查看文件

@@ -109,8 +109,8 @@ return [
109 109
     */
110 110
 
111 111
     'from' => [
112
-        'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
113
-        'name' => env('MAIL_FROM_NAME', 'Example'),
112
+        'address' => env('MAIL_FROM_ADDRESS', 'noreply@segrestafest.it'),
113
+        'name' => env('MAIL_FROM_NAME', 'Segrestafest'),
114 114
     ],
115 115
 
116 116
 ];

+ 29
- 0
database/migrations/2026_08_22_232525_fix__evento_form.php 查看文件

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::table('evento_form', function (Blueprint $table) {
15
+        $table->boolean('per_coperto')->default(false);
16
+        $table->json('impostazioni')->nullable();
17
+        });
18
+    }
19
+
20
+    /**
21
+     * Reverse the migrations.
22
+     */
23
+    public function down(): void
24
+    {
25
+        Schema::table('evento_form', function (Blueprint $table) {
26
+            $table->dropColumn(['per_coperto', 'impostazioni']);
27
+        });
28
+    }
29
+};

+ 24
- 0
database/migrations/2026_08_22_234242_fix__evento_form.php 查看文件

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        //
15
+    }
16
+
17
+    /**
18
+     * Reverse the migrations.
19
+     */
20
+    public function down(): void
21
+    {
22
+        //
23
+    }
24
+};

+ 26
- 0
database/migrations/2026_08_24_121900_add_numero_persone_to_prenotazione.php 查看文件

@@ -0,0 +1,26 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::table('prenotazione', function (Blueprint $table) {
12
+            if (! Schema::hasColumn('prenotazione', 'numero_persone')) {
13
+                $table->unsignedInteger('numero_persone')->nullable()->after('telefono');
14
+            }
15
+        });
16
+    }
17
+
18
+    public function down(): void
19
+    {
20
+        Schema::table('prenotazione', function (Blueprint $table) {
21
+            if (Schema::hasColumn('prenotazione', 'numero_persone')) {
22
+                $table->dropColumn('numero_persone');
23
+            }
24
+        });
25
+    }
26
+};

+ 22
- 0
database/migrations/2026_08_24_124800_make_prenotazione_telefono_nullable.php 查看文件

@@ -0,0 +1,22 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::table('prenotazione', function (Blueprint $table) {
12
+            $table->string('telefono')->nullable()->change();
13
+        });
14
+    }
15
+
16
+    public function down(): void
17
+    {
18
+        Schema::table('prenotazione', function (Blueprint $table) {
19
+            $table->string('telefono')->nullable(false)->change();
20
+        });
21
+    }
22
+};

+ 28
- 0
database/migrations/2026_08_24_154039_conferma_prenotazione.php 查看文件

@@ -0,0 +1,28 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::table('prenotazione', function (Blueprint $table) {
15
+            $table->boolean('confermata')->default(false);
16
+        });
17
+    }
18
+
19
+    /**
20
+     * Reverse the migrations.
21
+     */
22
+    public function down(): void
23
+    {
24
+        Schema::table('prenotazione', function (Blueprint $table) {
25
+            $table->dropColumn('confermata');
26
+        });
27
+    }
28
+};

+ 36
- 0
database/migrations/2026_08_26_093626_evento_sala.php 查看文件

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::create('evento_sala', function (Blueprint $table) {
12
+            $table->id();
13
+            $table->bigInteger('evento_id')->unsigned();
14
+            $table->string('nome')->nullable();
15
+            $table->string('immagine')->nullable();
16
+            $table->unsignedInteger('larghezza')->nullable();
17
+            $table->unsignedInteger('altezza')->nullable();
18
+            $table->json('info')->nullable();
19
+            $table->timestamps();
20
+
21
+            $table->unique('evento_id');
22
+        });
23
+
24
+        Schema::table('evento_sala', function (Blueprint $table) {
25
+            $table->foreign('evento_id')->references('id')->on('evento')->onDelete('cascade');
26
+        });
27
+    }
28
+
29
+    public function down(): void
30
+    {
31
+        Schema::table('evento_sala', function (Blueprint $table) {
32
+            $table->dropForeign(['evento_id']);
33
+        });
34
+        Schema::dropIfExists('evento_sala');
35
+    }
36
+};

+ 43
- 0
database/migrations/2026_08_26_093635_evento_tavolo.php 查看文件

@@ -0,0 +1,43 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::create('evento_tavolo', function (Blueprint $table) {
12
+            $table->id();
13
+            $table->bigInteger('evento_sala_id')->unsigned();
14
+            $table->string('label');
15
+            $table->unsignedInteger('posti');
16
+            $table->decimal('x', 8, 4)->default(0);
17
+            $table->decimal('y', 8, 4)->default(0);
18
+            $table->decimal('w', 8, 4)->default(8);
19
+            $table->decimal('h', 8, 4)->default(8);
20
+            $table->smallInteger('rotazione')->default(0);
21
+            $table->string('forma')->nullable()->default('rettangolo');
22
+            $table->string('gruppo_id')->nullable();
23
+            $table->unsignedSmallInteger('ordine_in_gruppo')->default(0);
24
+            $table->boolean('is_attivo')->default(true);
25
+            $table->json('info')->nullable();
26
+            $table->timestamps();
27
+
28
+            $table->index(['evento_sala_id', 'gruppo_id', 'ordine_in_gruppo'], 'evento_tavolo_gruppo_idx');
29
+        });
30
+
31
+        Schema::table('evento_tavolo', function (Blueprint $table) {
32
+            $table->foreign('evento_sala_id')->references('id')->on('evento_sala')->onDelete('cascade');
33
+        });
34
+    }
35
+
36
+    public function down(): void
37
+    {
38
+        Schema::table('evento_tavolo', function (Blueprint $table) {
39
+            $table->dropForeign(['evento_sala_id']);
40
+        });
41
+        Schema::dropIfExists('evento_tavolo');
42
+    }
43
+};

+ 37
- 0
database/migrations/2026_08_26_093656_prenotazione_assegnazione_tavolo.php 查看文件

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::create('prenotazione_assegnazione_tavolo', function (Blueprint $table) {
12
+            $table->id();
13
+            $table->bigInteger('prenotazione_id')->unsigned();
14
+            $table->bigInteger('evento_tavolo_id')->unsigned();
15
+            $table->unsignedInteger('posti_assegnati');
16
+            $table->unsignedSmallInteger('ordine')->default(0);
17
+            $table->timestamps();
18
+
19
+            $table->unique(['prenotazione_id', 'evento_tavolo_id'], 'prenotazione_tavolo_unique');
20
+            $table->index(['evento_tavolo_id'], 'assegnazione_tavolo_idx');
21
+        });
22
+
23
+        Schema::table('prenotazione_assegnazione_tavolo', function (Blueprint $table) {
24
+            $table->foreign('prenotazione_id')->references('id')->on('prenotazione')->onDelete('cascade');
25
+            $table->foreign('evento_tavolo_id')->references('id')->on('evento_tavolo')->onDelete('cascade');
26
+        });
27
+    }
28
+
29
+    public function down(): void
30
+    {
31
+        Schema::table('prenotazione_assegnazione_tavolo', function (Blueprint $table) {
32
+            $table->dropForeign(['prenotazione_id']);
33
+            $table->dropForeign(['evento_tavolo_id']);
34
+        });
35
+        Schema::dropIfExists('prenotazione_assegnazione_tavolo');
36
+    }
37
+};

+ 92
- 0
database/migrations/2026_08_26_220100_evento_sala_multiple.php 查看文件

@@ -0,0 +1,92 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\DB;
6
+use Illuminate\Support\Facades\Schema;
7
+
8
+return new class extends Migration
9
+{
10
+    public function up(): void
11
+    {
12
+        if (! Schema::hasColumn('evento_sala', 'ordine')) {
13
+            Schema::table('evento_sala', function (Blueprint $table) {
14
+                $table->unsignedSmallInteger('ordine')->default(0)->after('evento_id');
15
+            });
16
+        }
17
+
18
+        // Su MySQL l'unique su evento_id è usato anche dalla FK: va ricostruita.
19
+        $indexes = collect(DB::select('SHOW INDEX FROM evento_sala'));
20
+        $hasUniqueEvento = $indexes->contains(function ($row) {
21
+            return $row->Column_name === 'evento_id'
22
+                && (int) $row->Non_unique === 0
23
+                && $row->Key_name !== 'PRIMARY';
24
+        });
25
+
26
+        if ($hasUniqueEvento) {
27
+            Schema::table('evento_sala', function (Blueprint $table) {
28
+                $table->dropForeign(['evento_id']);
29
+            });
30
+
31
+            Schema::table('evento_sala', function (Blueprint $table) {
32
+                $table->dropUnique(['evento_id']);
33
+            });
34
+
35
+            Schema::table('evento_sala', function (Blueprint $table) {
36
+                $table->foreign('evento_id')->references('id')->on('evento')->onDelete('cascade');
37
+            });
38
+        }
39
+
40
+        $hasOrdineIdx = $indexes->contains(fn ($row) => $row->Key_name === 'evento_sala_evento_ordine_idx');
41
+        if (! $hasOrdineIdx) {
42
+            Schema::table('evento_sala', function (Blueprint $table) {
43
+                $table->index(['evento_id', 'ordine'], 'evento_sala_evento_ordine_idx');
44
+            });
45
+        }
46
+
47
+        // Normalizza ordine sulle sale esistenti
48
+        $ids = DB::table('evento_sala')->orderBy('evento_id')->orderBy('id')->get(['id', 'evento_id']);
49
+        $contatori = [];
50
+        foreach ($ids as $row) {
51
+            $eid = (int) $row->evento_id;
52
+            $contatori[$eid] = ($contatori[$eid] ?? 0);
53
+            DB::table('evento_sala')->where('id', $row->id)->update(['ordine' => $contatori[$eid]]);
54
+            $contatori[$eid]++;
55
+        }
56
+    }
57
+
58
+    public function down(): void
59
+    {
60
+        $duplicati = DB::table('evento_sala')
61
+            ->select('evento_id', DB::raw('COUNT(*) as c'))
62
+            ->groupBy('evento_id')
63
+            ->having('c', '>', 1)
64
+            ->count();
65
+
66
+        if ($duplicati > 0) {
67
+            throw new RuntimeException('Impossibile ripristinare unique su evento_id: esistono eventi con più sale.');
68
+        }
69
+
70
+        Schema::table('evento_sala', function (Blueprint $table) {
71
+            $table->dropForeign(['evento_id']);
72
+        });
73
+
74
+        $indexes = collect(DB::select('SHOW INDEX FROM evento_sala'));
75
+        if ($indexes->contains(fn ($row) => $row->Key_name === 'evento_sala_evento_ordine_idx')) {
76
+            Schema::table('evento_sala', function (Blueprint $table) {
77
+                $table->dropIndex('evento_sala_evento_ordine_idx');
78
+            });
79
+        }
80
+
81
+        if (Schema::hasColumn('evento_sala', 'ordine')) {
82
+            Schema::table('evento_sala', function (Blueprint $table) {
83
+                $table->dropColumn('ordine');
84
+            });
85
+        }
86
+
87
+        Schema::table('evento_sala', function (Blueprint $table) {
88
+            $table->unique('evento_id');
89
+            $table->foreign('evento_id')->references('id')->on('evento')->onDelete('cascade');
90
+        });
91
+    }
92
+};

+ 1
- 1
resources/views/consulta/index.blade.php 查看文件

@@ -1,4 +1,4 @@
1
-@extends('layouts/Layoutfront')
1
+@extends('layouts/layoutFront')
2 2
 
3 3
 @section('title', 'Consulta')
4 4
 

+ 136
- 0
resources/views/consulta/prenotazione/_partials/flash-swal.blade.php 查看文件

@@ -0,0 +1,136 @@
1
+@php
2
+  $flashMessage = null;
3
+  $flashIcon = 'info';
4
+
5
+  if (isset($errors) && $errors->any()) {
6
+    $flashMessage = $errors->first();
7
+    $flashIcon = 'error';
8
+  } elseif (session('error')) {
9
+    $flashMessage = session('error');
10
+    $flashIcon = 'error';
11
+  } elseif (session('success')) {
12
+    $flashMessage = session('success');
13
+    $flashIcon = 'success';
14
+  } elseif (session('warning')) {
15
+    $flashMessage = session('warning');
16
+    $flashIcon = 'warning';
17
+  } elseif (session('info')) {
18
+    $flashMessage = session('info');
19
+    $flashIcon = 'info';
20
+  }
21
+
22
+  $flashTitle = match ($flashIcon) {
23
+    'success' => 'Fatto',
24
+    'error' => 'Attenzione',
25
+    'warning' => 'Nota',
26
+    default => 'Informazione',
27
+  };
28
+
29
+  $flashHtml = null;
30
+  if ($flashMessage) {
31
+    $flashHtml = e($flashMessage);
32
+    $flashHtml = nl2br($flashHtml, false);
33
+    $flashHtml = preg_replace('/\.\s+(Controlla|Riprova|Se non)/u', '.<br>$1', $flashHtml) ?? $flashHtml;
34
+    $flashHtml = preg_replace('/\s+\((valido|debug)/iu', '<br>($1', $flashHtml) ?? $flashHtml;
35
+    $flashHtml = preg_replace('/\.\s+(?=\()/u', '.<br>', $flashHtml) ?? $flashHtml;
36
+  }
37
+@endphp
38
+
39
+@if($flashMessage)
40
+<script>
41
+  (function () {
42
+    var payload = {
43
+      icon: @json($flashIcon),
44
+      title: @json($flashTitle),
45
+      html: @json($flashHtml),
46
+      confirmButtonText: 'OK',
47
+      confirmButtonColor: '#f58220',
48
+      width: 'min(92vw, 400px)',
49
+      padding: '1.35rem 1.15rem 1.15rem',
50
+      buttonsStyling: true,
51
+      customClass: {
52
+        container: 'consulta-swal-container',
53
+        popup: 'consulta-swal-popup',
54
+        icon: 'consulta-swal-icon',
55
+        title: 'consulta-swal-title',
56
+        htmlContainer: 'consulta-swal-text',
57
+        actions: 'consulta-swal-actions',
58
+        confirmButton: 'consulta-swal-btn',
59
+      },
60
+    };
61
+
62
+    if (@json($flashIcon) === 'success') {
63
+      payload.timer = 9000;
64
+      payload.timerProgressBar = true;
65
+    }
66
+
67
+    function showFlash() {
68
+      if (typeof Swal === 'undefined') {
69
+        setTimeout(showFlash, 40);
70
+        return;
71
+      }
72
+      Swal.fire(payload);
73
+    }
74
+
75
+    if (document.readyState === 'loading') {
76
+      document.addEventListener('DOMContentLoaded', showFlash);
77
+    } else {
78
+      showFlash();
79
+    }
80
+  })();
81
+</script>
82
+<style>
83
+  .consulta-swal-popup.swal2-popup {
84
+    display: flex !important;
85
+    flex-direction: column !important;
86
+    align-items: center !important;
87
+    text-align: center !important;
88
+    border-radius: 1rem !important;
89
+    font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif !important;
90
+  }
91
+  .consulta-swal-popup .consulta-swal-icon.swal2-icon {
92
+    margin: 0.25rem auto 0.85rem !important;
93
+    transform: none !important;
94
+  }
95
+  .consulta-swal-popup .consulta-swal-title.swal2-title {
96
+    display: block !important;
97
+    width: 100% !important;
98
+    max-width: 100% !important;
99
+    max-inline-size: none !important;
100
+    margin: 0 0 0.65rem !important;
101
+    padding: 0 0.35rem !important;
102
+    font-size: 1.2rem !important;
103
+    font-weight: 800 !important;
104
+    line-height: 1.25 !important;
105
+    color: #15265c !important;
106
+    text-align: center !important;
107
+  }
108
+  .consulta-swal-popup .consulta-swal-text.swal2-html-container {
109
+    display: block !important;
110
+    width: 100% !important;
111
+    max-width: 100% !important;
112
+    margin: 0 !important;
113
+    padding: 0 0.35rem !important;
114
+    font-size: 0.95rem !important;
115
+    line-height: 1.55 !important;
116
+    color: #374151 !important;
117
+    text-align: center !important;
118
+    word-break: break-word;
119
+    overflow-wrap: anywhere;
120
+  }
121
+  .consulta-swal-popup .consulta-swal-actions.swal2-actions {
122
+    width: 100% !important;
123
+    margin: 1.1rem 0 0 !important;
124
+    padding: 0 !important;
125
+    justify-content: center !important;
126
+  }
127
+  .consulta-swal-popup .consulta-swal-btn.swal2-confirm {
128
+    min-height: 2.75rem !important;
129
+    min-width: 7rem !important;
130
+    margin: 0 !important;
131
+    padding: 0.55rem 1.35rem !important;
132
+    font-weight: 700 !important;
133
+    border-radius: 999px !important;
134
+  }
135
+</style>
136
+@endif

+ 102
- 0
resources/views/consulta/prenotazione/annulla.blade.php 查看文件

@@ -0,0 +1,102 @@
1
+@php
2
+  use Illuminate\Support\Facades\Storage;
3
+
4
+  $evento = $evento ?? $prenotazione->evento;
5
+  $attivita = $evento?->attivita;
6
+  $accent = $attivita?->colore ?: '#f58220';
7
+  $coverSrc = null;
8
+  $eventiDisk = Storage::disk('eventi');
9
+  if ($evento?->copertina && $eventiDisk->exists($evento->copertina)) {
10
+    $coverSrc = $eventiDisk->url($evento->copertina).'?v='.$eventiDisk->lastModified($evento->copertina);
11
+  }
12
+  $showUrl = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
13
+  $codice = strtoupper((string) $prenotazione->codice);
14
+@endphp
15
+
16
+@extends('layouts/guest')
17
+
18
+@section('title', 'Annulla prenotazione · '.$codice)
19
+
20
+@section('page-meta')
21
+  <script>document.documentElement.classList.add('prenota-public-page');</script>
22
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
23
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
24
+  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet" />
25
+@endsection
26
+
27
+@section('page-style')
28
+@include('prenotazione._partials.prenota-styles')
29
+<style>
30
+  .consulta-annulla__box {
31
+    margin: 0 0 1rem;
32
+    padding: 1.1rem;
33
+    border-radius: 1rem;
34
+    background: #fff;
35
+    border: 1px solid var(--evento-border);
36
+  }
37
+  .consulta-annulla__warn {
38
+    margin: 0 0 1rem;
39
+    padding: .85rem 1rem;
40
+    border-radius: .75rem;
41
+    background: #f8d7da;
42
+    border: 1px solid #f1aeb5;
43
+    color: #842029;
44
+    font-size: .92rem;
45
+  }
46
+</style>
47
+@endsection
48
+
49
+@section('content')
50
+@include('consulta.prenotazione._partials.flash-swal')
51
+
52
+<article class="prenota-public" style="--evento-accent: {{ $accent }};">
53
+  <div class="prenota-public__bg" aria-hidden="true"></div>
54
+
55
+  <header class="prenota-public__top">
56
+    <a href="{{ $showUrl }}" class="prenota-public__back">
57
+      <i class="bx bx-arrow-back"></i>
58
+      <span>Torna al riepilogo</span>
59
+    </a>
60
+    <img src="{{ asset('assets/img/logo_fest_L.png') }}" alt="Fest" class="prenota-public__brand-logo">
61
+  </header>
62
+
63
+  <section class="prenota-public__hero" aria-label="Copertina evento">
64
+    @if($coverSrc)
65
+      <img class="prenota-public__hero-img" src="{{ $coverSrc }}" alt="">
66
+    @else
67
+      <div class="prenota-public__hero-fallback" aria-hidden="true"></div>
68
+    @endif
69
+    <div class="prenota-public__hero-shade"></div>
70
+    <div class="prenota-public__hero-inner">
71
+      <div class="prenota-public__hero-content">
72
+        <p class="prenota-public__eyebrow">Annulla prenotazione</p>
73
+        <h1 class="prenota-public__title">{{ $evento?->nome ?? 'Prenotazione' }}</h1>
74
+      </div>
75
+    </div>
76
+  </section>
77
+
78
+  <div class="prenota-public__shell">
79
+    <p class="consulta-annulla__warn">
80
+      Stai per annullare la prenotazione <strong>{{ $codice }}</strong>. L’operazione non si può annullare da soli.
81
+    </p>
82
+
83
+    <div class="consulta-annulla__box">
84
+      <form method="POST" action="{{ route('consulta.prenotazione.annulla.conferma') }}">
85
+        @csrf
86
+        <div class="form-check mb-3">
87
+          <input class="form-check-input" type="checkbox" value="1" id="conferma-annulla" name="conferma" required>
88
+          <label class="form-check-label" for="conferma-annulla">
89
+            Confermo di voler annullare questa prenotazione
90
+          </label>
91
+        </div>
92
+        <div class="consulta-actions" style="justify-content:flex-start;">
93
+          <button type="submit" class="prenota-public__btn-submit" style="background:#b02a37;">
94
+            <i class="bx bx-trash me-1"></i> Annulla prenotazione
95
+          </button>
96
+          <a href="{{ $showUrl }}" class="prenota-public__btn-prev">Indietro</a>
97
+        </div>
98
+      </form>
99
+    </div>
100
+  </div>
101
+</article>
102
+@endsection

+ 362
- 0
resources/views/consulta/prenotazione/modifica.blade.php 查看文件

@@ -0,0 +1,362 @@
1
+@php
2
+  use Illuminate\Support\Facades\Storage;
3
+  use Carbon\Carbon;
4
+  use App\Models\EventoForm;
5
+
6
+  $evento = $evento ?? $prenotazione->evento;
7
+  $attivita = $evento?->attivita;
8
+  $accent = $attivita?->colore ?: '#f58220';
9
+  $coverSrc = null;
10
+  $eventiDisk = Storage::disk('eventi');
11
+  if ($evento?->copertina && $eventiDisk->exists($evento->copertina)) {
12
+    $coverSrc = $eventiDisk->url($evento->copertina).'?v='.$eventiDisk->lastModified($evento->copertina);
13
+  }
14
+
15
+  $richieste = $evento->form->filter(fn ($form) => $form->isVisibileAlCliente(null))->values();
16
+  $domandePerCoperto = $richieste->filter(fn ($f) => (bool) $f->per_coperto)->values();
17
+  $domandeSingole = $richieste->reject(fn ($f) => (bool) $f->per_coperto)->values();
18
+  $nPersone = max(1, (int) old('numero_persone', $prenotazione->numero_persone ?? 1));
19
+  $risposte = $risposte ?? [];
20
+  if (session()->hasOldInput()) {
21
+    $risposteFromOld = [];
22
+    foreach ((array) old() as $key => $value) {
23
+      if (! is_string($key) || ! str_starts_with($key, 'prenotazione_risposta_')) {
24
+        continue;
25
+      }
26
+      $risposteFromOld[(int) str_replace('prenotazione_risposta_', '', $key)] = $value;
27
+    }
28
+    if ($risposteFromOld !== []) {
29
+      $risposte = $risposteFromOld;
30
+    }
31
+  }
32
+  $showUrl = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
33
+@endphp
34
+
35
+@extends('layouts/guest')
36
+
37
+@section('title', 'Modifica prenotazione · '.$prenotazione->codice)
38
+
39
+@section('page-meta')
40
+  <script>document.documentElement.classList.add('prenota-public-page');</script>
41
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
42
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
43
+  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet" />
44
+@endsection
45
+
46
+@section('page-style')
47
+@include('prenotazione._partials.prenota-styles')
48
+<style>
49
+  .consulta-modifica .consulta-modifica__card {
50
+    margin: 0 0 1rem;
51
+    padding: 1rem 1.1rem;
52
+    border-radius: 1rem;
53
+    background: #fff;
54
+    border: 1px solid var(--evento-border);
55
+  }
56
+  .consulta-modifica .consulta-modifica__hint {
57
+    margin: 0 0 1rem;
58
+    padding: .75rem .9rem;
59
+    border-radius: .75rem;
60
+    background: #fff8f0;
61
+    border: 1px solid #f5d4b0;
62
+    font-size: .88rem;
63
+    color: var(--evento-navy);
64
+  }
65
+  .consulta-modifica .consulta-modifica__email {
66
+    opacity: .75;
67
+  }
68
+  .consulta-modifica .consulta-section-title {
69
+    margin: 0 0 .75rem;
70
+    font-size: .72rem;
71
+    font-weight: 700;
72
+    letter-spacing: .08em;
73
+    text-transform: uppercase;
74
+    color: var(--evento-muted);
75
+  }
76
+  .consulta-modifica .consulta-actions {
77
+    display: flex;
78
+    flex-wrap: wrap;
79
+    gap: .55rem;
80
+    justify-content: center;
81
+  }
82
+  .consulta-modifica .consulta-actions .prenota-public__btn-submit,
83
+  .consulta-modifica .consulta-actions .prenota-public__btn-prev {
84
+    text-decoration: none;
85
+    display: inline-flex;
86
+    align-items: center;
87
+    justify-content: center;
88
+    min-height: 2.6rem;
89
+    padding: .55rem 1.1rem;
90
+    border: 0;
91
+    cursor: pointer;
92
+  }
93
+</style>
94
+@endsection
95
+
96
+@section('content')
97
+@include('consulta.prenotazione._partials.flash-swal')
98
+
99
+<article class="prenota-public consulta-modifica" style="--evento-accent: {{ $accent }};">
100
+  <div class="prenota-public__bg" aria-hidden="true"></div>
101
+
102
+  <header class="prenota-public__top">
103
+    <a href="{{ $showUrl }}" class="prenota-public__back">
104
+      <i class="bx bx-arrow-back"></i>
105
+      <span>Torna al riepilogo</span>
106
+    </a>
107
+    <img src="{{ asset('assets/img/logo_fest_L.png') }}" alt="Fest" class="prenota-public__brand-logo">
108
+  </header>
109
+
110
+  <section class="prenota-public__hero" aria-label="Copertina evento">
111
+    @if($coverSrc)
112
+      <img class="prenota-public__hero-img" src="{{ $coverSrc }}" alt="">
113
+    @else
114
+      <div class="prenota-public__hero-fallback" aria-hidden="true"></div>
115
+    @endif
116
+    <div class="prenota-public__hero-shade"></div>
117
+    <div class="prenota-public__hero-inner">
118
+      <div class="prenota-public__hero-content">
119
+        <p class="prenota-public__eyebrow">Modifica prenotazione</p>
120
+        <h1 class="prenota-public__title">{{ $evento?->nome ?? 'Prenotazione' }}</h1>
121
+      </div>
122
+    </div>
123
+  </section>
124
+
125
+  <div class="prenota-public__shell">
126
+    <p class="consulta-modifica__hint">
127
+      Hai {{ $minutiSessione ?? 15 }} minuti per salvare. Se la prenotazione era confermata, dopo il salvataggio potrebbe tornare in attesa di conferma.
128
+    </p>
129
+
130
+    <form method="POST" action="{{ route('consulta.prenotazione.modifica.salva') }}" class="consulta-modifica__form">
131
+      @csrf
132
+
133
+      <div class="consulta-modifica__card">
134
+        <h2 class="consulta-section-title" style="margin-top:0;">I tuoi dati</h2>
135
+        <div class="row g-3">
136
+          <div class="col-sm-6">
137
+            <label class="form-label" for="mod-nome">Nome <span class="text-danger">*</span></label>
138
+            <input type="text" class="form-control" id="mod-nome" name="nome" value="{{ old('nome', $prenotazione->nome) }}" required>
139
+          </div>
140
+          <div class="col-sm-6">
141
+            <label class="form-label" for="mod-cognome">Cognome <span class="text-danger">*</span></label>
142
+            <input type="text" class="form-control" id="mod-cognome" name="cognome" value="{{ old('cognome', $prenotazione->cognome) }}" required>
143
+          </div>
144
+          <div class="col-sm-6">
145
+            <label class="form-label" for="mod-email">Email</label>
146
+            <input type="email" class="form-control consulta-modifica__email" id="mod-email" value="{{ $prenotazione->email }}" disabled readonly>
147
+            <p class="small text-muted mb-0 mt-1">L’email non si può cambiare da qui.</p>
148
+          </div>
149
+          <div class="col-sm-6">
150
+            <label class="form-label" for="mod-telefono">Telefono</label>
151
+            <input type="text" class="form-control" id="mod-telefono" name="telefono" value="{{ old('telefono', $prenotazione->telefono) }}">
152
+          </div>
153
+          <div class="col-sm-6">
154
+            <label class="form-label" for="mod-coperti">Persone <span class="text-danger">*</span></label>
155
+            <input type="number" min="1" max="100" class="form-control" id="mod-coperti" name="numero_persone" value="{{ $nPersone }}" required>
156
+          </div>
157
+          <div class="col-12">
158
+            <label class="form-label" for="mod-note">Note</label>
159
+            <textarea class="form-control" id="mod-note" name="note" rows="3">{{ old('note', $prenotazione->note) }}</textarea>
160
+          </div>
161
+        </div>
162
+      </div>
163
+
164
+      @if($domandeSingole->isNotEmpty())
165
+        <div class="consulta-modifica__card">
166
+          <h2 class="consulta-section-title" style="margin-top:0;">Domande</h2>
167
+          @foreach($domandeSingole as $form)
168
+            @include('prenotazione.cliente._partials.campo-richiesta', [
169
+              'form' => $form,
170
+              'defaultVal' => $risposte[$form->id] ?? null,
171
+            ])
172
+          @endforeach
173
+        </div>
174
+      @endif
175
+
176
+      @if($domandePerCoperto->isNotEmpty())
177
+        <div class="consulta-modifica__card">
178
+          <h2 class="consulta-section-title" style="margin-top:0;">Per persona</h2>
179
+          <p class="small text-muted mb-3">I campi sotto si aggiornano quando cambi il numero di persone.</p>
180
+          <div id="blocchi-coperti" data-coperti-host></div>
181
+          <template data-coperti-blocco-tpl>
182
+            <div class="mb-3 pb-2 consulta-modifica__coperto" data-coperto-blocco style="border-bottom:1px solid var(--evento-border);">
183
+              <p class="fw-bold mb-2" style="color:var(--evento-navy);">
184
+                Coperto <span data-coperto-label>1</span>
185
+              </p>
186
+              @foreach($domandePerCoperto as $form)
187
+                <div
188
+                  data-coperto-field
189
+                  data-form-id="{{ $form->id }}"
190
+                  data-multi="{{ $form->tipo === EventoForm::MULTISELEZIONE ? '1' : '0' }}"
191
+                  data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
192
+                >
193
+                  @include('prenotazione.cliente._partials.campo-richiesta', [
194
+                    'form' => $form,
195
+                    'index' => 0,
196
+                    'isTemplate' => true,
197
+                  ])
198
+                </div>
199
+              @endforeach
200
+            </div>
201
+          </template>
202
+        </div>
203
+      @endif
204
+
205
+      <div class="consulta-actions" style="margin-top:1rem;">
206
+        <button type="submit" class="prenota-public__btn-submit">
207
+          <i class="bx bx-save me-1"></i> Salva modifiche
208
+        </button>
209
+        <a href="{{ $showUrl }}" class="prenota-public__btn-prev">Annulla</a>
210
+      </div>
211
+    </form>
212
+  </div>
213
+</article>
214
+@endsection
215
+
216
+@section('page-script')
217
+@if($domandePerCoperto->isNotEmpty())
218
+<script>
219
+  document.addEventListener('DOMContentLoaded', function () {
220
+    var form = document.querySelector('.consulta-modifica__form');
221
+    var host = document.querySelector('[data-coperti-host]');
222
+    var tpl = document.querySelector('[data-coperti-blocco-tpl]');
223
+    var nEl = document.getElementById('mod-coperti');
224
+    if (!form || !host || !tpl || !nEl) return;
225
+
226
+    var seedRisposte = @json($risposte);
227
+
228
+    function capturePerCoperto() {
229
+      var out = {};
230
+      host.querySelectorAll('[data-coperto-blocco]').forEach(function (blocco, i) {
231
+        blocco.querySelectorAll('[data-coperto-field]').forEach(function (field) {
232
+          var id = field.getAttribute('data-form-id');
233
+          var isMulti = field.getAttribute('data-multi') === '1';
234
+          if (!out[id]) out[id] = [];
235
+
236
+          if (isMulti) {
237
+            var checked = [];
238
+            field.querySelectorAll('input[type="checkbox"]:checked').forEach(function (el) {
239
+              checked.push(el.value);
240
+            });
241
+            out[id][i] = checked;
242
+            return;
243
+          }
244
+
245
+          var el = field.querySelector('input:not([type="hidden"]), select, textarea');
246
+          if (!el) {
247
+            out[id][i] = '';
248
+            return;
249
+          }
250
+          if (el.type === 'checkbox') {
251
+            out[id][i] = el.checked ? el.value : '0';
252
+          } else {
253
+            out[id][i] = el.value;
254
+          }
255
+        });
256
+      });
257
+      return out;
258
+    }
259
+
260
+    function applyPerCoperto(valuesByForm) {
261
+      Object.keys(valuesByForm || {}).forEach(function (formId) {
262
+        var vals = valuesByForm[formId];
263
+        if (!Array.isArray(vals)) return;
264
+
265
+        host.querySelectorAll('[data-coperto-blocco]').forEach(function (blocco, i) {
266
+          var field = blocco.querySelector('[data-coperto-field][data-form-id="' + formId + '"]');
267
+          if (!field) return;
268
+          var v = vals[i];
269
+          if (v === undefined || v === null) return;
270
+          var isMulti = field.getAttribute('data-multi') === '1';
271
+
272
+          if (isMulti) {
273
+            var arr = Array.isArray(v) ? v.map(String) : [];
274
+            field.querySelectorAll('input[type="checkbox"]').forEach(function (el) {
275
+              el.checked = arr.indexOf(String(el.value)) !== -1;
276
+            });
277
+            return;
278
+          }
279
+
280
+          var el = field.querySelector('input:not([type="hidden"]), select, textarea');
281
+          if (!el) return;
282
+          if (el.type === 'checkbox') {
283
+            el.checked = String(v) === '1' || v === true || v === 1;
284
+          } else {
285
+            el.value = v;
286
+          }
287
+        });
288
+      });
289
+    }
290
+
291
+    function syncPerCoperto() {
292
+      var preserved = capturePerCoperto();
293
+      var n = parseInt(nEl.value, 10);
294
+      if (!n || n < 1) n = 1;
295
+      if (n > 100) n = 100;
296
+
297
+      host.innerHTML = '';
298
+      for (var i = 0; i < n; i++) {
299
+        var frag = tpl.content.cloneNode(true);
300
+        var label = frag.querySelector('[data-coperto-label]');
301
+        if (label) label.textContent = String(i + 1);
302
+
303
+        frag.querySelectorAll('[name]').forEach(function (el) {
304
+          el.name = el.name.replace('[0]', '[' + i + ']');
305
+          el.disabled = false;
306
+        });
307
+        frag.querySelectorAll('[id]').forEach(function (el) {
308
+          el.id = el.id.replace(/_0(?=_|$)/, '_' + i);
309
+        });
310
+        frag.querySelectorAll('[for]').forEach(function (el) {
311
+          el.htmlFor = el.htmlFor.replace(/_0(?=_|$)/, '_' + i);
312
+        });
313
+        frag.querySelectorAll('label').forEach(function (el) {
314
+          el.innerHTML = el.innerHTML.replace(/coperto 1/, 'coperto ' + (i + 1));
315
+        });
316
+
317
+        frag.querySelectorAll('[data-coperto-field]').forEach(function (field) {
318
+          var obbligatoria = field.getAttribute('data-obbligatoria') === '1';
319
+          var isMulti = field.getAttribute('data-multi') === '1';
320
+          field.querySelectorAll('input, select, textarea').forEach(function (el) {
321
+            if (el.type === 'hidden') return;
322
+            if (!obbligatoria || isMulti) return;
323
+            if (el.type === 'checkbox') {
324
+              if (el.value === '1') el.required = true;
325
+            } else {
326
+              el.required = true;
327
+            }
328
+          });
329
+        });
330
+
331
+        host.appendChild(frag);
332
+      }
333
+
334
+      applyPerCoperto(Object.assign({}, seedRisposte, preserved));
335
+    }
336
+
337
+    function validateMultiGroups() {
338
+      var fields = host.querySelectorAll('[data-coperto-field][data-multi="1"][data-obbligatoria="1"]');
339
+      for (var c = 0; c < fields.length; c++) {
340
+        var cboxes = fields[c].querySelectorAll('input[type="checkbox"]:not(:disabled)');
341
+        var cok = Array.prototype.some.call(cboxes, function (b) { return b.checked; });
342
+        if (!cok && cboxes.length) {
343
+          cboxes[0].setCustomValidity('Seleziona almeno un\'opzione');
344
+          cboxes[0].reportValidity();
345
+          return false;
346
+        }
347
+        if (cboxes.length) cboxes[0].setCustomValidity('');
348
+      }
349
+      return true;
350
+    }
351
+
352
+    nEl.addEventListener('change', syncPerCoperto);
353
+    nEl.addEventListener('input', syncPerCoperto);
354
+    form.addEventListener('submit', function (e) {
355
+      if (!validateMultiGroups()) e.preventDefault();
356
+    });
357
+
358
+    syncPerCoperto();
359
+  });
360
+</script>
361
+@endif
362
+@endsection

+ 487
- 152
resources/views/consulta/prenotazione/show.blade.php 查看文件

@@ -1,179 +1,514 @@
1
-@extends('layouts/guest')
2
-
3
-@section('title', 'La tua prenotazione')
4
-
5
-@section('page-style')
6
-<link rel="preconnect" href="https://fonts.googleapis.com" />
7
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8
-<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
9
-@endsection
10
-
11
-@section('app-css-extra')
12
-@vite(['resources/css/guest-client.css'])
13
-@endsection
14
-
15 1
 @php
16 2
   use Illuminate\Support\Facades\Storage;
3
+  use Carbon\Carbon;
17 4
   use App\Models\EventoForm;
5
+  use App\Models\Prenotazione;
18 6
 
19 7
   $evento = $prenotazione->evento;
20
-  $galleriaRaw = json_decode($evento?->galleria ?? '[]', true);
21
-  $galleriaItems = is_array($galleriaRaw) ? $galleriaRaw : [];
22
-  $coverUrl = null;
23
-  if (count($galleriaItems) > 0 && !empty($galleriaItems[0]['path'])) {
24
-      $coverUrl = Storage::disk('eventi')->url($galleriaItems[0]['path']);
25
-  } elseif ($evento?->copertina) {
26
-      $coverUrl = Storage::disk('eventi')->url($evento->copertina);
8
+  $attivita = $evento?->attivita;
9
+  $accent = $attivita?->colore ?: '#f58220';
10
+
11
+  $dataInizio = $evento?->data_inizio ? Carbon::parse($evento->data_inizio) : null;
12
+  $dataFine = $evento?->data_fine ? Carbon::parse($evento->data_fine) : null;
13
+  $sameDay = $dataInizio && $dataFine && $dataInizio->isSameDay($dataFine);
14
+  $allDayInizio = $dataInizio && $dataInizio->format('H:i') === '00:00';
15
+  $allDayFine = $dataFine && $dataFine->format('H:i') === '00:00';
16
+  $isAllDay = $allDayInizio && ($sameDay ? $allDayFine : true);
17
+
18
+  $dataLabel = null;
19
+  if ($dataInizio) {
20
+    if ($sameDay) {
21
+      $dataLabel = $dataInizio->translatedFormat('d M Y');
22
+      if (! $isAllDay) {
23
+        $oraFine = $dataFine ? $dataFine->format('H:i') : $dataInizio->format('H:i');
24
+        $dataLabel .= ' · '.$dataInizio->format('H:i').'–'.$oraFine;
25
+      }
26
+    } elseif ($isAllDay) {
27
+      $dataLabel = 'Dal '.$dataInizio->translatedFormat('d M Y').' al '.($dataFine ? $dataFine->translatedFormat('d M Y') : '—');
28
+    } else {
29
+      $dataLabel = $dataInizio->translatedFormat('d M Y').' '.$dataInizio->format('H:i');
30
+      if ($dataFine) {
31
+        $dataLabel .= ' → '.$dataFine->translatedFormat('d M Y').' '.$dataFine->format('H:i');
32
+      }
33
+    }
34
+  }
35
+
36
+  $eventiDisk = Storage::disk('eventi');
37
+  $coverSrc = null;
38
+  if ($evento?->copertina && $eventiDisk->exists($evento->copertina)) {
39
+    $coverSrc = $eventiDisk->url($evento->copertina).'?v='.$eventiDisk->lastModified($evento->copertina);
40
+  }
41
+
42
+  $backEventoUrl = $evento?->publicUrl();
43
+  $statoMeta = Prenotazione::getStati()->get($prenotazione->stato);
44
+  $statoLabel = $statoMeta['label'] ?? ucfirst((string) $prenotazione->stato);
45
+  $statoMessage = $statoMeta['message'] ?? null;
46
+  $isConfermata = (bool) $prenotazione->confermata || $prenotazione->stato === Prenotazione::STATO_CONFERMATA;
47
+  $coperti = max(1, (int) ($prenotazione->numero_persone ?? 1));
48
+  $nomeCompleto = trim(($prenotazione->nome ?? '').' '.($prenotazione->cognome ?? ''));
49
+  $puoModificare = $puoModificare ?? false;
50
+  $puoAnnullare = $puoAnnullare ?? false;
51
+
52
+  $statoTone = 'wait';
53
+  if ($isConfermata) {
54
+    $statoTone = 'ok';
55
+  } elseif (in_array($prenotazione->stato, [
56
+    Prenotazione::STATO_ANNULLATA,
57
+    Prenotazione::STATO_CANCELLATA,
58
+    Prenotazione::STATO_RIFIUTATA,
59
+  ], true)) {
60
+    $statoTone = 'bad';
61
+  }
62
+
63
+  $risposteGenerali = collect();
64
+  $rispostePerCoperto = [];
65
+
66
+  if ($evento && $evento->form) {
67
+    foreach ($evento->form as $form) {
68
+      $raw = $prenotazione->risposte->firstWhere('evento_form_id', $form->id)?->risposta;
69
+      $decoded = $form->decodeRispostaRaw($raw);
70
+
71
+      if ($form->per_coperto) {
72
+        $vals = is_array($decoded) ? array_values($decoded) : ($decoded === null ? [] : [$decoded]);
73
+        foreach ($vals as $i => $item) {
74
+          $txt = $form->formatSingoloValore($item);
75
+          if ($txt === '—' || $txt === '') {
76
+            continue;
77
+          }
78
+          $n = $i + 1;
79
+          if (! isset($rispostePerCoperto[$n])) {
80
+            $rispostePerCoperto[$n] = [];
81
+          }
82
+          $rispostePerCoperto[$n][] = [
83
+            'label' => $form->label,
84
+            'valore' => $txt,
85
+          ];
86
+        }
87
+        continue;
88
+      }
89
+
90
+      $txt = $form->formatRisposta($raw);
91
+      if ($txt === '—' || $txt === '' || $txt === null) {
92
+        continue;
93
+      }
94
+      $risposteGenerali->push([
95
+        'label' => $form->label,
96
+        'valore' => $txt,
97
+      ]);
98
+    }
27 99
   }
28 100
 @endphp
29 101
 
30
-@section('content')
102
+@extends('layouts/guest')
103
+
104
+@section('title', 'La tua prenotazione'.($evento ? ' · '.$evento->nome : ''))
105
+
106
+@section('page-meta')
107
+  <script>document.documentElement.classList.add('prenota-public-page');</script>
108
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
109
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
110
+  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet" />
111
+@endsection
31 112
 
113
+@section('page-style')
114
+@include('prenotazione._partials.prenota-styles')
32 115
 <style>
33
-  .guest-fest-qr-wrap svg {
116
+  .consulta-prenota .consulta-cards {
117
+    display: grid;
118
+    gap: .75rem;
119
+    margin: 0 0 1.15rem;
120
+  }
121
+  @media (min-width: 560px) {
122
+    .consulta-prenota .consulta-cards {
123
+      grid-template-columns: 1fr 1fr;
124
+      align-items: stretch;
125
+    }
126
+  }
127
+  .consulta-prenota .consulta-card {
128
+    padding: 1rem;
129
+    border-radius: 1rem;
130
+    background: #fff;
131
+    border: 1px solid var(--evento-border);
132
+    box-shadow: 0 8px 24px rgba(21, 38, 92, 0.05);
133
+  }
134
+  .consulta-prenota .consulta-card--qr {
135
+    text-align: center;
136
+  }
137
+  .consulta-prenota .consulta-card__label {
138
+    margin: 0 0 .55rem;
139
+    font-size: .68rem;
140
+    font-weight: 700;
141
+    letter-spacing: .1em;
142
+    text-transform: uppercase;
143
+    color: var(--evento-muted);
144
+  }
145
+  .consulta-prenota .consulta-card__qr {
146
+    display: inline-flex;
147
+    margin: 0 0 .7rem;
148
+    padding: .55rem;
149
+    border-radius: .85rem;
150
+    background: #fff;
151
+    box-shadow: inset 0 0 0 1px var(--evento-border);
152
+  }
153
+  .consulta-prenota .consulta-card__qr svg {
154
+    display: block;
155
+    width: 128px;
156
+    height: 128px;
34 157
     background: #15265c !important;
35 158
   }
159
+  .consulta-prenota .consulta-card__code {
160
+    margin: 0;
161
+    font-size: clamp(1.35rem, 5vw, 1.7rem);
162
+    font-weight: 800;
163
+    letter-spacing: .14em;
164
+    font-family: Consolas, "Courier New", monospace;
165
+    color: var(--evento-navy);
166
+    word-break: break-all;
167
+  }
168
+  .consulta-prenota .consulta-card--stato {
169
+    display: flex;
170
+    flex-direction: column;
171
+    justify-content: center;
172
+    gap: .55rem;
173
+    min-height: 100%;
174
+  }
175
+  .consulta-prenota .consulta-card__stato-badge {
176
+    display: inline-flex;
177
+    align-items: center;
178
+    gap: .4rem;
179
+    align-self: flex-start;
180
+    padding: .45rem .8rem;
181
+    border-radius: 999px;
182
+    font-size: .88rem;
183
+    font-weight: 800;
184
+  }
185
+  .consulta-prenota .consulta-card__stato-badge--ok {
186
+    color: #0f5132;
187
+    background: #d1e7dd;
188
+  }
189
+  .consulta-prenota .consulta-card__stato-badge--wait {
190
+    color: #664d03;
191
+    background: #fff3cd;
192
+  }
193
+  .consulta-prenota .consulta-card__stato-badge--bad {
194
+    color: #842029;
195
+    background: #f8d7da;
196
+  }
197
+  .consulta-prenota .consulta-card__stato-msg {
198
+    margin: 0;
199
+    font-size: .9rem;
200
+    line-height: 1.45;
201
+    color: var(--evento-muted);
202
+  }
203
+  .consulta-prenota .consulta-section-title {
204
+    margin: 0 0 .75rem;
205
+    font-size: .72rem;
206
+    font-weight: 700;
207
+    letter-spacing: .08em;
208
+    text-transform: uppercase;
209
+    color: var(--evento-muted);
210
+  }
211
+  .consulta-prenota .consulta-grid {
212
+    display: grid;
213
+    gap: .65rem;
214
+    margin: 0 0 1.15rem;
215
+  }
216
+  @media (min-width: 520px) {
217
+    .consulta-prenota .consulta-grid {
218
+      grid-template-columns: 1fr 1fr;
219
+    }
220
+  }
221
+  .consulta-prenota .consulta-cell {
222
+    padding: .75rem .85rem;
223
+    border-radius: .75rem;
224
+    background: rgba(21, 38, 92, 0.03);
225
+    border: 1px solid var(--evento-border);
226
+  }
227
+  .consulta-prenota .consulta-cell__k {
228
+    display: block;
229
+    margin-bottom: .2rem;
230
+    font-size: .68rem;
231
+    font-weight: 700;
232
+    letter-spacing: .06em;
233
+    text-transform: uppercase;
234
+    color: var(--evento-muted);
235
+  }
236
+  .consulta-prenota .consulta-cell__v {
237
+    display: block;
238
+    font-size: .95rem;
239
+    font-weight: 600;
240
+    color: var(--evento-navy);
241
+    word-break: break-word;
242
+  }
243
+  .consulta-prenota .consulta-qa {
244
+    margin: 0 0 1.15rem;
245
+    padding: 0;
246
+    border-radius: 1rem;
247
+    background: #fff;
248
+    border: 1px solid var(--evento-border);
249
+    overflow: hidden;
250
+  }
251
+  .consulta-prenota .consulta-qa__row {
252
+    display: flex;
253
+    flex-direction: column;
254
+    gap: .15rem;
255
+    padding: .8rem 1rem;
256
+  }
257
+  .consulta-prenota .consulta-qa__row + .consulta-qa__row {
258
+    border-top: 1px solid var(--evento-border);
259
+  }
260
+  .consulta-prenota .consulta-qa__q {
261
+    font-size: .78rem;
262
+    font-weight: 600;
263
+    color: var(--evento-muted);
264
+  }
265
+  .consulta-prenota .consulta-qa__a {
266
+    font-size: 1rem;
267
+    font-weight: 700;
268
+    color: var(--evento-navy);
269
+    line-height: 1.35;
270
+  }
271
+  .consulta-prenota .consulta-coperti {
272
+    display: flex;
273
+    flex-direction: column;
274
+    gap: .75rem;
275
+    margin: 0 0 1.15rem;
276
+  }
277
+  .consulta-prenota .consulta-coperto {
278
+    border-radius: 1rem;
279
+    background: #fff;
280
+    border: 1px solid var(--evento-border);
281
+    overflow: hidden;
282
+  }
283
+  .consulta-prenota .consulta-coperto__head {
284
+    display: flex;
285
+    align-items: center;
286
+    gap: .5rem;
287
+    padding: .7rem 1rem;
288
+    background: rgba(21, 38, 92, 0.04);
289
+    border-bottom: 1px solid var(--evento-border);
290
+    font-size: .82rem;
291
+    font-weight: 800;
292
+    color: var(--evento-navy);
293
+  }
294
+  .consulta-prenota .consulta-coperto__head i {
295
+    color: var(--evento-accent);
296
+    font-size: 1.1rem;
297
+  }
298
+  .consulta-prenota .consulta-coperto .consulta-qa__row:last-child {
299
+    border-bottom: 0;
300
+  }
301
+  .consulta-prenota .consulta-actions {
302
+    display: flex;
303
+    flex-wrap: wrap;
304
+    gap: .55rem;
305
+    justify-content: center;
306
+    margin-top: .35rem;
307
+  }
308
+  .consulta-prenota .consulta-actions .prenota-public__btn-submit,
309
+  .consulta-prenota .consulta-actions .prenota-public__btn-prev {
310
+    text-decoration: none;
311
+    display: inline-flex;
312
+    align-items: center;
313
+    justify-content: center;
314
+    min-height: 2.6rem;
315
+    padding: .55rem 1.1rem;
316
+    border: 0;
317
+    cursor: pointer;
318
+  }
36 319
 </style>
37
-<div class="container-xxl flex-grow-1 container-p-y p-10" style="justify-items: anchor-center;">
38
-<div class="guest-fest guest-fest w-px-400">
39
-  
40
-<div class="card-body guest-fest-card-body--compact">
41
-        {{-- Codice + stati prenotazione / pagamento --}}
42
-        <div class="guest-fest-codice-block mb-2">
320
+@endsection
321
+
322
+@section('content')
323
+@include('consulta.prenotazione._partials.flash-swal')
324
+
325
+<article class="prenota-public consulta-prenota" style="--evento-accent: {{ $accent }};">
326
+  <div class="prenota-public__bg" aria-hidden="true"></div>
327
+
328
+  <header class="prenota-public__top">
329
+    @if($backEventoUrl)
330
+      <a href="{{ $backEventoUrl }}" class="prenota-public__back">
331
+        <i class="bx bx-arrow-back"></i>
332
+        <span>Scheda evento</span>
333
+      </a>
334
+    @elseif($attivita?->slug)
335
+      <a href="{{ route('cliente.attivita.show', ['slug' => $attivita->slug]) }}" class="prenota-public__back">
336
+        <i class="bx bx-arrow-back"></i>
337
+        <span>{{ $attivita->nome }}</span>
338
+      </a>
339
+    @else
340
+      <a href="{{ route('welcome') }}" class="prenota-public__back">
341
+        <i class="bx bx-arrow-back"></i>
342
+        <span>Home</span>
343
+      </a>
344
+    @endif
345
+    <img src="{{ asset('assets/img/logo_fest_L.png') }}" alt="Fest" class="prenota-public__brand-logo">
346
+  </header>
347
+
348
+  <section class="prenota-public__hero" aria-label="Copertina evento">
349
+    @if($coverSrc)
350
+      <img class="prenota-public__hero-img" src="{{ $coverSrc }}" alt="">
351
+    @else
352
+      <div class="prenota-public__hero-fallback" aria-hidden="true"></div>
353
+    @endif
354
+    <div class="prenota-public__hero-shade"></div>
355
+    <div class="prenota-public__hero-inner">
356
+      <div class="prenota-public__hero-content">
357
+        @if($attivita?->nome)
358
+          <p class="prenota-public__eyebrow">{{ $attivita->nome }}</p>
359
+        @else
360
+          <p class="prenota-public__eyebrow">La tua prenotazione</p>
361
+        @endif
362
+        <h1 class="prenota-public__title">{{ $evento?->nome ?? 'Prenotazione' }}</h1>
363
+      </div>
364
+    </div>
365
+  </section>
366
+
367
+  <div class="prenota-public__shell">
368
+    <section class="prenota-public__summary" aria-label="Riepilogo evento">
369
+      <ul class="prenota-public__facts">
370
+        @if($dataLabel)
371
+          <li class="prenota-public__fact" aria-label="Quando">
372
+            <i class="bx bx-calendar" aria-hidden="true"></i>
373
+            <span>{{ $dataLabel }}</span>
374
+          </li>
375
+        @endif
376
+        <li class="prenota-public__fact" aria-label="Dove">
377
+          <i class="bx bx-map" aria-hidden="true"></i>
378
+          <span>
379
+            @if($evento?->luogo)
380
+              {{ $evento->luogoBreve() }}
381
+              @if($navUrl = $evento->navigaUrl())
382
+                <a href="{{ $navUrl }}" class="prenota-public__nav-link" target="_blank" rel="noopener noreferrer" title="Apri navigazione" aria-label="Apri navigazione">
383
+                  <i class="bx bx-navigation"></i>
384
+                </a>
385
+              @endif
386
+            @else
387
+              Luogo da definire
388
+            @endif
389
+          </span>
390
+        </li>
391
+        <li class="prenota-public__fact" aria-label="Persone">
392
+          <i class="bx bx-group" aria-hidden="true"></i>
393
+          <span>{{ $coperti }} {{ $coperti === 1 ? 'persona' : 'persone' }}</span>
394
+        </li>
395
+      </ul>
396
+    </section>
397
+
398
+    <section class="prenota-public__panel">
399
+      <div class="consulta-cards">
400
+        <div class="consulta-card consulta-card--qr">
401
+          <p class="consulta-card__label">Codice / QR</p>
43 402
           @isset($codiceQrSvg)
44
-            <div class="guest-fest-qr-wrap mb-2 " aria-hidden="true">
403
+            <div class="consulta-card__qr" aria-hidden="true">
45 404
               {!! $codiceQrSvg !!}
46 405
             </div>
47 406
           @endisset
48
-          <span class="guest-fest-codice-label">Codice prenotazione</span>
49
-          <div class="guest-fest-codice-big text-break">{{ $prenotazione->id }} # {{ $prenotazione->codice }}</div>
50
-          <div class="mt-3 small d-flex flex-wrap align-items-center gap-2">
51
-            {!! view('prenotazione._partials.stato-prenotazione', ['stato' => $prenotazione->stato]) !!}
52
-            {!! view('prenotazione._partials.stato-prenotazione', ['stato' => $prenotazione->stato_pagamento, 'label_completa' => true]) !!}
53
-          </div>
407
+          <p class="consulta-card__code">{{ strtoupper((string) $prenotazione->codice) }}</p>
54 408
         </div>
55 409
 
56
-        {{-- Accordion Dettagli: evento (link), contatti, richieste --}}
57
-        <div class="accordion guest-fest-accordion" id="accordionPrenotazioneDettagli">
58
-          <div class="accordion-item border-0">
59
-            <h2 class="accordion-header" id="headingDettagli">
60
-              <button
61
-                class="accordion-button collapsed guest-fest-accordion-btn rounded"
62
-                type="button"
63
-                data-bs-toggle="collapse"
64
-                data-bs-target="#collapseDettagli"
65
-                aria-expanded="false"
66
-                aria-controls="collapseDettagli"
67
-              >
68
-                Dettagli
69
-              </button>
70
-            </h2>
71
-            <div
72
-              id="collapseDettagli"
73
-              class="accordion-collapse collapse"
74
-              aria-labelledby="headingDettagli"
75
-              data-bs-parent="#accordionPrenotazioneDettagli"
76
-            >
77
-              <div class="accordion-body guest-fest-accordion-body pt-4">
78
-                @if($evento)
79
-                  <div class="guest-fest-accordion-event mb-3">
80
-                    <span class="guest-fest-contact-k d-block mb-1">Evento</span>
81
-                    <a
82
-                      href="{{ route('evento.show', ['evento_id' => $evento->id]) }}"
83
-                      class="guest-fest-event-external-link"
84
-                      target="_blank"
85
-                      rel="noopener noreferrer"
86
-                    >
87
-                      <span class="guest-fest-event-link-text">{{ $evento->nome }}</span>
88
-                      <i class="bx bx-link-external ms-1 align-middle" aria-hidden="true"></i>
89
-                    </a>
90
-                  </div>
91
-                @endif
92
-
93
-                <div class="guest-fest-contact-grid mb-3">
94
-                  <div class="guest-fest-contact-cell">
95
-                    <span class="guest-fest-contact-k">Nome</span>
96
-                    <span class="guest-fest-contact-v">{{ $prenotazione->nome }} {{ $prenotazione->cognome }}</span>
97
-                  </div>
98
-                  <div class="guest-fest-contact-cell">
99
-                    <span class="guest-fest-contact-k">Telefono</span>
100
-                    <span class="guest-fest-contact-v">{{ $prenotazione->telefono }}</span>
101
-                  </div>
102
-                  <div class="guest-fest-contact-cell">
103
-                    <span class="guest-fest-contact-k">Email</span>
104
-                    <span class="guest-fest-contact-v text-break">{{ $prenotazione->email }}</span>
105
-                  </div>
106
-                  <div class="guest-fest-contact-cell">
107
-                    <span class="guest-fest-contact-k">Note</span>
108
-                    <span class="guest-fest-contact-v">{{ $prenotazione->note ?: '—' }}</span>
109
-                  </div>
110
-                </div>
410
+        <div class="consulta-card consulta-card--stato">
411
+          <p class="consulta-card__label">Stato prenotazione</p>
412
+          <span class="consulta-card__stato-badge consulta-card__stato-badge--{{ $statoTone }}">
413
+            <i class="bx {{ $statoTone === 'ok' ? 'bx-check-circle' : ($statoTone === 'bad' ? 'bx-x-circle' : 'bx-time-five') }}" aria-hidden="true"></i>
414
+            {{ $statoLabel }}
415
+          </span>
416
+          @if($statoMessage)
417
+            <p class="consulta-card__stato-msg">{{ $statoMessage }}</p>
418
+          @endif
419
+        </div>
420
+      </div>
111 421
 
112
-                @if($evento && $evento->form && $evento->form->count())
113
-                  <h3 class="guest-fest-subsection-title mb-2">Richieste</h3>
114
-                  <div class="guest-fest-table-wrap guest-fest-qa-wrap">
115
-                    <table class="table table-sm mb-0 guest-fest-qa-table">
116
-                      <thead class="visually-hidden">
117
-                        <tr>
118
-                          <th scope="col">Domanda</th>
119
-                          <th scope="col">Risposta</th>
120
-                          <th scope="col">Prezzo</th>
121
-                        </tr>
122
-                      </thead>
123
-                      <tbody>
124
-                      @foreach($evento->form as $form)
125
-                        @php
126
-                          $risposta = $prenotazione->risposte->where('evento_form_id', $form->id)->first();
127
-                          $testoRisposta = '—';
128
-                          if ($form->tipo === EventoForm::ELENCO) {
129
-                              if ($risposta && $form->opzioni_form) {
130
-                                  $testoRisposta = optional($form->opzioni_form->where('id', $risposta->risposta)->first())->label ?? ($risposta->risposta ?? '—');
131
-                              } elseif ($risposta) {
132
-                                  $testoRisposta = $risposta->risposta;
133
-                              }
134
-                          } else {
135
-                              $testoRisposta = $risposta?->risposta ?? '—';
136
-                          }
137
-
138
-                          $prezzoRiga = '—';
139
-                          if ($form->tipo === EventoForm::ELENCO && $risposta && $form->opzioni_form) {
140
-                              $opt = $form->opzioni_form->where('id', $risposta->risposta)->first();
141
-                              if ($opt && $opt->costo !== null && (float) $opt->costo > 0) {
142
-                                  $prezzoRiga = number_format((float) $opt->costo, 2, ',', ' ') . ' €';
143
-                              }
144
-                          }
145
-                          if ($prezzoRiga === '—' && $form->costo !== null && (float) $form->costo > 0) {
146
-                              if ($form->tipo === EventoForm::NUMERO && $risposta && is_numeric($risposta->risposta)) {
147
-                                  $prezzoRiga = number_format((float) $risposta->risposta * (float) $form->costo, 2, ',', ' ') . ' €';
148
-                              } else {
149
-                                  $prezzoRiga = number_format((float) $form->costo, 2, ',', ' ') . ' €';
150
-                              }
151
-                         
152
-                          }
153
-                        @endphp
154
-                        <tr>
155
-                          <td class="guest-fest-qa-q">{{ $form->label }}</td>
156
-                          <td class="guest-fest-qa-a">{{ $form->getRisposta($prenotazione->id) }}</td>
157
-                          <td class="guest-fest-qa-p text-nowrap">{{ $prezzoRiga }}</td>
158
-                        </tr>
159
-                      @endforeach
160
-                      </tbody>
161
-                    </table>
162
-                  </div>
163
-                @else
164
-                  <p class="guest-fest-empty mb-0">Nessun campo modulo per questa prenotazione.</p>
165
-                @endif
422
+      <!-- <h2 class="consulta-section-title">I tuoi dati</h2> -->
423
+      <div class="consulta-grid">
424
+        <!-- <div class="consulta-cell">
425
+          <span class="consulta-cell__k">Nome</span>
426
+          <span class="consulta-cell__v">{{-- {{ $nomeCompleto ?: '—' }} --}}</span>
427
+        </div> -->
428
+        <!-- <div class="consulta-cell">
429
+          <span class="consulta-cell__k">Telefono</span>
430
+          <span class="consulta-cell__v">{{-- {{ $prenotazione->telefono ?: '—' }} --}}</span>
431
+        </div> -->
432
+        <!-- <div class="consulta-cell">
433
+          <span class="consulta-cell__k">Email</span>
434
+          <span class="consulta-cell__v">{{-- {{ $prenotazione->email ?: '—' }} --}}</span>
435
+        </div> -->
436
+        @if(filled($prenotazione->note))
437
+          <div class="consulta-cell">
438
+            <span class="consulta-cell__k">Note</span>
439
+            <span class="consulta-cell__v">{{ $prenotazione->note }}</span>
440
+          </div>
441
+        @endif
442
+      </div>
443
+
444
+      @if($risposteGenerali->isNotEmpty())
445
+        <h2 class="consulta-section-title">Risposte</h2>
446
+        <div class="consulta-qa">
447
+          @foreach($risposteGenerali as $row)
448
+            <div class="consulta-qa__row">
449
+              <span class="consulta-qa__q">{{ $row['label'] }}</span>
450
+              <span class="consulta-qa__a">{{ $row['valore'] }}</span>
451
+            </div>
452
+          @endforeach
453
+        </div>
454
+      @endif
455
+
456
+      @if(count($rispostePerCoperto) > 0)
457
+        <h2 class="consulta-section-title">Risposte per persona</h2>
458
+        <div class="consulta-coperti">
459
+          @foreach($rispostePerCoperto as $n => $righe)
460
+            <div class="consulta-coperto">
461
+              <div class="consulta-coperto__head">
462
+                <i class="bx bx-user" aria-hidden="true"></i>
463
+                <span>Coperto {{ $n }}</span>
166 464
               </div>
465
+              @foreach($righe as $row)
466
+                <div class="consulta-qa__row">
467
+                  <span class="consulta-qa__q">{{ $row['label'] }}</span>
468
+                  <span class="consulta-qa__a">{{ $row['valore'] }}</span>
469
+                </div>
470
+              @endforeach
167 471
             </div>
168
-          </div>
472
+          @endforeach
169 473
         </div>
474
+      @endif
170 475
 
171
-        <div class="guest-fest-actions">
172
-          <a href="{{ url()->previous() }}" class="btn btn-fest-soft">
173
-            <i class="bx bx-arrow-back me-1"></i> Indietro
476
+      <div class="consulta-actions">
477
+        @if($puoModificare)
478
+          <form method="POST" action="{{ route('consulta.prenotazione.richiedi-modifica') }}" class="d-inline">
479
+            @csrf
480
+            <input type="hidden" name="codice" value="{{ $prenotazione->codice }}">
481
+            <input type="hidden" name="azione" value="modifica">
482
+            <button type="submit" class="prenota-public__btn-submit">
483
+              <i class="bx bx-edit me-1"></i> Modifica
484
+            </button>
485
+          </form>
486
+        @endif
487
+        @if($puoAnnullare)
488
+          <form method="POST" action="{{ route('consulta.prenotazione.richiedi-modifica') }}" class="d-inline">
489
+            @csrf
490
+            <input type="hidden" name="codice" value="{{ $prenotazione->codice }}">
491
+            <input type="hidden" name="azione" value="annulla">
492
+            <button type="submit" class="prenota-public__btn-prev">
493
+              <i class="bx bx-trash me-1"></i> Annulla prenotazione
494
+            </button>
495
+          </form>
496
+        @endif
497
+        @if($evento?->data_inizio)
498
+          <!-- <a
499
+            href="{{-- route('consulta.prenotazione.calendario', ['codice' => $prenotazione->codice]) --}}"
500
+            class="prenota-public__btn-prev"
501
+          >
502
+            <i class="bx bx-calendar-plus me-1"></i> Aggiungi al calendario
503
+          </a> -->
504
+        @endif
505
+        @if($backEventoUrl)
506
+          <a href="{{ $backEventoUrl }}" class="prenota-public__btn-prev">
507
+            <i class="bx bx-calendar-event me-1"></i> Vai all’evento
174 508
           </a>
175
-        </div>
509
+        @endif
176 510
       </div>
177
-</div>
178
-</div>
511
+    </section>
512
+  </div>
513
+</article>
179 514
 @endsection

+ 1
- 1
resources/views/consulta/scontrino/show.blade.php 查看文件

@@ -472,7 +472,7 @@
472 472
   </div>
473 473
 
474 474
   <div class="scontrino-cliente__card scontrino-cliente__actions">
475
-    <button type="button" id="btn-notificami" class="scontrino-cliente__btn-notify" data-ordine-id="{{ $ordine->id }}">
475
+    <button type="button" id="btn-notificami" class="scontrino-cliente__btn-notify" data-ordine-codice="{{ $ordine->codice }}">
476 476
       <i class="bx bx-bell"></i> Avvisami quando è pronto
477 477
     </button>
478 478
     <p class="scontrino-cliente__notify-hint">Ricevi una notifica sul telefono appena l'ordine è pronto.</p>

+ 305
- 0
resources/views/evento/_partials/domande-prenotazione.blade.php 查看文件

@@ -0,0 +1,305 @@
1
+@php
2
+  use App\Models\EventoForm;
3
+
4
+  $domande = $evento->form
5
+    ? $evento->form->sortBy(['ordine_visualizzazione', 'id'])->values()
6
+    : collect();
7
+
8
+  $tipiSemplici = [
9
+    EventoForm::TESTO => ['label' => 'Testo', 'help' => 'Risposta libera', 'icon' => 'bx bx-text'],
10
+    EventoForm::NUMERO => ['label' => 'Numero', 'help' => 'Es. quanti siete', 'icon' => 'bx bx-hash'],
11
+    EventoForm::CHECKBOX => ['label' => 'Sì / No', 'help' => 'Una sola spunta', 'icon' => 'bx bx-checkbox-checked'],
12
+    EventoForm::SELEZIONE => ['label' => 'Scelta', 'help' => 'Una opzione dalla lista', 'icon' => 'bx bx-list-ul'],
13
+    EventoForm::MULTISELEZIONE => ['label' => 'Scelte multiple', 'help' => 'Più opzioni', 'icon' => 'bx bx-list-check'],
14
+  ];
15
+@endphp
16
+
17
+<style>
18
+  .domande-abbozzo__empty {
19
+    border: 1px dashed #d9dee3;
20
+    border-radius: .75rem;
21
+    padding: 1.5rem 1rem;
22
+    text-align: center;
23
+    color: #8592a3;
24
+  }
25
+  .domande-abbozzo__item {
26
+    display: flex;
27
+    align-items: center;
28
+    gap: .85rem;
29
+    padding: .9rem 1rem;
30
+    border: 1px solid #e7e7e8;
31
+    border-radius: .65rem;
32
+    background: #fff;
33
+  }
34
+  .domande-abbozzo__item + .domande-abbozzo__item {
35
+    margin-top: .6rem;
36
+  }
37
+  .domande-abbozzo__item.sortable-ghost {
38
+    opacity: .45;
39
+  }
40
+  .domande-abbozzo__item.sortable-chosen {
41
+    box-shadow: 0 4px 14px rgba(67, 89, 113, .12);
42
+  }
43
+  .domande-abbozzo__handle {
44
+    color: #c2c6d1;
45
+    cursor: grab;
46
+    display: inline-flex;
47
+    align-items: center;
48
+    justify-content: center;
49
+    flex-shrink: 0;
50
+    width: 1.75rem;
51
+    height: 1.75rem;
52
+    line-height: 1;
53
+    user-select: none;
54
+  }
55
+  .domande-abbozzo__handle:active {
56
+    cursor: grabbing;
57
+  }
58
+  .domande-abbozzo__handle i {
59
+    font-size: 1.35rem;
60
+  }
61
+  .domande-abbozzo__menu-btn {
62
+    width: 2.25rem;
63
+    height: 2.25rem;
64
+    display: inline-flex;
65
+    align-items: center;
66
+    justify-content: center;
67
+  }
68
+  .domande-abbozzo__menu-btn i {
69
+    font-size: 1.5rem;
70
+    line-height: 1;
71
+  }
72
+  .domande-abbozzo__tipo {
73
+    width: 2.25rem;
74
+    height: 2.25rem;
75
+    border-radius: .5rem;
76
+    display: inline-flex;
77
+    align-items: center;
78
+    justify-content: center;
79
+    background: rgba(105, 108, 255, .08);
80
+    color: #696cff;
81
+    flex-shrink: 0;
82
+  }
83
+  .domande-abbozzo__tipo-card {
84
+    cursor: pointer;
85
+    border: 1px solid #e7e7e8;
86
+    border-radius: .65rem;
87
+    padding: .85rem .75rem;
88
+    text-align: center;
89
+    height: 100%;
90
+    transition: border-color .15s ease, box-shadow .15s ease;
91
+  }
92
+  .domande-abbozzo__tipo-card:hover,
93
+  .domande-abbozzo__tipo-card.is-selected {
94
+    border-color: #696cff;
95
+    box-shadow: 0 0 0 1px #696cff;
96
+  }
97
+  .domande-abbozzo__preview {
98
+    border: 1px solid #e7e7e8;
99
+    border-radius: .75rem;
100
+    background: #f8f9fa;
101
+    padding: 1rem;
102
+  }
103
+  .domande-abbozzo__preview .form-control,
104
+  .domande-abbozzo__preview .form-select {
105
+    background: #fff;
106
+  }
107
+  .domande-abbozzo__preset .btn {
108
+    border-radius: 999px;
109
+  }
110
+</style>
111
+
112
+<div class="row g-4">
113
+  <div class="col-lg-7">
114
+    <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
115
+      <div>
116
+        <h6 class="mb-1">Cosa chiediamo al cliente?</h6>
117
+        <p class="small text-muted mb-0">Contatti e coperti sono già previsti. Qui aggiungi solo le domande extra.</p>
118
+      </div>
119
+      <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#modalNuovaDomanda">
120
+        <i class="bx bx-plus me-1"></i> Aggiungi domanda
121
+      </button>
122
+    </div>
123
+
124
+    <div class="domande-abbozzo__preset d-flex flex-wrap gap-2 mb-3">
125
+      <span class="small text-muted align-self-center me-1">Partenza rapida:</span>
126
+      <form method="POST" action="{{ route('evento.preset-domande') }}" class="d-inline">
127
+        @csrf
128
+        <input type="hidden" name="evento_id" value="{{ $evento->id }}">
129
+        <input type="hidden" name="preset" value="allergie">
130
+        <button type="submit" class="btn btn-sm btn-label-secondary" title="Aggiunge la domanda sulle allergie">Allergie</button>
131
+      </form>
132
+      <form method="POST" action="{{ route('evento.preset-domande') }}" class="d-inline">
133
+        @csrf
134
+        <input type="hidden" name="evento_id" value="{{ $evento->id }}">
135
+        <input type="hidden" name="preset" value="orario">
136
+        <button type="submit" class="btn btn-sm btn-label-secondary" title="Aggiunge Pranzo/Cena">Orario</button>
137
+      </form>
138
+      <form method="POST" action="{{ route('evento.preset-domande') }}" class="d-inline">
139
+        @csrf
140
+        <input type="hidden" name="evento_id" value="{{ $evento->id }}">
141
+        <input type="hidden" name="preset" value="bambini">
142
+        <button type="submit" class="btn btn-sm btn-label-secondary" title="Aggiunge il numero di bambini">Bambini</button>
143
+      </form>
144
+      <form method="POST" action="{{ route('evento.preset-domande') }}" class="d-inline">
145
+        @csrf
146
+        <input type="hidden" name="evento_id" value="{{ $evento->id }}">
147
+        <input type="hidden" name="preset" value="pasto">
148
+        <button type="submit" class="btn btn-sm btn-primary" title="Orario + allergie + bambini">Set pasto</button>
149
+      </form>
150
+    </div>
151
+
152
+    @if($domande->isEmpty())
153
+      <div class="domande-abbozzo__empty">
154
+        <i class="bx bx-message-square-detail bx-md mb-2 d-block"></i>
155
+        <p class="mb-2 fw-medium text-heading">Nessuna domanda extra</p>
156
+        <p class="small mb-3">Il cliente compilerà solo i dati di contatto. Aggiungi una domanda se ti serve altro.</p>
157
+        <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#modalNuovaDomanda">
158
+          Aggiungi la prima domanda
159
+        </button>
160
+      </div>
161
+    @else
162
+      <div
163
+        id="lista-domande-abbozzo"
164
+        data-reorder-url="{{ route('evento.domanda.reorder') }}"
165
+        data-evento-id="{{ $evento->id }}"
166
+      >
167
+        @foreach($domande as $index => $domanda)
168
+          @php
169
+            $meta = $tipiSemplici[$domanda->tipo] ?? [
170
+              'label' => EventoForm::getTipi()[$domanda->tipo]['label'] ?? $domanda->tipo,
171
+              'icon' => EventoForm::getTipi()[$domanda->tipo]['icon'] ?? 'bx bx-help-circle',
172
+            ];
173
+          @endphp
174
+          <div class="domande-abbozzo__item" data-id="{{ $domanda->id }}">
175
+            <span class="domande-abbozzo__handle" title="Trascina per riordinare"><i class="bx bx-menu"></i></span>
176
+            <span class="domande-abbozzo__tipo" title="{{ $meta['label'] }}">
177
+              <i class="{{ $meta['icon'] }}"></i>
178
+            </span>
179
+            <div class="flex-grow-1 min-w-0">
180
+              <div class="d-flex flex-wrap align-items-center gap-2">
181
+                <span class="fw-semibold text-truncate">{{ $domanda->label ?: 'Senza titolo' }}</span>
182
+                @if($domanda->obbligatoria)
183
+                  <span class="badge bg-label-danger">Obbligatoria</span>
184
+                @else
185
+                  <span class="badge bg-label-secondary">Facoltativa</span>
186
+                @endif
187
+                @if($domanda->per_coperto)
188
+                  <span class="badge bg-label-info">Per coperto</span>
189
+                @endif
190
+                <span class="badge bg-label-primary">{{ $meta['label'] }}</span>
191
+              </div>
192
+              @if(filled($domanda->descrizione))
193
+                <div class="small text-muted mt-1">{{ $domanda->descrizione }}</div>
194
+              @endif
195
+            </div>
196
+            <div class="dropdown">
197
+              <button type="button" class="btn btn-icon btn-text-secondary rounded-pill domande-abbozzo__menu-btn" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Azioni">
198
+                <i class="bx bx-dots-vertical-rounded"></i>
199
+              </button>
200
+              <ul class="dropdown-menu dropdown-menu-end">
201
+                <li>
202
+                  <button
203
+                    type="button"
204
+                    class="dropdown-item btn-edit-domanda"
205
+                    data-id="{{ $domanda->id }}"
206
+                    data-label="{{ $domanda->label }}"
207
+                    data-descrizione="{{ $domanda->descrizione ?? '' }}"
208
+                    data-tipo="{{ $domanda->tipo }}"
209
+                    data-obbligatoria="{{ $domanda->obbligatoria ? '1' : '0' }}"
210
+                    data-per-coperto="{{ $domanda->per_coperto ? '1' : '0' }}"
211
+                    data-opzioni="{{ $domanda->opzioni_form->pluck('label')->implode("\n") }}"
212
+                  >
213
+                    <i class="bx bx-edit-alt me-1"></i> Modifica
214
+                  </button>
215
+                </li>
216
+                <li>
217
+                  <form method="POST" action="{{ route('evento.domanda.destroy', ['domanda_id' => $domanda->id]) }}" onsubmit="return confirm('Eliminare questa domanda?');">
218
+                    @csrf
219
+                    @method('DELETE')
220
+                    <button type="submit" class="dropdown-item text-danger">
221
+                      <i class="bx bx-trash me-1"></i> Elimina
222
+                    </button>
223
+                  </form>
224
+                </li>
225
+              </ul>
226
+            </div>
227
+          </div>
228
+        @endforeach
229
+      </div>
230
+    @endif
231
+
232
+    <p class="small text-muted mt-3 mb-0">
233
+      <i class="bx bx-info-circle me-1"></i>
234
+      Trascina le domande per cambiare l’ordine. Puoi anche modificarle o eliminarle in qualsiasi momento.
235
+    </p>
236
+  </div>
237
+
238
+  <div class="col-lg-5">
239
+    <div class="domande-abbozzo__preview">
240
+      <div class="d-flex align-items-center justify-content-between mb-3">
241
+        <h6 class="mb-0">Anteprima cliente</h6>
242
+        <span class="badge bg-label-secondary">Solo lettura</span>
243
+      </div>
244
+
245
+      <div class="mb-3">
246
+        <label class="form-label small">Nome</label>
247
+        <input type="text" class="form-control form-control-sm" disabled placeholder="Mario">
248
+      </div>
249
+      <div class="mb-3">
250
+        <label class="form-label small">Cognome</label>
251
+        <input type="text" class="form-control form-control-sm" disabled placeholder="Rossi">
252
+      </div>
253
+      <div class="mb-3">
254
+        <label class="form-label small">Email</label>
255
+        <input type="email" class="form-control form-control-sm" disabled placeholder="mario@email.it">
256
+      </div>
257
+      <div class="mb-3">
258
+        <label class="form-label small">Telefono</label>
259
+        <input type="text" class="form-control form-control-sm" disabled placeholder="333…">
260
+      </div>
261
+      <div class="mb-3">
262
+        <label class="form-label small">Coperti <span class="text-danger">*</span></label>
263
+        <input type="number" class="form-control form-control-sm" disabled placeholder="2" value="2">
264
+      </div>
265
+
266
+      @forelse($domande as $domanda)
267
+        <div class="mb-3 domande-abbozzo__preview-item" data-id="{{ $domanda->id }}">
268
+          <label class="form-label small">
269
+            {{ $domanda->label ?: 'Domanda' }}
270
+            @if($domanda->obbligatoria)<span class="text-danger">*</span>@endif
271
+            @if($domanda->per_coperto)
272
+              <span class="badge bg-label-info ms-1">× coperti</span>
273
+            @endif
274
+          </label>
275
+          @if($domanda->per_coperto)
276
+            <p class="small text-muted mb-1">Anteprima: una risposta per ogni coperto (es. 2 persone → 2 campi).</p>
277
+          @endif
278
+          @switch($domanda->tipo)
279
+            @case(EventoForm::NUMERO)
280
+              <input type="number" class="form-control form-control-sm" disabled placeholder="0">
281
+              @break
282
+            @case(EventoForm::CHECKBOX)
283
+              <div class="form-check">
284
+                <input class="form-check-input" type="checkbox" disabled>
285
+                <label class="form-check-label small">Sì</label>
286
+              </div>
287
+              @break
288
+            @case(EventoForm::SELEZIONE)
289
+            @case(EventoForm::MULTISELEZIONE)
290
+            @case(EventoForm::RADIO)
291
+            @case(EventoForm::ELENCO)
292
+              <select class="form-select form-select-sm" disabled>
293
+                <option>Scegli…</option>
294
+              </select>
295
+              @break
296
+            @default
297
+              <input type="text" class="form-control form-control-sm" disabled placeholder="Risposta">
298
+          @endswitch
299
+        </div>
300
+      @empty
301
+        <p class="small text-muted mb-0">Nessun campo extra nell’anteprima.</p>
302
+      @endforelse
303
+    </div>
304
+  </div>
305
+</div>

+ 97
- 0
resources/views/evento/_partials/email-anteprima.blade.php 查看文件

@@ -0,0 +1,97 @@
1
+@php
2
+  $tipo = $tipo ?? 'inviata';
3
+  $indicazioni = trim((string) ($indicazioni ?? ''));
4
+  $coverUrl = $coverUrl ?? null;
5
+  $attivitaNome = $attivitaNome ?? 'Attività';
6
+  $eventoNome = $eventoNome ?? 'Evento';
7
+  $dataEvento = $dataEvento ?? null;
8
+  $luogo = $luogo ?? null;
9
+  $previewId = $previewId ?? 'email-preview-'.$tipo;
10
+
11
+  $eyebrow = $tipo === 'confermata' ? 'Prenotazione confermata' : 'Prenotazione ricevuta';
12
+  $saluto = $tipo === 'confermata'
13
+    ? 'la tua prenotazione è confermata. Porta con te questo codice.'
14
+    : 'abbiamo ricevuto la tua richiesta. È in attesa di conferma da parte degli organizzatori.';
15
+  $cta = $tipo === 'confermata' ? 'Apri il riepilogo' : 'Vedi il riepilogo';
16
+
17
+  $navy = '#15265c';
18
+  $orange = '#f58220';
19
+  $muted = '#6b7280';
20
+  $border = '#e2e6ef';
21
+  $surface = '#f0f2f8';
22
+@endphp
23
+
24
+<div class="email-anteprima" id="{{ $previewId }}" aria-hidden="true">
25
+  <div class="email-anteprima__chrome">
26
+    <span class="email-anteprima__dot"></span>
27
+    <span class="email-anteprima__dot"></span>
28
+    <span class="email-anteprima__dot"></span>
29
+    <span class="email-anteprima__chrome-label">Anteprima email</span>
30
+  </div>
31
+
32
+  <div class="email-anteprima__frame" style="background:{{ $surface }};">
33
+    <div class="email-anteprima__card" style="border-color:{{ $border }};">
34
+      @if($coverUrl)
35
+        <img class="email-anteprima__cover" src="{{ $coverUrl }}" alt="">
36
+        <div class="email-anteprima__head" style="background:{{ $navy }};">
37
+          <p class="email-anteprima__eyebrow">{{ $eyebrow }}</p>
38
+          <p class="email-anteprima__title">{{ $attivitaNome }}</p>
39
+        </div>
40
+      @else
41
+        <div class="email-anteprima__head email-anteprima__head--grad" style="--ea-navy:{{ $navy }};--ea-orange:{{ $orange }};">
42
+          <p class="email-anteprima__eyebrow">{{ $eyebrow }}</p>
43
+          <p class="email-anteprima__title">{{ $attivitaNome }}</p>
44
+        </div>
45
+      @endif
46
+
47
+      <div class="email-anteprima__body" style="color:{{ $navy }};">
48
+        <p class="email-anteprima__hello">
49
+          Ciao Mario Rossi,<br>
50
+          {{ $saluto }}
51
+        </p>
52
+
53
+        @if($tipo === 'inviata')
54
+          <div class="email-anteprima__note" style="background:#fff8f0;border-color:#f5d4b0;color:{{ $navy }};">
55
+            Ti invieremo un’altra email quando la prenotazione sarà <strong>confermata</strong>.
56
+          </div>
57
+        @endif
58
+
59
+        <div
60
+          class="email-anteprima__indicazioni{{ $indicazioni === '' ? ' is-empty' : '' }}"
61
+          data-email-indicazioni
62
+          style="background:{{ $surface }};border-color:{{ $border }};color:{{ $navy }};"
63
+        >{{ $indicazioni !== '' ? $indicazioni : 'Il testo che scrivi comparirà qui.' }}</div>
64
+
65
+        <div class="email-anteprima__code" style="background:{{ $surface }};border-color:{{ $border }};">
66
+          <p class="email-anteprima__code-label" style="color:{{ $muted }};">Codice prenotazione</p>
67
+          <p class="email-anteprima__code-value" style="color:{{ $navy }};">ABC123</p>
68
+        </div>
69
+
70
+        <dl class="email-anteprima__facts" style="color:{{ $navy }};">
71
+          <div>
72
+            <dt style="color:{{ $muted }};">Evento</dt>
73
+            <dd>{{ $eventoNome }}</dd>
74
+          </div>
75
+          @if($dataEvento)
76
+            <div>
77
+              <dt style="color:{{ $muted }};">Quando</dt>
78
+              <dd>{{ $dataEvento }}</dd>
79
+            </div>
80
+          @endif
81
+          @if($luogo)
82
+            <div>
83
+              <dt style="color:{{ $muted }};">Dove</dt>
84
+              <dd>{{ $luogo }}</dd>
85
+            </div>
86
+          @endif
87
+          <div>
88
+            <dt style="color:{{ $muted }};">Persone</dt>
89
+            <dd>2</dd>
90
+          </div>
91
+        </dl>
92
+
93
+        <div class="email-anteprima__cta" style="background:{{ $orange }};">{{ $cta }}</div>
94
+      </div>
95
+    </div>
96
+  </div>
97
+</div>

+ 764
- 0
resources/views/evento/_partials/public-styles.blade.php 查看文件

@@ -0,0 +1,764 @@
1
+<style>
2
+  html.evento-public-page,
3
+  html.evento-public-page body {
4
+    height: 100%;
5
+    margin: 0;
6
+    background: #f4f5f9;
7
+  }
8
+
9
+  html.evento-public-page .layout-wrapper,
10
+  html.evento-public-page .content-wrapper,
11
+  html.evento-public-page .layout-page,
12
+  html.evento-public-page .container-xxl {
13
+    padding: 0 !important;
14
+    margin: 0 !important;
15
+    max-width: 100% !important;
16
+  }
17
+
18
+  html.evento-public-page .d-flex.flex-column.min-vh-100 {
19
+    min-height: 100vh !important;
20
+    min-height: 100dvh !important;
21
+  }
22
+
23
+  html.evento-public-page .misc-wrapper {
24
+    display: flex !important;
25
+    flex-direction: column !important;
26
+    min-height: 100vh;
27
+    min-height: 100dvh;
28
+    width: 100%;
29
+    max-width: 100%;
30
+    padding: 0;
31
+    margin: 0;
32
+    align-items: stretch !important;
33
+    justify-content: flex-start !important;
34
+    text-align: start !important;
35
+    background: transparent;
36
+  }
37
+
38
+  html.evento-public-page .row.mt-3.mb-3,
39
+  html.evento-public-page footer {
40
+    display: none !important;
41
+  }
42
+
43
+  .evento-public {
44
+    --evento-accent: #f58220;
45
+    --evento-navy: #15265c;
46
+    --evento-muted: #5b6579;
47
+    --evento-purple: #602d91;
48
+    --evento-surface: #ffffff;
49
+    --evento-border: rgba(21, 38, 92, 0.1);
50
+    --evento-gutter: clamp(0.85rem, 3vw, 1.25rem);
51
+
52
+    position: relative;
53
+    width: 100%;
54
+    max-width: 100%;
55
+    min-height: 100vh;
56
+    min-height: 100dvh;
57
+    display: flex;
58
+    flex-direction: column;
59
+    font-family: "Plus Jakarta Sans", "Public Sans", system-ui, sans-serif;
60
+    color: var(--evento-navy);
61
+    overflow-x: hidden;
62
+  }
63
+
64
+  .evento-public__bg {
65
+    position: fixed;
66
+    inset: 0;
67
+    z-index: 0;
68
+    pointer-events: none;
69
+    background: linear-gradient(150deg, #fff7f0 0%, #f3ebf8 42%, #e8eef8 100%);
70
+  }
71
+
72
+  .evento-public__bg::before {
73
+    content: "";
74
+    position: absolute;
75
+    inset: 0;
76
+    background:
77
+      radial-gradient(ellipse 70% 45% at 12% 12%, color-mix(in srgb, var(--evento-accent) 28%, transparent) 0%, transparent 55%),
78
+      radial-gradient(ellipse 55% 40% at 90% 80%, rgba(96, 45, 145, 0.18) 0%, transparent 52%);
79
+  }
80
+
81
+  .evento-public__top {
82
+    position: sticky;
83
+    top: 0;
84
+    z-index: 20;
85
+    display: flex;
86
+    align-items: center;
87
+    justify-content: space-between;
88
+    gap: 1rem;
89
+    width: 100%;
90
+    padding: calc(0.75rem + env(safe-area-inset-top, 0px)) var(--evento-gutter) 0.75rem;
91
+    background: rgba(255, 255, 255, 0.92);
92
+    backdrop-filter: blur(10px);
93
+    border-bottom: 1px solid var(--evento-border);
94
+  }
95
+
96
+  .evento-public__top::after {
97
+    content: "";
98
+    position: absolute;
99
+    left: 0;
100
+    right: 0;
101
+    bottom: 0;
102
+    height: 3px;
103
+    background: linear-gradient(90deg, var(--evento-accent) 0%, var(--evento-purple) 55%, var(--evento-navy) 100%);
104
+    opacity: 0.85;
105
+  }
106
+
107
+  .evento-public__back {
108
+    display: inline-flex;
109
+    align-items: center;
110
+    gap: 0.35rem;
111
+    font-size: 0.88rem;
112
+    font-weight: 600;
113
+    color: var(--evento-navy);
114
+    text-decoration: none;
115
+    max-width: 70%;
116
+  }
117
+
118
+  .evento-public__back span {
119
+    overflow: hidden;
120
+    text-overflow: ellipsis;
121
+    white-space: nowrap;
122
+  }
123
+
124
+  .evento-public__back:hover {
125
+    color: var(--evento-accent);
126
+  }
127
+
128
+  .evento-public__brand-logo {
129
+    display: block;
130
+    height: 1.55rem;
131
+    width: auto;
132
+    opacity: 0.92;
133
+  }
134
+
135
+  .evento-public__hero {
136
+    position: relative;
137
+    z-index: 1;
138
+    width: 100%;
139
+    min-height: clamp(220px, 42vw, 360px);
140
+    overflow: hidden;
141
+    background: #d8dee9;
142
+  }
143
+
144
+  .evento-public__hero-inner {
145
+    position: relative;
146
+    z-index: 1;
147
+    width: 100%;
148
+    max-width: 920px;
149
+    margin: 0 auto;
150
+    padding: 0 var(--evento-gutter);
151
+  }
152
+
153
+  .evento-public__hero-img {
154
+    position: absolute;
155
+    inset: 0;
156
+    width: 100%;
157
+    height: 100%;
158
+    object-fit: cover;
159
+  }
160
+
161
+  .evento-public__hero-fallback {
162
+    position: absolute;
163
+    inset: 0;
164
+    background: linear-gradient(125deg, var(--evento-navy) 0%, var(--evento-purple) 50%, var(--evento-accent) 100%);
165
+  }
166
+
167
+  .evento-public__hero-shade {
168
+    position: absolute;
169
+    inset: 0;
170
+    background: linear-gradient(180deg, rgba(12, 18, 40, 0.2) 0%, rgba(12, 18, 40, 0.82) 100%);
171
+  }
172
+
173
+  .evento-public__hero-content {
174
+    position: relative;
175
+    z-index: 1;
176
+    display: flex;
177
+    flex-direction: column;
178
+    justify-content: flex-end;
179
+    min-height: clamp(220px, 42vw, 360px);
180
+    padding: 1.35rem 0 1.5rem;
181
+    color: #fff;
182
+  }
183
+
184
+  .evento-public__eyebrow {
185
+    margin: 0 0 0.3rem;
186
+    font-size: 0.72rem;
187
+    font-weight: 700;
188
+    letter-spacing: 0.06em;
189
+    text-transform: uppercase;
190
+    color: #fff;
191
+    opacity: 0.9;
192
+  }
193
+
194
+  .evento-public__title {
195
+    margin: 0;
196
+    font-size: clamp(1.55rem, 4.5vw, 2.35rem);
197
+    font-weight: 800;
198
+    letter-spacing: -0.03em;
199
+    line-height: 1.15;
200
+    color: #fff;
201
+    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 4px 18px rgba(0, 0, 0, 0.35);
202
+  }
203
+
204
+  .evento-public__shell {
205
+    position: relative;
206
+    z-index: 2;
207
+    width: 100%;
208
+    max-width: 920px;
209
+    margin: 0 auto;
210
+    padding: 0 var(--evento-gutter) 1.5rem;
211
+    display: flex;
212
+    flex-direction: column;
213
+  }
214
+
215
+  .evento-public__summary {
216
+    position: relative;
217
+    z-index: 3;
218
+    margin-top: -0.85rem;
219
+    padding: 0.15rem 0 0.25rem;
220
+  }
221
+
222
+  .evento-public__facts {
223
+    display: flex;
224
+    flex-wrap: wrap;
225
+    align-items: center;
226
+    gap: 0.5rem 0.85rem;
227
+    margin: 0;
228
+    padding: 0.7rem 0.95rem;
229
+    list-style: none;
230
+    background: #fff;
231
+    border-radius: 0.85rem;
232
+    border: 1px solid rgba(21, 38, 92, 0.06);
233
+    box-shadow: 0 2px 12px rgba(21, 38, 92, 0.06);
234
+  }
235
+
236
+  .evento-public__fact {
237
+    display: inline-flex;
238
+    align-items: center;
239
+    gap: 0.4rem;
240
+    font-size: 0.84rem;
241
+    font-weight: 600;
242
+    line-height: 1.35;
243
+    color: var(--evento-navy);
244
+  }
245
+
246
+  .evento-public__fact i {
247
+    flex-shrink: 0;
248
+    color: var(--evento-accent);
249
+    font-size: 1.05rem;
250
+    line-height: 1;
251
+  }
252
+
253
+  .evento-public__fact:not(:last-child):not(:has(+ .evento-public__fact--price))::after {
254
+    content: "";
255
+    display: inline-block;
256
+    width: 3px;
257
+    height: 3px;
258
+    margin-left: 0.85rem;
259
+    border-radius: 50%;
260
+    background: rgba(21, 38, 92, 0.22);
261
+    vertical-align: middle;
262
+  }
263
+
264
+  .evento-public__fact--price {
265
+    gap: 0;
266
+    margin-left: auto;
267
+  }
268
+
269
+  .evento-public__fact--price span {
270
+    font-size: 1.05rem;
271
+    font-weight: 800;
272
+    letter-spacing: -0.02em;
273
+    color: var(--evento-accent);
274
+  }
275
+
276
+  .evento-public__fact--ok,
277
+  .evento-public__fact--ok i,
278
+  .evento-public__fact--ok span {
279
+    color: #1b7a4a;
280
+  }
281
+
282
+  .evento-public__fact--mute,
283
+  .evento-public__fact--mute i,
284
+  .evento-public__fact--mute span {
285
+    color: var(--evento-muted);
286
+  }
287
+
288
+  .evento-public__fact--confirm,
289
+  .evento-public__fact--confirm i,
290
+  .evento-public__fact--confirm span {
291
+    color: var(--evento-purple);
292
+  }
293
+
294
+  @media (max-width: 479px) {
295
+    .evento-public__facts {
296
+      flex-direction: column;
297
+      align-items: flex-start;
298
+      gap: 0.55rem;
299
+    }
300
+
301
+    .evento-public__fact:not(:last-child)::after {
302
+      display: none;
303
+    }
304
+
305
+    .evento-public__fact--price {
306
+      align-self: flex-end;
307
+      margin-left: 0;
308
+    }
309
+  }
310
+
311
+  .evento-public__nav-link {
312
+    display: inline-flex;
313
+    align-items: center;
314
+    justify-content: center;
315
+    width: 1.55rem;
316
+    height: 1.55rem;
317
+    margin-left: 0.35rem;
318
+    border-radius: 999px;
319
+    background: var(--evento-accent);
320
+    color: #fff !important;
321
+    text-decoration: none;
322
+    vertical-align: middle;
323
+    box-shadow: 0 2px 8px color-mix(in srgb, var(--evento-accent) 35%, transparent);
324
+  }
325
+
326
+  .evento-public__nav-link i {
327
+    font-size: 0.9rem;
328
+    color: #fff !important;
329
+    line-height: 1;
330
+  }
331
+
332
+  .evento-public__chips {
333
+    display: flex;
334
+    flex-wrap: wrap;
335
+    gap: 0.45rem;
336
+    margin-top: 0.7rem;
337
+  }
338
+
339
+  .evento-public__chip {
340
+    display: inline-flex;
341
+    align-items: center;
342
+    gap: 0.35rem;
343
+    padding: 0.35rem 0.7rem;
344
+    border-radius: 999px;
345
+    font-size: 0.78rem;
346
+    font-weight: 600;
347
+    line-height: 1.25;
348
+  }
349
+
350
+  .evento-public__chip i {
351
+    font-size: 1rem;
352
+    line-height: 1;
353
+  }
354
+
355
+  .evento-public__chip--confirm {
356
+    background: color-mix(in srgb, var(--evento-purple) 8%, #fff);
357
+    border: 1px solid color-mix(in srgb, var(--evento-purple) 22%, transparent);
358
+    color: var(--evento-purple);
359
+  }
360
+
361
+  .evento-public__chip--posti {
362
+    background: color-mix(in srgb, var(--evento-accent) 8%, #fff);
363
+    border: 1px solid color-mix(in srgb, var(--evento-accent) 22%, transparent);
364
+    color: var(--evento-navy);
365
+  }
366
+
367
+  .evento-public__chip--posti i {
368
+    color: var(--evento-accent);
369
+  }
370
+
371
+  .evento-public__chip--posti-ok {
372
+    background: color-mix(in srgb, #1b7a4a 8%, #fff);
373
+    border-color: color-mix(in srgb, #1b7a4a 22%, transparent);
374
+    color: #1b7a4a;
375
+  }
376
+
377
+  .evento-public__chip--posti-ok i {
378
+    color: #1b7a4a;
379
+  }
380
+
381
+  .evento-public__chip--posti-mute {
382
+    background: rgba(255, 255, 255, 0.9);
383
+    border-color: rgba(21, 38, 92, 0.1);
384
+    color: var(--evento-muted);
385
+  }
386
+
387
+  .evento-public__chip--posti-mute i {
388
+    color: var(--evento-muted);
389
+  }
390
+
391
+  .evento-public {
392
+    padding-bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
393
+  }
394
+
395
+  @media (min-width: 768px) {
396
+    .evento-public {
397
+      padding-bottom: 2rem;
398
+    }
399
+  }
400
+
401
+  .evento-public__shell {
402
+    position: relative;
403
+    z-index: 2;
404
+    width: 100%;
405
+    max-width: 920px;
406
+    margin: 0 auto;
407
+    padding: 0 var(--evento-gutter) 1.5rem;
408
+    display: flex;
409
+    flex-direction: column;
410
+    gap: 0;
411
+  }
412
+
413
+  .evento-public__cta-desktop {
414
+    display: flex;
415
+    justify-content: center;
416
+    margin-top: 1.2rem;
417
+  }
418
+
419
+  .evento-public__cta {
420
+    position: relative;
421
+    display: inline-flex;
422
+    align-items: center;
423
+    justify-content: center;
424
+    gap: 0.55rem;
425
+    width: 100%;
426
+    max-width: 18.5rem;
427
+    min-height: 3.1rem;
428
+    padding: 0.55rem 0.7rem 0.55rem 0.55rem;
429
+    overflow: hidden;
430
+    isolation: isolate;
431
+    border: 0;
432
+    border-radius: 999px;
433
+    background:
434
+      linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, transparent 42%),
435
+      linear-gradient(135deg, color-mix(in srgb, var(--evento-accent) 88%, #fff) 0%, var(--evento-accent) 48%, color-mix(in srgb, var(--evento-accent) 72%, #c45a00) 100%);
436
+    color: #fff !important;
437
+    font-size: 1.02rem;
438
+    font-weight: 800;
439
+    letter-spacing: -0.01em;
440
+    text-decoration: none !important;
441
+    box-shadow:
442
+      0 1px 0 rgba(255, 255, 255, 0.28) inset,
443
+      0 8px 22px color-mix(in srgb, var(--evento-accent) 38%, transparent),
444
+      0 2px 6px rgba(21, 38, 92, 0.08);
445
+    transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
446
+  }
447
+
448
+  .evento-public__cta::after {
449
+    content: "";
450
+    position: absolute;
451
+    inset: 0;
452
+    z-index: -1;
453
+    background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.28) 50%, transparent 65%);
454
+    transform: translateX(-120%);
455
+    transition: transform 0.55s ease;
456
+  }
457
+
458
+  .evento-public__cta:hover {
459
+    transform: translateY(-2px);
460
+    filter: brightness(1.04);
461
+    box-shadow:
462
+      0 1px 0 rgba(255, 255, 255, 0.32) inset,
463
+      0 14px 28px color-mix(in srgb, var(--evento-accent) 46%, transparent),
464
+      0 4px 10px rgba(21, 38, 92, 0.1);
465
+    color: #fff !important;
466
+  }
467
+
468
+  .evento-public__cta:hover::after {
469
+    transform: translateX(120%);
470
+  }
471
+
472
+  .evento-public__cta:active {
473
+    transform: translateY(0);
474
+    filter: brightness(0.98);
475
+  }
476
+
477
+  .evento-public__cta-icon {
478
+    display: inline-flex;
479
+    align-items: center;
480
+    justify-content: center;
481
+    width: 2.15rem;
482
+    height: 2.15rem;
483
+    border-radius: 999px;
484
+    background: rgba(255, 255, 255, 0.22);
485
+    box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset;
486
+    font-size: 1.15rem;
487
+    line-height: 1;
488
+  }
489
+
490
+  .evento-public__cta-label {
491
+    flex: 1;
492
+    text-align: center;
493
+    padding-inline: 0.15rem;
494
+  }
495
+
496
+  .evento-public__cta-arrow {
497
+    display: inline-flex;
498
+    align-items: center;
499
+    justify-content: center;
500
+    width: 1.55rem;
501
+    font-size: 1.25rem;
502
+    line-height: 1;
503
+    opacity: 0.92;
504
+    transition: transform 0.18s ease;
505
+  }
506
+
507
+  .evento-public__cta:hover .evento-public__cta-arrow {
508
+    transform: translateX(2px);
509
+  }
510
+
511
+  .evento-public__cta-note {
512
+    display: flex;
513
+    align-items: flex-start;
514
+    gap: 0.55rem;
515
+    width: 100%;
516
+    max-width: 28rem;
517
+    padding: 0.85rem 1rem;
518
+    border-radius: 0.9rem;
519
+    background: #fff;
520
+    border: 1px solid rgba(21, 38, 92, 0.08);
521
+    box-shadow: 0 2px 10px rgba(21, 38, 92, 0.05);
522
+    color: var(--evento-muted);
523
+    font-size: 0.9rem;
524
+    line-height: 1.45;
525
+    font-weight: 500;
526
+  }
527
+
528
+  .evento-public__cta-note i {
529
+    margin-top: 0.1rem;
530
+    flex-shrink: 0;
531
+    color: var(--evento-purple);
532
+    font-size: 1.2rem;
533
+  }
534
+
535
+  .evento-public__section {
536
+    margin-top: 1.25rem;
537
+    padding-top: 0;
538
+  }
539
+
540
+  .evento-public__section + .evento-public__section {
541
+    padding-top: 1.15rem;
542
+    border-top: 1px solid rgba(21, 38, 92, 0.08);
543
+  }
544
+
545
+  .evento-public__section-title {
546
+    margin: 0 0 0.75rem;
547
+    font-size: 0.72rem;
548
+    font-weight: 700;
549
+    letter-spacing: 0.07em;
550
+    text-transform: uppercase;
551
+    color: var(--evento-purple);
552
+    padding-left: 0.45rem;
553
+    border-left: 3px solid var(--evento-accent);
554
+  }
555
+
556
+  .evento-public__poster {
557
+    margin: 1.15rem 0 0;
558
+    padding: 0;
559
+    text-align: center;
560
+  }
561
+
562
+  .evento-public__poster-trigger {
563
+    position: relative;
564
+    display: block;
565
+    width: fit-content;
566
+    max-width: 100%;
567
+    margin: 0 auto;
568
+    padding: 0;
569
+    border: 0;
570
+    background: transparent;
571
+    cursor: zoom-in;
572
+  }
573
+
574
+  .evento-public__poster-trigger img,
575
+  .evento-public__poster img {
576
+    display: block;
577
+    width: 100%;
578
+    max-width: 520px;
579
+    height: auto;
580
+    max-height: min(80vh, 720px);
581
+    margin: 0 auto;
582
+    object-fit: contain;
583
+    border-radius: 0.75rem;
584
+    box-shadow: 0 10px 32px rgba(21, 38, 92, 0.14);
585
+    transition: transform 0.18s ease, box-shadow 0.18s ease;
586
+  }
587
+
588
+  .evento-public__poster-trigger:hover img {
589
+    transform: translateY(-2px);
590
+    box-shadow: 0 14px 36px rgba(21, 38, 92, 0.18);
591
+  }
592
+
593
+  .evento-public__poster-zoom {
594
+    position: absolute;
595
+    right: 0.7rem;
596
+    bottom: 0.7rem;
597
+    display: inline-flex;
598
+    align-items: center;
599
+    justify-content: center;
600
+    width: 2.1rem;
601
+    height: 2.1rem;
602
+    border-radius: 999px;
603
+    background: rgba(21, 38, 92, 0.72);
604
+    color: #fff;
605
+    font-size: 1.15rem;
606
+    box-shadow: 0 4px 12px rgba(21, 38, 92, 0.25);
607
+    pointer-events: none;
608
+  }
609
+
610
+  html.evento-lightbox-open,
611
+  html.evento-lightbox-open body {
612
+    overflow: hidden;
613
+  }
614
+
615
+  .evento-lightbox[hidden] {
616
+    display: none !important;
617
+  }
618
+
619
+  .evento-lightbox {
620
+    position: fixed;
621
+    inset: 0;
622
+    z-index: 1080;
623
+    display: grid;
624
+    place-items: center;
625
+    padding: 1rem;
626
+  }
627
+
628
+  .evento-lightbox__backdrop {
629
+    position: absolute;
630
+    inset: 0;
631
+    border: 0;
632
+    padding: 0;
633
+    background: rgba(12, 18, 40, 0.78);
634
+    backdrop-filter: blur(4px);
635
+    cursor: zoom-out;
636
+  }
637
+
638
+  .evento-lightbox__dialog {
639
+    position: relative;
640
+    z-index: 1;
641
+    max-width: min(96vw, 920px);
642
+    max-height: min(92vh, 920px);
643
+    padding: 0;
644
+  }
645
+
646
+  .evento-lightbox__img {
647
+    display: block;
648
+    width: auto;
649
+    height: auto;
650
+    max-width: min(96vw, 920px);
651
+    max-height: min(88vh, 880px);
652
+    margin: 0 auto;
653
+    border-radius: 0.65rem;
654
+    box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
655
+    background: #fff;
656
+  }
657
+
658
+  .evento-lightbox__close {
659
+    position: absolute;
660
+    top: -0.55rem;
661
+    right: -0.55rem;
662
+    z-index: 2;
663
+    display: inline-flex;
664
+    align-items: center;
665
+    justify-content: center;
666
+    width: 2.35rem;
667
+    height: 2.35rem;
668
+    border: 0;
669
+    border-radius: 999px;
670
+    background: #fff;
671
+    color: var(--evento-navy);
672
+    font-size: 1.45rem;
673
+    line-height: 1;
674
+    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
675
+    cursor: pointer;
676
+  }
677
+
678
+  .evento-lightbox__close:hover {
679
+    color: var(--evento-accent);
680
+  }
681
+
682
+  .evento-public__desc {
683
+    margin: 1rem 0 0;
684
+    padding: 0.95rem 1.05rem;
685
+    background: #fff;
686
+    border-radius: 0.85rem;
687
+    border: 1px solid rgba(21, 38, 92, 0.06);
688
+    box-shadow: 0 2px 12px rgba(21, 38, 92, 0.06);
689
+    font-size: 0.95rem;
690
+    font-weight: 500;
691
+    line-height: 1.65;
692
+    color: #2b3550;
693
+  }
694
+
695
+  .evento-public__gallery {
696
+    display: grid;
697
+    grid-template-columns: repeat(2, minmax(0, 1fr));
698
+    gap: 0.65rem;
699
+  }
700
+
701
+  @media (min-width: 640px) {
702
+    .evento-public__gallery {
703
+      grid-template-columns: repeat(3, minmax(0, 1fr));
704
+    }
705
+  }
706
+
707
+  .evento-public__gallery-item {
708
+    display: block;
709
+    aspect-ratio: 4 / 3;
710
+    overflow: hidden;
711
+    border-radius: 0.65rem;
712
+    background: #e8ebf2;
713
+    box-shadow: 0 1px 4px rgba(21, 38, 92, 0.08);
714
+    transition: transform 0.15s ease, box-shadow 0.15s ease;
715
+  }
716
+
717
+  .evento-public__gallery-item:hover {
718
+    transform: translateY(-2px);
719
+    box-shadow: 0 10px 22px rgba(21, 38, 92, 0.12);
720
+  }
721
+
722
+  .evento-public__gallery-item img {
723
+    width: 100%;
724
+    height: 100%;
725
+    object-fit: cover;
726
+  }
727
+
728
+  .evento-public__admin {
729
+    margin-top: 1rem;
730
+    text-align: center;
731
+  }
732
+
733
+  .evento-public__empty-media {
734
+    text-align: center;
735
+    color: var(--evento-muted);
736
+    font-size: 0.9rem;
737
+  }
738
+
739
+  .evento-public__sticky-cta {
740
+    position: fixed;
741
+    left: 0;
742
+    right: 0;
743
+    bottom: 0;
744
+    z-index: 30;
745
+    display: flex;
746
+    justify-content: center;
747
+    padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
748
+    background: rgba(255, 255, 255, 0.94);
749
+    backdrop-filter: blur(10px);
750
+    border-top: 1px solid var(--evento-border);
751
+    box-shadow: 0 -8px 24px rgba(21, 38, 92, 0.08);
752
+  }
753
+
754
+  .evento-public__sticky-cta .evento-public__cta {
755
+    max-width: 20rem;
756
+  }
757
+
758
+  @media (min-width: 768px) {
759
+    .evento-public__sticky-cta {
760
+      display: none;
761
+    }
762
+  }
763
+</style>
764
+

+ 572
- 0
resources/views/evento/admin_show.blade.php 查看文件

@@ -0,0 +1,572 @@
1
+@php
2
+  use Carbon\Carbon;
3
+  use Illuminate\Support\Facades\Auth;
4
+
5
+  $configData = Helper::appClasses();
6
+  $statoMeta = $stati[$evento->stato] ?? null;
7
+  $dataInizio = $evento->data_inizio ? Carbon::parse($evento->data_inizio) : null;
8
+  $dataFine = $evento->data_fine ? Carbon::parse($evento->data_fine) : null;
9
+  $sameDay = $dataInizio && $dataFine && $dataInizio->isSameDay($dataFine);
10
+
11
+  $dataLabel = 'Data non impostata';
12
+  if ($dataInizio) {
13
+    if ($sameDay) {
14
+      $dataLabel = $dataInizio->translatedFormat('d M Y').' · '.$dataInizio->format('H:i');
15
+      if ($dataFine) {
16
+        $dataLabel .= '–'.$dataFine->format('H:i');
17
+      }
18
+    } else {
19
+      $dataLabel = $dataInizio->translatedFormat('d M Y · H:i');
20
+      if ($dataFine) {
21
+        $dataLabel .= ' → '.$dataFine->translatedFormat('d M Y · H:i');
22
+      }
23
+    }
24
+  }
25
+@endphp
26
+
27
+@extends('layouts/layoutMaster')
28
+
29
+@section('title', 'Evento · '.$evento->nome)
30
+
31
+@section('pageTitle')
32
+<div class="d-flex flex-column">
33
+  <h4 class="mb-0">
34
+    <i class="bx bx-calendar-event d-none d-md-inline-flex"></i>
35
+    Cruscotto evento
36
+  </h4>
37
+  <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block">
38
+    <ol class="breadcrumb breadcrumb-custom-icon mb-0">
39
+      <li class="breadcrumb-item">
40
+        <a href="{{ route('evento.index') }}">Eventi</a>
41
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
42
+      </li>
43
+      <li class="breadcrumb-item active text-primary">{{ $evento->nome }}</li>
44
+    </ol>
45
+  </nav>
46
+</div>
47
+@endsection
48
+
49
+@section('content')
50
+@include('_partials.status')
51
+
52
+<style>
53
+  .evento-hub-shell {
54
+    background: #fff;
55
+    border: 1px solid rgba(67, 89, 113, .1);
56
+    border-radius: 1rem;
57
+    padding: 1.15rem;
58
+    box-shadow: 0 0.25rem 1.25rem rgba(67, 89, 113, .04);
59
+  }
60
+  @media (min-width: 992px) {
61
+    .evento-hub-shell {
62
+      padding: 1.5rem 1.6rem;
63
+    }
64
+  }
65
+  .evento-hub {
66
+    display: grid;
67
+    gap: 1.35rem;
68
+    align-items: start;
69
+  }
70
+  @media (min-width: 992px) {
71
+    .evento-hub {
72
+      grid-template-columns: auto minmax(0, 1fr);
73
+      gap: 2rem;
74
+      align-items: stretch;
75
+    }
76
+  }
77
+  .evento-hub__poster-wrap {
78
+    position: relative;
79
+    display: flex;
80
+    justify-content: center;
81
+  }
82
+  .evento-hub__poster {
83
+    border-radius: .9rem;
84
+    overflow: hidden;
85
+    background: linear-gradient(160deg, rgba(67, 89, 113, .07), rgba(67, 89, 113, .02));
86
+    aspect-ratio: 3 / 4;
87
+    box-shadow: 0 10px 28px rgba(38, 50, 64, .12);
88
+    width: 100%;
89
+    display: flex;
90
+    align-items: center;
91
+    justify-content: center;
92
+  }
93
+  .evento-hub__poster img {
94
+    display: block;
95
+    width: 100%;
96
+    height: auto;
97
+    max-width: 100%;
98
+  }
99
+  @media (min-width: 992px) {
100
+    .evento-hub__poster-wrap {
101
+      height: 100%;
102
+      align-self: stretch;
103
+    }
104
+    .evento-hub__poster {
105
+      height: 100%;
106
+      width: auto;
107
+      max-width: min(480px, 42vw);
108
+      aspect-ratio: auto;
109
+      background: transparent;
110
+      box-shadow: none;
111
+    }
112
+    .evento-hub__poster img {
113
+      width: auto;
114
+      height: 100%;
115
+      max-width: min(480px, 42vw);
116
+      max-height: 100%;
117
+      object-fit: contain;
118
+      border-radius: .9rem;
119
+      box-shadow: 0 10px 28px rgba(38, 50, 64, .12);
120
+    }
121
+  }
122
+  .evento-hub__poster-empty {
123
+    width: 100%;
124
+    height: 100%;
125
+    min-height: 240px;
126
+    min-width: 180px;
127
+    display: flex;
128
+    flex-direction: column;
129
+    align-items: center;
130
+    justify-content: center;
131
+    gap: .4rem;
132
+    color: #a1acb8;
133
+    border-radius: .9rem;
134
+    background: linear-gradient(160deg, rgba(67, 89, 113, .07), rgba(67, 89, 113, .02));
135
+    box-shadow: 0 10px 28px rgba(38, 50, 64, .12);
136
+  }
137
+  .evento-hub__title {
138
+    margin: 0 0 .55rem;
139
+    font-size: 1.45rem;
140
+    font-weight: 700;
141
+    letter-spacing: -.02em;
142
+    color: #2f3a46;
143
+    line-height: 1.25;
144
+  }
145
+  .evento-hub__meta {
146
+    display: flex;
147
+    flex-wrap: wrap;
148
+    gap: .4rem;
149
+    margin-bottom: .85rem;
150
+  }
151
+  .evento-hub__facts {
152
+    display: flex;
153
+    flex-direction: column;
154
+    gap: .4rem;
155
+    margin-bottom: 1.15rem;
156
+  }
157
+  .evento-hub__fact {
158
+    display: flex;
159
+    align-items: flex-start;
160
+    gap: .55rem;
161
+    color: #697a8d;
162
+    font-size: .9rem;
163
+    line-height: 1.4;
164
+  }
165
+  .evento-hub__fact i {
166
+    margin-top: .12rem;
167
+    color: #a1acb8;
168
+    font-size: 1.05rem;
169
+    flex-shrink: 0;
170
+  }
171
+  .evento-hub__kpi {
172
+    display: grid;
173
+    grid-template-columns: repeat(2, minmax(0, 1fr));
174
+    gap: .55rem;
175
+    margin-bottom: 1.15rem;
176
+  }
177
+  @media (min-width: 576px) {
178
+    .evento-hub__kpi {
179
+      grid-template-columns: repeat(4, minmax(0, 1fr));
180
+    }
181
+  }
182
+  .evento-hub__kpi-item {
183
+    background: rgba(67, 89, 113, .04);
184
+    border-radius: .7rem;
185
+    padding: .7rem .75rem;
186
+    min-width: 0;
187
+  }
188
+  .evento-hub__kpi-item strong {
189
+    display: block;
190
+    font-size: 1.3rem;
191
+    font-weight: 700;
192
+    color: #2f3a46;
193
+    line-height: 1.15;
194
+  }
195
+  .evento-hub__kpi-item strong span {
196
+    font-size: .8rem;
197
+    font-weight: 500;
198
+    color: #8592a3;
199
+  }
200
+  .evento-hub__kpi-item small {
201
+    display: block;
202
+    margin-top: .2rem;
203
+    font-size: .72rem;
204
+    letter-spacing: .02em;
205
+    text-transform: uppercase;
206
+    color: #8592a3;
207
+  }
208
+  .evento-hub-alert {
209
+    display: flex;
210
+    flex-wrap: wrap;
211
+    align-items: flex-start;
212
+    gap: .55rem .85rem;
213
+    margin: 0 0 1.15rem;
214
+    padding: .75rem .9rem;
215
+    border-radius: .7rem;
216
+    border: 1px solid transparent;
217
+    font-size: .88rem;
218
+    line-height: 1.4;
219
+  }
220
+  .evento-hub-alert.is-warning {
221
+    background: rgba(255, 171, 0, .1);
222
+    border-color: rgba(255, 171, 0, .35);
223
+    color: #7a5b00;
224
+  }
225
+  .evento-hub-alert.is-info {
226
+    background: rgba(3, 195, 236, .1);
227
+    border-color: rgba(3, 195, 236, .3);
228
+    color: #0c5c6e;
229
+  }
230
+  .evento-hub-alert__text {
231
+    flex: 1 1 12rem;
232
+    min-width: 0;
233
+  }
234
+  .evento-hub-alert__text strong {
235
+    font-weight: 700;
236
+  }
237
+  .evento-hub-alert__actions {
238
+    display: flex;
239
+    flex-wrap: wrap;
240
+    gap: .35rem;
241
+  }
242
+  .evento-hub-alert__actions .btn {
243
+    min-height: 34px;
244
+  }
245
+  .evento-hub-actions {
246
+    display: flex;
247
+    flex-wrap: wrap;
248
+    gap: .5rem;
249
+    margin-bottom: 1.35rem;
250
+  }
251
+  .evento-hub-actions .btn {
252
+    min-height: 42px;
253
+  }
254
+  .evento-hub__section {
255
+    margin: 0 0 .65rem;
256
+    font-size: .72rem;
257
+    font-weight: 700;
258
+    letter-spacing: .06em;
259
+    text-transform: uppercase;
260
+    color: #8592a3;
261
+  }
262
+  .evento-hub-links {
263
+    display: grid;
264
+    gap: .55rem;
265
+  }
266
+  @media (min-width: 768px) {
267
+    .evento-hub-links {
268
+      grid-template-columns: repeat(2, minmax(0, 1fr));
269
+    }
270
+  }
271
+  .evento-hub-link {
272
+    display: flex;
273
+    align-items: center;
274
+    gap: .8rem;
275
+    padding: .8rem .9rem;
276
+    border: 1px solid rgba(67, 89, 113, .1);
277
+    border-radius: .75rem;
278
+    text-decoration: none;
279
+    color: inherit;
280
+    background: #fff;
281
+    min-height: 66px;
282
+    transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
283
+  }
284
+  .evento-hub-link:hover {
285
+    border-color: rgba(var(--bs-primary-rgb), .4);
286
+    box-shadow: 0 .35rem 1rem rgba(67, 89, 113, .08);
287
+    transform: translateY(-1px);
288
+    color: inherit;
289
+  }
290
+  .evento-hub-link__icon {
291
+    display: inline-flex;
292
+    width: 2.35rem;
293
+    height: 2.35rem;
294
+    flex-shrink: 0;
295
+    align-items: center;
296
+    justify-content: center;
297
+    border-radius: .6rem;
298
+    background: rgba(var(--bs-primary-rgb), .1);
299
+    color: var(--bs-primary);
300
+    font-size: 1.15rem;
301
+  }
302
+  .evento-hub-link__body {
303
+    flex: 1;
304
+    min-width: 0;
305
+  }
306
+  .evento-hub-link strong {
307
+    display: block;
308
+    font-size: .93rem;
309
+    color: #2f3a46;
310
+  }
311
+  .evento-hub-link span {
312
+    display: block;
313
+    font-size: .78rem;
314
+    color: #8592a3;
315
+    margin-top: .12rem;
316
+    line-height: 1.35;
317
+  }
318
+  .evento-hub-link__chev {
319
+    color: #c5ccd6;
320
+    font-size: 1.1rem;
321
+    flex-shrink: 0;
322
+  }
323
+  .evento-hub-link:hover .evento-hub-link__chev {
324
+    color: var(--bs-primary);
325
+  }
326
+  @media (max-width: 991.98px) {
327
+    .evento-hub__poster-wrap {
328
+      max-width: 240px;
329
+      margin-inline: auto;
330
+    }
331
+  }
332
+</style>
333
+
334
+<a href="{{ route('evento.index') }}" class="btn btn-label-secondary btn-sm d-inline-flex d-md-none align-items-center mb-3" style="min-height: 42px;">
335
+  <i class="bx bx-arrow-back me-1"></i> Eventi
336
+</a>
337
+
338
+<div class="evento-hub-shell">
339
+  <div class="evento-hub">
340
+    <div class="evento-hub__poster-wrap">
341
+      <div class="evento-hub__poster">
342
+        @if($locandinaUrl)
343
+          <img src="{{ $locandinaUrl }}" alt="Locandina {{ $evento->nome }}">
344
+        @else
345
+          <div class="evento-hub__poster-empty">
346
+            <i class="bx bx-image bx-lg"></i>
347
+            <span class="small">Nessuna locandina</span>
348
+          </div>
349
+        @endif
350
+      </div>
351
+    </div>
352
+
353
+    <div class="evento-hub__content">
354
+      <h1 class="evento-hub__title">{{ $evento->nome }}</h1>
355
+
356
+      <div class="evento-hub__meta">
357
+        @if($statoMeta)
358
+          <span class="badge bg-label-{{ $statoMeta['color'] }}">{{ $statoMeta['nome'] }}</span>
359
+        @endif
360
+        @if($evento->is_attivo)
361
+          <span class="badge bg-label-success">Pubblicato</span>
362
+        @else
363
+          <span class="badge bg-label-secondary">Bozza</span>
364
+        @endif
365
+        @if($stats['prenotabile'])
366
+          <span class="badge bg-label-primary">Prenotazioni aperte</span>
367
+        @elseif($stats['messaggio_prenotazione'])
368
+          <span class="badge bg-label-warning">{{ $stats['messaggio_prenotazione'] }}</span>
369
+        @endif
370
+      </div>
371
+
372
+      <div class="evento-hub__facts">
373
+        @if($evento->attivita?->nome)
374
+          <div class="evento-hub__fact">
375
+            <i class="bx bx-store"></i>
376
+            <span>{{ $evento->attivita->nome }}</span>
377
+          </div>
378
+        @endif
379
+        <div class="evento-hub__fact">
380
+          <i class="bx bx-calendar"></i>
381
+          <span>{{ $dataLabel }}</span>
382
+        </div>
383
+        @if($evento->luogoBreve())
384
+          <div class="evento-hub__fact">
385
+            <i class="bx bx-map"></i>
386
+            <span>{{ $evento->luogoBreve() }}</span>
387
+          </div>
388
+        @endif
389
+      </div>
390
+
391
+      <div class="evento-hub__kpi" aria-label="Riepilogo prenotazioni">
392
+        <div class="evento-hub__kpi-item">
393
+          <strong>{{ $stats['prenotazioni'] }}</strong>
394
+          <small>Prenotazioni</small>
395
+        </div>
396
+        <div class="evento-hub__kpi-item">
397
+          <strong>{{ $stats['confermate'] }}</strong>
398
+          <small>Confermate</small>
399
+        </div>
400
+        <div class="evento-hub__kpi-item">
401
+          <strong>
402
+            {{ $stats['coperti_confermati'] }}@if($stats['posti'] !== null)<span>/{{ $stats['posti'] }}</span>@endif
403
+          </strong>
404
+          <small>Coperti</small>
405
+        </div>
406
+        <div class="evento-hub__kpi-item">
407
+          <strong>{{ $stats['tavoli'] }}</strong>
408
+          <small>Tavoli</small>
409
+        </div>
410
+      </div>
411
+
412
+      @if(!empty($alertPosti))
413
+        <div class="evento-hub-alert is-{{ $alertPosti['tipo'] }}" role="status">
414
+          <div class="evento-hub-alert__text">
415
+            @if($alertPosti['tipo'] === 'warning')
416
+              <strong>Posti non allineati.</strong>
417
+              La piantina ha {{ $alertPosti['posti_piantina'] }} posti, l’evento ne prevede {{ $alertPosti['posti_evento'] }}.
418
+              Rischi di accettare più prenotazioni di quante sedie hai in sala.
419
+            @else
420
+              <strong>Posti non allineati.</strong>
421
+              La piantina ha {{ $alertPosti['posti_piantina'] }} posti, l’evento ne prevede {{ $alertPosti['posti_evento'] }}.
422
+              Il limite prenotabile resta quello dell’evento.
423
+            @endif
424
+          </div>
425
+          <div class="evento-hub-alert__actions">
426
+            @if(Auth::user()->can('edit-evento'))
427
+              <a href="{{ route('evento.piantina.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-label-secondary">Piantina</a>
428
+              <a href="{{ route('evento.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-label-secondary">Evento</a>
429
+            @endif
430
+          </div>
431
+        </div>
432
+      @endif
433
+
434
+      <div class="evento-hub-actions">
435
+        @if(Auth::user()->can('edit-evento'))
436
+          <a href="{{ route('evento.edit', ['evento_id' => $evento->id]) }}" class="btn btn-primary">
437
+            <i class="bx bx-edit-alt me-1"></i> Modifica
438
+          </a>
439
+        @endif
440
+        @if(Auth::user()->can('view-evento'))
441
+          <a href="{{ $evento->publicUrl() }}" class="btn btn-label-secondary" target="_blank" rel="noopener">
442
+            <i class="bx bx-link-external me-1"></i> Vedi pubblico
443
+          </a>
444
+        @endif
445
+      </div>
446
+
447
+      <div class="evento-hub__section">Strumenti</div>
448
+      <div class="evento-hub-links">
449
+        @if(Auth::user()->can('view-prenotazione'))
450
+          <a href="{{ route('prenotazione.index.evento', ['evento_id' => $evento->id]) }}" class="evento-hub-link">
451
+            <span class="evento-hub-link__icon"><i class="bx bx-calendar-check"></i></span>
452
+            <span class="evento-hub-link__body">
453
+              <strong>Prenotazioni</strong>
454
+              <span>Ospiti, conferma e richieste</span>
455
+            </span>
456
+            <i class="bx bx-chevron-right evento-hub-link__chev"></i>
457
+          </a>
458
+        @endif
459
+
460
+        @if(Auth::user()->can('edit-evento'))
461
+          <a href="{{ route('evento.piantina.edit', ['evento_id' => $evento->id]) }}" class="evento-hub-link">
462
+            <span class="evento-hub-link__icon"><i class="bx bx-map-alt"></i></span>
463
+            <span class="evento-hub-link__body">
464
+              <strong>Piantina</strong>
465
+              <span>Sala e tavoli</span>
466
+            </span>
467
+            <i class="bx bx-chevron-right evento-hub-link__chev"></i>
468
+          </a>
469
+
470
+          <a href="{{ route('evento.piantina.assegnazioni', ['evento_id' => $evento->id]) }}" class="evento-hub-link">
471
+            <span class="evento-hub-link__icon"><i class="bx bx-chair"></i></span>
472
+            <span class="evento-hub-link__body">
473
+              <strong>Assegnazioni</strong>
474
+              <span>Ospiti a tavola</span>
475
+            </span>
476
+            <i class="bx bx-chevron-right evento-hub-link__chev"></i>
477
+          </a>
478
+        @endif
479
+
480
+        @if(Auth::user()->can('view-prenotazione'))
481
+          <a href="{{ route('prenotazione.report.index', ['evento_id' => $evento->id]) }}" class="evento-hub-link">
482
+            <span class="evento-hub-link__icon"><i class="bx bx-printer"></i></span>
483
+            <span class="evento-hub-link__body">
484
+              <strong>Report / stampa</strong>
485
+              <span>Liste, piantina, segnaposto</span>
486
+            </span>
487
+            <i class="bx bx-chevron-right evento-hub-link__chev"></i>
488
+          </a>
489
+        @endif
490
+
491
+        @if(Auth::user()->can('edit-evento'))
492
+          <a href="{{ route('evento.gallery.edit', ['evento_id' => $evento->id]) }}" class="evento-hub-link">
493
+            <span class="evento-hub-link__icon"><i class="bx bx-image-alt"></i></span>
494
+            <span class="evento-hub-link__body">
495
+              <strong>Galleria</strong>
496
+              <span>
497
+                Foto evento
498
+                @if($stats['galleria'] > 0)
499
+                  ({{ $stats['galleria'] }})
500
+                @endif
501
+              </span>
502
+            </span>
503
+            <i class="bx bx-chevron-right evento-hub-link__chev"></i>
504
+          </a>
505
+        @endif
506
+      </div>
507
+    </div>
508
+  </div>
509
+</div>
510
+@endsection
511
+
512
+@section('page-script')
513
+<script>
514
+  document.addEventListener('DOMContentLoaded', function () {
515
+    var hub = document.querySelector('.evento-hub');
516
+    var poster = hub && hub.querySelector('.evento-hub__poster');
517
+    var img = poster && poster.querySelector('img');
518
+    var content = hub && hub.querySelector('.evento-hub__content');
519
+    if (!hub || !poster || !content) return;
520
+
521
+    function resetPoster() {
522
+      poster.style.width = '';
523
+      poster.style.height = '';
524
+      if (img) {
525
+        img.style.height = '';
526
+        img.style.width = '';
527
+        img.style.maxWidth = '';
528
+      }
529
+    }
530
+
531
+    function syncPoster() {
532
+      if (!window.matchMedia('(min-width: 992px)').matches) {
533
+        resetPoster();
534
+        return;
535
+      }
536
+      if (!img || !img.naturalWidth) return;
537
+
538
+      var h = content.offsetHeight;
539
+      if (h < 120) return;
540
+
541
+      var maxW = Math.min(520, Math.floor(hub.clientWidth * 0.46));
542
+      var ratio = img.naturalWidth / img.naturalHeight;
543
+      var w = Math.round(h * ratio);
544
+
545
+      if (w > maxW) {
546
+        w = maxW;
547
+        h = Math.round(w / ratio);
548
+      }
549
+
550
+      poster.style.height = content.offsetHeight + 'px';
551
+      poster.style.width = w + 'px';
552
+      img.style.height = h + 'px';
553
+      img.style.width = 'auto';
554
+      img.style.maxWidth = 'none';
555
+    }
556
+
557
+    function run() {
558
+      syncPoster();
559
+    }
560
+
561
+    if (img) {
562
+      if (img.complete) run();
563
+      else img.addEventListener('load', run);
564
+    }
565
+
566
+    window.addEventListener('resize', run);
567
+    if (window.ResizeObserver) {
568
+      new ResizeObserver(run).observe(content);
569
+    }
570
+  });
571
+</script>
572
+@endsection

+ 1097
- 213
resources/views/evento/edit.blade.php
文件差异内容过多而无法显示
查看文件


+ 25
- 3
resources/views/evento/index.blade.php 查看文件

@@ -36,9 +36,9 @@ $configData = Helper::appClasses();
36 36
 @endsection
37 37
 
38 38
 @section('pageTitle')
39
-<div class="d-flex flex-column">
40
-  <h4 class="mb-1 text-sm-small mt-md-4 mt-lg-4"> 
41
-    <i class="bx bx-calendar-check d-none d-md-inline-flex d-lg-inline-flex"></i> Eventi</h4>
39
+<div class="d-flex flex-column ">
40
+  <h4 class="mb-1 text-sm-small mt-md-4 mt-lg-4 "> 
41
+    <i class="bx bx-sun d-none d-md-inline-flex d-lg-inline-flex"></i> Eventi</h4>
42 42
     <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block d-lg-block">
43 43
             <ol class="breadcrumb breadcrumb-custom-icon">
44 44
       
@@ -123,5 +123,27 @@ $configData = Helper::appClasses();
123 123
   }
124 124
 
125 125
 
126
+  function nuovoEvento(){
127
+  Swal.fire({
128
+    title: 'Nuovo Evento',
129
+    input: 'text',
130
+    inputLabel: 'Inserisci il nome dell\'evento',
131
+    inputPlaceholder: 'Nome evento',
132
+    showCancelButton: true,
133
+    confirmButtonText: 'Crea',
134
+    cancelButtonText: 'Annulla',
135
+    inputValidator: (value) => {
136
+      if (!value) {
137
+        return 'Il nome è obbligatorio!';
138
+      }
139
+    }
140
+  }).then((result) => {
141
+    if (result.isConfirmed && result.value) {
142
+     window.location.href = "{{ route('evento.crea')}}?nome="+result.value;
143
+    }
144
+  });
145
+
146
+  }
147
+
126 148
 </script>
127 149
 @endsection

+ 6
- 37
resources/views/evento/menu.blade.php 查看文件

@@ -1,6 +1,5 @@
1 1
 <?php
2 2
 use Illuminate\Support\Facades\Auth;
3
-use Carbon\Carbon;
4 3
 ?>
5 4
 
6 5
 <div class="dropdown">
@@ -9,47 +8,17 @@ use Carbon\Carbon;
9 8
     </button>
10 9
     <div class="dropdown-menu">
11 10
         @if(Auth::user()->can('view-evento'))
12
-        <a href="{{ route('evento.show', ['evento_id' => $entity->id]) }}" class="dropdown-item">
13
-            <i class="bx bx-show me-1"></i> Vedi evento
14
-        @endif
15
-
16
-        @if(Auth::user()->can('view-prenotazione'))
17
-        <a href="{{ route('prenotazione.index.evento', ['evento_id' => $entity->id]) }}" class="dropdown-item">
18
-            <i class="bx bx-calendar-check me-1"></i> Vedi prenotazioni
19
-        </a>
20
-        @endif
21
-
22
-        <!-- @if(Auth::user()->can('edit-evento'))
23
-        <a href="#" class="dropdown-item editor_edit">
24
-            <i class="bx bx-edit-alt me-1"></i> Modifica
11
+        <a href="{{ route('evento.admin.show', ['evento_id' => $entity->id]) }}" class="dropdown-item text-black">
12
+            <i class="bx bx-grid-alt me-1"></i> Apri evento
25 13
         </a>
26
-        @endif -->
27
-
28
-        @if(Auth::user()->can('edit-evento'))
29
-        <a href="{{ route('evento.edit', ['evento_id' => $entity->id]) }}" class="dropdown-item">
30
-            <i class="bx bx-edit-alt me-1"></i> Modifica
14
+        <a href="{{ $entity->publicUrl() }}" class="dropdown-item text-black" target="_blank" rel="noopener">
15
+            <i class="bx bx-link-external me-1"></i> Vedi pubblico
31 16
         </a>
32 17
         @endif
33 18
 
34
-        @if(Auth::user()->can('edit-evento'))
35
-        <a href="{{ route('evento.gallery.edit', ['evento_id' => $entity->id]) }}" class="dropdown-item">
36
-            <i class="bx bx-image-alt me-1"></i> Galleria
37
-        </a>
38
-        @endif
39
-
40
-        <!-- @if(Auth::user()->can('edit-prenotazione') )
41
-        <a href="#" class="dropdown-item editor_edit">
42
-            <i class="bx bx-calendar-check me-1"></i> Apri prenotazioni
43
-        </a> 
44
-        @endif -->
45
-    
46
-        <!-- @if(Auth::user()->can('edit-prenotazione') && Carbon::parse($entity->apertura_prenotazione)->isPast())
47
-        <a href="#" class="dropdown-item editor_edit">
48
-            <i class="bx bx-calendar-check me-1"></i> Chiudi prenotazioni
49
-        </a>
50
-        @endif -->
51 19
         @if(Auth::user()->can('delete-evento'))
52
-        <a href="#" class="dropdown-item editor_delete">
20
+        <div class="dropdown-divider"></div>
21
+        <a href="#" class="dropdown-item editor_delete text-danger">
53 22
             <i class="bx bx-trash me-1"></i> Elimina
54 23
         </a>
55 24
         @endif

+ 35
- 0
resources/views/evento/modal_nuovo.blade.php 查看文件

@@ -0,0 +1,35 @@
1
+<!-- Modal Nuovo Evento -->
2
+<div class="modal fade" id="modalNuovoEvento" tabindex="-1" aria-labelledby="modalNuovoEventoLabel" aria-hidden="true">
3
+  <div class="modal-dialog">
4
+    <div class="modal-content">
5
+      <form action="{{ route('evento.store') }}" method="POST">
6
+        @csrf
7
+        <div class="modal-header">
8
+          <h5 class="modal-title" id="modalNuovoEventoLabel">Nuovo Evento</h5>
9
+          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
10
+        </div>
11
+        <div class="modal-body">
12
+          <div class="mb-3">
13
+            <label for="nome_evento" class="form-label">Nome evento</label>
14
+            <input
15
+              type="text"
16
+              class="form-control @error('nome_evento') is-invalid @enderror"
17
+              id="nome_evento"
18
+              name="nome_evento"
19
+              required
20
+              autofocus
21
+              value="{{ old('nome_evento') }}"
22
+            >
23
+            @error('nome_evento')
24
+              <div class="invalid-feedback">{{ $message }}</div>
25
+            @enderror
26
+          </div>
27
+        </div>
28
+        <div class="modal-footer">
29
+          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
30
+          <button type="submit" class="btn btn-primary">Crea evento</button>
31
+        </div>
32
+      </form>
33
+    </div>
34
+  </div>
35
+</div>

+ 65
- 0
resources/views/evento/piantina/_partials/sale-tabs.blade.php 查看文件

@@ -0,0 +1,65 @@
1
+@php
2
+  $sale = isset($sale) ? collect($sale) : collect([$sala]);
3
+  $currentSalaId = (int) $sala->id;
4
+  $tabsRoute = $tabsRoute ?? 'evento.piantina.edit';
5
+  $canEditSale = auth()->check() && auth()->user()->can('edit-evento');
6
+  $showManage = $showManage ?? true;
7
+@endphp
8
+
9
+<div class="piantina-sale-tabs d-flex flex-wrap align-items-center gap-1">
10
+  <div class="btn-group btn-group-sm flex-wrap" role="group" aria-label="Sale">
11
+    @foreach($sale as $s)
12
+      <a
13
+        href="{{ route($tabsRoute, ['evento_id' => $evento->id, 'sala_id' => $s->id]) }}"
14
+        class="btn {{ (int) $s->id === $currentSalaId ? 'btn-primary' : 'btn-outline-primary' }}"
15
+      >
16
+        {{ filled($s->nome) ? $s->nome : ('Sala #'.$s->id) }}
17
+      </a>
18
+    @endforeach
19
+  </div>
20
+
21
+  @if($canEditSale && $showManage)
22
+    <div class="dropdown">
23
+      <button
24
+        type="button"
25
+        class="btn btn-sm btn-label-primary"
26
+        data-bs-toggle="dropdown"
27
+        aria-expanded="false"
28
+        title="Aggiungi sala"
29
+      >
30
+        <i class="bx bx-plus"></i>
31
+      </button>
32
+      <div class="dropdown-menu dropdown-menu-end p-2" style="min-width: 12rem;">
33
+        <form method="POST" action="{{ route('evento.piantina.sala.store', ['evento_id' => $evento->id]) }}">
34
+          @csrf
35
+          <label class="form-label small mb-1" for="nuova-sala-nome">Nome sala</label>
36
+          <input
37
+            type="text"
38
+            id="nuova-sala-nome"
39
+            name="nome"
40
+            class="form-control form-control-sm mb-2"
41
+            placeholder="es. Esterno"
42
+            maxlength="255"
43
+            required
44
+          >
45
+          <button type="submit" class="btn btn-sm btn-primary w-100">Aggiungi</button>
46
+        </form>
47
+      </div>
48
+    </div>
49
+
50
+    @if($sale->count() > 1 && $tabsRoute === 'evento.piantina.edit')
51
+      <form
52
+        method="POST"
53
+        action="{{ route('evento.piantina.sala.destroy', ['evento_id' => $evento->id, 'sala_id' => $sala->id]) }}"
54
+        class="d-inline"
55
+        onsubmit="return confirm('Eliminare la sala «{{ filled($sala->nome) ? $sala->nome : ('#'.$sala->id) }}» e tutti i suoi tavoli?');"
56
+      >
57
+        @csrf
58
+        @method('DELETE')
59
+        <button type="submit" class="btn btn-sm btn-icon btn-label-danger" title="Elimina sala corrente">
60
+          <i class="bx bx-trash"></i>
61
+        </button>
62
+      </form>
63
+    @endif
64
+  @endif
65
+</div>

+ 873
- 0
resources/views/evento/piantina/assegnazioni.blade.php 查看文件

@@ -0,0 +1,873 @@
1
+@php
2
+  $configData = Helper::appClasses();
3
+@endphp
4
+
5
+@extends('layouts/layoutMaster')
6
+
7
+@section('title', 'Assegnazioni · '.$evento->nome)
8
+
9
+@section('pageTitle')
10
+<div class="d-flex flex-column">
11
+  <h4 class="mb-0">
12
+    <i class="bx bx-chair d-none d-md-inline-flex"></i>
13
+    Assegnazioni · {{ $evento->nome }}
14
+  </h4>
15
+  <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block">
16
+    <ol class="breadcrumb breadcrumb-custom-icon mb-0">
17
+      <li class="breadcrumb-item">
18
+        <a href="{{ route('evento.index') }}">Eventi</a>
19
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
20
+      </li>
21
+      <li class="breadcrumb-item">
22
+        <a href="{{ route('evento.admin.show', ['evento_id' => $evento->id]) }}">{{ $evento->nome }}</a>
23
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
24
+      </li>
25
+      <li class="breadcrumb-item active text-primary">Assegnazioni</li>
26
+    </ol>
27
+  </nav>
28
+</div>
29
+@endsection
30
+
31
+@section('content')
32
+@include('_partials.status')
33
+
34
+<style>
35
+  .asseg-map {
36
+    position: relative;
37
+    width: 100%;
38
+    aspect-ratio: {{ ($sala->larghezza && $sala->altezza) ? ($sala->larghezza.'/'.$sala->altezza) : '4 / 3' }};
39
+    background: #eef1f6;
40
+    border: 1px solid #d9dee3;
41
+    border-radius: .75rem;
42
+    overflow: hidden;
43
+    user-select: none;
44
+  }
45
+  .asseg-map__img {
46
+    position: absolute;
47
+    inset: 0;
48
+    width: 100%;
49
+    height: 100%;
50
+    object-fit: fill;
51
+    pointer-events: none;
52
+  }
53
+  .asseg-map__empty {
54
+    position: absolute;
55
+    inset: 0;
56
+    display: flex;
57
+    align-items: center;
58
+    justify-content: center;
59
+    color: #8592a3;
60
+    padding: 1rem;
61
+    text-align: center;
62
+  }
63
+  .asseg-tavolo {
64
+    position: absolute;
65
+    display: flex;
66
+    flex-direction: column;
67
+    align-items: center;
68
+    justify-content: center;
69
+    gap: .05rem;
70
+    min-width: 0;
71
+    min-height: 0;
72
+    padding: .1rem .15rem;
73
+    border: 2px solid #15265c;
74
+    color: #fff;
75
+    font-weight: 800;
76
+    font-size: clamp(.55rem, 1.35vw, .78rem);
77
+    line-height: 1.05;
78
+    cursor: pointer;
79
+    box-shadow: 0 2px 8px rgba(21, 38, 92, 0.14);
80
+    z-index: 2;
81
+    overflow: hidden;
82
+    text-align: center;
83
+    transition: transform .12s ease, box-shadow .12s ease;
84
+  }
85
+  .asseg-tavolo.is-rettangolo { border-radius: .4rem; }
86
+  .asseg-tavolo.is-cerchio { border-radius: 999px; }
87
+  .asseg-tavolo.is-libero { background: rgba(25, 135, 84, 0.9); border-color: #146c43; }
88
+  .asseg-tavolo.is-parziale { background: rgba(245, 130, 32, 0.92); }
89
+  .asseg-tavolo.is-pieno { background: rgba(220, 53, 69, 0.9); border-color: #b02a37; }
90
+  .asseg-tavolo.is-selected {
91
+    outline: 2px solid #15265c;
92
+    outline-offset: 2px;
93
+    z-index: 5;
94
+    transform: scale(1.03);
95
+  }
96
+  .asseg-tavolo.is-drop-ok {
97
+    box-shadow: 0 0 0 3px rgba(25, 135, 84, 0.55);
98
+    z-index: 6;
99
+  }
100
+  .asseg-tavolo.is-drop-no {
101
+    box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.45);
102
+  }
103
+  .asseg-tavolo__meta {
104
+    font-size: clamp(.45rem, 1vw, .62rem);
105
+    font-weight: 600;
106
+    opacity: .95;
107
+  }
108
+  .asseg-grid-overlay {
109
+    position: absolute;
110
+    inset: 0;
111
+    pointer-events: none;
112
+    z-index: 1;
113
+  }
114
+  .asseg-grid-overlay .cell {
115
+    position: absolute;
116
+    border: 1px solid rgba(21, 38, 92, 0.12);
117
+    box-sizing: border-box;
118
+  }
119
+  .asseg-hint { font-size: .8rem; color: #697a8d; }
120
+  .asseg-status { font-size: .78rem; font-weight: 600; }
121
+  .asseg-status.is-ok { color: #198754; }
122
+  .asseg-status.is-err { color: #dc3545; }
123
+  .asseg-status.is-busy { color: #8592a3; }
124
+  .asseg-prenota-list {
125
+    max-height: min(70vh, 640px);
126
+    overflow: auto;
127
+  }
128
+  .asseg-prenota {
129
+    border: 1px solid #d9dee3;
130
+    border-radius: .65rem;
131
+    padding: .7rem .85rem;
132
+    margin-bottom: .6rem;
133
+    background: #fff;
134
+    cursor: grab;
135
+    transition: border-color .12s ease, box-shadow .12s ease;
136
+  }
137
+  .asseg-prenota:active { cursor: grabbing; }
138
+  .asseg-prenota.is-selected {
139
+    border-color: #15265c;
140
+    box-shadow: 0 0 0 2px rgba(21, 38, 92, 0.18);
141
+  }
142
+  .asseg-prenota.is-done { opacity: .72; }
143
+  .asseg-prenota.is-dragging { opacity: .45; }
144
+  .asseg-chip {
145
+    display: inline-flex;
146
+    align-items: center;
147
+    gap: .4rem;
148
+    background: #f2f4f8;
149
+    border: 1px solid rgba(67, 89, 113, .12);
150
+    border-radius: .5rem;
151
+    padding: .25rem .3rem .25rem .55rem;
152
+    font-size: .78rem;
153
+    margin: .2rem .2rem 0 0;
154
+    max-width: 100%;
155
+  }
156
+  .asseg-chip__label {
157
+    font-weight: 600;
158
+    color: #384551;
159
+    white-space: nowrap;
160
+  }
161
+  .asseg-chip__btn {
162
+    display: inline-flex;
163
+    align-items: center;
164
+    justify-content: center;
165
+    gap: .2rem;
166
+    border: 0;
167
+    border-radius: .35rem;
168
+    background: rgba(220, 53, 69, .1);
169
+    color: #dc3545;
170
+    font-size: .72rem;
171
+    font-weight: 700;
172
+    line-height: 1;
173
+    padding: .35rem .5rem;
174
+    min-height: 1.85rem;
175
+    min-width: 2.4rem;
176
+    cursor: pointer;
177
+    white-space: nowrap;
178
+  }
179
+  .asseg-chip__btn:hover,
180
+  .asseg-chip__btn:focus-visible {
181
+    background: #dc3545;
182
+    color: #fff;
183
+  }
184
+  .asseg-chip__btn i {
185
+    font-size: .95rem;
186
+    line-height: 1;
187
+  }
188
+  .asseg-topbar {
189
+    display: flex;
190
+    flex-wrap: wrap;
191
+    align-items: center;
192
+    gap: .5rem .75rem;
193
+    margin-bottom: .85rem;
194
+    padding: .55rem .75rem;
195
+    border: 1px solid rgba(67, 89, 113, .1);
196
+    border-radius: .75rem;
197
+    background: #fff;
198
+  }
199
+  .asseg-topbar .btn { min-height: 36px; }
200
+  .asseg-topbar__sale {
201
+    display: flex;
202
+    flex-wrap: wrap;
203
+    align-items: center;
204
+    gap: .4rem;
205
+    min-width: 0;
206
+    flex: 1 1 auto;
207
+  }
208
+  .asseg-topbar__right {
209
+    display: flex;
210
+    flex-wrap: wrap;
211
+    align-items: center;
212
+    gap: .45rem;
213
+    margin-left: auto;
214
+  }
215
+  .asseg-sticky {
216
+    position: sticky;
217
+    top: 4.5rem;
218
+    z-index: 20;
219
+    display: none;
220
+    background: #fff;
221
+    border: 1px solid rgba(67, 89, 113, .14);
222
+    border-radius: .7rem;
223
+    padding: .55rem .85rem;
224
+    margin-bottom: .85rem;
225
+    box-shadow: 0 6px 18px rgba(21, 38, 92, 0.06);
226
+  }
227
+  .asseg-sticky.is-ready {
228
+    display: block;
229
+    border-color: rgba(var(--bs-primary-rgb), .45);
230
+    background: rgba(var(--bs-primary-rgb), .04);
231
+  }
232
+  .asseg-sticky__label {
233
+    font-size: .7rem;
234
+    font-weight: 700;
235
+    letter-spacing: .04em;
236
+    text-transform: uppercase;
237
+    color: #8592a3;
238
+    margin-bottom: .15rem;
239
+  }
240
+  .asseg-sticky__body {
241
+    display: flex;
242
+    flex-wrap: wrap;
243
+    align-items: center;
244
+    gap: .5rem .75rem;
245
+  }
246
+  .asseg-sticky__name {
247
+    font-weight: 800;
248
+    color: #384551;
249
+  }
250
+  .asseg-prenota-cta {
251
+    margin-top: .45rem;
252
+    font-size: .78rem;
253
+    font-weight: 700;
254
+    color: #566a7f;
255
+  }
256
+  .asseg-prenota.is-selected .asseg-prenota-cta { color: var(--bs-primary); }
257
+  .asseg-layout {
258
+    display: grid;
259
+    gap: 1rem;
260
+    margin-bottom: 1rem;
261
+  }
262
+  .asseg-layout__map { grid-area: map; }
263
+  .asseg-layout__ospiti { grid-area: ospiti; }
264
+  .asseg-layout {
265
+    grid-template-areas:
266
+      "ospiti"
267
+      "map";
268
+  }
269
+  @media (min-width: 1200px) {
270
+    .asseg-layout {
271
+      grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
272
+      grid-template-areas: "map ospiti";
273
+      align-items: start;
274
+      gap: 1.15rem;
275
+    }
276
+    .asseg-prenota { cursor: grab; }
277
+  }
278
+  .asseg-panel {
279
+    border: 1px solid rgba(67, 89, 113, .1);
280
+    border-radius: .85rem;
281
+    background: #fff;
282
+    overflow: hidden;
283
+  }
284
+  .asseg-panel__head {
285
+    display: flex;
286
+    flex-wrap: wrap;
287
+    justify-content: space-between;
288
+    align-items: center;
289
+    gap: .5rem;
290
+    padding: .85rem 1rem .4rem;
291
+  }
292
+  .asseg-panel__title {
293
+    margin: 0;
294
+    font-size: 1rem;
295
+    font-weight: 600;
296
+    color: #384551;
297
+  }
298
+  .asseg-panel__body {
299
+    padding: .55rem 1rem 1rem;
300
+  }
301
+  .asseg-legend {
302
+    display: flex;
303
+    flex-wrap: wrap;
304
+    gap: .35rem .75rem;
305
+  }
306
+  .asseg-legend span {
307
+    display: inline-flex;
308
+    align-items: center;
309
+    gap: .35rem;
310
+    margin-right: 0;
311
+    font-size: .75rem;
312
+    color: #8592a3;
313
+  }
314
+  .asseg-legend i {
315
+    width: .65rem;
316
+    height: .65rem;
317
+    border-radius: 2px;
318
+    display: inline-block;
319
+  }
320
+  .asseg-detail {
321
+    border: 1px solid rgba(67, 89, 113, .1);
322
+    border-radius: .65rem;
323
+    padding: .7rem .8rem;
324
+    min-height: 3.5rem;
325
+    background: rgba(67, 89, 113, .03);
326
+  }
327
+  .asseg-ospiti-tools {
328
+    padding: 0 1rem .55rem;
329
+  }
330
+  .asseg-ospiti-actions {
331
+    display: flex;
332
+    flex-wrap: wrap;
333
+    gap: .4rem;
334
+    padding: .85rem 1rem .35rem;
335
+  }
336
+  .asseg-ospiti-actions .btn {
337
+    min-height: 38px;
338
+  }
339
+  .asseg-layout__ospiti .asseg-panel__head {
340
+    padding-top: .35rem;
341
+  }
342
+  @media (max-width: 1199.98px) {
343
+    .asseg-map {
344
+      max-height: 52vh;
345
+      min-height: 220px;
346
+    }
347
+    .asseg-prenota-list {
348
+      max-height: min(48vh, 420px);
349
+    }
350
+    .asseg-prenota {
351
+      padding: .9rem 1rem;
352
+      min-height: 72px;
353
+    }
354
+    .asseg-filter .btn,
355
+    .asseg-topbar .btn,
356
+    #asseg-search {
357
+      min-height: 42px;
358
+    }
359
+    .asseg-chip__btn {
360
+      min-height: 2.25rem;
361
+      min-width: 3rem;
362
+      padding: .4rem .65rem;
363
+      font-size: .78rem;
364
+    }
365
+    .asseg-sticky { top: 4.25rem; }
366
+  }
367
+</style>
368
+@php $backUrl = route('evento.admin.show', ['evento_id' => $evento->id]); @endphp
369
+
370
+<div class="asseg-topbar">
371
+  <a href="{{ $backUrl }}" class="btn btn-label-secondary btn-sm">
372
+    <i class="bx bx-arrow-back me-1"></i> Cruscotto
373
+  </a>
374
+  <a href="{{ route('evento.piantina.edit', ['evento_id' => $evento->id, 'sala_id' => $sala->id]) }}" class="btn btn-label-secondary btn-sm">
375
+    <i class="bx bx-map-alt me-1"></i> Piantina
376
+  </a>
377
+  <div class="asseg-topbar__sale">
378
+    @include('evento.piantina._partials.sale-tabs', [
379
+      'evento' => $evento,
380
+      'sala' => $sala,
381
+      'sale' => $sale ?? collect([$sala]),
382
+      'tabsRoute' => 'evento.piantina.assegnazioni',
383
+      'showManage' => false,
384
+    ])
385
+  </div>
386
+  <div class="asseg-topbar__right">
387
+    <span class="asseg-status" id="asseg-status"></span>
388
+  </div>
389
+</div>
390
+
391
+<div class="asseg-sticky" id="asseg-sticky">
392
+  <div class="asseg-sticky__label">Ospite scelto</div>
393
+  <div class="asseg-sticky__body">
394
+    <div class="asseg-hint mb-0" id="asseg-sel-hint">Nessun ospite scelto</div>
395
+    <button type="button" class="btn btn-sm btn-label-secondary d-none" id="asseg-clear-sel">Annulla</button>
396
+  </div>
397
+</div>
398
+
399
+<div class="asseg-layout">
400
+  <div class="asseg-layout__map">
401
+    <div class="asseg-panel">
402
+      <div class="asseg-panel__head">
403
+        <h5 class="asseg-panel__title">Piantina</h5>
404
+        <div class="asseg-legend">
405
+          <span><i style="background:#198754"></i> Liberi</span>
406
+          <span><i style="background:#f58220"></i> Parziale</span>
407
+          <span><i style="background:#dc3545"></i> Pieno</span>
408
+        </div>
409
+      </div>
410
+      <div class="asseg-panel__body">
411
+        <div class="asseg-map" id="asseg-map" @unless($piantinaUrl) data-empty="1" @endunless>
412
+          @if($piantinaUrl)
413
+            <img class="asseg-map__img" src="{{ $piantinaUrl }}" alt="Piantina" draggable="false">
414
+          @else
415
+            <div class="asseg-map__empty">Manca la foto della sala: aprila da «Piantina».</div>
416
+          @endif
417
+          <div class="asseg-grid-overlay" id="asseg-grid-overlay" hidden></div>
418
+        </div>
419
+
420
+        <div class="asseg-detail mt-3" id="asseg-tavolo-detail">
421
+          <div class="text-muted small">Tocca un tavolo per vedere chi è seduto.</div>
422
+        </div>
423
+      </div>
424
+    </div>
425
+  </div>
426
+
427
+  <div class="asseg-layout__ospiti">
428
+    <div class="asseg-panel">
429
+      <div class="asseg-ospiti-actions">
430
+        <form
431
+          method="POST"
432
+          action="{{ route('evento.piantina.assegna.automatico', ['evento_id' => $evento->id]) }}"
433
+          class="d-inline"
434
+          onsubmit="return confirm('Assegnare in automatico le prenotazioni ancora da sedere su questa sala?\n\n• Prima i gruppi più grandi sul tavolo giusto\n• Poi riempie i buchi (tutti i posti insieme)\n• Se serve, spezza sulla stessa colonna\n\nLe assegnazioni già fatte restano.');"
435
+        >
436
+          @csrf
437
+          <input type="hidden" name="sala_id" value="{{ $sala->id }}">
438
+          <button type="submit" class="btn btn-primary btn-sm">
439
+            <i class="bx bx-bot me-1"></i> Automatico
440
+          </button>
441
+        </form>
442
+        <a href="{{ route('prenotazione.report.index', ['evento_id' => $evento->id]) }}" class="btn btn-label-primary btn-sm">
443
+          <i class="bx bx-printer me-1"></i> Report
444
+        </a>
445
+      </div>
446
+      <div class="asseg-panel__head">
447
+        <h5 class="asseg-panel__title">Ospiti</h5>
448
+      </div>
449
+      <div class="asseg-ospiti-tools">
450
+        <div class="btn-group btn-group-sm w-100 asseg-filter" role="group">
451
+          <button type="button" class="btn btn-outline-secondary active" data-filter="da_assegnare">Da sedere</button>
452
+          <button type="button" class="btn btn-outline-secondary" data-filter="tutte">Tutti</button>
453
+          <button type="button" class="btn btn-outline-secondary" data-filter="assegnate">Seduti</button>
454
+        </div>
455
+        <input type="search" class="form-control form-control-sm mt-2" id="asseg-search" placeholder="Cerca nome o codice…">
456
+        <div class="asseg-hint mt-2 mb-0">Scegli un ospite, poi tocca un tavolo sulla mappa.</div>
457
+      </div>
458
+      <div class="asseg-panel__body pt-0">
459
+        <div class="asseg-prenota-list" id="asseg-prenota-list"></div>
460
+      </div>
461
+    </div>
462
+  </div>
463
+</div>
464
+@endsection
465
+
466
+@section('page-script')
467
+<script>
468
+(function () {
469
+  var map = document.getElementById('asseg-map');
470
+  if (!map) return;
471
+
472
+  var csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
473
+  var salaId = @json((int) $sala->id);
474
+  var assegnaUrl = @json(route('evento.piantina.assegna', ['evento_id' => $evento->id]));
475
+  var destroyUrlTpl = @json(route('evento.piantina.assegnazione.destroy', ['evento_id' => $evento->id, 'assegnazione_id' => '__ID__']));
476
+  var tavoli = @json($tavoliJs);
477
+  var prenotazioni = @json($prenotazioniJs);
478
+  var griglia = @json($griglia ?? []);
479
+  var hasImage = !map.getAttribute('data-empty');
480
+  var gridOverlay = document.getElementById('asseg-grid-overlay');
481
+
482
+  function withSalaId(payload) {
483
+    var out = payload ? Object.assign({}, payload) : {};
484
+    out.sala_id = salaId;
485
+    return out;
486
+  }
487
+
488
+  function urlWithSala(url) {
489
+    var sep = url.indexOf('?') >= 0 ? '&' : '?';
490
+    return url + sep + 'sala_id=' + encodeURIComponent(salaId);
491
+  }
492
+
493
+  var selectedPrenotazioneId = null;
494
+  var selectedTavoloId = null;
495
+  var draggingPrenotazioneId = null;
496
+  var filter = 'da_assegnare';
497
+  var search = '';
498
+  var statusEl = document.getElementById('asseg-status');
499
+  var listEl = document.getElementById('asseg-prenota-list');
500
+  var detailEl = document.getElementById('asseg-tavolo-detail');
501
+  var hintEl = document.getElementById('asseg-sel-hint');
502
+  var searchEl = document.getElementById('asseg-search');
503
+  var stickyEl = document.getElementById('asseg-sticky');
504
+  var clearSelBtn = document.getElementById('asseg-clear-sel');
505
+
506
+  function setStatus(text, cls) {
507
+    if (!statusEl) return;
508
+    statusEl.textContent = text || '';
509
+    statusEl.className = 'asseg-status' + (cls ? ' ' + cls : '');
510
+  }
511
+
512
+  function escapeHtml(s) {
513
+    return String(s || '')
514
+      .replace(/&/g, '&amp;')
515
+      .replace(/</g, '&lt;')
516
+      .replace(/>/g, '&gt;')
517
+      .replace(/"/g, '&quot;');
518
+  }
519
+
520
+  function findTavolo(id) {
521
+    return tavoli.find(function (t) { return Number(t.id) === Number(id); });
522
+  }
523
+
524
+  function findPrenotazione(id) {
525
+    return prenotazioni.find(function (p) { return Number(p.id) === Number(id); });
526
+  }
527
+
528
+  function mergeTavolo(payload) {
529
+    if (!payload) return;
530
+    var idx = tavoli.findIndex(function (t) { return Number(t.id) === Number(payload.id); });
531
+    if (idx >= 0) tavoli[idx] = Object.assign({}, tavoli[idx], payload);
532
+  }
533
+
534
+  function mergePrenotazione(payload) {
535
+    if (!payload) return;
536
+    var idx = prenotazioni.findIndex(function (p) { return Number(p.id) === Number(payload.id); });
537
+    if (idx >= 0) prenotazioni[idx] = Object.assign({}, prenotazioni[idx], payload);
538
+  }
539
+
540
+  function fillClass(t) {
541
+    if (t.occupati <= 0) return 'is-libero';
542
+    if (t.occupati >= t.posti) return 'is-pieno';
543
+    return 'is-parziale';
544
+  }
545
+
546
+  function canAssignTo(p, t) {
547
+    if (!p || !t || p.rimasti < 1 || t.liberi < 1) return false;
548
+    if (!p.assegnazioni || !p.assegnazioni.length) return true;
549
+    var ok = p.assegnazioni.some(function (a) {
550
+      if (Number(a.tavolo_id) === Number(t.id)) return true;
551
+      if (!a.gruppo_id || !t.gruppo_id) return false;
552
+      return a.gruppo_id === t.gruppo_id;
553
+    });
554
+    return ok;
555
+  }
556
+
557
+  function updateHint() {
558
+    var p = selectedPrenotazioneId ? findPrenotazione(selectedPrenotazioneId) : null;
559
+    if (stickyEl) stickyEl.classList.toggle('is-ready', !!p);
560
+    if (clearSelBtn) clearSelBtn.classList.toggle('d-none', !p);
561
+    if (!hintEl) return;
562
+    if (!p) {
563
+      hintEl.innerHTML = 'Nessun ospite scelto — tocca un nome nell’elenco';
564
+      return;
565
+    }
566
+    hintEl.innerHTML = '<span class="asseg-sticky__name">' + escapeHtml(p.nome) + '</span> · ' +
567
+      p.rimasti + (p.rimasti === 1 ? ' posto da sedere' : ' posti da sedere') +
568
+      ' — ora tocca un tavolo sulla piantina';
569
+  }
570
+
571
+  function renderSalaGrid() {
572
+    if (!gridOverlay) return;
573
+    if (!(griglia && griglia.modalita === 'celle') || !hasImage) {
574
+      gridOverlay.hidden = true;
575
+      gridOverlay.innerHTML = '';
576
+      return;
577
+    }
578
+    var cols = Math.max(1, Number(griglia.cols) || 10);
579
+    var rows = Math.max(1, Number(griglia.rows) || 4);
580
+    var mt = Number(griglia.margin_top) || 0;
581
+    var mr = Number(griglia.margin_right) || 0;
582
+    var mb = Number(griglia.margin_bottom) || 0;
583
+    var ml = Number(griglia.margin_left) || 0;
584
+    var cellW = (100 - ml - mr) / cols;
585
+    var cellH = (100 - mt - mb) / rows;
586
+    var html = '';
587
+    for (var r = 0; r < rows; r++) {
588
+      for (var c = 0; c < cols; c++) {
589
+        html += '<div class="cell" style="left:' + (ml + c * cellW) + '%;top:' + (mt + r * cellH) +
590
+          '%;width:' + cellW + '%;height:' + cellH + '%"></div>';
591
+      }
592
+    }
593
+    gridOverlay.hidden = false;
594
+    gridOverlay.innerHTML = html;
595
+  }
596
+
597
+  function renderMap() {
598
+    map.querySelectorAll('.asseg-tavolo').forEach(function (n) { n.remove(); });
599
+    if (!hasImage) return;
600
+
601
+    tavoli.forEach(function (t) {
602
+      var el = document.createElement('div');
603
+      el.className = 'asseg-tavolo ' + fillClass(t) + (t.forma === 'cerchio' ? ' is-cerchio' : ' is-rettangolo');
604
+      if (Number(selectedTavoloId) === Number(t.id)) el.classList.add('is-selected');
605
+      el.dataset.id = t.id;
606
+      el.style.left = t.x + '%';
607
+      el.style.top = t.y + '%';
608
+      el.style.width = t.w + '%';
609
+      el.style.height = t.h + '%';
610
+      el.innerHTML =
611
+        '<div>' + escapeHtml(t.label) + '</div>' +
612
+        '<div class="asseg-tavolo__meta">' + t.occupati + '/' + t.posti + '</div>';
613
+      el.title = t.label + ' · ' + t.liberi + ' liberi';
614
+      el.addEventListener('click', function () {
615
+        selectedTavoloId = t.id;
616
+        renderMap();
617
+        renderDetail();
618
+        if (selectedPrenotazioneId) {
619
+          assegna(selectedPrenotazioneId, t.id);
620
+        }
621
+      });
622
+      el.addEventListener('dragover', function (e) {
623
+        e.preventDefault();
624
+        var p = findPrenotazione(draggingPrenotazioneId || selectedPrenotazioneId);
625
+        el.classList.toggle('is-drop-ok', canAssignTo(p, t));
626
+        el.classList.toggle('is-drop-no', !canAssignTo(p, t));
627
+      });
628
+      el.addEventListener('dragleave', function () {
629
+        el.classList.remove('is-drop-ok', 'is-drop-no');
630
+      });
631
+      el.addEventListener('drop', function (e) {
632
+        e.preventDefault();
633
+        el.classList.remove('is-drop-ok', 'is-drop-no');
634
+        var id = draggingPrenotazioneId
635
+          || e.dataTransfer.getData('text/prenotazione-id')
636
+          || selectedPrenotazioneId;
637
+        draggingPrenotazioneId = null;
638
+        if (id) {
639
+          selectedPrenotazioneId = Number(id);
640
+          selectedTavoloId = t.id;
641
+          updateHint();
642
+          renderList();
643
+          renderMap();
644
+          renderDetail();
645
+          assegna(id, t.id);
646
+        }
647
+      });
648
+      map.appendChild(el);
649
+    });
650
+  }
651
+
652
+  function renderDetail() {
653
+    var t = selectedTavoloId ? findTavolo(selectedTavoloId) : null;
654
+    if (!t) {
655
+      detailEl.innerHTML = '<div class="text-muted small">Tocca un tavolo per vedere chi è seduto.</div>';
656
+      return;
657
+    }
658
+    var rows = (t.assegnazioni || []).map(function (a) {
659
+      return '<div class="d-flex justify-content-between align-items-center gap-2 mb-1">' +
660
+        '<span><strong>' + escapeHtml(a.nome) + '</strong> · ' + a.posti + ' posti' +
661
+        (a.codice ? ' <span class="text-muted">(' + escapeHtml(String(a.codice).toUpperCase()) + ')</span>' : '') +
662
+        '</span>' +
663
+        '<button type="button" class="btn btn-sm btn-label-danger py-1 px-2" data-remove="' + a.id + '">Togli</button>' +
664
+        '</div>';
665
+    }).join('');
666
+
667
+    detailEl.innerHTML =
668
+      '<div class="d-flex justify-content-between align-items-start gap-2 mb-2">' +
669
+        '<div><strong>' + escapeHtml(t.label) + '</strong>' +
670
+          (t.gruppo_id ? ' <span class="badge bg-label-secondary">' + escapeHtml(t.gruppo_id) + '</span>' : '') +
671
+          '<div class="small text-muted">' + t.occupati + '/' + t.posti + ' occupati · ' + t.liberi + ' liberi</div>' +
672
+        '</div>' +
673
+      '</div>' +
674
+      (rows || '<div class="text-muted small">Nessuno seduto a questo tavolo.</div>');
675
+
676
+    detailEl.querySelectorAll('[data-remove]').forEach(function (btn) {
677
+      btn.addEventListener('click', function () {
678
+        rimuovi(btn.getAttribute('data-remove'));
679
+      });
680
+    });
681
+  }
682
+
683
+  function matchesFilter(p) {
684
+    if (filter === 'da_assegnare') return p.rimasti > 0;
685
+    if (filter === 'assegnate') return p.assegnati > 0 && p.rimasti === 0;
686
+    return true;
687
+  }
688
+
689
+  function matchesSearch(p) {
690
+    if (!search) return true;
691
+    var q = search.toLowerCase();
692
+    return String(p.nome || '').toLowerCase().indexOf(q) >= 0
693
+      || String(p.codice || '').toLowerCase().indexOf(q) >= 0;
694
+  }
695
+
696
+  function renderList() {
697
+    var items = prenotazioni.filter(matchesFilter).filter(matchesSearch);
698
+    if (!items.length) {
699
+      listEl.innerHTML = '<p class="text-muted small mb-0">Nessun ospite in questo elenco.</p>';
700
+      return;
701
+    }
702
+
703
+    listEl.innerHTML = items.map(function (p) {
704
+      var chips = (p.assegnazioni || []).map(function (a) {
705
+        return '<span class="asseg-chip">' +
706
+          '<span class="asseg-chip__label">' + escapeHtml(a.label || '?') + ' · ' + a.posti + ' posti</span>' +
707
+          '<button type="button" class="asseg-chip__btn" data-remove="' + a.id + '" title="Togli dal tavolo">' +
708
+            '<i class="bx bx-x" aria-hidden="true"></i> Togli' +
709
+          '</button>' +
710
+        '</span>';
711
+      }).join('');
712
+
713
+      return '<div class="asseg-prenota' +
714
+        (Number(selectedPrenotazioneId) === Number(p.id) ? ' is-selected' : '') +
715
+        (p.rimasti === 0 ? ' is-done' : '') +
716
+        '" draggable="true" data-id="' + p.id + '">' +
717
+        '<div class="d-flex justify-content-between gap-2">' +
718
+          '<div><strong>' + escapeHtml(p.nome) + '</strong>' +
719
+            ' <span class="text-muted small">' + escapeHtml(String(p.codice || '').toUpperCase()) + '</span></div>' +
720
+          '<span class="badge ' + (p.confermata ? 'bg-success' : 'bg-info') + '">' + escapeHtml(p.stato) + '</span>' +
721
+        '</div>' +
722
+        '<div class="small text-muted mt-1">' + p.assegnati + '/' + p.coperti + ' posti a tavola' +
723
+          (p.rimasti > 0 ? ' · <strong>' + p.rimasti + ' da sedere</strong>' : '') +
724
+        '</div>' +
725
+        (chips ? '<div class="mt-1">' + chips + '</div>' : '') +
726
+        (p.rimasti > 0
727
+          ? '<div class="asseg-prenota-cta">' + (Number(selectedPrenotazioneId) === Number(p.id)
728
+            ? 'Scelto — ora tocca un tavolo'
729
+            : 'Tocca per scegliere') + '</div>'
730
+          : '') +
731
+      '</div>';
732
+    }).join('');
733
+
734
+    listEl.querySelectorAll('.asseg-prenota').forEach(function (card) {
735
+      card.addEventListener('click', function (e) {
736
+        if (e.target.closest('[data-remove]')) return;
737
+        selectedPrenotazioneId = Number(card.dataset.id);
738
+        updateHint();
739
+        renderList();
740
+        if (window.matchMedia('(max-width: 1199.98px)').matches) {
741
+          setTimeout(function () {
742
+            map.scrollIntoView({ behavior: 'smooth', block: 'center' });
743
+          }, 80);
744
+        }
745
+      });
746
+      card.addEventListener('dragstart', function (e) {
747
+        selectedPrenotazioneId = Number(card.dataset.id);
748
+        draggingPrenotazioneId = selectedPrenotazioneId;
749
+        card.classList.add('is-dragging');
750
+        e.dataTransfer.setData('text/prenotazione-id', String(card.dataset.id));
751
+        e.dataTransfer.effectAllowed = 'copy';
752
+        updateHint();
753
+        renderList();
754
+      });
755
+      card.addEventListener('dragend', function () {
756
+        draggingPrenotazioneId = null;
757
+        card.classList.remove('is-dragging');
758
+        map.querySelectorAll('.asseg-tavolo').forEach(function (el) {
759
+          el.classList.remove('is-drop-ok', 'is-drop-no');
760
+        });
761
+      });
762
+    });
763
+
764
+    listEl.querySelectorAll('[data-remove]').forEach(function (btn) {
765
+      btn.addEventListener('click', function (e) {
766
+        e.stopPropagation();
767
+        rimuovi(btn.getAttribute('data-remove'));
768
+      });
769
+    });
770
+  }
771
+
772
+  async function apiJson(url, method, body) {
773
+    setStatus('Salvataggio…', 'is-busy');
774
+    var payload = withSalaId(body || {});
775
+    var res = await fetch(urlWithSala(url), {
776
+      method: method,
777
+      headers: {
778
+        'Content-Type': 'application/json',
779
+        'Accept': 'application/json',
780
+        'X-CSRF-TOKEN': csrf,
781
+        'X-Requested-With': 'XMLHttpRequest',
782
+      },
783
+      body: method === 'GET' || method === 'HEAD' ? null : JSON.stringify(payload),
784
+    });
785
+    var data = null;
786
+    try { data = await res.json(); } catch (err) {}
787
+    if (!res.ok) {
788
+      var msg = (data && (data.error || data.message)) || 'Errore';
789
+      setStatus(msg, 'is-err');
790
+      throw new Error(msg);
791
+    }
792
+    setStatus('Salvato', 'is-ok');
793
+    setTimeout(function () { setStatus(''); }, 1600);
794
+    return data;
795
+  }
796
+
797
+  async function assegna(prenotazioneId, tavoloId) {
798
+    var p = findPrenotazione(prenotazioneId);
799
+    var t = findTavolo(tavoloId);
800
+    if (!canAssignTo(p, t)) {
801
+      setStatus(
802
+        !p || p.rimasti < 1
803
+          ? 'Questo ospite è già seduto'
804
+          : (!t || t.liberi < 1
805
+            ? 'Questo tavolo è pieno'
806
+            : 'Per spezzare i posti usa un tavolo della stessa colonna'),
807
+        'is-err'
808
+      );
809
+      return;
810
+    }
811
+    try {
812
+      var data = await apiJson(assegnaUrl, 'POST', {
813
+        prenotazione_id: Number(prenotazioneId),
814
+        evento_tavolo_id: Number(tavoloId),
815
+        posti_assegnati: p.rimasti,
816
+      });
817
+      mergeTavolo(data.tavolo);
818
+      mergePrenotazione(data.prenotazione);
819
+      if (data.prenotazione && data.prenotazione.rimasti < 1) {
820
+        selectedPrenotazioneId = null;
821
+      }
822
+      updateHint();
823
+      renderMap();
824
+      renderList();
825
+      renderDetail();
826
+    } catch (err) {
827
+      console.error(err);
828
+    }
829
+  }
830
+
831
+  async function rimuovi(assegnazioneId) {
832
+    try {
833
+      var data = await apiJson(destroyUrlTpl.replace('__ID__', assegnazioneId), 'DELETE', null);
834
+      mergeTavolo(data.tavolo);
835
+      mergePrenotazione(data.prenotazione);
836
+      updateHint();
837
+      renderMap();
838
+      renderList();
839
+      renderDetail();
840
+    } catch (err) {
841
+      console.error(err);
842
+    }
843
+  }
844
+
845
+  document.querySelectorAll('[data-filter]').forEach(function (btn) {
846
+    btn.addEventListener('click', function () {
847
+      filter = btn.getAttribute('data-filter');
848
+      document.querySelectorAll('[data-filter]').forEach(function (b) {
849
+        b.classList.toggle('active', b === btn);
850
+      });
851
+      renderList();
852
+    });
853
+  });
854
+
855
+  searchEl?.addEventListener('input', function () {
856
+    search = (searchEl.value || '').trim();
857
+    renderList();
858
+  });
859
+
860
+  clearSelBtn?.addEventListener('click', function () {
861
+    selectedPrenotazioneId = null;
862
+    updateHint();
863
+    renderList();
864
+  });
865
+
866
+  updateHint();
867
+  renderSalaGrid();
868
+  renderMap();
869
+  renderList();
870
+  renderDetail();
871
+})();
872
+</script>
873
+@endsection

+ 1734
- 0
resources/views/evento/piantina/edit.blade.php
文件差异内容过多而无法显示
查看文件


+ 301
- 349
resources/views/evento/show.blade.php 查看文件

@@ -2,387 +2,339 @@
2 2
   use Illuminate\Support\Facades\Auth;
3 3
   use Illuminate\Support\Facades\Storage;
4 4
   use Carbon\Carbon;
5
+
6
+  $attivita = $evento->attivita;
7
+  $accent = $attivita?->colore ?: '#f58220';
8
+  $prenotabile = $prenotabileOra ?? $evento->isPrenotabileOra();
9
+  $messaggioNonPrenotabile = $messaggioPrenotazione ?? $evento->messaggioStatoPrenotazione() ?? 'Prenotazioni non disponibili al momento.';
10
+
11
+  $dataInizio = $evento->data_inizio ? Carbon::parse($evento->data_inizio) : null;
12
+  $dataFine = $evento->data_fine ? Carbon::parse($evento->data_fine) : null;
13
+  $sameDay = $dataInizio && $dataFine && $dataInizio->isSameDay($dataFine);
14
+  $allDayInizio = $dataInizio && $dataInizio->format('H:i') === '00:00';
15
+  $allDayFine = $dataFine && $dataFine->format('H:i') === '00:00';
16
+  $isAllDay = $allDayInizio && ($sameDay ? $allDayFine : true);
17
+
18
+  $dataLabel = null;
19
+  if ($dataInizio) {
20
+    if ($sameDay) {
21
+      $dataLabel = $dataInizio->translatedFormat('d M Y');
22
+      if (! $isAllDay) {
23
+        $oraFine = $dataFine ? $dataFine->format('H:i') : $dataInizio->format('H:i');
24
+        $dataLabel .= ' · '.$dataInizio->format('H:i').'–'.$oraFine;
25
+      }
26
+    } elseif ($isAllDay) {
27
+      $dataLabel = 'Dal '.$dataInizio->translatedFormat('d M Y').' al '.($dataFine ? $dataFine->translatedFormat('d M Y') : '—');
28
+    } else {
29
+      $dataLabel = $dataInizio->translatedFormat('d M Y').' '.$dataInizio->format('H:i');
30
+      if ($dataFine) {
31
+        $dataLabel .= ' → '.$dataFine->translatedFormat('d M Y').' '.$dataFine->format('H:i');
32
+      }
33
+    }
34
+  }
35
+
36
+  $postiLabel = null;
37
+  $postiClass = 'ok';
38
+  if (! $evento->nascondiPostiDisponibili()) {
39
+    if ($evento->posti === null) {
40
+      $postiLabel = null;
41
+    } elseif ((int) $evento->posti > 0) {
42
+      $postiLabel = (int) $evento->posti.' posti';
43
+      $postiClass = 'ok';
44
+    } else {
45
+      $postiLabel = 'Posti illimitati';
46
+      $postiClass = 'mute';
47
+    }
48
+  }
49
+
50
+  $prezzoLabel = null;
51
+  if ($evento->prezzo !== null && (float) $evento->prezzo > 0) {
52
+    $importo = '€ '.number_format((float) $evento->prezzo, 2, ',', '.');
53
+    $prezzoLabel = ! empty($evento->prezzo_fisso) ? $importo : 'da '.$importo;
54
+  } elseif ($evento->prezzo !== null && (float) $evento->prezzo == 0) {
55
+    $prezzoLabel = 'Ingresso gratuito';
56
+  }
57
+
58
+  $coverSrc = null;
59
+  $eventiDisk = Storage::disk('eventi');
60
+  if ($evento->copertina && $eventiDisk->exists($evento->copertina)) {
61
+    $coverSrc = $eventiDisk->url($evento->copertina).'?v='.$eventiDisk->lastModified($evento->copertina);
62
+  } elseif (count($galleriaItems) > 0 && ! empty($galleriaItems[0]['path'])) {
63
+    $coverSrc = $eventiDisk->url($galleriaItems[0]['path']);
64
+  }
65
+
66
+  $haLocandina = (bool) $evento->immagine;
67
+  $nGalleria = count($galleriaItems);
68
+  $haGalleria = $nGalleria > 0;
69
+
70
+  $backAttivitaUrl = ($attivita && $attivita->slug)
71
+    ? route('cliente.attivita.show', ['slug' => $attivita->slug])
72
+    : null;
73
+
74
+  $mostraPosti = filled($postiLabel);
75
+  $mostraConferma = ! $evento->confermaAutomatica();
76
+  $extraCount = ($mostraPosti ? 1 : 0) + ($mostraConferma ? 1 : 0);
77
+  $extraInBarra = $extraCount === 1;
78
+  $extraInChips = $extraCount > 1;
5 79
 @endphp
6 80
 
7 81
 @extends('layouts/guest')
8 82
 
9
-@section('title', 'Evento - '.$evento->nome)
10
-
11
-@section('page-style')
12
-<link rel="preconnect" href="https://fonts.googleapis.com" />
13
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
-<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
15
-@endsection
83
+@section('title', $evento->nome.' · Evento')
16 84
 
17
-@section('app-css-extra')
18
-@vite(['resources/css/guest-client.css'])
85
+@section('page-meta')
86
+  <script>document.documentElement.classList.add('evento-public-page');</script>
87
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
88
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
89
+  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet" />
19 90
 @endsection
20 91
 
21
-@section('pageTitle')
22
-<div class="d-flex flex-column align-items-center">
23
-  <h2 class="mb-1 fw-bold">
24
-    <i class="bx bx-calendar-check"></i>
25
-    {{ $evento->nome }}
26
-  </h2>
27
-  <small class="text-muted">Scheda evento</small>
28
-</div>
92
+@section('page-style')
93
+@include('evento._partials.public-styles')
29 94
 @endsection
30 95
 
31 96
 @section('content')
32
-<style>
33
-  .evento-cover {
34
-    width: 100%;
35
-    height: 310px;
36
-    display: flex;
37
-    align-items: center;
38
-    justify-content: center;
39
-    overflow: hidden;
40
-    border-top-left-radius: 1rem;
41
-    border-top-right-radius: 1rem;
42
-    position: relative;
43
-    margin-bottom: -50px;
44
-    background: linear-gradient(120deg, #d1d7e6 0%, #b5cae6 100%);
45
-  }
46
-  .evento-cover-media {
47
-    position: absolute;
48
-    inset: 0;
49
-    width: 100%;
50
-    height: 100%;
51
-  }
52
-  .evento-cover-image {
53
-    position: absolute;
54
-    inset: 0;
55
-    width: 100%;
56
-    height: 100%;
57
-    object-fit: cover;
58
-  }
59
-  .evento-cover-placeholder {
60
-    position: absolute;
61
-    inset: 0;
62
-    display: flex;
63
-    align-items: center;
64
-    justify-content: center;
65
-    color: #607080;
66
-    font-size: 0.95rem;
67
-  }
68
-  .evento-cover .carousel {
69
-    height: 100%;
70
-  }
71
-  .evento-cover .carousel-inner {
72
-    height: 100%;
73
-  }
74
-  .evento-cover .carousel-item {
75
-    height: 100%;
76
-  }
77
-  .evento-cover .carousel-item img {
78
-    width: 100%;
79
-    height: 100%;
80
-    object-fit: cover;
81
-  }
82
-  .evento-banner {
83
-    margin-top: 60px;
84
-    width: 100%;
85
-    background: #fff;
86
-    border-radius: 0 0 1rem 1rem;
87
-    padding: 2rem 2rem 1.5rem 2rem;
88
-    box-shadow: 0 4px 30px 0 rgba(44, 62, 80, 0.10);
89
-    min-height: 240px;
90
-  }
91
-  .evento-media-zone {
92
-    margin-top: 1.75rem;
93
-    padding-top: 1.5rem;
94
-    border-top: 1px solid rgba(226, 230, 239, 0.95);
95
-  }
96
-  .evento-poster-card {
97
-    border: 1px solid var(--fest-border, #e2e6ef);
98
-    border-radius: 0.75rem;
99
-    background: linear-gradient(165deg, #fafbfd 0%, #f0f2f8 100%);
100
-    padding: 0.65rem;
101
-    box-shadow: 0 2px 14px rgba(21, 38, 92, 0.07);
102
-  }
103
-  .evento-poster-card img {
104
-    display: block;
105
-    width: 100%;
106
-    height: auto;
107
-    max-height: min(72vh, 560px);
108
-    object-fit: contain;
109
-    border-radius: 0.5rem;
110
-    background: #fff;
111
-  }
112
-  .evento-gallery-grid a {
113
-    display: block;
114
-    border-radius: 0.5rem;
115
-    overflow: hidden;
116
-    transition: transform 0.15s ease, box-shadow 0.15s ease;
117
-    box-shadow: 0 1px 4px rgba(21, 38, 92, 0.08);
118
-  }
119
-  .evento-gallery-grid a:hover {
120
-    transform: translateY(-2px);
121
-    box-shadow: 0 10px 24px rgba(21, 38, 92, 0.12);
122
-  }
123
-  .evento-gallery-grid .ratio {
124
-    background: #e8ebf2;
125
-  }
126
-  .evento-gallery-grid img {
127
-    object-fit: cover;
128
-  }
129
-  @media (max-width: 768px) {
130
-    .evento-cover {
131
-      height: 180px;
132
-    }
133
-    .evento-banner {
134
-      padding: 1.2rem 0.5rem 1rem 0.5rem;
135
-    }
136
-    .evento-poster-card img {
137
-      max-height: 65vh;
138
-    }
139
-  }
140
-</style>
141
-
142 97
 @include('_partials.status')
143 98
 
144
-<div class="guest-fest guest-fest-bg">
145
-<div class="row justify-content-center">
146
-  <div class="col-xxl-7 col-lg-10 mt-2 mb-4">
147
-    <div class="card p-0 border-0 shadow-sm" style="border-radius: 1rem; overflow: hidden;">
148
-
149
-      <div class="evento-cover">
150
-        <div class="evento-cover-media">
151
-          @if(count($galleriaItems) > 0)
152
-            <div id="eventoCarousel" class="carousel carousel-dark slide carousel-fade h-100" data-bs-ride="carousel">
153
-              <div class="carousel-indicators">
154
-                @foreach($galleriaItems as $key => $foto)
155
-                  <button type="button" data-bs-target="#eventoCarousel" data-bs-slide-to="{{ $key }}"
156
-                    class="{{ $key === 0 ? 'active' : '' }}"
157
-                    @if($key === 0) aria-current="true" @endif
158
-                    aria-label="Slide {{ $key+1 }}"></button>
159
-                @endforeach
160
-              </div>
161
-              <div class="carousel-inner h-100">
162
-                @foreach($galleriaItems as $key => $foto)
163
-                  <div class="carousel-item h-100{{ $key === 0 ? ' active' : '' }}">
164
-                    <img class="d-block w-100" src="{{ Storage::disk('eventi')->url($foto['path']) }}" alt="Slide {{ $key+1 }}">
165
-                  </div>
166
-                @endforeach
167
-              </div>
168
-              <a class="carousel-control-prev" href="#eventoCarousel" role="button" data-bs-slide="prev">
169
-                <span class="carousel-control-prev-icon" aria-hidden="true"></span>
170
-                <span class="visually-hidden">Precedente</span>
171
-              </a>
172
-              <a class="carousel-control-next" href="#eventoCarousel" role="button" data-bs-slide="next">
173
-                <span class="carousel-control-next-icon" aria-hidden="true"></span>
174
-                <span class="visually-hidden">Successivo</span>
175
-              </a>
176
-            </div>
177
-          @elseif($evento->copertina)
178
-            <img
179
-              class="evento-cover-image"
180
-              src="{{ Storage::disk('eventi')->url($evento->copertina) }}"
181
-              alt="Copertina {{ $evento->nome }}"
182
-            >
183
-          @else
184
-            <div class="evento-cover-placeholder bg-light">
185
-              <span><i class="bx bx-image me-1"></i> Nessuna copertina</span>
186
-            </div>
187
-          @endif
188
-        </div>
99
+<article class="evento-public" style="--evento-accent: {{ $accent }};">
100
+  <div class="evento-public__bg" aria-hidden="true"></div>
101
+
102
+  <header class="evento-public__top">
103
+    @if($backAttivitaUrl)
104
+      <a href="{{ $backAttivitaUrl }}" class="evento-public__back">
105
+        <i class="bx bx-arrow-back"></i>
106
+        <span>{{ $attivita->nome ?? 'Attività' }}</span>
107
+      </a>
108
+    @else
109
+      <a href="{{ route('welcome') }}" class="evento-public__back">
110
+        <i class="bx bx-arrow-back"></i>
111
+        <span>Home</span>
112
+      </a>
113
+    @endif
114
+    <img src="{{ asset('assets/img/logo_fest_L.png') }}" alt="Fest" class="evento-public__brand-logo">
115
+  </header>
116
+
117
+  <section class="evento-public__hero" aria-label="Copertina evento">
118
+    @if($coverSrc)
119
+      <img
120
+        class="evento-public__hero-img"
121
+        src="{{ $coverSrc }}"
122
+        alt="Copertina {{ $evento->nome }}"
123
+      >
124
+    @else
125
+      <div class="evento-public__hero-fallback" aria-hidden="true"></div>
126
+    @endif
127
+    <div class="evento-public__hero-shade"></div>
128
+    <div class="evento-public__hero-inner">
129
+      <div class="evento-public__hero-content">
130
+        @if($attivita?->nome)
131
+          <p class="evento-public__eyebrow">{{ $attivita->nome }}</p>
132
+        @endif
133
+        <h1 class="evento-public__title">{{ $evento->nome }}</h1>
189 134
       </div>
135
+    </div>
136
+  </section>
190 137
 
191
-      <div class="evento-banner">
192
-        <h2 class="guest-fest-page-title text-center mb-2" style="font-size: clamp(1.25rem, 3vw, 1.65rem);">
193
-          {{ $evento->nome }}
194
-        </h2>
195
-        <div class="d-flex flex-wrap justify-content-center align-items-center gap-3 small mb-3 text-secondary">
196
-
197
-          @php
198
-            $dataInizio = $evento->data_inizio ? Carbon::parse($evento->data_inizio) : null;
199
-            $dataFine   = $evento->data_fine   ? Carbon::parse($evento->data_fine) : null;
200
-
201
-            $sameDay = $dataInizio && $dataFine && $dataInizio->isSameDay($dataFine);
202
-            $allDayInizio = $dataInizio && $dataInizio->format('H:i') === '00:00';
203
-            $allDayFine = $dataFine && $dataFine->format('H:i') === '00:00';
204
-            $isAllDay = $allDayInizio && ($sameDay ? $allDayFine : true);
205
-          @endphp
206
-
207
-          @if($dataInizio)
208
-            @if($sameDay)
209
-              <div>
210
-                <i class="bx bx-calendar fs-5 align-middle" style="color: #ffbc42;"></i>
211
-                <span class="fw-semibold">Giorno:</span>
212
-                <span>{{ $dataInizio->format('d/m/Y') }}</span>
213
-                @if(!$isAllDay)
214
-                  <span class="mx-2">
215
-                    <i class="bx bx-time fs-5 align-middle" style="color:#8257e5;"></i>
216
-                    <span>
217
-                      {{ $dataInizio->format('H:i') }} - {{ $dataFine ? $dataFine->format('H:i') : $dataInizio->format('H:i') }}
218
-                    </span>
219
-                  </span>
220
-                @endif
221
-              </div>
222
-            @else
223
-              @if($isAllDay)
224
-                <div>
225
-                  <i class="bx bx-calendar fs-5 align-middle" style="color: #ffbc42;"></i>
226
-                  <span class="fw-semibold">Dal</span>
227
-                  <span>{{ $dataInizio->format('d/m/Y') }}</span>
228
-                  <span class="fw-semibold">al</span>
229
-                  <span>{{ $dataFine ? $dataFine->format('d/m/Y') : '-' }}</span>
230
-                </div>
231
-              @else
232
-                <div>
233
-                  <i class="bx bx-calendar fs-5 align-middle" style="color: #ffbc42;"></i>
234
-                  <span class="fw-semibold">Inizio:</span>
235
-                  <span>{{ $dataInizio->format('d/m/Y') }}</span>
236
-                  <i class="bx bx-time fs-5 align-middle ms-1" style="color:#8257e5;"></i>
237
-                  <span>{{ $dataInizio->format('H:i') }}</span>
238
-                </div>
239
-                @if($dataFine)
240
-                  <div>
241
-                    <i class="bx bx-calendar-minus fs-5 align-middle" style="color: #2ec4b6;"></i>
242
-                    <span class="fw-semibold">Fine:</span>
243
-                    <span>{{ $dataFine->format('d/m/Y') }}</span>
244
-                    <i class="bx bx-time fs-5 align-middle ms-1" style="color:#8257e5;"></i>
245
-                    <span>{{ $dataFine->format('H:i') }}</span>
246
-                  </div>
247
-                @endif
138
+  <div class="evento-public__shell">
139
+    <section class="evento-public__summary" aria-label="Informazioni evento">
140
+      <ul class="evento-public__facts">
141
+        @if($dataLabel)
142
+          <li class="evento-public__fact" aria-label="Quando">
143
+            <i class="bx bx-calendar" aria-hidden="true"></i>
144
+            <span>{{ $dataLabel }}</span>
145
+          </li>
146
+        @endif
147
+        <li class="evento-public__fact" aria-label="Dove">
148
+          <i class="bx bx-map" aria-hidden="true"></i>
149
+          <span>
150
+            @if($evento->luogo)
151
+              {{ $evento->luogoBreve() }}
152
+              @if($navUrl = $evento->navigaUrl())
153
+                <a href="{{ $navUrl }}" class="evento-public__nav-link" target="_blank" rel="noopener noreferrer" title="Apri navigazione" aria-label="Apri navigazione">
154
+                  <i class="bx bx-navigation"></i>
155
+                </a>
248 156
               @endif
249
-            @endif
250
-          @endif
251
-
252
-          <div>
253
-            <i class="bx bx-map fs-5 align-middle" style="color: #54428e;"></i>
254
-            <span class="fw-semibold">Luogo:</span>
255
-            <span>{{ $evento->luogo ?? '-' }}</span>
256
-          </div>
257
-          <div>
258
-            <i class="bx bx-user-plus fs-5 align-middle" style="color: #f45b69;"></i>
259
-            <span class="fw-semibold">Posti:</span>
260
-            @if($evento->posti > 0)
261
-              <span class="text-success">{{ $evento->posti }} posti</span>
262 157
             @else
263
-              <span class="text-danger">Illimitati</span>
158
+              Luogo da definire
264 159
             @endif
265
-          </div>
266
-        </div>
160
+          </span>
161
+        </li>
162
+        @if($extraInBarra && $mostraPosti)
163
+          <li class="evento-public__fact evento-public__fact--{{ $postiClass }}" aria-label="Posti">
164
+            <i class="bx bx-group" aria-hidden="true"></i>
165
+            <span>{{ $postiLabel }}</span>
166
+          </li>
167
+        @endif
168
+        @if($extraInBarra && $mostraConferma)
169
+          <li class="evento-public__fact evento-public__fact--confirm" aria-label="Conferma" title="La prenotazione diventa valida dopo la conferma degli organizzatori">
170
+            <i class="bx bx-check-shield" aria-hidden="true"></i>
171
+            <span>Da confermare</span>
172
+          </li>
173
+        @endif
174
+        @if($prezzoLabel)
175
+          <li class="evento-public__fact evento-public__fact--price" aria-label="Prezzo">
176
+            <span>{{ $prezzoLabel }}</span>
177
+          </li>
178
+        @endif
179
+      </ul>
267 180
 
268
-        @if($evento->prezzo)
269
-          <div class="text-center mb-3">
270
-            <span class="px-3 py-1 border border-primary rounded-pill bg-light text-primary fw-semibold d-inline-flex align-items-center gap-1 shadow-sm">
271
-              <i class="bx bx-euro"></i>
272
-              @if($evento->prezzo_fisso)
273
-                €{{ number_format($evento->prezzo, 2, ',', '.') }}
274
-              @else
275
-                €{{ number_format($evento->prezzo, 2, ',', '.') }}
276
-                <span class="text-muted ms-1 fw-light" style="font-size:0.95em;">variabile</span>
277
-              @endif
181
+      @if($extraInChips)
182
+        <div class="evento-public__chips">
183
+          @if($mostraPosti)
184
+            <span class="evento-public__chip evento-public__chip--posti evento-public__chip--posti-{{ $postiClass }}">
185
+              <i class="bx bx-group" aria-hidden="true"></i>
186
+              {{ $postiLabel }}
187
+            </span>
188
+          @endif
189
+          @if($mostraConferma)
190
+            <span class="evento-public__chip evento-public__chip--confirm" title="La prenotazione diventa valida dopo la conferma degli organizzatori">
191
+              <i class="bx bx-check-shield" aria-hidden="true"></i>
192
+              Soggetta a conferma degli organizzatori
278 193
             </span>
194
+          @endif
195
+        </div>
196
+      @endif
197
+
198
+      <div class="evento-public__cta-desktop">
199
+        @if($prenotabile)
200
+          <a href="{{ $evento->prenotaUrl() }}" class="evento-public__cta">
201
+            <span class="evento-public__cta-icon" aria-hidden="true"><i class="bx bx-calendar-check"></i></span>
202
+            <span class="evento-public__cta-label">Prenota ora</span>
203
+            <span class="evento-public__cta-arrow" aria-hidden="true"><i class="bx bx-right-arrow-alt"></i></span>
204
+          </a>
205
+        @else
206
+          <div class="evento-public__cta-note">
207
+            <i class="bx bx-info-circle"></i>
208
+            <span>{{ $messaggioNonPrenotabile }}</span>
279 209
           </div>
280 210
         @endif
211
+      </div>
212
+    </section>
281 213
 
282
-        @if($evento->descrizione)
283
-          <p class="mt-2 px-2 text-dark text-start" style="font-size: 1.06rem; line-height: 1.55;">
284
-            {!! nl2br(e($evento->descrizione)) !!}
285
-          </p>
286
-        @endif
214
+    @if($evento->descrizione)
215
+      <section class="evento-public__desc" aria-label="Descrizione">
216
+        {!! nl2br(e($evento->descrizione)) !!}
217
+      </section>
218
+    @endif
287 219
 
288
-        <div class="text-center mb-2 mt-3">
289
-          <a href="{{ route('prenotazone.prenota_form', ['evento_id' => $evento->id]) }}" class="btn btn-lg btn-primary d-inline-flex align-items-center gap-2 shadow-sm" style="border-radius:2rem;">
290
-            <i class="bx bx-calendar-plus fs-5"></i> Prenota ora
291
-          </a>
292
-        </div>
220
+    @if($haLocandina)
221
+      @php $locandinaSrc = Storage::disk('eventi')->url($evento->immagine); @endphp
222
+      <figure class="evento-public__poster">
223
+        <button
224
+          type="button"
225
+          class="evento-public__poster-trigger"
226
+          data-evento-lightbox
227
+          data-src="{{ $locandinaSrc }}"
228
+          data-alt="Locandina {{ $evento->nome }}"
229
+          aria-label="Ingrandisci locandina"
230
+        >
231
+          <img
232
+            src="{{ $locandinaSrc }}"
233
+            alt="Locandina {{ $evento->nome }}"
234
+            loading="lazy"
235
+          >
236
+          <span class="evento-public__poster-zoom" aria-hidden="true">
237
+            <i class="bx bx-zoom-in"></i>
238
+          </span>
239
+        </button>
240
+      </figure>
241
+    @endif
293 242
 
294
-        @php
295
-          $haLocandina = (bool) $evento->immagine;
296
-          $nGalleria = count($galleriaItems);
297
-          $haGalleria = $nGalleria > 0;
298
-        @endphp
243
+    @if($haGalleria)
244
+      <section class="evento-public__section">
245
+        <h2 class="evento-public__section-title">Galleria</h2>
246
+        <div class="evento-public__gallery">
247
+          @foreach($galleriaItems as $key => $foto)
248
+            @php $src = Storage::disk('eventi')->url($foto['path']); @endphp
249
+            <a href="{{ $src }}" target="_blank" rel="noopener noreferrer" class="evento-public__gallery-item" title="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}">
250
+              <img src="{{ $src }}" alt="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}" loading="lazy">
251
+            </a>
252
+          @endforeach
253
+        </div>
254
+      </section>
255
+    @endif
299 256
 
257
+    @auth
258
+      @if(Auth::user()->can('edit-evento'))
300 259
         @if($haLocandina || $haGalleria)
301
-          <div class="evento-media-zone">
302
-            @if($haLocandina && $haGalleria)
303
-              <div class="row g-4 align-items-start">
304
-                <div class="col-lg-4">
305
-                  <h3 class="guest-fest-subsection-title mb-3">Locandina</h3>
306
-                  <div class="evento-poster-card">
307
-                    <img
308
-                      src="{{ Storage::disk('eventi')->url($evento->immagine) }}"
309
-                      alt="Locandina {{ $evento->nome }}"
310
-                      loading="lazy"
311
-                    >
312
-                  </div>
313
-                </div>
314
-                <div class="col-lg-8">
315
-                  <h3 class="guest-fest-subsection-title mb-3">Galleria</h3>
316
-                  <p class="small text-muted mb-3">Tocca o clicca un'immagine per aprirla a tutto schermo. In cima trovi le stesse foto nel carousel.</p>
317
-                  <div class="row row-cols-2 row-cols-md-3 g-3 evento-gallery-grid">
318
-                    @foreach($galleriaItems as $key => $foto)
319
-                      @php $src = Storage::disk('eventi')->url($foto['path']); @endphp
320
-                      <div class="col">
321
-                        <a href="{{ $src }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none" title="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}">
322
-                          <div class="ratio ratio-4x3">
323
-                            <img src="{{ $src }}" alt="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}" class="w-100 h-100" loading="lazy">
324
-                          </div>
325
-                        </a>
326
-                      </div>
327
-                    @endforeach
328
-                  </div>
329
-                </div>
330
-              </div>
331
-            @elseif($haLocandina)
332
-              <h3 class="guest-fest-subsection-title mb-3">Locandina</h3>
333
-              <div class="mx-auto evento-poster-card" style="max-width: 420px;">
334
-                <img
335
-                  src="{{ Storage::disk('eventi')->url($evento->immagine) }}"
336
-                  alt="Locandina {{ $evento->nome }}"
337
-                  loading="lazy"
338
-                >
339
-              </div>
340
-            @else
341
-              <h3 class="guest-fest-subsection-title mb-3">Galleria</h3>
342
-              <p class="small text-muted mb-3">Tocca o clicca un'immagine per aprirla a tutto schermo. In cima trovi le stesse foto nel carousel.</p>
343
-              <div class="row row-cols-2 row-cols-md-3 g-3 evento-gallery-grid">
344
-                @foreach($galleriaItems as $key => $foto)
345
-                  @php $src = Storage::disk('eventi')->url($foto['path']); @endphp
346
-                  <div class="col">
347
-                    <a href="{{ $src }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none" title="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}">
348
-                      <div class="ratio ratio-4x3">
349
-                        <img src="{{ $src }}" alt="{{ $foto['nome'] ?? 'Foto '.($key + 1) }}" class="w-100 h-100" loading="lazy">
350
-                      </div>
351
-                    </a>
352
-                  </div>
353
-                @endforeach
354
-              </div>
355
-            @endif
356
-
357
-            @auth
358
-              @if(Auth::user()->can('edit-evento'))
359
-                <div class="text-center mt-4 pt-2">
360
-                  <a href="{{ route('evento.gallery.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-outline-primary">
361
-                    <i class="bx bx-images"></i> Gestisci galleria
362
-                  </a>
363
-                </div>
364
-              @endif
365
-            @endauth
260
+          <div class="evento-public__admin">
261
+            <a href="{{ route('evento.gallery.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-outline-primary">
262
+              <i class="bx bx-images"></i> Gestisci galleria
263
+            </a>
366 264
           </div>
367 265
         @else
368
-          <div class="evento-media-zone text-center py-3 text-muted border rounded bg-light bg-opacity-50">
369
-            <p class="mb-0 small">Nessuna locandina o galleria foto per questo evento.</p>
370
-            @auth
371
-              @if(Auth::user()->can('edit-evento'))
372
-                <div class="mt-3">
373
-                  <a href="{{ route('evento.gallery.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-primary">
374
-                    <i class="bx bx-plus"></i> Aggiungi foto alla galleria
375
-                  </a>
376
-                </div>
377
-              @endif
378
-            @endauth
379
-          </div>
266
+          <section class="evento-public__section evento-public__empty-media">
267
+            <p class="mb-2">Nessuna locandina o galleria.</p>
268
+            <a href="{{ route('evento.gallery.edit', ['evento_id' => $evento->id]) }}" class="btn btn-sm btn-primary">
269
+              <i class="bx bx-plus"></i> Aggiungi foto
270
+            </a>
271
+          </section>
380 272
         @endif
273
+      @endif
274
+    @endauth
275
+  </div>
381 276
 
382
-      </div>
277
+  @if($prenotabile)
278
+    <div class="evento-public__sticky-cta">
279
+      <a href="{{ $evento->prenotaUrl() }}" class="evento-public__cta">
280
+        <span class="evento-public__cta-icon" aria-hidden="true"><i class="bx bx-calendar-check"></i></span>
281
+        <span class="evento-public__cta-label">Prenota ora</span>
282
+        <span class="evento-public__cta-arrow" aria-hidden="true"><i class="bx bx-right-arrow-alt"></i></span>
283
+      </a>
383 284
     </div>
285
+  @endif
286
+</article>
287
+
288
+<div class="evento-lightbox" id="evento-lightbox" hidden>
289
+  <button type="button" class="evento-lightbox__backdrop" data-evento-lightbox-close aria-label="Chiudi"></button>
290
+  <div class="evento-lightbox__dialog" role="dialog" aria-modal="true" aria-label="Locandina ingrandita">
291
+    <button type="button" class="evento-lightbox__close" data-evento-lightbox-close aria-label="Chiudi">
292
+      <i class="bx bx-x"></i>
293
+    </button>
294
+    <img class="evento-lightbox__img" src="" alt="">
384 295
   </div>
385 296
 </div>
386
-</div>
297
+@endsection
298
+
299
+@section('page-script')
300
+<script>
301
+  (function () {
302
+    const lightbox = document.getElementById('evento-lightbox');
303
+    const img = lightbox?.querySelector('.evento-lightbox__img');
304
+    if (!lightbox || !img) return;
305
+
306
+    let lastFocus = null;
307
+
308
+    function openLightbox(src, alt) {
309
+      lastFocus = document.activeElement;
310
+      img.src = src;
311
+      img.alt = alt || 'Locandina';
312
+      lightbox.hidden = false;
313
+      document.documentElement.classList.add('evento-lightbox-open');
314
+      lightbox.querySelector('[data-evento-lightbox-close]')?.focus();
315
+    }
316
+
317
+    function closeLightbox() {
318
+      lightbox.hidden = true;
319
+      document.documentElement.classList.remove('evento-lightbox-open');
320
+      img.removeAttribute('src');
321
+      img.alt = '';
322
+      if (lastFocus && typeof lastFocus.focus === 'function') lastFocus.focus();
323
+    }
324
+
325
+    document.querySelectorAll('[data-evento-lightbox]').forEach(function (btn) {
326
+      btn.addEventListener('click', function () {
327
+        openLightbox(btn.getAttribute('data-src'), btn.getAttribute('data-alt'));
328
+      });
329
+    });
330
+
331
+    lightbox.querySelectorAll('[data-evento-lightbox-close]').forEach(function (el) {
332
+      el.addEventListener('click', closeLightbox);
333
+    });
387 334
 
335
+    document.addEventListener('keydown', function (event) {
336
+      if (event.key === 'Escape' && !lightbox.hidden) closeLightbox();
337
+    });
338
+  })();
339
+</script>
388 340
 @endsection

+ 105
- 0
resources/views/prenotazione/_partials/campo-cella.blade.php 查看文件

@@ -0,0 +1,105 @@
1
+@php
2
+  use App\Models\EventoForm;
3
+
4
+  $index = $index ?? 0;
5
+  $isTemplate = ! empty($isTemplate);
6
+  $suffix = '_'.$index;
7
+  $inputId = 'prenotazione_risposta_'.$form->id.$suffix;
8
+  $isMulti = $form->tipo === EventoForm::MULTISELEZIONE;
9
+
10
+  if ($isMulti) {
11
+    $inputName = 'prenotazione_risposta_'.$form->id.'['.$index.'][]';
12
+  } else {
13
+    $inputName = 'prenotazione_risposta_'.$form->id.'['.$index.']';
14
+  }
15
+
16
+  $required = $form->obbligatoria && ! $isTemplate;
17
+  $disabled = $isTemplate;
18
+@endphp
19
+
20
+@if($form->tipo === EventoForm::ELENCO || $form->tipo === EventoForm::SELEZIONE || $form->tipo === EventoForm::RADIO)
21
+  <select
22
+    class="form-select form-select-sm"
23
+    id="{{ $inputId }}"
24
+    name="{{ $inputName }}"
25
+    @required($required)
26
+    @disabled($disabled)
27
+  >
28
+    <option value="">—</option>
29
+    @foreach($form->opzioni_form as $opzione)
30
+      <option value="{{ $opzione->id }}">{{ $opzione->label }}</option>
31
+    @endforeach
32
+  </select>
33
+@elseif($form->tipo === EventoForm::MULTISELEZIONE)
34
+  <div class="prenota-cella-multi" data-multi-cell>
35
+    @foreach($form->opzioni_form as $opzione)
36
+      <div class="form-check form-check-sm mb-0">
37
+        <input
38
+          class="form-check-input"
39
+          type="checkbox"
40
+          id="{{ $inputId }}_{{ $opzione->id }}"
41
+          name="{{ $inputName }}"
42
+          value="{{ $opzione->id }}"
43
+          @disabled($disabled)
44
+        >
45
+        <label class="form-check-label small" for="{{ $inputId }}_{{ $opzione->id }}">{{ $opzione->label }}</label>
46
+      </div>
47
+    @endforeach
48
+  </div>
49
+@elseif($form->tipo === EventoForm::NUMERO)
50
+  <input
51
+    type="number"
52
+    class="form-control form-control-sm"
53
+    id="{{ $inputId }}"
54
+    name="{{ $inputName }}"
55
+    value=""
56
+    inputmode="numeric"
57
+    @required($required)
58
+    @disabled($disabled)
59
+  >
60
+@elseif($form->tipo === EventoForm::ORA)
61
+  <input
62
+    type="time"
63
+    class="form-control form-control-sm"
64
+    id="{{ $inputId }}"
65
+    name="{{ $inputName }}"
66
+    value=""
67
+    @required($required)
68
+    @disabled($disabled)
69
+  >
70
+@elseif($form->tipo === EventoForm::DATA)
71
+  <input
72
+    type="date"
73
+    class="form-control form-control-sm"
74
+    id="{{ $inputId }}"
75
+    name="{{ $inputName }}"
76
+    value=""
77
+    @required($required)
78
+    @disabled($disabled)
79
+  >
80
+@elseif($form->tipo === EventoForm::CHECKBOX)
81
+  <div class="form-check mb-0 d-flex justify-content-center">
82
+    @unless($form->obbligatoria)
83
+      <input type="hidden" name="{{ $inputName }}" value="0" @disabled($disabled)>
84
+    @endunless
85
+    <input
86
+      class="form-check-input"
87
+      type="checkbox"
88
+      id="{{ $inputId }}"
89
+      name="{{ $inputName }}"
90
+      value="1"
91
+      @required($required)
92
+      @disabled($disabled)
93
+    >
94
+  </div>
95
+@else
96
+  <input
97
+    type="text"
98
+    class="form-control form-control-sm"
99
+    id="{{ $inputId }}"
100
+    name="{{ $inputName }}"
101
+    value=""
102
+    @required($required)
103
+    @disabled($disabled)
104
+  >
105
+@endif

+ 4
- 0
resources/views/prenotazione/_partials/modal.blade.php 查看文件

@@ -32,6 +32,10 @@
32 32
             <div class="col-4 fw-bold">Stato:</div>
33 33
             <div class="col-8">{{ view('prenotazione._partials.stato-prenotazione', ['stato' => $prenotazione->stato]) }}</div>
34 34
         </div>
35
+        <div class="row mb-3">
36
+            <div class="col-4 fw-bold">Confermata:</div>
37
+            <div class="col-8">{{ view('_partials.available', ['available' => $prenotazione->confermata]) }}</div>
38
+        </div>
35 39
         @if(!empty($prenotazione->note))
36 40
         <div class="row mb-3">
37 41
             <div class="col-4 fw-bold">Note:</div>

+ 564
- 0
resources/views/prenotazione/_partials/prenota-styles.blade.php 查看文件

@@ -0,0 +1,564 @@
1
+<style>
2
+  html.prenota-public-page,
3
+  html.prenota-public-page body {
4
+    height: 100%;
5
+    margin: 0;
6
+    background: #f4f5f9;
7
+  }
8
+
9
+  html.prenota-public-page .layout-wrapper,
10
+  html.prenota-public-page .content-wrapper,
11
+  html.prenota-public-page .layout-page,
12
+  html.prenota-public-page .container-xxl {
13
+    padding: 0 !important;
14
+    margin: 0 !important;
15
+    max-width: 100% !important;
16
+  }
17
+
18
+  html.prenota-public-page .d-flex.flex-column.min-vh-100 {
19
+    min-height: 100vh !important;
20
+    min-height: 100dvh !important;
21
+  }
22
+
23
+  html.prenota-public-page .misc-wrapper {
24
+    display: flex !important;
25
+    flex-direction: column !important;
26
+    min-height: 100vh;
27
+    min-height: 100dvh;
28
+    width: 100%;
29
+    max-width: 100%;
30
+    padding: 0;
31
+    margin: 0;
32
+    align-items: stretch !important;
33
+    justify-content: flex-start !important;
34
+    text-align: start !important;
35
+    background: transparent;
36
+  }
37
+
38
+  html.prenota-public-page .row.mt-3.mb-3,
39
+  html.prenota-public-page footer {
40
+    display: none !important;
41
+  }
42
+
43
+  .prenota-public {
44
+    --evento-accent: #f58220;
45
+    --evento-navy: #15265c;
46
+    --evento-muted: #5b6579;
47
+    --evento-purple: #602d91;
48
+    --evento-surface: #ffffff;
49
+    --evento-border: rgba(21, 38, 92, 0.1);
50
+    --evento-gutter: clamp(0.85rem, 3vw, 1.25rem);
51
+
52
+    position: relative;
53
+    width: 100%;
54
+    max-width: 100%;
55
+    min-height: 100vh;
56
+    min-height: 100dvh;
57
+    display: flex;
58
+    flex-direction: column;
59
+    font-family: "Plus Jakarta Sans", "Public Sans", system-ui, sans-serif;
60
+    color: var(--evento-navy);
61
+    padding-bottom: 2rem;
62
+    overflow-x: hidden;
63
+  }
64
+
65
+  .prenota-public__bg {
66
+    position: fixed;
67
+    inset: 0;
68
+    z-index: 0;
69
+    pointer-events: none;
70
+    background: linear-gradient(150deg, #fff7f0 0%, #f3ebf8 42%, #e8eef8 100%);
71
+  }
72
+
73
+  .prenota-public__bg::before {
74
+    content: "";
75
+    position: absolute;
76
+    inset: 0;
77
+    background:
78
+      radial-gradient(ellipse 70% 45% at 12% 12%, color-mix(in srgb, var(--evento-accent) 28%, transparent) 0%, transparent 55%),
79
+      radial-gradient(ellipse 55% 40% at 90% 80%, rgba(96, 45, 145, 0.18) 0%, transparent 52%);
80
+  }
81
+
82
+  .prenota-public__top {
83
+    position: sticky;
84
+    top: 0;
85
+    z-index: 20;
86
+    display: flex;
87
+    align-items: center;
88
+    justify-content: space-between;
89
+    gap: 1rem;
90
+    width: 100%;
91
+    padding: calc(0.75rem + env(safe-area-inset-top, 0px)) var(--evento-gutter) 0.75rem;
92
+    background: rgba(255, 255, 255, 0.92);
93
+    backdrop-filter: blur(10px);
94
+    border-bottom: 1px solid var(--evento-border);
95
+  }
96
+
97
+  .prenota-public__top::after {
98
+    content: "";
99
+    position: absolute;
100
+    left: 0;
101
+    right: 0;
102
+    bottom: 0;
103
+    height: 3px;
104
+    background: linear-gradient(90deg, var(--evento-accent) 0%, var(--evento-purple) 55%, var(--evento-navy) 100%);
105
+    opacity: 0.85;
106
+  }
107
+
108
+  .prenota-public__back {
109
+    display: inline-flex;
110
+    align-items: center;
111
+    gap: 0.35rem;
112
+    font-size: 0.88rem;
113
+    font-weight: 600;
114
+    color: var(--evento-navy);
115
+    text-decoration: none;
116
+    max-width: 70%;
117
+  }
118
+
119
+  .prenota-public__back span {
120
+    overflow: hidden;
121
+    text-overflow: ellipsis;
122
+    white-space: nowrap;
123
+  }
124
+
125
+  .prenota-public__back:hover {
126
+    color: var(--evento-accent);
127
+  }
128
+
129
+  .prenota-public__brand-logo {
130
+    display: block;
131
+    height: 1.55rem;
132
+    width: auto;
133
+    opacity: 0.92;
134
+  }
135
+
136
+  .prenota-public__hero {
137
+    position: relative;
138
+    z-index: 1;
139
+    width: 100%;
140
+    min-height: clamp(160px, 28vw, 240px);
141
+    overflow: hidden;
142
+    background: #d8dee9;
143
+  }
144
+
145
+  .prenota-public__hero-inner {
146
+    position: relative;
147
+    z-index: 1;
148
+    width: 100%;
149
+    max-width: 720px;
150
+    margin: 0 auto;
151
+    padding: 0 var(--evento-gutter);
152
+  }
153
+
154
+  .prenota-public__hero-img {
155
+    position: absolute;
156
+    inset: 0;
157
+    width: 100%;
158
+    height: 100%;
159
+    object-fit: cover;
160
+  }
161
+
162
+  .prenota-public__hero-fallback {
163
+    position: absolute;
164
+    inset: 0;
165
+    background: linear-gradient(125deg, var(--evento-navy) 0%, var(--evento-purple) 50%, var(--evento-accent) 100%);
166
+  }
167
+
168
+  .prenota-public__hero-shade {
169
+    position: absolute;
170
+    inset: 0;
171
+    background: linear-gradient(180deg, rgba(12, 18, 40, 0.2) 0%, rgba(12, 18, 40, 0.82) 100%);
172
+  }
173
+
174
+  .prenota-public__hero-content {
175
+    position: relative;
176
+    z-index: 1;
177
+    display: flex;
178
+    flex-direction: column;
179
+    justify-content: flex-end;
180
+    min-height: clamp(160px, 28vw, 240px);
181
+    padding: 1.1rem 0 1.25rem;
182
+    color: #fff;
183
+  }
184
+
185
+  .prenota-public__eyebrow {
186
+    margin: 0 0 0.3rem;
187
+    font-size: 0.72rem;
188
+    font-weight: 700;
189
+    letter-spacing: 0.06em;
190
+    text-transform: uppercase;
191
+    color: #fff;
192
+    opacity: 0.9;
193
+  }
194
+
195
+  .prenota-public__title {
196
+    margin: 0;
197
+    font-size: clamp(1.35rem, 4vw, 1.9rem);
198
+    font-weight: 800;
199
+    letter-spacing: -0.03em;
200
+    line-height: 1.15;
201
+    color: #fff;
202
+    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 4px 18px rgba(0, 0, 0, 0.35);
203
+  }
204
+
205
+  .prenota-public__shell {
206
+    position: relative;
207
+    z-index: 2;
208
+    width: 100%;
209
+    max-width: 720px;
210
+    margin: 0 auto;
211
+    padding: 0 var(--evento-gutter) 1.5rem;
212
+    display: flex;
213
+    flex-direction: column;
214
+  }
215
+
216
+  .prenota-public__summary {
217
+    position: relative;
218
+    z-index: 3;
219
+    margin-top: -0.85rem;
220
+    padding: 0.15rem 0 0.25rem;
221
+  }
222
+
223
+  .prenota-public__facts {
224
+    display: flex;
225
+    flex-wrap: wrap;
226
+    align-items: center;
227
+    gap: 0.5rem 0.85rem;
228
+    margin: 0;
229
+    padding: 0.7rem 0.95rem;
230
+    list-style: none;
231
+    background: #fff;
232
+    border-radius: 0.85rem;
233
+    border: 1px solid rgba(21, 38, 92, 0.06);
234
+    box-shadow: 0 2px 12px rgba(21, 38, 92, 0.06);
235
+  }
236
+
237
+  .prenota-public__fact {
238
+    display: inline-flex;
239
+    align-items: center;
240
+    gap: 0.4rem;
241
+    font-size: 0.84rem;
242
+    font-weight: 600;
243
+    line-height: 1.35;
244
+    color: var(--evento-navy);
245
+  }
246
+
247
+  .prenota-public__fact i {
248
+    flex-shrink: 0;
249
+    color: var(--evento-accent);
250
+    font-size: 1.05rem;
251
+    line-height: 1;
252
+  }
253
+
254
+  .prenota-public__fact:not(:last-child):not(:has(+ .prenota-public__fact--price))::after {
255
+    content: "";
256
+    display: inline-block;
257
+    width: 3px;
258
+    height: 3px;
259
+    margin-left: 0.85rem;
260
+    border-radius: 50%;
261
+    background: rgba(21, 38, 92, 0.22);
262
+    vertical-align: middle;
263
+  }
264
+
265
+  .prenota-public__fact--price {
266
+    gap: 0;
267
+    margin-left: auto;
268
+  }
269
+
270
+  .prenota-public__fact--price span {
271
+    font-size: 1.05rem;
272
+    font-weight: 800;
273
+    letter-spacing: -0.02em;
274
+    color: var(--evento-accent);
275
+  }
276
+
277
+  .prenota-public__fact--ok,
278
+  .prenota-public__fact--ok i,
279
+  .prenota-public__fact--ok span {
280
+    color: #1b7a4a;
281
+  }
282
+
283
+  .prenota-public__fact--mute,
284
+  .prenota-public__fact--mute i,
285
+  .prenota-public__fact--mute span {
286
+    color: var(--evento-muted);
287
+  }
288
+
289
+  .prenota-public__fact--confirm,
290
+  .prenota-public__fact--confirm i,
291
+  .prenota-public__fact--confirm span {
292
+    color: var(--evento-purple);
293
+  }
294
+
295
+  @media (max-width: 479px) {
296
+    .prenota-public__facts {
297
+      flex-direction: column;
298
+      align-items: flex-start;
299
+      gap: 0.55rem;
300
+    }
301
+
302
+    .prenota-public__fact:not(:last-child)::after {
303
+      display: none;
304
+    }
305
+
306
+    .prenota-public__fact--price {
307
+      align-self: flex-end;
308
+      margin-left: 0;
309
+    }
310
+  }
311
+
312
+  .prenota-public__nav-link {
313
+    display: inline-flex;
314
+    align-items: center;
315
+    justify-content: center;
316
+    width: 1.55rem;
317
+    height: 1.55rem;
318
+    margin-left: 0.35rem;
319
+    border-radius: 999px;
320
+    background: var(--evento-accent);
321
+    color: #fff !important;
322
+    text-decoration: none;
323
+    vertical-align: middle;
324
+    box-shadow: 0 2px 8px color-mix(in srgb, var(--evento-accent) 35%, transparent);
325
+  }
326
+
327
+  .prenota-public__nav-link i {
328
+    font-size: 0.9rem;
329
+    color: #fff !important;
330
+    line-height: 1;
331
+  }
332
+
333
+  .prenota-public__chips {
334
+    display: flex;
335
+    flex-wrap: wrap;
336
+    gap: 0.45rem;
337
+    margin-top: 0.7rem;
338
+  }
339
+
340
+  .prenota-public__chip {
341
+    display: inline-flex;
342
+    align-items: center;
343
+    gap: 0.35rem;
344
+    padding: 0.35rem 0.7rem;
345
+    border-radius: 999px;
346
+    font-size: 0.78rem;
347
+    font-weight: 600;
348
+    line-height: 1.25;
349
+  }
350
+
351
+  .prenota-public__chip i {
352
+    font-size: 1rem;
353
+    line-height: 1;
354
+  }
355
+
356
+  .prenota-public__chip--confirm {
357
+    background: color-mix(in srgb, var(--evento-purple) 8%, #fff);
358
+    border: 1px solid color-mix(in srgb, var(--evento-purple) 22%, transparent);
359
+    color: var(--evento-purple);
360
+  }
361
+
362
+  .prenota-public__chip--posti {
363
+    background: color-mix(in srgb, var(--evento-accent) 8%, #fff);
364
+    border: 1px solid color-mix(in srgb, var(--evento-accent) 22%, transparent);
365
+    color: var(--evento-navy);
366
+  }
367
+
368
+  .prenota-public__chip--posti i {
369
+    color: var(--evento-accent);
370
+  }
371
+
372
+  .prenota-public__chip--posti-ok {
373
+    background: color-mix(in srgb, #1b7a4a 8%, #fff);
374
+    border-color: color-mix(in srgb, #1b7a4a 22%, transparent);
375
+    color: #1b7a4a;
376
+  }
377
+
378
+  .prenota-public__chip--posti-ok i {
379
+    color: #1b7a4a;
380
+  }
381
+
382
+  .prenota-public__chip--posti-mute {
383
+    background: rgba(255, 255, 255, 0.9);
384
+    border-color: rgba(21, 38, 92, 0.1);
385
+    color: var(--evento-muted);
386
+  }
387
+
388
+  .prenota-public__chip--posti-mute i {
389
+    color: var(--evento-muted);
390
+  }
391
+
392
+  .prenota-public__panel {
393
+    margin-top: 1rem;
394
+    padding: 1.15rem 1rem 1.35rem;
395
+    background: var(--evento-surface);
396
+    border: 1px solid var(--evento-border);
397
+    border-radius: 1rem;
398
+    box-shadow: 0 10px 28px rgba(21, 38, 92, 0.08);
399
+  }
400
+
401
+  @media (min-width: 768px) {
402
+    .prenota-public__panel {
403
+      padding: 1.35rem 1.35rem 1.5rem;
404
+    }
405
+  }
406
+
407
+  .prenota-public__panel-intro {
408
+    margin: 0 0 1.1rem;
409
+    font-size: 0.9rem;
410
+    color: var(--evento-muted);
411
+    line-height: 1.45;
412
+  }
413
+
414
+  .prenota-form-section .form-label {
415
+    font-weight: 600;
416
+    font-size: 0.875rem;
417
+    margin-bottom: 0.35rem;
418
+    color: var(--evento-navy);
419
+  }
420
+
421
+  .prenota-field-stack .input-group > .form-control,
422
+  .prenota-field-stack .input-group > .form-select,
423
+  .prenota-field-stack .form-control,
424
+  .prenota-field-stack .form-select {
425
+    min-height: 2.85rem;
426
+  }
427
+
428
+  .prenota-field-stack .input-group-text {
429
+    background: #f7f8fc;
430
+    border-color: rgba(21, 38, 92, 0.12);
431
+    color: var(--evento-muted);
432
+  }
433
+
434
+  .prenota-field-stack .form-control,
435
+  .prenota-field-stack .form-select {
436
+    border-color: rgba(21, 38, 92, 0.12);
437
+  }
438
+
439
+  .prenota-field-stack .form-control:focus,
440
+  .prenota-field-stack .form-select:focus {
441
+    border-color: color-mix(in srgb, var(--evento-accent) 55%, #fff);
442
+    box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--evento-accent) 18%, transparent);
443
+  }
444
+
445
+  .prenota-wizard-step {
446
+    display: none;
447
+    animation: prenotaWizardFade 0.2s ease;
448
+  }
449
+
450
+  .prenota-wizard-step.is-active {
451
+    display: block;
452
+  }
453
+
454
+  @keyframes prenotaWizardFade {
455
+    from { opacity: 0.35; }
456
+    to { opacity: 1; }
457
+  }
458
+
459
+  .prenota-wizard-progress-num {
460
+    font-size: 0.7rem;
461
+    font-weight: 700;
462
+    letter-spacing: 0.06em;
463
+    text-transform: uppercase;
464
+    color: var(--evento-muted);
465
+  }
466
+
467
+  .prenota-wizard-dots {
468
+    display: flex;
469
+    justify-content: center;
470
+    gap: 0.4rem;
471
+    flex-wrap: wrap;
472
+  }
473
+
474
+  .prenota-wizard-dot {
475
+    width: 8px;
476
+    height: 8px;
477
+    border-radius: 999px;
478
+    background: #d1d5db;
479
+    transition: background 0.2s ease, transform 0.2s ease;
480
+  }
481
+
482
+  .prenota-wizard-dot.is-done {
483
+    background: var(--evento-accent);
484
+  }
485
+
486
+  .prenota-wizard-dot.is-current {
487
+    background: var(--evento-purple);
488
+    transform: scale(1.25);
489
+  }
490
+
491
+  .prenota-step-title {
492
+    margin: 0 0 0.35rem;
493
+    font-size: 1.1rem;
494
+    font-weight: 800;
495
+    letter-spacing: -0.02em;
496
+    color: var(--evento-navy);
497
+  }
498
+
499
+  .prenota-step-hint {
500
+    margin: 0 0 1.1rem;
501
+    font-size: 0.88rem;
502
+    color: var(--evento-muted);
503
+    line-height: 1.45;
504
+  }
505
+
506
+  #prenotaWizardNav {
507
+    margin-top: 1.25rem;
508
+    padding-top: 1.1rem;
509
+    border-top: 1px solid rgba(21, 38, 92, 0.08);
510
+  }
511
+
512
+  #prenotaWizardNav .btn {
513
+    min-height: 2.85rem;
514
+  }
515
+
516
+  .prenota-public__btn-next,
517
+  .prenota-public__btn-submit {
518
+    display: inline-flex;
519
+    align-items: center;
520
+    justify-content: center;
521
+    gap: 0.4rem;
522
+    min-height: 3rem;
523
+    padding: 0.55rem 1.25rem;
524
+    border: 0;
525
+    border-radius: 999px;
526
+    background:
527
+      linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, transparent 42%),
528
+      linear-gradient(135deg, color-mix(in srgb, var(--evento-accent) 88%, #fff) 0%, var(--evento-accent) 48%, color-mix(in srgb, var(--evento-accent) 72%, #c45a00) 100%);
529
+    color: #fff !important;
530
+    font-size: 0.98rem;
531
+    font-weight: 800;
532
+    box-shadow:
533
+      0 1px 0 rgba(255, 255, 255, 0.28) inset,
534
+      0 8px 22px color-mix(in srgb, var(--evento-accent) 38%, transparent);
535
+    transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
536
+  }
537
+
538
+  .prenota-public__btn-next:hover,
539
+  .prenota-public__btn-submit:hover {
540
+    transform: translateY(-1px);
541
+    filter: brightness(1.04);
542
+    color: #fff !important;
543
+  }
544
+
545
+  .prenota-public__btn-prev {
546
+    border-radius: 999px;
547
+    font-weight: 600;
548
+    border-color: rgba(21, 38, 92, 0.16);
549
+    color: var(--evento-navy);
550
+  }
551
+
552
+  .guest-fest-accordion-btn {
553
+    background: #f7f8fc !important;
554
+    color: var(--evento-navy) !important;
555
+    font-weight: 600;
556
+    box-shadow: none !important;
557
+  }
558
+
559
+  .guest-fest-accordion-body {
560
+    background: #fafbfd;
561
+    border-radius: 0.65rem;
562
+    border: 1px solid rgba(21, 38, 92, 0.06);
563
+  }
564
+</style>

+ 150
- 0
resources/views/prenotazione/cliente/_partials/campo-richiesta.blade.php 查看文件

@@ -0,0 +1,150 @@
1
+@php
2
+  use App\Models\EventoForm;
3
+
4
+  $index = $index ?? null;
5
+  $isTemplate = ! empty($isTemplate);
6
+  $suffix = $index !== null ? '_'.$index : '';
7
+  $inputId = 'prenotazione_risposta_'.$form->id.$suffix;
8
+  $isMulti = $form->tipo === EventoForm::MULTISELEZIONE;
9
+  $isPerCoperto = (bool) $form->per_coperto;
10
+
11
+  if ($isPerCoperto && $isMulti) {
12
+    $inputName = 'prenotazione_risposta_'.$form->id.'['.($index ?? 0).'][]';
13
+  } elseif ($isPerCoperto) {
14
+    $inputName = 'prenotazione_risposta_'.$form->id.'['.($index ?? 0).']';
15
+  } elseif ($isMulti) {
16
+    $inputName = 'prenotazione_risposta_'.$form->id.'[]';
17
+  } else {
18
+    $inputName = 'prenotazione_risposta_'.$form->id;
19
+  }
20
+
21
+  $required = $form->obbligatoria && ! $isTemplate;
22
+  $disabled = $isTemplate;
23
+  $oldKey = 'prenotazione_risposta_'.$form->id;
24
+  $defaultVal = $defaultVal ?? null;
25
+  $rawOld = old($oldKey, null);
26
+  if ($isPerCoperto && $index !== null) {
27
+    if (is_array($rawOld)) {
28
+      $oldVal = $rawOld[$index] ?? $defaultVal;
29
+    } else {
30
+      $oldVal = $defaultVal;
31
+    }
32
+  } else {
33
+    $oldVal = $rawOld !== null ? $rawOld : $defaultVal;
34
+  }
35
+@endphp
36
+
37
+<div class="prenota-richiesta {{ $isPerCoperto ? 'prenota-richiesta--coperto' : '' }}">
38
+  @if($index === null || $index === 0)
39
+    <label class="form-label" for="{{ $inputId }}">
40
+      {{ $form->label }}
41
+      @if($form->obbligatoria)<span class="text-danger">*</span>@endif
42
+      @if($isPerCoperto)
43
+        <span class="text-muted fw-normal">
44
+          @if($index !== null) · coperto {{ $index + 1 }}@endif
45
+        </span>
46
+      @endif
47
+    </label>
48
+  @else
49
+    <label class="form-label" for="{{ $inputId }}">
50
+      {{ $form->label }}
51
+      @if($form->obbligatoria)<span class="text-danger">*</span>@endif
52
+      <span class="text-muted fw-normal"> · coperto {{ $index + 1 }}</span>
53
+    </label>
54
+  @endif
55
+
56
+  @if(filled($form->descrizione) && ($index === null || $index === 0))
57
+    <p class="small text-muted mb-2">{{ $form->descrizione }}</p>
58
+  @endif
59
+
60
+  @if($form->tipo === EventoForm::ELENCO || $form->tipo === EventoForm::SELEZIONE || $form->tipo === EventoForm::RADIO)
61
+    <select
62
+      class="form-select"
63
+      id="{{ $inputId }}"
64
+      name="{{ $inputName }}"
65
+      @required($required)
66
+      @disabled($disabled)
67
+    >
68
+      <option value="">Seleziona</option>
69
+      @foreach($form->opzioni_form as $opzione)
70
+        <option
71
+          value="{{ $opzione->id }}"
72
+          @selected((string) ($oldVal ?? '') === (string) $opzione->id)
73
+        >{{ $opzione->label }}</option>
74
+      @endforeach
75
+    </select>
76
+  @elseif($form->tipo === EventoForm::MULTISELEZIONE)
77
+    @foreach($form->opzioni_form as $opzione)
78
+      <div class="form-check">
79
+        <input
80
+          class="form-check-input"
81
+          type="checkbox"
82
+          id="{{ $inputId }}_{{ $opzione->id }}"
83
+          name="{{ $inputName }}"
84
+          value="{{ $opzione->id }}"
85
+          @disabled($disabled)
86
+          @checked(in_array((string) $opzione->id, array_map('strval', (array) $oldVal), true))
87
+        >
88
+        <label class="form-check-label" for="{{ $inputId }}_{{ $opzione->id }}">{{ $opzione->label }}</label>
89
+      </div>
90
+    @endforeach
91
+  @elseif($form->tipo === EventoForm::NUMERO)
92
+    <input
93
+      type="number"
94
+      class="form-control"
95
+      id="{{ $inputId }}"
96
+      name="{{ $inputName }}"
97
+      value="{{ $oldVal ?? '' }}"
98
+      inputmode="numeric"
99
+      @required($required)
100
+      @disabled($disabled)
101
+    >
102
+  @elseif($form->tipo === EventoForm::ORA)
103
+    <input
104
+      type="time"
105
+      class="form-control"
106
+      id="{{ $inputId }}"
107
+      name="{{ $inputName }}"
108
+      value="{{ $oldVal ?? '' }}"
109
+      @required($required)
110
+      @disabled($disabled)
111
+    >
112
+  @elseif($form->tipo === EventoForm::DATA)
113
+    <input
114
+      type="date"
115
+      class="form-control"
116
+      id="{{ $inputId }}"
117
+      name="{{ $inputName }}"
118
+      value="{{ $oldVal ?? '' }}"
119
+      @required($required)
120
+      @disabled($disabled)
121
+    >
122
+  @elseif($form->tipo === EventoForm::CHECKBOX)
123
+    <div class="form-check">
124
+      @unless($form->obbligatoria)
125
+        <input type="hidden" name="{{ $inputName }}" value="0" @disabled($disabled)>
126
+      @endunless
127
+      <input
128
+        class="form-check-input"
129
+        type="checkbox"
130
+        id="{{ $inputId }}"
131
+        name="{{ $inputName }}"
132
+        value="1"
133
+        @checked((string) ($oldVal ?? '') === '1')
134
+        @required($required)
135
+        @disabled($disabled)
136
+      >
137
+      <label class="form-check-label" for="{{ $inputId }}">Sì</label>
138
+    </div>
139
+  @else
140
+    <input
141
+      type="text"
142
+      class="form-control"
143
+      id="{{ $inputId }}"
144
+      name="{{ $inputName }}"
145
+      value="{{ $oldVal ?? '' }}"
146
+      @required($required)
147
+      @disabled($disabled)
148
+    >
149
+  @endif
150
+</div>

+ 462
- 463
resources/views/prenotazione/cliente/prenota.blade.php
文件差异内容过多而无法显示
查看文件


+ 449
- 0
resources/views/prenotazione/edit.blade.php 查看文件

@@ -0,0 +1,449 @@
1
+@php
2
+  use App\Models\EventoForm;
3
+  use App\Models\Prenotazione;
4
+  $configData = Helper::appClasses();
5
+  $backUrl = route('prenotazione.index.evento', ['evento_id' => $evento->id]);
6
+  $domande = $domande ?? collect();
7
+  $risposte = $risposte ?? [];
8
+  $stati = Prenotazione::getStati();
9
+  $domandePerCoperto = $domande->filter(fn ($f) => (bool) $f->per_coperto)->values();
10
+  $domandeSingole = $domande->reject(fn ($f) => (bool) $f->per_coperto)->values();
11
+
12
+  $risposteJs = $risposte;
13
+  if (session()->hasOldInput()) {
14
+    $risposteJs = [];
15
+    foreach ((array) old() as $key => $value) {
16
+      if (! is_string($key) || ! str_starts_with($key, 'prenotazione_risposta_')) {
17
+        continue;
18
+      }
19
+      $risposteJs[(int) str_replace('prenotazione_risposta_', '', $key)] = $value;
20
+    }
21
+  }
22
+@endphp
23
+
24
+@extends('layouts/layoutMaster')
25
+
26
+@section('title', 'Modifica prenotazione · '.$prenotazione->codice)
27
+
28
+@section('pageTitle')
29
+<div class="d-flex flex-column">
30
+  <h4 class="mb-0 text-sm-small">
31
+    <i class="bx bx-edit d-none d-md-inline-flex"></i>
32
+    Modifica prenotazione
33
+    <span class="text-muted fw-normal">· {{ strtoupper($prenotazione->codice) }}</span>
34
+  </h4>
35
+  <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block">
36
+    <ol class="breadcrumb breadcrumb-custom-icon mb-0">
37
+      <li class="breadcrumb-item">
38
+        <a href="{{ route('evento.index') }}">Eventi</a>
39
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
40
+      </li>
41
+      <li class="breadcrumb-item">
42
+        <a href="{{ $backUrl }}">Prenotazioni</a>
43
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
44
+      </li>
45
+      <li class="breadcrumb-item active text-primary">Modifica</li>
46
+    </ol>
47
+  </nav>
48
+</div>
49
+@endsection
50
+
51
+@section('content')
52
+@include('_partials.status')
53
+
54
+<style>
55
+  .prenota-admin .report-section-title {
56
+    display: flex;
57
+    align-items: center;
58
+    gap: .55rem;
59
+    margin: 0 0 .85rem;
60
+    font-size: .72rem;
61
+    font-weight: 700;
62
+    letter-spacing: .08em;
63
+    text-transform: uppercase;
64
+    color: #8592a3;
65
+  }
66
+  .prenota-admin .report-section-title::after {
67
+    content: "";
68
+    flex: 1 1 auto;
69
+    height: 1px;
70
+    background: rgba(67, 89, 113, .12);
71
+  }
72
+  .prenota-admin .report-section-title i {
73
+    font-size: 1rem;
74
+    color: #696cff;
75
+  }
76
+  .prenota-admin .prenota-richiesta {
77
+    margin-bottom: 1rem;
78
+  }
79
+  .prenota-admin .prenota-coperti-table {
80
+    margin-bottom: 0;
81
+    font-size: .875rem;
82
+  }
83
+  .prenota-admin .prenota-coperti-table thead th {
84
+    background: rgba(67, 89, 113, .04);
85
+    font-size: .72rem;
86
+    font-weight: 700;
87
+    letter-spacing: .04em;
88
+    text-transform: uppercase;
89
+    color: #697a8d;
90
+    white-space: nowrap;
91
+    vertical-align: bottom;
92
+  }
93
+  .prenota-admin .prenota-coperti-table th:first-child,
94
+  .prenota-admin .prenota-coperti-table td:first-child {
95
+    width: 4.5rem;
96
+    text-align: center;
97
+    font-weight: 600;
98
+    background: rgba(67, 89, 113, .03);
99
+    vertical-align: middle;
100
+  }
101
+  .prenota-admin .prenota-coperti-table td {
102
+    vertical-align: middle;
103
+    min-width: 8.5rem;
104
+  }
105
+  .prenota-admin .prenota-coperti-table .prenota-cella-multi {
106
+    display: flex;
107
+    flex-direction: column;
108
+    gap: .2rem;
109
+  }
110
+  .prenota-admin .prenota-coperti-hint {
111
+    font-size: .8rem;
112
+    color: #8592a3;
113
+    margin: -.25rem 0 .85rem;
114
+  }
115
+</style>
116
+
117
+<div class="prenota-admin">
118
+  <form id="prenotaAdminForm" method="POST" action="{{ route('prenotazione.update', ['prenotazione_id' => $prenotazione->id]) }}">
119
+    @csrf
120
+    <input type="hidden" name="evento_id" value="{{ $evento->id }}">
121
+
122
+    <div class="row g-3">
123
+      <div class="col-lg-8">
124
+        <div class="card mb-3">
125
+          <div class="card-body">
126
+            <div class="report-section-title"><i class="bx bx-user"></i> Anagrafica</div>
127
+            <div class="row g-3">
128
+              <div class="col-md-6">
129
+                <label class="form-label" for="cognome">Cognome <span class="text-danger">*</span></label>
130
+                <input type="text" class="form-control" name="cognome" id="cognome" value="{{ old('cognome', $prenotazione->cognome) }}" required>
131
+              </div>
132
+              <div class="col-md-6">
133
+                <label class="form-label" for="nome">Nome <span class="text-danger">*</span></label>
134
+                <input type="text" class="form-control" name="nome" id="nome" value="{{ old('nome', $prenotazione->nome) }}" required>
135
+              </div>
136
+              <div class="col-md-6">
137
+                <label class="form-label" for="email">Email <span class="text-danger">*</span></label>
138
+                <input type="email" class="form-control" name="email" id="email" value="{{ old('email', $prenotazione->email) }}" required>
139
+              </div>
140
+              <div class="col-md-6">
141
+                <label class="form-label" for="telefono">Telefono</label>
142
+                <input type="text" class="form-control" name="telefono" id="telefono" value="{{ old('telefono', $prenotazione->telefono) }}">
143
+              </div>
144
+              <div class="col-md-4">
145
+                <label class="form-label" for="numero_persone">Coperti <span class="text-danger">*</span></label>
146
+                <input type="number" class="form-control" name="numero_persone" id="numero_persone" min="1" value="{{ old('numero_persone', max(1, (int) $prenotazione->numero_persone)) }}" required>
147
+              </div>
148
+              <div class="col-12">
149
+                <label class="form-label" for="note">Note</label>
150
+                <textarea class="form-control" name="note" id="note" rows="2">{{ old('note', $prenotazione->note) }}</textarea>
151
+              </div>
152
+            </div>
153
+          </div>
154
+        </div>
155
+
156
+        @if($domandePerCoperto->isNotEmpty() || $domandeSingole->isNotEmpty())
157
+          <div class="card mb-3">
158
+            <div class="card-body">
159
+              <div class="report-section-title"><i class="bx bx-message-square-detail"></i> Richieste evento</div>
160
+
161
+              @if($domandePerCoperto->isNotEmpty())
162
+                <p class="prenota-coperti-hint">Una riga per coperto · le colonne sono le domande individuali.</p>
163
+                <div class="table-responsive mb-3" data-coperti-wrap>
164
+                  <table class="table table-sm table-bordered prenota-coperti-table align-middle">
165
+                    <thead>
166
+                      <tr>
167
+                        <th scope="col">#</th>
168
+                        @foreach($domandePerCoperto as $form)
169
+                          <th
170
+                            scope="col"
171
+                            data-per-coperto
172
+                            data-form-id="{{ $form->id }}"
173
+                            data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
174
+                            data-multi="{{ $form->tipo === EventoForm::MULTISELEZIONE ? '1' : '0' }}"
175
+                            title="{{ $form->descrizione }}"
176
+                          >
177
+                            {{ $form->label }}
178
+                            @if($form->obbligatoria)<span class="text-danger">*</span>@endif
179
+                          </th>
180
+                        @endforeach
181
+                      </tr>
182
+                    </thead>
183
+                    <tbody data-coperti-tbody></tbody>
184
+                  </table>
185
+                </div>
186
+
187
+                <template data-coperti-row-tpl>
188
+                  <tr data-coperto-row>
189
+                    <td data-coperto-label>1</td>
190
+                    @foreach($domandePerCoperto as $form)
191
+                      <td
192
+                        data-coperto-cell
193
+                        data-form-id="{{ $form->id }}"
194
+                        data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
195
+                        data-multi="{{ $form->tipo === EventoForm::MULTISELEZIONE ? '1' : '0' }}"
196
+                      >
197
+                        @include('prenotazione._partials.campo-cella', [
198
+                          'form' => $form,
199
+                          'index' => 0,
200
+                          'isTemplate' => true,
201
+                        ])
202
+                      </td>
203
+                    @endforeach
204
+                  </tr>
205
+                </template>
206
+              @endif
207
+
208
+              @foreach($domandeSingole as $form)
209
+                @php $defaultVal = old('prenotazione_risposta_'.$form->id, $risposte[$form->id] ?? null); @endphp
210
+                <div
211
+                  @if($form->tipo === EventoForm::MULTISELEZIONE)
212
+                    data-multi-group
213
+                    data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
214
+                  @endif
215
+                >
216
+                  @include('prenotazione.cliente._partials.campo-richiesta', [
217
+                    'form' => $form,
218
+                    'defaultVal' => $defaultVal,
219
+                  ])
220
+                </div>
221
+              @endforeach
222
+            </div>
223
+          </div>
224
+        @endif
225
+      </div>
226
+
227
+      <div class="col-lg-4">
228
+        <div class="card mb-3">
229
+          <div class="card-body">
230
+            <div class="report-section-title"><i class="bx bx-info-circle"></i> Evento</div>
231
+            <div class="fw-semibold mb-1">{{ $evento->nome }}</div>
232
+            @if($evento->data_inizio)
233
+              <div class="small text-muted mb-2">
234
+                {{ \Carbon\Carbon::parse($evento->data_inizio)->translatedFormat('d M Y H:i') }}
235
+              </div>
236
+            @endif
237
+            <div class="small">
238
+              Codice: <span class="fw-semibold">{{ strtoupper($prenotazione->codice) }}</span>
239
+            </div>
240
+          </div>
241
+        </div>
242
+
243
+        <div class="card mb-3">
244
+          <div class="card-body">
245
+            <div class="report-section-title"><i class="bx bx-check-shield"></i> Stato</div>
246
+            <div class="form-check form-switch mb-3">
247
+              <input class="form-check-input" type="checkbox" name="confermata" value="1" id="confermata" @checked(old('confermata', $prenotazione->confermata))>
248
+              <label class="form-check-label" for="confermata">Confermata</label>
249
+            </div>
250
+            <label class="form-label" for="stato">Stato</label>
251
+            <select class="form-select" name="stato" id="stato">
252
+              @foreach($stati as $key => $stato)
253
+                <option value="{{ $key }}" @selected(old('stato', $prenotazione->stato) === $key)>
254
+                  {{ $stato['label'] }}
255
+                </option>
256
+              @endforeach
257
+            </select>
258
+            <p class="small text-muted mt-2 mb-0">Se spunti «Confermata», lo stato viene allineato a «Confermata».</p>
259
+          </div>
260
+        </div>
261
+
262
+        <div class="d-flex flex-wrap gap-2">
263
+          <a href="{{ $backUrl }}" class="btn btn-label-secondary">Annulla</a>
264
+          <button type="submit" class="btn btn-primary ms-auto">
265
+            <i class="bx bx-save"></i> Salva modifiche
266
+          </button>
267
+        </div>
268
+      </div>
269
+    </div>
270
+  </form>
271
+</div>
272
+@endsection
273
+
274
+@section('page-script')
275
+<script>
276
+  document.addEventListener('DOMContentLoaded', function () {
277
+    var form = document.getElementById('prenotaAdminForm');
278
+    if (!form) return;
279
+
280
+    var confermataEl = document.getElementById('confermata');
281
+    var statoEl = document.getElementById('stato');
282
+    var seedRisposte = @json($risposteJs);
283
+    var tbody = form.querySelector('[data-coperti-tbody]');
284
+    var rowTpl = form.querySelector('[data-coperti-row-tpl]');
285
+
286
+    function capturePerCoperto() {
287
+      var out = {};
288
+      if (!tbody) return out;
289
+
290
+      tbody.querySelectorAll('[data-coperto-row]').forEach(function (row, i) {
291
+        row.querySelectorAll('[data-coperto-cell]').forEach(function (cell) {
292
+          var id = cell.getAttribute('data-form-id');
293
+          var isMulti = cell.getAttribute('data-multi') === '1';
294
+          if (!out[id]) out[id] = [];
295
+
296
+          if (isMulti) {
297
+            var checked = [];
298
+            cell.querySelectorAll('input[type="checkbox"]:checked').forEach(function (el) {
299
+              checked.push(el.value);
300
+            });
301
+            out[id][i] = checked;
302
+          } else {
303
+            var el = cell.querySelector('input:not([type="hidden"]), select, textarea');
304
+            if (!el) {
305
+              out[id][i] = '';
306
+              return;
307
+            }
308
+            if (el.type === 'checkbox') {
309
+              out[id][i] = el.checked ? el.value : '0';
310
+            } else {
311
+              out[id][i] = el.value;
312
+            }
313
+          }
314
+        });
315
+      });
316
+      return out;
317
+    }
318
+
319
+    function applyPerCoperto(valuesByForm) {
320
+      if (!tbody) return;
321
+      Object.keys(valuesByForm || {}).forEach(function (formId) {
322
+        var vals = valuesByForm[formId];
323
+        if (!Array.isArray(vals)) return;
324
+
325
+        tbody.querySelectorAll('[data-coperto-row]').forEach(function (row, i) {
326
+          var cell = row.querySelector('[data-coperto-cell][data-form-id="' + formId + '"]');
327
+          if (!cell) return;
328
+          var v = vals[i];
329
+          if (v === undefined || v === null) return;
330
+          var isMulti = cell.getAttribute('data-multi') === '1';
331
+
332
+          if (isMulti) {
333
+            var arr = Array.isArray(v) ? v.map(String) : [];
334
+            cell.querySelectorAll('input[type="checkbox"]').forEach(function (el) {
335
+              el.checked = arr.indexOf(String(el.value)) !== -1;
336
+            });
337
+            return;
338
+          }
339
+
340
+          var el = cell.querySelector('input:not([type="hidden"]), select, textarea');
341
+          if (!el) return;
342
+          if (el.type === 'checkbox') {
343
+            el.checked = String(v) === '1' || v === true || v === 1;
344
+          } else {
345
+            el.value = v;
346
+          }
347
+        });
348
+      });
349
+    }
350
+
351
+    function syncPerCoperto() {
352
+      if (!tbody || !rowTpl) return;
353
+
354
+      var preserved = capturePerCoperto();
355
+      var nEl = document.getElementById('numero_persone');
356
+      var n = nEl ? parseInt(nEl.value, 10) : 1;
357
+      if (!n || n < 1) n = 1;
358
+
359
+      tbody.innerHTML = '';
360
+      for (var i = 0; i < n; i++) {
361
+        var frag = rowTpl.content.cloneNode(true);
362
+        var row = frag.querySelector('[data-coperto-row]');
363
+        var label = frag.querySelector('[data-coperto-label]');
364
+        if (label) label.textContent = String(i + 1);
365
+
366
+        frag.querySelectorAll('[name]').forEach(function (el) {
367
+          el.name = el.name.replace('[0]', '[' + i + ']');
368
+          el.disabled = false;
369
+        });
370
+        frag.querySelectorAll('[id]').forEach(function (el) {
371
+          el.id = el.id.replace(/_0(?=_|$)/, '_' + i);
372
+        });
373
+        frag.querySelectorAll('[for]').forEach(function (el) {
374
+          el.htmlFor = el.htmlFor.replace(/_0(?=_|$)/, '_' + i);
375
+        });
376
+
377
+        frag.querySelectorAll('[data-coperto-cell]').forEach(function (cell) {
378
+          var obbligatoria = cell.getAttribute('data-obbligatoria') === '1';
379
+          var isMulti = cell.getAttribute('data-multi') === '1';
380
+          cell.querySelectorAll('input, select, textarea').forEach(function (el) {
381
+            if (el.type === 'hidden') return;
382
+            if (!obbligatoria || isMulti) return;
383
+            if (el.type === 'checkbox') {
384
+              if (el.value === '1') el.required = true;
385
+            } else {
386
+              el.required = true;
387
+            }
388
+          });
389
+        });
390
+
391
+        tbody.appendChild(frag);
392
+      }
393
+
394
+      applyPerCoperto(Object.assign({}, seedRisposte, preserved));
395
+    }
396
+
397
+    function validateMultiGroups() {
398
+      var groups = form.querySelectorAll('[data-multi-group][data-obbligatoria="1"]');
399
+      for (var g = 0; g < groups.length; g++) {
400
+        var boxes = groups[g].querySelectorAll('input[type="checkbox"]:not(:disabled)');
401
+        var ok = Array.prototype.some.call(boxes, function (b) { return b.checked; });
402
+        if (!ok && boxes.length) {
403
+          boxes[0].setCustomValidity('Seleziona almeno un\'opzione');
404
+          boxes[0].reportValidity();
405
+          return false;
406
+        }
407
+        if (boxes.length) boxes[0].setCustomValidity('');
408
+      }
409
+
410
+      if (tbody) {
411
+        var cells = tbody.querySelectorAll('[data-coperto-cell][data-multi="1"][data-obbligatoria="1"]');
412
+        for (var c = 0; c < cells.length; c++) {
413
+          var cboxes = cells[c].querySelectorAll('input[type="checkbox"]:not(:disabled)');
414
+          var cok = Array.prototype.some.call(cboxes, function (b) { return b.checked; });
415
+          if (!cok && cboxes.length) {
416
+            cboxes[0].setCustomValidity('Seleziona almeno un\'opzione');
417
+            cboxes[0].reportValidity();
418
+            return false;
419
+          }
420
+          if (cboxes.length) cboxes[0].setCustomValidity('');
421
+        }
422
+      }
423
+      return true;
424
+    }
425
+
426
+    var nPersoneEl = document.getElementById('numero_persone');
427
+    if (nPersoneEl) {
428
+      nPersoneEl.addEventListener('change', syncPerCoperto);
429
+      nPersoneEl.addEventListener('input', syncPerCoperto);
430
+    }
431
+
432
+    if (confermataEl && statoEl) {
433
+      confermataEl.addEventListener('change', function () {
434
+        if (confermataEl.checked) {
435
+          statoEl.value = @json(\App\Models\Prenotazione::STATO_CONFERMATA);
436
+        }
437
+      });
438
+    }
439
+
440
+    form.addEventListener('submit', function (e) {
441
+      if (!validateMultiGroups()) {
442
+        e.preventDefault();
443
+      }
444
+    });
445
+
446
+    syncPerCoperto();
447
+  });
448
+</script>
449
+@endsection

+ 40
- 0
resources/views/prenotazione/email/_partials/header.blade.php 查看文件

@@ -0,0 +1,40 @@
1
+@php
2
+  $navy = $navy ?? '#15265c';
3
+  $orange = $orange ?? '#f58220';
4
+  $coverUrl = $coverUrl ?? null;
5
+  $eyebrow = $eyebrow ?? '';
6
+  $title = $title ?? '';
7
+@endphp
8
+@if($coverUrl)
9
+  <tr>
10
+    <td style="padding:0;line-height:0;font-size:0;">
11
+      <img
12
+        src="{{ $coverUrl }}"
13
+        alt="{{ $title }}"
14
+        width="440"
15
+        style="display:block;width:100%;max-width:440px;height:180px;object-fit:cover;border:0;"
16
+      >
17
+    </td>
18
+  </tr>
19
+  <tr>
20
+    <td style="padding:16px 24px;background:{{ $navy }};">
21
+      <p style="margin:0;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.75);">
22
+        {{ $eyebrow }}
23
+      </p>
24
+      <p style="margin:6px 0 0;font-size:20px;font-weight:700;color:#ffffff;line-height:1.25;">
25
+        {{ $title }}
26
+      </p>
27
+    </td>
28
+  </tr>
29
+@else
30
+  <tr>
31
+    <td style="padding:20px 24px 12px;background:linear-gradient(125deg, {{ $navy }} 0%, #602d91 55%, {{ $orange }} 100%);">
32
+      <p style="margin:0;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.75);">
33
+        {{ $eyebrow }}
34
+      </p>
35
+      <p style="margin:6px 0 0;font-size:20px;font-weight:700;color:#ffffff;line-height:1.25;">
36
+        {{ $title }}
37
+      </p>
38
+    </td>
39
+  </tr>
40
+@endif

+ 131
- 0
resources/views/prenotazione/email/annullata.blade.php 查看文件

@@ -0,0 +1,131 @@
1
+@php
2
+  use App\Models\Prenotazione;
3
+  use Carbon\Carbon;
4
+
5
+  $evento = $prenotazione->evento;
6
+  $attivita = $evento?->attivita;
7
+  $attivitaNome = $attivita?->nome ?: 'Prenotazione';
8
+  $nomePersona = trim(($prenotazione->nome ?? '').' '.($prenotazione->cognome ?? '')) ?: 'Ciao';
9
+  $codice = strtoupper((string) ($prenotazione->codice ?? ''));
10
+  $dataEvento = $evento?->data_inizio
11
+    ? Carbon::parse($evento->data_inizio)->translatedFormat('d M Y · H:i')
12
+    : null;
13
+  $luogo = $evento?->luogoBreve();
14
+  $coperti = max(1, (int) ($prenotazione->numero_persone ?? 1));
15
+  $riepilogoUrl = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
16
+  $coverUrl = $evento?->emailCopertinaUrl();
17
+  $statoMessage = Prenotazione::getStati()->get(Prenotazione::STATO_ANNULLATA)['message']
18
+    ?? 'La prenotazione è stata annullata dall\'organizzatore';
19
+
20
+  $navy = '#15265c';
21
+  $orange = '#f58220';
22
+  $muted = '#6b7280';
23
+  $border = '#e2e6ef';
24
+  $surface = '#f0f2f8';
25
+  $danger = '#d7352a';
26
+  $dangerBg = '#fff1f0';
27
+  $dangerBorder = '#fac6c6';
28
+@endphp
29
+<!DOCTYPE html>
30
+<html lang="it">
31
+<head>
32
+  <meta charset="utf-8">
33
+  <meta name="viewport" content="width=device-width, initial-scale=1">
34
+  <title>Prenotazione annullata</title>
35
+</head>
36
+<body style="margin:0;padding:0;background:{{ $surface }};font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{{ $navy }};">
37
+  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{{ $surface }};padding:24px 12px;">
38
+    <tr>
39
+      <td align="center">
40
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:440px;background:#ffffff;border:1px solid {{ $border }};border-radius:14px;overflow:hidden;">
41
+          @include('prenotazione.email._partials.header', [
42
+            'coverUrl' => $coverUrl,
43
+            'eyebrow' => 'Prenotazione annullata',
44
+            'title' => $attivitaNome,
45
+            'navy' => $navy,
46
+            'orange' => $orange,
47
+          ])
48
+
49
+          <tr>
50
+            <td style="padding:24px;">
51
+              <p style="margin:0 0 16px;font-size:15px;line-height:1.5;color:{{ $navy }};">
52
+                Ciao {{ $nomePersona }},<br>
53
+                {{ $statoMessage }}.
54
+              </p>
55
+
56
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:{{ $dangerBg }};border:1px solid {{ $dangerBorder }};border-radius:10px;">
57
+                <tr>
58
+                  <td style="padding:12px 14px;font-size:13px;line-height:1.45;color:{{ $danger }};">
59
+                    Non è più possibile partecipare con questa prenotazione.
60
+                    Se pensi si tratti di un errore, contatta l’organizzazione.
61
+                  </td>
62
+                </tr>
63
+              </table>
64
+
65
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
66
+                <tr>
67
+                  <td align="center" style="padding:18px 16px;">
68
+                    <p style="margin:0 0 6px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:{{ $muted }};">
69
+                      Codice prenotazione
70
+                    </p>
71
+                    <p style="margin:0;font-size:28px;font-weight:700;letter-spacing:.12em;color:{{ $navy }};font-family:Consolas,'Courier New',monospace;">
72
+                      {{ $codice ?: '—' }}
73
+                    </p>
74
+                  </td>
75
+                </tr>
76
+              </table>
77
+
78
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 22px;border-collapse:collapse;">
79
+                @if($evento)
80
+                  <tr>
81
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};width:34%;vertical-align:top;">Evento</td>
82
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;font-weight:600;color:{{ $navy }};">{{ $evento->nome }}</td>
83
+                  </tr>
84
+                @endif
85
+                @if($dataEvento)
86
+                  <tr>
87
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Quando</td>
88
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $dataEvento }}</td>
89
+                  </tr>
90
+                @endif
91
+                @if($luogo)
92
+                  <tr>
93
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Dove</td>
94
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $luogo }}</td>
95
+                  </tr>
96
+                @endif
97
+                <tr>
98
+                  <td style="padding:10px 0;font-size:12px;color:{{ $muted }};vertical-align:top;">Persone</td>
99
+                  <td style="padding:10px 0;font-size:15px;color:{{ $navy }};">{{ $coperti }}</td>
100
+                </tr>
101
+              </table>
102
+
103
+              <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 8px;">
104
+                <tr>
105
+                  <td align="center" style="border-radius:999px;background:{{ $orange }};">
106
+                    <a href="{{ $riepilogoUrl }}" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:700;color:#ffffff;text-decoration:none;">
107
+                      Vedi il riepilogo
108
+                    </a>
109
+                  </td>
110
+                </tr>
111
+              </table>
112
+
113
+              <p style="margin:18px 0 0;font-size:12px;line-height:1.5;color:{{ $muted }};text-align:center;">
114
+                Conserva il codice: ti servirà per consultare la prenotazione.
115
+              </p>
116
+            </td>
117
+          </tr>
118
+
119
+          <tr>
120
+            <td style="padding:14px 24px 18px;border-top:1px solid {{ $border }};background:#fafbfd;">
121
+              <p style="margin:0;font-size:12px;color:{{ $muted }};text-align:center;">
122
+                {{ $attivitaNome }}
123
+              </p>
124
+            </td>
125
+          </tr>
126
+        </table>
127
+      </td>
128
+    </tr>
129
+  </table>
130
+</body>
131
+</html>

+ 133
- 0
resources/views/prenotazione/email/confermata.blade.php 查看文件

@@ -0,0 +1,133 @@
1
+@php
2
+  use Carbon\Carbon;
3
+
4
+  $evento = $prenotazione->evento;
5
+  $attivita = $evento?->attivita;
6
+  $attivitaNome = $attivita?->nome ?: 'Prenotazione';
7
+  $nomePersona = trim(($prenotazione->nome ?? '').' '.($prenotazione->cognome ?? '')) ?: 'Ciao';
8
+  $codice = strtoupper((string) ($prenotazione->codice ?? ''));
9
+  $dataEvento = $evento?->data_inizio
10
+    ? Carbon::parse($evento->data_inizio)->translatedFormat('d M Y · H:i')
11
+    : null;
12
+  $luogo = $evento?->luogoBreve();
13
+  $coperti = max(1, (int) ($prenotazione->numero_persone ?? 1));
14
+  $riepilogoUrl = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
15
+  $coverUrl = $evento?->emailCopertinaUrl();
16
+  $indicazioni = $evento?->emailTestoConfermata();
17
+  $calendarioUrl = '';
18
+
19
+  $navy = '#15265c';
20
+  $orange = '#f58220';
21
+  $muted = '#6b7280';
22
+  $border = '#e2e6ef';
23
+  $surface = '#f0f2f8';
24
+@endphp
25
+<!DOCTYPE html>
26
+<html lang="it">
27
+<head>
28
+  <meta charset="utf-8">
29
+  <meta name="viewport" content="width=device-width, initial-scale=1">
30
+  <title>Prenotazione confermata</title>
31
+</head>
32
+<body style="margin:0;padding:0;background:{{ $surface }};font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{{ $navy }};">
33
+  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{{ $surface }};padding:24px 12px;">
34
+    <tr>
35
+      <td align="center">
36
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:440px;background:#ffffff;border:1px solid {{ $border }};border-radius:14px;overflow:hidden;">
37
+          @include('prenotazione.email._partials.header', [
38
+            'coverUrl' => $coverUrl,
39
+            'eyebrow' => 'Prenotazione confermata',
40
+            'title' => $attivitaNome,
41
+            'navy' => $navy,
42
+            'orange' => $orange,
43
+          ])
44
+
45
+          <tr>
46
+            <td style="padding:24px;">
47
+              <p style="margin:0 0 16px;font-size:15px;line-height:1.5;color:{{ $navy }};">
48
+                Ciao {{ $nomePersona }},<br>
49
+                la tua prenotazione è confermata. Porta con te questo codice.
50
+              </p>
51
+
52
+              @if($indicazioni)
53
+                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
54
+                  <tr>
55
+                    <td style="padding:14px 16px;font-size:14px;line-height:1.55;color:{{ $navy }};white-space:pre-line;">{{ $indicazioni }}</td>
56
+                  </tr>
57
+                </table>
58
+              @endif
59
+
60
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
61
+                <tr>
62
+                  <td align="center" style="padding:18px 16px;">
63
+                    <p style="margin:0 0 6px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:{{ $muted }};">
64
+                      Codice prenotazione
65
+                    </p>
66
+                    <p style="margin:0;font-size:28px;font-weight:700;letter-spacing:.12em;color:{{ $navy }};font-family:Consolas,'Courier New',monospace;">
67
+                      {{ $codice ?: '—' }}
68
+                    </p>
69
+                  </td>
70
+                </tr>
71
+              </table>
72
+
73
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 22px;border-collapse:collapse;">
74
+                @if($evento)
75
+                  <tr>
76
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};width:34%;vertical-align:top;">Evento</td>
77
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;font-weight:600;color:{{ $navy }};">{{ $evento->nome }}</td>
78
+                  </tr>
79
+                @endif
80
+                @if($dataEvento)
81
+                  <tr>
82
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Quando</td>
83
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $dataEvento }}</td>
84
+                  </tr>
85
+                @endif
86
+                @if($luogo)
87
+                  <tr>
88
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Dove</td>
89
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $luogo }}</td>
90
+                  </tr>
91
+                @endif
92
+                <tr>
93
+                  <td style="padding:10px 0;font-size:12px;color:{{ $muted }};vertical-align:top;">Persone</td>
94
+                  <td style="padding:10px 0;font-size:15px;color:{{ $navy }};">{{ $coperti }}</td>
95
+                </tr>
96
+              </table>
97
+
98
+              <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 8px;">
99
+                <tr>
100
+                  <td align="center" style="border-radius:999px;background:{{ $orange }};">
101
+                    <a href="{{ $riepilogoUrl }}" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:700;color:#ffffff;text-decoration:none;">
102
+                      Apri il riepilogo
103
+                    </a>
104
+                  </td>
105
+                </tr>
106
+              </table>
107
+
108
+              @if($calendarioUrl)
109
+                <p style="margin:14px 0 0;font-size:13px;line-height:1.5;color:{{ $muted }};text-align:center;">
110
+                  <a href="{{ $calendarioUrl }}" style="color:{{ $orange }};font-weight:600;text-decoration:none;">Aggiungi al calendario</a>
111
+                  <span style="color:{{ $muted }};"> · oppure apri l’allegato .ics</span>
112
+                </p>
113
+              @endif
114
+
115
+              <p style="margin:18px 0 0;font-size:12px;line-height:1.5;color:{{ $muted }};text-align:center;">
116
+                Se non puoi più venire, avvisa gli organizzatori.
117
+              </p>
118
+            </td>
119
+          </tr>
120
+
121
+          <tr>
122
+            <td style="padding:14px 24px 18px;border-top:1px solid {{ $border }};background:#fafbfd;">
123
+              <p style="margin:0;font-size:12px;color:{{ $muted }};text-align:center;">
124
+                {{ $attivitaNome }}
125
+              </p>
126
+            </td>
127
+          </tr>
128
+        </table>
129
+      </td>
130
+    </tr>
131
+  </table>
132
+</body>
133
+</html>

+ 141
- 0
resources/views/prenotazione/email/inviata.blade.php 查看文件

@@ -0,0 +1,141 @@
1
+@php
2
+  use Carbon\Carbon;
3
+
4
+  $evento = $prenotazione->evento;
5
+  $attivita = $evento?->attivita;
6
+  $attivitaNome = $attivita?->nome ?: 'Prenotazione';
7
+  $nomePersona = trim(($prenotazione->nome ?? '').' '.($prenotazione->cognome ?? '')) ?: 'Ciao';
8
+  $codice = strtoupper((string) ($prenotazione->codice ?? ''));
9
+  $dataEvento = $evento?->data_inizio
10
+    ? Carbon::parse($evento->data_inizio)->translatedFormat('d M Y · H:i')
11
+    : null;
12
+  $luogo = $evento?->luogoBreve();
13
+  $coperti = max(1, (int) ($prenotazione->numero_persone ?? 1));
14
+  $riepilogoUrl = route('consulta.prenotazione.show', ['codice' => $prenotazione->codice]);
15
+  $coverUrl = $evento?->emailCopertinaUrl();
16
+  $indicazioni = $evento?->emailTestoInviata();
17
+  $calendarioUrl = '';
18
+
19
+  $navy = '#15265c';
20
+  $orange = '#f58220';
21
+  $muted = '#6b7280';
22
+  $border = '#e2e6ef';
23
+  $surface = '#f0f2f8';
24
+@endphp
25
+<!DOCTYPE html>
26
+<html lang="it">
27
+<head>
28
+  <meta charset="utf-8">
29
+  <meta name="viewport" content="width=device-width, initial-scale=1">
30
+  <title>Prenotazione ricevuta</title>
31
+</head>
32
+<body style="margin:0;padding:0;background:{{ $surface }};font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{{ $navy }};">
33
+  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{{ $surface }};padding:24px 12px;">
34
+    <tr>
35
+      <td align="center">
36
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:440px;background:#ffffff;border:1px solid {{ $border }};border-radius:14px;overflow:hidden;">
37
+          @include('prenotazione.email._partials.header', [
38
+            'coverUrl' => $coverUrl,
39
+            'eyebrow' => 'Prenotazione ricevuta',
40
+            'title' => $attivitaNome,
41
+            'navy' => $navy,
42
+            'orange' => $orange,
43
+          ])
44
+
45
+          <tr>
46
+            <td style="padding:24px;">
47
+              <p style="margin:0 0 16px;font-size:15px;line-height:1.5;color:{{ $navy }};">
48
+                Ciao {{ $nomePersona }},<br>
49
+                abbiamo ricevuto la tua richiesta. È in attesa di conferma da parte degli organizzatori.
50
+              </p>
51
+
52
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:#fff8f0;border:1px solid #f5d4b0;border-radius:10px;">
53
+                <tr>
54
+                  <td style="padding:12px 14px;font-size:13px;line-height:1.45;color:{{ $navy }};">
55
+                    Ti invieremo un’altra email quando la prenotazione sarà <strong>confermata</strong>.
56
+                  </td>
57
+                </tr>
58
+              </table>
59
+
60
+              @if($indicazioni)
61
+                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
62
+                  <tr>
63
+                    <td style="padding:14px 16px;font-size:14px;line-height:1.55;color:{{ $navy }};white-space:pre-line;">{{ $indicazioni }}</td>
64
+                  </tr>
65
+                </table>
66
+              @endif
67
+
68
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
69
+                <tr>
70
+                  <td align="center" style="padding:18px 16px;">
71
+                    <p style="margin:0 0 6px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:{{ $muted }};">
72
+                      Codice prenotazione
73
+                    </p>
74
+                    <p style="margin:0;font-size:28px;font-weight:700;letter-spacing:.12em;color:{{ $navy }};font-family:Consolas,'Courier New',monospace;">
75
+                      {{ $codice ?: '—' }}
76
+                    </p>
77
+                  </td>
78
+                </tr>
79
+              </table>
80
+
81
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 22px;border-collapse:collapse;">
82
+                @if($evento)
83
+                  <tr>
84
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};width:34%;vertical-align:top;">Evento</td>
85
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;font-weight:600;color:{{ $navy }};">{{ $evento->nome }}</td>
86
+                  </tr>
87
+                @endif
88
+                @if($dataEvento)
89
+                  <tr>
90
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Quando</td>
91
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $dataEvento }}</td>
92
+                  </tr>
93
+                @endif
94
+                @if($luogo)
95
+                  <tr>
96
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:12px;color:{{ $muted }};vertical-align:top;">Dove</td>
97
+                    <td style="padding:10px 0;border-bottom:1px solid {{ $border }};font-size:15px;color:{{ $navy }};">{{ $luogo }}</td>
98
+                  </tr>
99
+                @endif
100
+                <tr>
101
+                  <td style="padding:10px 0;font-size:12px;color:{{ $muted }};vertical-align:top;">Persone</td>
102
+                  <td style="padding:10px 0;font-size:15px;color:{{ $navy }};">{{ $coperti }}</td>
103
+                </tr>
104
+              </table>
105
+
106
+              <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 8px;">
107
+                <tr>
108
+                  <td align="center" style="border-radius:999px;background:{{ $orange }};">
109
+                    <a href="{{ $riepilogoUrl }}" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:700;color:#ffffff;text-decoration:none;">
110
+                      Vedi il riepilogo
111
+                    </a>
112
+                  </td>
113
+                </tr>
114
+              </table>
115
+
116
+              @if($calendarioUrl)
117
+                <p style="margin:14px 0 0;font-size:13px;line-height:1.5;color:{{ $muted }};text-align:center;">
118
+                  <a href="{{ $calendarioUrl }}" style="color:{{ $orange }};font-weight:600;text-decoration:none;">Aggiungi al calendario</a>
119
+                  <span style="color:{{ $muted }};"> · oppure apri l’allegato .ics</span>
120
+                </p>
121
+              @endif
122
+
123
+              <p style="margin:18px 0 0;font-size:12px;line-height:1.5;color:{{ $muted }};text-align:center;">
124
+                Conserva il codice: ti servirà per consultare la prenotazione.
125
+              </p>
126
+            </td>
127
+          </tr>
128
+
129
+          <tr>
130
+            <td style="padding:14px 24px 18px;border-top:1px solid {{ $border }};background:#fafbfd;">
131
+              <p style="margin:0;font-size:12px;color:{{ $muted }};text-align:center;">
132
+                {{ $attivitaNome }}
133
+              </p>
134
+            </td>
135
+          </tr>
136
+        </table>
137
+      </td>
138
+    </tr>
139
+  </table>
140
+</body>
141
+</html>

+ 87
- 0
resources/views/prenotazione/email/link-modifica.blade.php 查看文件

@@ -0,0 +1,87 @@
1
+@php
2
+  use Carbon\Carbon;
3
+
4
+  $evento = $prenotazione->evento;
5
+  $attivita = $evento?->attivita;
6
+  $attivitaNome = $attivita?->nome ?: 'Prenotazione';
7
+  $nomePersona = trim(($prenotazione->nome ?? '').' '.($prenotazione->cognome ?? '')) ?: 'Ciao';
8
+  $codice = strtoupper((string) ($prenotazione->codice ?? ''));
9
+  $isAnnulla = ($azione ?? 'modifica') === 'annulla';
10
+  $coverUrl = $evento?->emailCopertinaUrl();
11
+  $minuti = $minuti ?? 15;
12
+
13
+  $navy = '#15265c';
14
+  $orange = '#f58220';
15
+  $muted = '#6b7280';
16
+  $border = '#e2e6ef';
17
+  $surface = '#f0f2f8';
18
+
19
+  $eyebrow = $isAnnulla ? 'Annulla prenotazione' : 'Modifica prenotazione';
20
+  $cta = $isAnnulla ? 'Annulla prenotazione' : 'Modifica prenotazione';
21
+  $intro = $isAnnulla
22
+    ? 'Hai chiesto di annullare la prenotazione. Il link è valido per '.$minuti.' minuti e si può usare una sola volta.'
23
+    : 'Hai chiesto di modificare la prenotazione. Il link è valido per '.$minuti.' minuti e si può usare una sola volta.';
24
+@endphp
25
+<!DOCTYPE html>
26
+<html lang="it">
27
+<head>
28
+  <meta charset="utf-8">
29
+  <meta name="viewport" content="width=device-width, initial-scale=1">
30
+  <title>{{ $eyebrow }}</title>
31
+</head>
32
+<body style="margin:0;padding:0;background:{{ $surface }};font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{{ $navy }};">
33
+  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{{ $surface }};padding:24px 12px;">
34
+    <tr>
35
+      <td align="center">
36
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:440px;background:#ffffff;border:1px solid {{ $border }};border-radius:14px;overflow:hidden;">
37
+          @include('prenotazione.email._partials.header', [
38
+            'coverUrl' => $coverUrl,
39
+            'eyebrow' => $eyebrow,
40
+            'title' => $attivitaNome,
41
+            'navy' => $navy,
42
+            'orange' => $orange,
43
+          ])
44
+
45
+          <tr>
46
+            <td style="padding:24px;">
47
+              <p style="margin:0 0 16px;font-size:15px;line-height:1.5;color:{{ $navy }};">
48
+                Ciao {{ $nomePersona }},<br>
49
+                {{ $intro }}
50
+              </p>
51
+
52
+              <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:{{ $surface }};border:1px solid {{ $border }};border-radius:10px;">
53
+                <tr>
54
+                  <td align="center" style="padding:16px;">
55
+                    <p style="margin:0 0 4px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:{{ $muted }};">Codice</p>
56
+                    <p style="margin:0;font-size:22px;font-weight:700;letter-spacing:.12em;color:{{ $navy }};font-family:Consolas,'Courier New',monospace;">{{ $codice ?: '—' }}</p>
57
+                  </td>
58
+                </tr>
59
+              </table>
60
+
61
+              <table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 8px;">
62
+                <tr>
63
+                  <td align="center" style="border-radius:999px;background:{{ $orange }};">
64
+                    <a href="{{ $urlFirmato }}" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:700;color:#ffffff;text-decoration:none;">
65
+                      {{ $cta }}
66
+                    </a>
67
+                  </td>
68
+                </tr>
69
+              </table>
70
+
71
+              <p style="margin:16px 0 0;font-size:12px;line-height:1.5;color:{{ $muted }};text-align:center;">
72
+                Se non hai richiesto tu questa email, puoi ignorarla.
73
+              </p>
74
+            </td>
75
+          </tr>
76
+
77
+          <tr>
78
+            <td style="padding:14px 24px 18px;border-top:1px solid {{ $border }};background:#fafbfd;">
79
+              <p style="margin:0;font-size:12px;color:{{ $muted }};text-align:center;">{{ $attivitaNome }}</p>
80
+            </td>
81
+          </tr>
82
+        </table>
83
+      </td>
84
+    </tr>
85
+  </table>
86
+</body>
87
+</html>

+ 70
- 11
resources/views/prenotazione/index.blade.php 查看文件

@@ -39,9 +39,9 @@ $configData = Helper::appClasses();
39 39
 <div class="d-flex flex-column">
40 40
   <h4 class="mb-0 text-sm-small mt-md-4 mt-lg-4"> 
41 41
     <i class="bx bx-calendar d-none d-md-inline-flex d-lg-inline-flex"></i> 
42
-    Prenotazioni
42
+    <!-- Prenotazioni -->
43 43
     @if(isset($evento))
44
-    <a href="{{ route('evento.show', $evento->id) }}" class="text-success">
44
+    <a href="{{ $evento->publicUrl() }}" class="text-dark" target="_blank" rel="noopener">
45 45
        {{ $evento->nome }}
46 46
     </a>
47 47
     @endif
@@ -61,14 +61,19 @@ $configData = Helper::appClasses();
61 61
                 <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
62 62
               </li>
63 63
               @endif
64
-              <li class="breadcrumb-item active text-primary">
65
-                <a href="{{ route('prenotazione.index') }}">Prenotazioni</a>
64
+              <li class="breadcrumb-item">
66 65
                 @if(isset($evento))
66
+                <a href="{{ route('evento.admin.show', ['evento_id' => $evento->id]) }}">
67
+                  {{ $evento->nome }}
68
+                </a>
67 69
                 <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
70
+              </li>
68 71
                 @endif
69 72
               </li>
70 73
               @if(isset($evento))
71
-              <li class="breadcrumb-item active text-primary">{{ $evento->nome }}</li>
74
+              <li class="breadcrumb-item active text-primary">
75
+              <a href="{{ route('prenotazione.index') }}">Prenotazioni</a>
76
+              </li>
72 77
               @endif
73 78
             </ol>
74 79
           </nav>
@@ -134,10 +139,11 @@ div.dt-action-buttons .dt-buttons .btn:last-child {
134 139
     } );
135 140
 
136 141
     $("#dataTable_prenotazione").on('dblclick', 'tbody td', function (e) {
137
-      window.LaravelDataTables["dataTable_prenotazione-editor"].edit( $(this).closest('tr'), {
138
-        title: 'Modifica',
139
-        buttons: 'Aggiorna',
140
-      });
142
+      var row = window.LaravelDataTables["dataTable_prenotazione"].row($(this).closest('tr')).data();
143
+      if (row && row.id) {
144
+        var editUrl = @json(route('prenotazione.edit', ['prenotazione_id' => 0]));
145
+        window.location.href = editUrl.replace(/\/0$/, '/' + row.id);
146
+      }
141 147
     });
142 148
 
143 149
   });
@@ -155,10 +161,63 @@ div.dt-action-buttons .dt-buttons .btn:last-child {
155 161
 
156 162
 
157 163
   function reportPrenotazioni(){
158
-    console.log('reportPrenotazioni');
159
-    window.location.href = "{{ route('prenotazione.report.index') }}";
164
+    @if(isset($evento))
165
+      window.location.href = "{{ route('prenotazione.report.index', ['evento_id' => $evento->id]) }}";
166
+    @else
167
+      Swal.fire({
168
+        title: 'Evento richiesto',
169
+        text: 'Apri le prenotazioni di un evento per generare i report.',
170
+        icon: 'info',
171
+      });
172
+    @endif
160 173
   };
161 174
 
175
+  function confermaTuttePrenotazioni(){
176
+    @if(isset($evento))
177
+      Swal.fire({
178
+        title: 'Conferma tutte le prenotazioni',
179
+        text: 'Sei sicuro di voler confermare tutte le prenotazioni?',
180
+        icon: 'warning',
181
+        showCancelButton: true,
182
+        confirmButtonColor: '#3085d6',
183
+        cancelButtonColor: '#d33',
184
+      }).then(function (result) {
185
+        if (!result.isConfirmed) return;
186
+        $.ajax({
187
+          url: "{{ route('prenotazione.conferma') }}",
188
+          type: 'POST',
189
+          data: {
190
+            _token: "{{ csrf_token() }}",
191
+            evento_id: "{{ $evento->id }}",
192
+            tutte: true
193
+          },
194
+          success: function(response) {
195
+            Swal.fire({
196
+              title: 'Prenotazioni confermate',
197
+              text: 'La conferma delle prenotazioni è stata avviata',
198
+              icon: 'success',
199
+            });
200
+          },
201
+          error: function(xhr, status, error) {
202
+            Swal.fire({
203
+              title: 'Errore',
204
+              text: 'Si è verificato un errore',
205
+              icon: 'error',
206
+            });
207
+          },
208
+          complete: function() {
209
+            window.LaravelDataTables["dataTable_prenotazione"].ajax.reload();
210
+          }
211
+        });
212
+      });
213
+    @else
214
+      Swal.fire({
215
+        title: 'Evento richiesto',
216
+        text: 'Apri le prenotazioni di un evento per confermarle.',
217
+        icon: 'info',
218
+      });
219
+    @endif
220
+  }
162 221
 
163 222
 </script>
164 223
 @endsection

+ 21
- 2
resources/views/prenotazione/menu.blade.php 查看文件

@@ -8,13 +8,32 @@ use Illuminate\Support\Facades\Auth;
8 8
     </button>
9 9
     <div class="dropdown-menu">
10 10
         @if(Auth::user()->can('edit-prenotazione'))
11
-        <a href="#" class="dropdown-item editor_edit">
11
+        <a href="{{ route('prenotazione.edit', ['prenotazione_id' => $entity->id]) }}" class="dropdown-item">
12 12
             <i class="bx bx-edit-alt me-1"></i> Modifica
13 13
         </a>
14
+
15
+        @if($entity->stato !== 'confermata')
16
+        <form action="{{ route('prenotazione.conferma', ['prenotazione_id' => $entity->id, 'evento_id' => $entity->evento_id]) }}" method="post">
17
+            @csrf
18
+            @method('POST')
19
+            <button type="submit" class="dropdown-item">
20
+                <i class="bx bx-check-circle me-1"></i> Conferma
21
+            </button>
22
+        </form>
23
+        @elseif($entity->stato !== 'annullata')
24
+        <form action="{{ route('prenotazione.annulla', ['prenotazione_id' => $entity->id, 'evento_id' => $entity->evento_id]) }}" method="post">
25
+            @csrf
26
+            @method('POST')
27
+            <button type="submit" class="dropdown-item">
28
+                <i class="bx bx-x-circle me-1"></i> Annulla
29
+            </button>
30
+        </form>
31
+        @endif
32
+
14 33
         @endif
15 34
         
16 35
         @if(Auth::user()->can('delete-prenotazione'))
17
-        <a href="#" class="dropdown-item editor_delete">
36
+        <a href="#" class="dropdown-item editor_delete text-danger">
18 37
             <i class="bx bx-trash me-1"></i> Elimina
19 38
         </a>
20 39
         @endif

+ 335
- 0
resources/views/prenotazione/nuova.blade.php 查看文件

@@ -0,0 +1,335 @@
1
+@php
2
+  use App\Models\EventoForm;
3
+  use App\Models\Prenotazione;
4
+  $configData = Helper::appClasses();
5
+  $backUrl = $evento
6
+    ? route('prenotazione.index.evento', ['evento_id' => $evento->id])
7
+    : route('prenotazione.index');
8
+  $domande = $domande ?? collect();
9
+  $stati = Prenotazione::getStati();
10
+@endphp
11
+
12
+@extends('layouts/layoutMaster')
13
+
14
+@section('title', $evento ? 'Nuova prenotazione · '.$evento->nome : 'Nuova prenotazione')
15
+
16
+@section('pageTitle')
17
+<div class="d-flex flex-column">
18
+  <h4 class="mb-0 text-sm-small">
19
+    <i class="bx bx-plus-circle d-none d-md-inline-flex"></i>
20
+    Nuova prenotazione
21
+    @if($evento)
22
+      <span class="text-muted fw-normal">· {{ $evento->nome }}</span>
23
+    @endif
24
+  </h4>
25
+  <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block">
26
+    <ol class="breadcrumb breadcrumb-custom-icon mb-0">
27
+      <li class="breadcrumb-item">
28
+        <a href="{{ route('evento.index') }}">Eventi</a>
29
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
30
+      </li>
31
+      @if($evento)
32
+        <li class="breadcrumb-item">
33
+          <a href="{{ $backUrl }}">Prenotazioni</a>
34
+          <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
35
+        </li>
36
+      @endif
37
+      <li class="breadcrumb-item active text-primary">Nuova</li>
38
+    </ol>
39
+  </nav>
40
+</div>
41
+@endsection
42
+
43
+@section('content')
44
+@include('_partials.status')
45
+
46
+<style>
47
+  .prenota-admin .report-section-title {
48
+    display: flex;
49
+    align-items: center;
50
+    gap: .55rem;
51
+    margin: 0 0 .85rem;
52
+    font-size: .72rem;
53
+    font-weight: 700;
54
+    letter-spacing: .08em;
55
+    text-transform: uppercase;
56
+    color: #8592a3;
57
+  }
58
+  .prenota-admin .report-section-title::after {
59
+    content: "";
60
+    flex: 1 1 auto;
61
+    height: 1px;
62
+    background: rgba(67, 89, 113, .12);
63
+  }
64
+  .prenota-admin .report-section-title i {
65
+    font-size: 1rem;
66
+    color: #696cff;
67
+  }
68
+  .prenota-admin .prenota-richiesta {
69
+    margin-bottom: 1rem;
70
+  }
71
+  .prenota-admin [data-coperto-copy] {
72
+    padding: .75rem;
73
+    border: 1px dashed rgba(67, 89, 113, .18);
74
+    border-radius: .5rem;
75
+    margin-bottom: .65rem;
76
+    background: rgba(67, 89, 113, .03);
77
+  }
78
+</style>
79
+
80
+<div class="prenota-admin">
81
+  @unless($evento)
82
+    <div class="card mb-3">
83
+      <div class="card-body">
84
+        <div class="report-section-title"><i class="bx bx-calendar-event"></i> Seleziona evento</div>
85
+        <p class="text-muted small mb-3">Scegli l&apos;evento per cui creare la prenotazione.</p>
86
+        <form method="GET" action="{{ route('prenotazione.nuova') }}" class="row g-3 align-items-end">
87
+          <div class="col-md-8">
88
+            <label class="form-label" for="evento_id">Evento</label>
89
+            <select class="form-select" name="evento_id" id="evento_id" required>
90
+              <option value="">—</option>
91
+              @foreach($eventi as $ev)
92
+                <option value="{{ $ev->id }}">
93
+                  {{ $ev->nome }}
94
+                  @if($ev->data_inizio)
95
+                    · {{ \Carbon\Carbon::parse($ev->data_inizio)->format('d/m/Y') }}
96
+                  @endif
97
+                </option>
98
+              @endforeach
99
+            </select>
100
+          </div>
101
+          <div class="col-md-4">
102
+            <button type="submit" class="btn btn-primary w-100">Continua</button>
103
+          </div>
104
+        </form>
105
+      </div>
106
+    </div>
107
+  @else
108
+    <form id="prenotaAdminForm" method="POST" action="{{ route('prenotazione.storeNuova') }}">
109
+      @csrf
110
+      <input type="hidden" name="evento_id" value="{{ $evento->id }}">
111
+
112
+      <div class="row g-3">
113
+        <div class="col-lg-8">
114
+          <div class="card mb-3">
115
+            <div class="card-body">
116
+              <div class="report-section-title"><i class="bx bx-user"></i> Anagrafica</div>
117
+              <div class="row g-3">
118
+                <div class="col-md-6">
119
+                  <label class="form-label" for="cognome">Cognome <span class="text-danger">*</span></label>
120
+                  <input type="text" class="form-control" name="cognome" id="cognome" value="{{ old('cognome') }}" required>
121
+                </div>
122
+                <div class="col-md-6">
123
+                  <label class="form-label" for="nome">Nome <span class="text-danger">*</span></label>
124
+                  <input type="text" class="form-control" name="nome" id="nome" value="{{ old('nome') }}" required>
125
+                </div>
126
+                <div class="col-md-6">
127
+                  <label class="form-label" for="email">Email <span class="text-danger">*</span></label>
128
+                  <input type="email" class="form-control" name="email" id="email" value="{{ old('email') }}" required>
129
+                </div>
130
+                <div class="col-md-6">
131
+                  <label class="form-label" for="telefono">Telefono</label>
132
+                  <input type="text" class="form-control" name="telefono" id="telefono" value="{{ old('telefono') }}">
133
+                </div>
134
+                <div class="col-md-4">
135
+                  <label class="form-label" for="numero_persone">Coperti <span class="text-danger">*</span></label>
136
+                  <input type="number" class="form-control" name="numero_persone" id="numero_persone" min="1" value="{{ old('numero_persone', 1) }}" required>
137
+                </div>
138
+                <div class="col-12">
139
+                  <label class="form-label" for="note">Note</label>
140
+                  <textarea class="form-control" name="note" id="note" rows="2">{{ old('note') }}</textarea>
141
+                </div>
142
+              </div>
143
+            </div>
144
+          </div>
145
+
146
+          @if($domande->isNotEmpty())
147
+            <div class="card mb-3">
148
+              <div class="card-body">
149
+                <div class="report-section-title"><i class="bx bx-message-square-detail"></i> Richieste evento</div>
150
+                @foreach($domande as $form)
151
+                  @if($form->per_coperto)
152
+                    <div
153
+                      class="mb-3"
154
+                      data-per-coperto
155
+                      data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
156
+                      data-multi="{{ $form->tipo === EventoForm::MULTISELEZIONE ? '1' : '0' }}"
157
+                    >
158
+                      <div data-coperto-host="{{ $form->id }}"></div>
159
+                      <template data-coperto-tpl="{{ $form->id }}">
160
+                        <div data-coperto-copy>
161
+                          @include('prenotazione.cliente._partials.campo-richiesta', [
162
+                            'form' => $form,
163
+                            'index' => 0,
164
+                            'isTemplate' => true,
165
+                          ])
166
+                        </div>
167
+                      </template>
168
+                    </div>
169
+                  @else
170
+                    <div
171
+                      @if($form->tipo === EventoForm::MULTISELEZIONE)
172
+                        data-multi-group
173
+                        data-obbligatoria="{{ $form->obbligatoria ? '1' : '0' }}"
174
+                      @endif
175
+                    >
176
+                      @include('prenotazione.cliente._partials.campo-richiesta', ['form' => $form])
177
+                    </div>
178
+                  @endif
179
+                @endforeach
180
+              </div>
181
+            </div>
182
+          @endif
183
+        </div>
184
+
185
+        <div class="col-lg-4">
186
+          <div class="card mb-3">
187
+            <div class="card-body">
188
+              <div class="report-section-title"><i class="bx bx-info-circle"></i> Evento</div>
189
+              <div class="fw-semibold mb-1">{{ $evento->nome }}</div>
190
+              @if($evento->data_inizio)
191
+                <div class="small text-muted mb-2">
192
+                  {{ \Carbon\Carbon::parse($evento->data_inizio)->translatedFormat('d M Y H:i') }}
193
+                </div>
194
+              @endif
195
+              <a href="{{ route('prenotazione.nuova') }}" class="small">Cambia evento</a>
196
+            </div>
197
+          </div>
198
+
199
+          <div class="card mb-3">
200
+            <div class="card-body">
201
+              <div class="report-section-title"><i class="bx bx-check-shield"></i> Stato</div>
202
+              <div class="form-check form-switch mb-3">
203
+                <input class="form-check-input" type="checkbox" name="confermata" value="1" id="confermata" @checked(old('confermata', true))>
204
+                <label class="form-check-label" for="confermata">Confermata subito</label>
205
+              </div>
206
+              <label class="form-label" for="stato">Stato</label>
207
+              <select class="form-select" name="stato" id="stato">
208
+                @foreach($stati as $key => $stato)
209
+                  <option value="{{ $key }}" @selected(old('stato', Prenotazione::STATO_CONFERMATA) === $key)>
210
+                    {{ $stato['label'] }}
211
+                  </option>
212
+                @endforeach
213
+              </select>
214
+              <p class="small text-muted mt-2 mb-0">Se confermi subito, lo stato verrà allineato a «Confermata».</p>
215
+            </div>
216
+          </div>
217
+
218
+          <div class="d-flex flex-wrap gap-2">
219
+            <a href="{{ $backUrl }}" class="btn btn-label-secondary">Annulla</a>
220
+            <button type="submit" class="btn btn-primary ms-auto">
221
+              <i class="bx bx-save"></i> Salva prenotazione
222
+            </button>
223
+          </div>
224
+        </div>
225
+      </div>
226
+    </form>
227
+  @endunless
228
+</div>
229
+@endsection
230
+
231
+@section('page-script')
232
+@if($evento)
233
+<script>
234
+  document.addEventListener('DOMContentLoaded', function () {
235
+    var form = document.getElementById('prenotaAdminForm');
236
+    if (!form) return;
237
+
238
+    var confermataEl = document.getElementById('confermata');
239
+    var statoEl = document.getElementById('stato');
240
+
241
+    function syncPerCoperto() {
242
+      var nEl = document.getElementById('numero_persone');
243
+      var n = nEl ? parseInt(nEl.value, 10) : 1;
244
+      if (!n || n < 1) n = 1;
245
+
246
+      document.querySelectorAll('[data-coperto-host]').forEach(function (host) {
247
+        var id = host.getAttribute('data-coperto-host');
248
+        var wrap = host.closest('[data-per-coperto]');
249
+        var tpl = wrap ? wrap.querySelector('[data-coperto-tpl="' + id + '"]') : null;
250
+        if (!tpl) return;
251
+        var obbligatoria = wrap.getAttribute('data-obbligatoria') === '1';
252
+        var isMulti = wrap.getAttribute('data-multi') === '1';
253
+        host.innerHTML = '';
254
+        for (var i = 0; i < n; i++) {
255
+          var frag = tpl.content.cloneNode(true);
256
+          frag.querySelectorAll('[name]').forEach(function (el) {
257
+            el.name = el.name.replace('[0]', '[' + i + ']');
258
+            el.disabled = false;
259
+          });
260
+          frag.querySelectorAll('[id]').forEach(function (el) {
261
+            el.id = el.id.replace(/_0(?=_|$)/, '_' + i);
262
+          });
263
+          frag.querySelectorAll('[for]').forEach(function (el) {
264
+            el.htmlFor = el.htmlFor.replace(/_0(?=_|$)/, '_' + i);
265
+          });
266
+          frag.querySelectorAll('label').forEach(function (el) {
267
+            el.innerHTML = el.innerHTML.replace(/coperto 1/, 'coperto ' + (i + 1));
268
+          });
269
+          frag.querySelectorAll('input, select, textarea').forEach(function (el) {
270
+            if (el.type === 'hidden') return;
271
+            if (!obbligatoria || isMulti) return;
272
+            if (el.type === 'checkbox') {
273
+              if (el.value === '1') el.required = true;
274
+            } else {
275
+              el.required = true;
276
+            }
277
+          });
278
+          host.appendChild(frag);
279
+        }
280
+      });
281
+    }
282
+
283
+    function validateMultiGroups() {
284
+      var groups = form.querySelectorAll('[data-multi-group][data-obbligatoria="1"]');
285
+      for (var g = 0; g < groups.length; g++) {
286
+        var boxes = groups[g].querySelectorAll('input[type="checkbox"]:not(:disabled)');
287
+        var ok = Array.prototype.some.call(boxes, function (b) { return b.checked; });
288
+        if (!ok && boxes.length) {
289
+          boxes[0].setCustomValidity('Seleziona almeno un\'opzione');
290
+          boxes[0].reportValidity();
291
+          return false;
292
+        }
293
+        if (boxes.length) boxes[0].setCustomValidity('');
294
+      }
295
+
296
+      var coperti = form.querySelectorAll('[data-per-coperto][data-multi="1"][data-obbligatoria="1"] [data-coperto-copy]');
297
+      for (var c = 0; c < coperti.length; c++) {
298
+        var cboxes = coperti[c].querySelectorAll('input[type="checkbox"]:not(:disabled)');
299
+        var cok = Array.prototype.some.call(cboxes, function (b) { return b.checked; });
300
+        if (!cok && cboxes.length) {
301
+          cboxes[0].setCustomValidity('Seleziona almeno un\'opzione');
302
+          cboxes[0].reportValidity();
303
+          return false;
304
+        }
305
+        if (cboxes.length) cboxes[0].setCustomValidity('');
306
+      }
307
+      return true;
308
+    }
309
+
310
+    var nPersoneEl = document.getElementById('numero_persone');
311
+    if (nPersoneEl) {
312
+      nPersoneEl.addEventListener('change', syncPerCoperto);
313
+      nPersoneEl.addEventListener('input', syncPerCoperto);
314
+    }
315
+
316
+    if (confermataEl && statoEl) {
317
+      confermataEl.addEventListener('change', function () {
318
+        if (confermataEl.checked) {
319
+          statoEl.value = @json(\App\Models\Prenotazione::STATO_CONFERMATA);
320
+        }
321
+      });
322
+    }
323
+
324
+    form.addEventListener('submit', function (e) {
325
+      syncPerCoperto();
326
+      if (!validateMultiGroups()) {
327
+        e.preventDefault();
328
+      }
329
+    });
330
+
331
+    syncPerCoperto();
332
+  });
333
+</script>
334
+@endif
335
+@endsection

+ 12
- 0
resources/views/prenotazione/report/_segnaposto-faccia.blade.php 查看文件

@@ -0,0 +1,12 @@
1
+<div class="faccia">
2
+  <div class="faccia-top">
3
+    @if($logoSrc)
4
+      <img class="faccia-logo" src="{{ $logoSrc }}" alt="">
5
+    @endif
6
+    <div class="faccia-evento">{{ $evento->nome }}</div>
7
+  </div>
8
+  <div class="faccia-nome">{{ $nomeOspite }}</div>
9
+  @if(!empty($codicePrenotazione))
10
+    <div class="faccia-codice">{{ $codicePrenotazione }}</div>
11
+  @endif
12
+</div>

+ 403
- 42
resources/views/prenotazione/report/index.blade.php 查看文件

@@ -1,64 +1,425 @@
1
-<?php
2
-use App\Models\Role;
3
-use Illuminate\Support\Facades\Auth;
4
-?>
5 1
 @php
6
-$configData = Helper::appClasses();
2
+  use App\Models\EventoForm;
3
+  $configData = Helper::appClasses();
4
+  $presetAttivo = old('preset', 'lista_ingresso');
5
+  $backUrl = route('evento.admin.show', ['evento_id' => $evento->id]);
7 6
 @endphp
8 7
 
9 8
 @extends('layouts/layoutMaster')
10 9
 
11
-@section('title', 'Prenotazioni')
12
-
13
-@section('vendor-style')
14
-@vite([
15
-'resources/assets/vendor/libs/datatables-bs5/datatables.bootstrap5.scss',
16
-'resources/assets/vendor/libs/datatables-responsive-bs5/responsive.bootstrap5.scss',
17
-'resources/assets/vendor/libs/datatables-buttons-bs5/buttons.bootstrap5.scss',
18
-'resources/assets/vendor/libs/flatpickr/flatpickr.scss',
19
-'resources/assets/vendor/libs/@form-validation/form-validation.scss'
20
-])
21
-@endsection
22
-
23
-<!-- Vendor Scripts -->
24
-@section('vendor-script')
25
-@vite([
26
-'resources/assets/vendor/libs/moment/moment.js',
27
-'resources/assets/vendor/libs/flatpickr/flatpickr.js',
28
-'resources/assets/vendor/libs/@form-validation/popular.js',
29
-'resources/assets/vendor/libs/@form-validation/bootstrap5.js',
30
-'resources/assets/vendor/libs/@form-validation/auto-focus.js',
31
-'resources/assets/vendor/libs/@form-validation/popular.js',
32
-'resources/assets/vendor/libs/@form-validation/bootstrap5.js',
33
-'resources/assets/vendor/libs/@form-validation/auto-focus.js',
34
-'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js'
35
-])
36
-@endsection
10
+@section('title', 'Report · '.$evento->nome)
37 11
 
38 12
 @section('pageTitle')
39 13
 <div class="d-flex flex-column">
40
-  <h4 class="mb-0 mt-4"> 
41
-    <i class="bx bx-file"></i> 
42
-    Report Prenotazioni
14
+  <h4 class="mb-0">
15
+    <i class="bx bx-printer d-none d-md-inline-flex"></i>
16
+    Report
43 17
   </h4>
44
-
18
+  <nav aria-label="breadcrumb" style="font-size: smaller;" class="d-none d-md-block">
19
+    <ol class="breadcrumb breadcrumb-custom-icon mb-0">
20
+      <li class="breadcrumb-item">
21
+        <a href="{{ route('evento.index') }}">Eventi</a>
22
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
23
+      </li>
24
+      <li class="breadcrumb-item">
25
+        <a href="{{ $backUrl }}">{{ $evento->nome }}</a>
26
+        <i class="breadcrumb-icon icon-base bx bx-chevron-right align-middle"></i>
27
+      </li>
28
+      <li class="breadcrumb-item active text-primary">Report</li>
29
+    </ol>
30
+  </nav>
45 31
 </div>
46 32
 @endsection
47 33
 
48 34
 @section('content')
35
+@include('_partials.status')
36
+
37
+<style>
38
+  .report-layout {
39
+    display: grid;
40
+    gap: 1rem;
41
+    align-items: start;
42
+  }
43
+  @media (min-width: 992px) {
44
+    .report-layout {
45
+      grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.1fr) minmax(240px, .75fr);
46
+      gap: 1.15rem;
47
+    }
48
+  }
49
+  .report-col {
50
+    border: 1px solid rgba(67, 89, 113, .1);
51
+    border-radius: .85rem;
52
+    background: #fff;
53
+    padding: 1.1rem 1.15rem;
54
+    height: 100%;
55
+  }
56
+  .report-col__title {
57
+    margin: 0 0 .85rem;
58
+    font-size: .72rem;
59
+    font-weight: 700;
60
+    letter-spacing: .06em;
61
+    text-transform: uppercase;
62
+    color: #8592a3;
63
+  }
64
+  .report-col__step {
65
+    display: inline-flex;
66
+    width: 1.35rem;
67
+    height: 1.35rem;
68
+    align-items: center;
69
+    justify-content: center;
70
+    border-radius: 999px;
71
+    background: var(--bs-primary);
72
+    color: #fff;
73
+    font-size: .7rem;
74
+    font-weight: 700;
75
+    margin-right: .4rem;
76
+    vertical-align: middle;
77
+  }
78
+  .report-tabs {
79
+    display: flex;
80
+    flex-direction: column;
81
+    gap: .55rem;
82
+  }
83
+  .report-tab {
84
+    cursor: pointer;
85
+    border: 1px solid rgba(67, 89, 113, .12);
86
+    border-radius: .7rem;
87
+    padding: .85rem .9rem;
88
+    margin: 0;
89
+    background: #fff;
90
+    transition: border-color .15s ease, box-shadow .15s ease;
91
+  }
92
+  .report-tab:hover {
93
+    border-color: rgba(67, 89, 113, .28);
94
+  }
95
+  .report-tab.is-active {
96
+    border-color: var(--bs-primary);
97
+    box-shadow: 0 0 0 1px var(--bs-primary);
98
+  }
99
+  .report-tab input {
100
+    position: absolute;
101
+    opacity: 0;
102
+    pointer-events: none;
103
+  }
104
+  .report-tab__row {
105
+    display: flex;
106
+    gap: .75rem;
107
+    align-items: flex-start;
108
+  }
109
+  .report-tab__icon {
110
+    display: inline-flex;
111
+    width: 2.25rem;
112
+    height: 2.25rem;
113
+    flex-shrink: 0;
114
+    align-items: center;
115
+    justify-content: center;
116
+    border-radius: .5rem;
117
+    background: rgba(67, 89, 113, .06);
118
+    color: #566a7f;
119
+    font-size: 1.15rem;
120
+  }
121
+  .report-tab.is-active .report-tab__icon {
122
+    background: rgba(var(--bs-primary-rgb), .12);
123
+    color: var(--bs-primary);
124
+  }
125
+  .report-tab strong {
126
+    display: block;
127
+    font-size: .92rem;
128
+    color: #384551;
129
+  }
130
+  .report-tab span {
131
+    display: block;
132
+    font-size: .78rem;
133
+    color: #8592a3;
134
+    margin-top: .2rem;
135
+    line-height: 1.35;
136
+  }
137
+  .report-section {
138
+    margin-bottom: 1.15rem;
139
+  }
140
+  .report-section:last-child {
141
+    margin-bottom: 0;
142
+  }
143
+  .report-section__label {
144
+    margin: 0 0 .55rem;
145
+    font-size: .8rem;
146
+    font-weight: 600;
147
+    color: #566a7f;
148
+  }
149
+  .report-chips {
150
+    display: flex;
151
+    flex-wrap: wrap;
152
+    gap: .4rem;
153
+  }
154
+  .report-chip {
155
+    display: inline-flex;
156
+    align-items: center;
157
+    gap: .35rem;
158
+    margin: 0;
159
+    padding: .4rem .7rem;
160
+    min-height: 38px;
161
+    border: 1px solid rgba(67, 89, 113, .14);
162
+    border-radius: 999px;
163
+    background: #fff;
164
+    cursor: pointer;
165
+    font-size: .82rem;
166
+    user-select: none;
167
+  }
168
+  .report-chip:has(input:checked) {
169
+    border-color: var(--bs-primary);
170
+    background: rgba(var(--bs-primary-rgb), .08);
171
+    color: var(--bs-primary);
172
+  }
173
+  .report-chip input {
174
+    margin: 0;
175
+  }
176
+  .report-actions {
177
+    display: flex;
178
+    flex-direction: column;
179
+    gap: .55rem;
180
+  }
181
+  .report-actions .btn {
182
+    min-height: 44px;
183
+    width: 100%;
184
+    justify-content: center;
185
+  }
186
+  .report-actions__hint {
187
+    font-size: .8rem;
188
+    color: #8592a3;
189
+    line-height: 1.4;
190
+    margin: 0;
191
+  }
192
+  .report-back {
193
+    margin-bottom: .85rem;
194
+  }
195
+  .report-back .btn {
196
+    min-height: 40px;
197
+  }
198
+  @media (min-width: 992px) {
199
+    .report-col--actions {
200
+      position: sticky;
201
+      top: 4.5rem;
202
+    }
203
+  }
204
+</style>
205
+
206
+<a href="{{ $backUrl }}" class="btn btn-label-secondary btn-sm report-back">
207
+  <i class="bx bx-arrow-back me-1"></i> Torna all’evento
208
+</a>
209
+
210
+<form id="reportConfigForm" method="GET" action="{{ route('prenotazione.report.print') }}" target="_blank">
211
+  <input type="hidden" name="evento_id" value="{{ $evento->id }}">
49 212
 
50
-<div class="row justify-content-center">
51
-    <div class="col-xxl mb-4 mt-2">
52
-    <div class="card">
53
-        <div class="card-body">
54
-                <h5 class="card-title">Report Prenotazioni</h5>
213
+  <div class="report-layout">
214
+    {{-- Colonna 1: cosa stampare --}}
215
+    <div class="report-col">
216
+      <h2 class="report-col__title">
217
+        <span class="report-col__step">1</span>
218
+        Cosa vuoi stampare?
219
+      </h2>
220
+      <div class="report-tabs">
221
+        @foreach($presets as $key => $preset)
222
+          <label class="report-tab {{ $presetAttivo === $key ? 'is-active' : '' }}" data-preset="{{ $key }}">
223
+            <input type="radio" name="preset" value="{{ $key }}" @checked($presetAttivo === $key)>
224
+            <div class="report-tab__row">
225
+              <div class="report-tab__icon"><i class="bx {{ $preset['icon'] }}"></i></div>
226
+              <div>
227
+                <strong>{{ $preset['nome'] }}</strong>
228
+                <span>{{ $preset['descrizione'] }}</span>
229
+              </div>
55 230
             </div>
231
+          </label>
232
+        @endforeach
233
+      </div>
234
+    </div>
235
+
236
+    {{-- Colonna 2: filtri e domande --}}
237
+    <div class="report-col">
238
+      <h2 class="report-col__title">
239
+        <span class="report-col__step">2</span>
240
+        Filtri e colonne
241
+      </h2>
242
+
243
+      <div class="report-section">
244
+        <div class="report-section__label">Filtri</div>
245
+        <div class="d-flex flex-column gap-2">
246
+          <div class="form-check mb-0">
247
+            <input class="form-check-input" type="checkbox" name="solo_confermate" value="1" id="solo_confermate" checked>
248
+            <label class="form-check-label" for="solo_confermate">Solo confermate</label>
249
+          </div>
250
+          <div class="form-check mb-0">
251
+            <input class="form-check-input" type="checkbox" name="escludi_annullate" value="1" id="escludi_annullate" checked>
252
+            <label class="form-check-label" for="escludi_annullate">Escludi annullate</label>
253
+          </div>
254
+        </div>
255
+      </div>
256
+
257
+      <div id="segnapostoFormatoWrap" class="report-section d-none">
258
+        <div class="report-section__label">Formato pieghevole</div>
259
+        <div class="report-tabs">
260
+          <label class="report-tab is-active" for="formato_verticale">
261
+            <input type="radio" name="segnaposto_formato" id="formato_verticale" value="verticale" checked>
262
+            <strong>Verticale</strong>
263
+            <span>A5 orizzontale · facce alte</span>
264
+          </label>
265
+          <label class="report-tab" for="formato_orizzontale">
266
+            <input type="radio" name="segnaposto_formato" id="formato_orizzontale" value="orizzontale">
267
+            <strong>Orizzontale</strong>
268
+            <span>A5 verticale · facce larghe</span>
269
+          </label>
270
+        </div>
271
+      </div>
272
+
273
+      <div id="reportColonneWrap">
274
+        <div class="report-section">
275
+          <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
276
+            <div class="report-section__label mb-0">Colonne</div>
277
+            <div class="small text-primary d-none" id="reportSpazioHint"></div>
278
+          </div>
279
+          <div class="text-muted small mb-2">Codice sempre primo · CHECK-IN sempre in coda</div>
280
+          <div class="report-chips">
281
+            @foreach($campiFissi as $key => $campo)
282
+              <label class="report-chip" for="col_fisso_{{ $key }}">
283
+                <input
284
+                  class="form-check-input report-col-fisso m-0"
285
+                  type="checkbox"
286
+                  name="colonne[]"
287
+                  value="{{ $key }}"
288
+                  id="col_fisso_{{ $key }}"
289
+                  data-campo="{{ $key }}"
290
+                >
291
+                {{ $key === 'nome' ? 'Nome' : ($key === 'numero_persone' ? 'Coperti' : $campo['label']) }}
292
+              </label>
293
+            @endforeach
294
+          </div>
56 295
         </div>
296
+
297
+        @if($domande->isNotEmpty())
298
+          <div class="report-section">
299
+            <div class="report-section__label">Domande evento</div>
300
+            <div class="report-chips">
301
+              @foreach($domande as $domanda)
302
+                <label class="report-chip" for="col_form_{{ $domanda->id }}" title="{{ EventoForm::getLabel($domanda->tipo) }}{{ $domanda->per_coperto ? ' · per coperto' : '' }}">
303
+                  <input
304
+                    class="form-check-input report-col-domanda m-0"
305
+                    type="checkbox"
306
+                    name="colonne[]"
307
+                    value="form:{{ $domanda->id }}"
308
+                    id="col_form_{{ $domanda->id }}"
309
+                    data-campo="form:{{ $domanda->id }}"
310
+                  >
311
+                  {{ $domanda->label ?: 'Domanda #'.$domanda->id }}
312
+                </label>
313
+              @endforeach
314
+            </div>
315
+          </div>
316
+        @endif
317
+      </div>
57 318
     </div>
58
-</div>
319
+
320
+    {{-- Colonna 3: azioni --}}
321
+    <div class="report-col report-col--actions">
322
+      <h2 class="report-col__title">
323
+        <span class="report-col__step">3</span>
324
+        Stampa
325
+      </h2>
326
+      <div class="report-actions">
327
+        <button type="submit" name="output" value="html" class="btn btn-label-primary" id="btnReportAnteprima">
328
+          <i class="bx bx-show me-1"></i> Anteprima
329
+        </button>
330
+        <button type="submit" name="output" value="pdf" class="btn btn-primary" id="btnReportStampa">
331
+          <i class="bx bx-printer me-1"></i> PDF
332
+        </button>
333
+        <p class="report-actions__hint">
334
+          Scegli il tipo a sinistra, regola i filtri, poi apri l’anteprima o scarica il PDF.
335
+        </p>
336
+      </div>
337
+    </div>
338
+  </div>
339
+</form>
59 340
 @endsection
60 341
 
61 342
 @section('page-script')
343
+<script>
344
+  document.addEventListener('DOMContentLoaded', function () {
345
+    var presets = @json($presets);
346
+    var form = document.getElementById('reportConfigForm');
347
+    if (!form) return;
348
+
349
+    var presetCards = form.querySelectorAll('.report-tab[data-preset]');
350
+    var soloConfermate = document.getElementById('solo_confermate');
351
+    var spazioHint = document.getElementById('reportSpazioHint');
352
+    var segnapostoWrap = document.getElementById('segnapostoFormatoWrap');
353
+    var colonneWrap = document.getElementById('reportColonneWrap');
354
+    var formatoTabs = form.querySelectorAll('#segnapostoFormatoWrap .report-tab');
355
+
356
+    function updateSpazioHint() {
357
+      if (!spazioHint) return;
358
+      var n = form.querySelectorAll('input[name="colonne[]"]:checked').length;
359
+      var preset = (form.querySelector('input[name="preset"]:checked') || {}).value;
360
+      if (preset === 'segnaposto' || preset === 'piantina') {
361
+        spazioHint.classList.add('d-none');
362
+        return;
363
+      }
364
+      if (n >= 7) {
365
+        spazioHint.textContent = 'Molte colonne → PDF orizzontale';
366
+        spazioHint.classList.remove('d-none');
367
+      } else if (n >= 5) {
368
+        spazioHint.textContent = 'Carattere PDF ridotto';
369
+        spazioHint.classList.remove('d-none');
370
+      } else {
371
+        spazioHint.classList.add('d-none');
372
+      }
373
+    }
374
+
375
+    function syncSegnapostoOptions(key) {
376
+      var isSegnaposto = key === 'segnaposto';
377
+      var isPiantina = key === 'piantina';
378
+      if (segnapostoWrap) segnapostoWrap.classList.toggle('d-none', !isSegnaposto);
379
+      if (colonneWrap) colonneWrap.classList.toggle('d-none', isSegnaposto || isPiantina);
380
+    }
62 381
 
382
+    function applyPreset(key) {
383
+      var preset = presets[key];
384
+      if (!preset) return;
63 385
 
64
-@endsection
386
+      presetCards.forEach(function (card) {
387
+        card.classList.toggle('is-active', card.getAttribute('data-preset') === key);
388
+      });
389
+
390
+      form.querySelectorAll('.report-col-fisso, .report-col-domanda').forEach(function (el) {
391
+        el.checked = preset.colonne.indexOf(el.value) !== -1;
392
+      });
393
+
394
+      if (soloConfermate) {
395
+        soloConfermate.checked = !!preset.solo_confermate;
396
+      }
397
+      syncSegnapostoOptions(key);
398
+      updateSpazioHint();
399
+    }
400
+
401
+    presetCards.forEach(function (card) {
402
+      card.addEventListener('click', function () {
403
+        var key = card.getAttribute('data-preset');
404
+        var radio = card.querySelector('input[type="radio"]');
405
+        if (radio) radio.checked = true;
406
+        applyPreset(key);
407
+      });
408
+    });
409
+
410
+    formatoTabs.forEach(function (tab) {
411
+      tab.addEventListener('click', function () {
412
+        formatoTabs.forEach(function (t) { t.classList.remove('is-active'); });
413
+        tab.classList.add('is-active');
414
+      });
415
+    });
416
+
417
+    form.querySelectorAll('input[name="colonne[]"]').forEach(function (el) {
418
+      el.addEventListener('change', updateSpazioHint);
419
+    });
420
+
421
+    var initial = form.querySelector('input[name="preset"]:checked');
422
+    applyPreset(initial ? initial.value : 'lista_ingresso');
423
+  });
424
+</script>
425
+@endsection

+ 29
- 0
resources/views/prenotazione/report/lista.blade.php 查看文件

@@ -0,0 +1,29 @@
1
+<table class="lista">
2
+  <thead>
3
+    <tr>
4
+      @foreach($colonne as $col)
5
+        <th
6
+          class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif"
7
+          style="width: {{ $col['width'] ?? 'auto' }}%;"
8
+        >{{ $col['label_short'] ?? $col['label'] }}</th>
9
+      @endforeach
10
+    </tr>
11
+  </thead>
12
+  <tbody>
13
+    @foreach($righe as $riga)
14
+      <tr>
15
+        @foreach($colonne as $col)
16
+          <td class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif">
17
+            @if($col['key'] === 'confermata')
18
+              <span class="icon-conf {{ ($riga['celle'][$col['key']] ?? '') === '✓' ? 'is-yes' : 'is-no' }}">{{ $riga['celle'][$col['key']] ?? '—' }}</span>
19
+            @elseif(($col['type'] ?? '') === 'form')
20
+              {!! $riga['celle'][$col['key']] ?? '—' !!}
21
+            @else
22
+              {!! nl2br(e($riga['celle'][$col['key']] ?? '—')) !!}
23
+            @endif
24
+          </td>
25
+        @endforeach
26
+      </tr>
27
+    @endforeach
28
+  </tbody>
29
+</table>

+ 440
- 0
resources/views/prenotazione/report/pdf.blade.php 查看文件

@@ -0,0 +1,440 @@
1
+<!DOCTYPE html>
2
+<html lang="it">
3
+<head>
4
+  <meta charset="utf-8">
5
+  <title>{{ $reportTitle }} · {{ $evento->nome }}</title>
6
+  <style>
7
+    @php
8
+      $brandAccent = ($accent ?? null) ?: ($evento->attivita?->colore ?: '#f58220');
9
+      $brandNavy = '#15265c';
10
+      $brandPurple = '#602d91';
11
+      $brandSurface = '#f0f2f8';
12
+      $brandBorder = '#e2e6ef';
13
+    @endphp
14
+    @page {
15
+      margin: {{ ($preset['layout'] ?? '') === 'segnaposto' ? '0' : '24mm 12mm 18mm 12mm' }};
16
+    }
17
+    * { box-sizing: border-box; }
18
+    body {
19
+      font-family: DejaVu Sans, Helvetica, Arial, sans-serif;
20
+      font-size: {{ $fontSize ?? 10 }}px;
21
+      color: #111827;
22
+      margin: 0;
23
+      line-height: 1.35;
24
+    }
25
+
26
+    table.lista {
27
+      width: 100%;
28
+      border-collapse: collapse;
29
+      table-layout: fixed;
30
+    }
31
+    thead {
32
+      display: table-header-group;
33
+    }
34
+    tfoot {
35
+      display: table-footer-group;
36
+    }
37
+    tr.lista-row {
38
+      page-break-inside: avoid;
39
+    }
40
+    table.lista thead th {
41
+      background: #eef2f7;
42
+      color: #1e293b;
43
+      font-size: {{ max(7, ($fontSize ?? 10) - 1) }}px;
44
+      font-weight: bold;
45
+      text-align: left;
46
+      padding: 8px 6px;
47
+      border-top: 0;
48
+      border-left: 0;
49
+      border-right: 0;
50
+      border-bottom: 1.5px solid #64748b;
51
+      word-wrap: break-word;
52
+    }
53
+    table.lista tbody td {
54
+      border-bottom: 1px solid #e2e8f0;
55
+      padding: 7px 6px;
56
+      vertical-align: top;
57
+      word-wrap: break-word;
58
+      overflow-wrap: break-word;
59
+      color: #0f172a;
60
+      line-height: 1.35;
61
+    }
62
+    table.lista tbody tr:nth-child(even) td {
63
+      background: #f8fafc;
64
+    }
65
+    .col-codice {
66
+      text-align: center;
67
+      vertical-align: middle;
68
+      color: #0f172a;
69
+      font-variant-numeric: tabular-nums;
70
+      font-family: DejaVu Sans Mono, DejaVu Sans, monospace;
71
+      letter-spacing: 0.06em;
72
+      font-weight: bold;
73
+      font-size: {{ ($fontSize ?? 10) + 5 }}px;
74
+      line-height: 1.2;
75
+    }
76
+    table.lista thead th.col-codice {
77
+      text-align: center;
78
+      vertical-align: middle;
79
+      font-size: {{ max(8, ($fontSize ?? 10) + 1) }}px;
80
+    }
81
+    table.lista tbody td.col-codice {
82
+      text-align: center;
83
+      vertical-align: middle;
84
+      padding-top: 9px;
85
+      padding-bottom: 9px;
86
+    }
87
+    .col-arrivato,
88
+    .col-confermata {
89
+      text-align: center;
90
+    }
91
+    .col-nome {
92
+      font-size: {{ ($fontSize ?? 10) + 2 }}px;
93
+      font-weight: bold;
94
+    }
95
+    .icon-conf {
96
+      display: inline-block;
97
+      width: 1.45em;
98
+      height: 1.45em;
99
+      line-height: 1.45em;
100
+      border-radius: 50%;
101
+      text-align: center;
102
+      font-weight: bold;
103
+      font-size: 0.95em;
104
+      color: #ffffff;
105
+    }
106
+    .icon-conf.is-yes { background: #16a34a; }
107
+    .icon-conf.is-no { background: #dc2626; }
108
+    .coperto-line {
109
+      display: block;
110
+      margin: 0 0 3px 0;
111
+      line-height: 1.35;
112
+    }
113
+    .coperto-ico {
114
+      display: inline-block;
115
+      width: 11px;
116
+      height: 11px;
117
+      margin-right: 3px;
118
+      vertical-align: -1px;
119
+    }
120
+    .empty {
121
+      color: #6b7280;
122
+      font-style: italic;
123
+      margin-top: 28px;
124
+      text-align: center;
125
+    }
126
+
127
+    .tavolo-block {
128
+      margin: 0 0 8mm;
129
+      page-break-inside: avoid;
130
+      border: 0.5pt solid {{ $brandBorder }};
131
+      border-top: 2.8pt solid {{ $brandAccent }};
132
+    }
133
+    .tavolo-gruppo-title {
134
+      margin: 5mm 0 2.2mm;
135
+      padding-left: 2.2mm;
136
+      border-left: 2.2pt solid {{ $brandAccent }};
137
+      font-size: {{ ($fontSize ?? 10) + 1 }}px;
138
+      font-weight: bold;
139
+      text-transform: uppercase;
140
+      letter-spacing: 0.06em;
141
+      color: {{ $brandPurple }};
142
+    }
143
+    .tavolo-gruppo-title.is-first {
144
+      margin-top: 0;
145
+    }
146
+    .tavolo-gruppo-title.is-page-break {
147
+      page-break-before: always;
148
+      break-before: page;
149
+      margin-top: 0;
150
+    }
151
+    .tavolo-block.is-empty { opacity: 1; }
152
+    .tavolo-head {
153
+      padding: 2.4mm 2.8mm;
154
+      background: {{ $brandSurface }};
155
+      border-bottom: 0.5pt solid {{ $brandBorder }};
156
+    }
157
+    .tavolo-head-table {
158
+      width: 100%;
159
+      border-collapse: collapse;
160
+    }
161
+    .tavolo-head-table td {
162
+      border: 0;
163
+      padding: 0;
164
+      vertical-align: middle;
165
+    }
166
+    .tavolo-label {
167
+      font-size: {{ ($fontSize ?? 10) + 2 }}px;
168
+      font-weight: bold;
169
+      color: {{ $brandNavy }};
170
+      letter-spacing: 0.01em;
171
+    }
172
+    .tavolo-gruppo {
173
+      font-size: {{ max(7, ($fontSize ?? 10) - 2) }}px;
174
+      color: #6b7280;
175
+      margin-left: 2mm;
176
+      font-weight: normal;
177
+    }
178
+    .tavolo-meta {
179
+      font-size: {{ max(7, ($fontSize ?? 10) - 1) }}px;
180
+      color: #6b7280;
181
+      margin-top: 0.8mm;
182
+    }
183
+    .tavolo-disponibili {
184
+      text-align: right;
185
+      white-space: nowrap;
186
+      padding: 0 0 0 3mm;
187
+      background: transparent;
188
+      border: 0;
189
+      color: {{ $brandNavy }};
190
+    }
191
+    .tavolo-disponibili.is-zero {
192
+      background: transparent;
193
+      border: 0;
194
+      color: #94a3b8;
195
+    }
196
+    .tavolo-disponibili-n {
197
+      font-size: {{ ($fontSize ?? 10) + 9 }}px;
198
+      font-weight: bold;
199
+      line-height: 1;
200
+      display: inline-block;
201
+      vertical-align: middle;
202
+      margin-right: 1.5mm;
203
+      letter-spacing: -0.02em;
204
+      color: {{ $brandNavy }};
205
+    }
206
+    .tavolo-disponibili-l {
207
+      font-size: {{ max(6, ($fontSize ?? 10) - 3) }}px;
208
+      font-weight: bold;
209
+      text-transform: uppercase;
210
+      letter-spacing: 0.04em;
211
+      display: inline-block;
212
+      vertical-align: middle;
213
+      line-height: 1.15;
214
+      text-align: left;
215
+      color: {{ $brandAccent }};
216
+    }
217
+    .tavolo-disponibili.is-zero .tavolo-disponibili-n { color: #94a3b8; }
218
+    .tavolo-disponibili.is-zero .tavolo-disponibili-l { color: #94a3b8; }
219
+    .tavolo-block.is-full .tavolo-head { background: {{ $brandSurface }}; }
220
+    .tavolo-block.is-unassigned .tavolo-head { background: {{ $brandSurface }}; }
221
+    .tavolo-block.is-unassigned {
222
+      border-top-color: {{ $brandPurple }};
223
+    }
224
+    .tavolo-block table.lista thead th {
225
+      background: #fff;
226
+      color: {{ $brandNavy }};
227
+      border-bottom: 0.5pt solid {{ $brandBorder }};
228
+      font-weight: bold;
229
+      letter-spacing: 0.03em;
230
+      padding: 5px 6px;
231
+    }
232
+    .tavolo-block table.lista tbody tr:nth-child(even) td {
233
+      background: #f8f9fc;
234
+    }
235
+    table.lista tr.is-blank td {
236
+      height: 7mm;
237
+      border-bottom: 0.4pt solid #c5cad6;
238
+      background: #fff;
239
+    }
240
+    .tavolo-spezza-badge {
241
+      display: inline-block;
242
+      margin-left: 1.5mm;
243
+      padding: 0.3mm 1.3mm;
244
+      font-size: {{ max(6, ($fontSize ?? 10) - 2) }}px;
245
+      font-weight: bold;
246
+      letter-spacing: 0.02em;
247
+      color: {{ $brandAccent }};
248
+      border: 0.5pt solid {{ $brandAccent }};
249
+      vertical-align: middle;
250
+      white-space: nowrap;
251
+    }
252
+    table.lista tbody tr.is-spezzata td {
253
+      background: #fffaf5;
254
+    }
255
+    table.lista tbody tr.is-spezzata:nth-child(even) td {
256
+      background: #fff6ee;
257
+    }
258
+
259
+    .piantina-report-sala { margin: 0 0 6mm; }
260
+    .piantina-report-sala.is-next { page-break-before: always; }
261
+    .piantina-report-sala__title {
262
+      margin: 0 0 2.5mm;
263
+      font-size: 11pt;
264
+      font-weight: bold;
265
+      color: #15265c;
266
+    }
267
+    .piantina-report-map {
268
+      position: relative;
269
+      width: 100%;
270
+      height: 160mm;
271
+      background: #eef1f6;
272
+      border: 0.6pt solid #cbd5e1;
273
+      overflow: hidden;
274
+    }
275
+    .piantina-report-img {
276
+      position: absolute;
277
+      top: 0;
278
+      left: 0;
279
+      width: 100%;
280
+      height: 100%;
281
+    }
282
+    .piantina-report-tavolo {
283
+      position: absolute;
284
+      padding: 0.7mm;
285
+      border: 0.8pt solid #15265c;
286
+      color: #fff;
287
+      font-weight: bold;
288
+      font-size: 10pt;
289
+      line-height: 1.1;
290
+      text-align: center;
291
+      overflow: hidden;
292
+    }
293
+    .piantina-report-tavolo.is-rettangolo { border-radius: 1.5mm; }
294
+    .piantina-report-tavolo.is-cerchio { border-radius: 50%; }
295
+    .piantina-report-tavolo.is-libero { background-color: #198754; }
296
+    .piantina-report-tavolo.is-parziale { background-color: #f58220; }
297
+    .piantina-report-tavolo.is-pieno { background-color: #dc3545; }
298
+    .piantina-report-tavolo__label { font-size: 13pt; font-weight: bold; }
299
+    .piantina-report-tavolo__posti { font-size: 8.5pt; font-weight: bold; }
300
+    .piantina-report-tavolo__ospiti { font-size: 7.5pt; font-weight: normal; margin-top: 0.5mm; }
301
+    .piantina-report-legend { margin-top: 6mm; page-break-before: always; }
302
+    .piantina-report-legend__title {
303
+      font-size: 10pt;
304
+      font-weight: bold;
305
+      text-transform: uppercase;
306
+      margin-bottom: 2mm;
307
+    }
308
+    .piantina-report-legend__empty { color: #94a3b8; font-style: italic; }
309
+
310
+    /* Segnaposto PDF: un'immagine = una pagina (piega già nel PNG) */
311
+    div.foglio-page {
312
+      page-break-before: always;
313
+      page-break-inside: avoid;
314
+      margin: 0;
315
+      padding: 0;
316
+    }
317
+    div.foglio-page.is-first {
318
+      page-break-before: auto;
319
+    }
320
+    img.foglio-img {
321
+      display: block;
322
+      border: 0;
323
+      margin: 0;
324
+      padding: 0;
325
+    }
326
+    div.foglio-page--verticale img.foglio-img {
327
+      width: 594pt;
328
+      height: 418pt;
329
+    }
330
+    div.foglio-page--orizzontale img.foglio-img {
331
+      width: 418pt;
332
+      height: 594pt;
333
+    }
334
+
335
+    /* Anteprima HTML (senza immagini PDF) */
336
+    table.pieghevole {
337
+      border-collapse: collapse;
338
+      table-layout: fixed;
339
+      page-break-before: always;
340
+      page-break-inside: avoid;
341
+      margin: 0;
342
+      padding: 0;
343
+    }
344
+    table.pieghevole.is-first {
345
+      page-break-before: auto;
346
+    }
347
+    table.pieghevole--verticale {
348
+      width: 595.28pt;
349
+      height: 419.53pt;
350
+    }
351
+    table.pieghevole--verticale td.panel {
352
+      width: 297.64pt;
353
+      height: 419.53pt;
354
+      padding: 0;
355
+      margin: 0;
356
+      vertical-align: top;
357
+      text-align: center;
358
+      overflow: hidden;
359
+    }
360
+    table.pieghevole--verticale td.panel--left {
361
+      border-right: 0.75pt dashed #94a3b8;
362
+    }
363
+    table.pieghevole--orizzontale {
364
+      width: 419.53pt;
365
+      height: 595.28pt;
366
+    }
367
+    table.pieghevole--orizzontale td.panel {
368
+      width: 419.53pt;
369
+      height: 297.64pt;
370
+      padding: 0;
371
+      margin: 0;
372
+      vertical-align: top;
373
+      text-align: center;
374
+      overflow: hidden;
375
+    }
376
+    table.pieghevole--orizzontale td.panel--top {
377
+      border-bottom: 0.75pt dashed #94a3b8;
378
+    }
379
+
380
+    .faccia {
381
+      width: 100%;
382
+      padding: 10mm 8mm;
383
+    }
384
+    .faccia-top {
385
+      margin-bottom: 10mm;
386
+    }
387
+    table.pieghevole--orizzontale .faccia-top {
388
+      margin-bottom: 6mm;
389
+    }
390
+    .faccia-logo {
391
+      max-width: 28mm;
392
+      max-height: 16mm;
393
+      margin: 0 auto 4mm auto;
394
+      display: block;
395
+    }
396
+    .faccia-evento {
397
+      font-size: 10px;
398
+      font-weight: bold;
399
+      color: #334155;
400
+      letter-spacing: 0.02em;
401
+      text-transform: uppercase;
402
+      margin: 0;
403
+    }
404
+    .faccia-nome {
405
+      font-size: 22px;
406
+      font-weight: bold;
407
+      color: #0f172a;
408
+      line-height: 1.2;
409
+      word-wrap: break-word;
410
+    }
411
+    .faccia-codice {
412
+      margin-top: 3mm;
413
+      font-size: 11px;
414
+      color: #64748b;
415
+      letter-spacing: 0.08em;
416
+      font-family: DejaVu Sans Mono, DejaVu Sans, monospace;
417
+    }
418
+    table.pieghevole--orizzontale .faccia-nome {
419
+      font-size: 26px;
420
+    }
421
+    table.pieghevole--orizzontale td.panel--ruotato .faccia {
422
+      -webkit-transform: rotate(180deg);
423
+      transform: rotate(180deg);
424
+    }
425
+  </style>
426
+</head>
427
+<body>
428
+  @if($righe->isEmpty() && ($preset['layout'] ?? '') !== 'piantina')
429
+    <p class="empty">Nessuna prenotazione da stampare con i filtri selezionati.</p>
430
+  @elseif(($preset['layout'] ?? '') === 'segnaposto')
431
+    @include('prenotazione.report.segnaposto', ['outputHtml' => false])
432
+  @elseif(($preset['layout'] ?? '') === 'piantina')
433
+    @include('prenotazione.report.piantina', ['outputPdf' => true])
434
+  @elseif(($preset['layout'] ?? '') === 'tavoli')
435
+    @include('prenotazione.report.tavoli')
436
+  @else
437
+    @include('prenotazione.report.lista')
438
+  @endif
439
+</body>
440
+</html>

+ 110
- 0
resources/views/prenotazione/report/piantina.blade.php 查看文件

@@ -0,0 +1,110 @@
1
+@php
2
+  $map = $piantinaReport ?? null;
3
+  $isPdf = ! empty($outputPdf);
4
+  $sale = is_array($map['sale'] ?? null) ? $map['sale'] : [];
5
+  $legenda = is_array($map['legenda'] ?? null) ? $map['legenda'] : [];
6
+  $multiSala = ! empty($map['multi_sala']) || count($sale) > 1;
7
+  $hasAnyImage = collect($sale)->contains(fn ($s) => ! empty($s['url']) || ! empty($s['path']));
8
+  $hasAnyTavolo = collect($sale)->contains(fn ($s) => ! empty($s['tavoli']));
9
+@endphp
10
+
11
+@if(!$map || $sale === [])
12
+  <p class="empty">Piantina non disponibile.</p>
13
+@elseif(! $hasAnyImage)
14
+  <p class="empty">Carica prima l’immagine piantina dall’editor sala.</p>
15
+@elseif(! $hasAnyTavolo)
16
+  <p class="empty">Nessun tavolo sulla piantina.</p>
17
+@else
18
+  <div class="piantina-report-wrap">
19
+    @foreach($sale as $salaIdx => $salaMap)
20
+      @php
21
+        $salaNome = $salaMap['nome'] ?? ('Sala #'.($salaIdx + 1));
22
+        $hasImg = ($isPdf && ! empty($salaMap['path']) && is_file($salaMap['path']))
23
+          || (! $isPdf && ! empty($salaMap['url']))
24
+          || (! empty($salaMap['path']) && is_file($salaMap['path']))
25
+          || ! empty($salaMap['url']);
26
+      @endphp
27
+
28
+      <div class="piantina-report-sala{{ $salaIdx > 0 ? ' is-next' : '' }}">
29
+        @if($multiSala)
30
+          <div class="piantina-report-sala__title">{{ $salaNome }}</div>
31
+        @endif
32
+
33
+        @if(! $hasImg)
34
+          <p class="empty">Nessuna immagine per {{ $salaNome }}.</p>
35
+        @elseif(empty($salaMap['tavoli']))
36
+          <p class="empty">Nessun tavolo in {{ $salaNome }}.</p>
37
+        @else
38
+          <div class="piantina-report-map" style="aspect-ratio: {{ $salaMap['aspect'] ?? '4 / 3' }};">
39
+            @if($isPdf && ! empty($salaMap['path']) && is_file($salaMap['path']))
40
+              <img class="piantina-report-img" src="{{ $salaMap['path'] }}" alt="Piantina {{ $salaNome }}">
41
+            @elseif(! empty($salaMap['url']))
42
+              <img class="piantina-report-img" src="{{ $salaMap['url'] }}" alt="Piantina {{ $salaNome }}">
43
+            @elseif(! empty($salaMap['path']) && is_file($salaMap['path']))
44
+              <img class="piantina-report-img" src="{{ $salaMap['path'] }}" alt="Piantina {{ $salaNome }}">
45
+            @endif
46
+
47
+            @foreach($salaMap['tavoli'] as $t)
48
+              @php
49
+                $fill = $t['fill'] ?? 'libero';
50
+                $ospiti = $t['ospiti'] ?? [];
51
+                $maxShow = 3;
52
+              @endphp
53
+              <div
54
+                class="piantina-report-tavolo is-{{ $fill }} {{ ($t['forma'] ?? '') === 'cerchio' ? 'is-cerchio' : 'is-rettangolo' }}"
55
+                style="left: {{ $t['x'] }}%; top: {{ $t['y'] }}%; width: {{ $t['w'] }}%; height: {{ $t['h'] }}%;"
56
+              >
57
+                <div class="piantina-report-tavolo__label">{{ $t['label'] }}</div>
58
+                <div class="piantina-report-tavolo__posti">{{ $t['occupati'] }}/{{ $t['posti'] }}</div>
59
+                @if($ospiti !== [])
60
+                  <div class="piantina-report-tavolo__ospiti">
61
+                    @foreach(array_slice($ospiti, 0, $maxShow) as $o)
62
+                      <div>{{ $o['nome'] }}@if(($o['posti'] ?? 1) > 1) ({{ $o['posti'] }})@endif</div>
63
+                    @endforeach
64
+                    @if(count($ospiti) > $maxShow)
65
+                      <div>+{{ count($ospiti) - $maxShow }}</div>
66
+                    @endif
67
+                  </div>
68
+                @endif
69
+              </div>
70
+            @endforeach
71
+          </div>
72
+        @endif
73
+      </div>
74
+    @endforeach
75
+
76
+    <div class="piantina-report-legend">
77
+      <div class="piantina-report-legend__title">Legenda ospiti</div>
78
+      @if($legenda === [])
79
+        <p class="piantina-report-legend__empty">Nessuna prenotazione assegnata ai tavoli.</p>
80
+      @else
81
+        <table class="lista piantina-legend-table">
82
+          <thead>
83
+            <tr>
84
+              @if($multiSala)
85
+                <th style="width: 16%;">Sala</th>
86
+              @endif
87
+              <th style="width: 14%;">Tavolo</th>
88
+              <th>Nome</th>
89
+              <th style="width: 12%;">Posti</th>
90
+              <th style="width: 16%;">Codice</th>
91
+            </tr>
92
+          </thead>
93
+          <tbody>
94
+            @foreach($legenda as $row)
95
+              <tr class="lista-row">
96
+                @if($multiSala)
97
+                  <td class="col-codice">{{ $row['sala'] ?? '—' }}</td>
98
+                @endif
99
+                <td class="col-codice"><strong>{{ $row['tavolo'] }}</strong></td>
100
+                <td class="col-nome">{{ $row['nome'] }}</td>
101
+                <td class="col-codice">{{ $row['posti'] }}</td>
102
+                <td class="col-codice">{{ strtoupper((string) ($row['codice'] ?? '—')) }}</td>
103
+              </tr>
104
+            @endforeach
105
+          </tbody>
106
+        </table>
107
+      @endif
108
+    </div>
109
+  </div>
110
+@endif

+ 54
- 0
resources/views/prenotazione/report/segnaposto.blade.php 查看文件

@@ -0,0 +1,54 @@
1
+@php
2
+  $logoSrc = ($outputHtml ?? false) ? ($logoUrl ?? null) : ($logoPath ?? null);
3
+  $formato = ($segnapostoFormato ?? 'verticale') === 'orizzontale' ? 'orizzontale' : 'verticale';
4
+  $usaFaccePdf = ! empty($usaFaccePdf);
5
+  $pagine = [];
6
+  foreach ($righe as $riga) {
7
+      for ($c = 0; $c < $riga['copie']; $c++) {
8
+          $pagine[] = $riga;
9
+      }
10
+  }
11
+@endphp
12
+
13
+@foreach($pagine as $i => $riga)
14
+  @php
15
+    $nomeOspite = $riga['celle']['nome'] ?? $riga['nome'] ?: '—';
16
+    $faccia = [
17
+      'nomeOspite' => $nomeOspite,
18
+      'logoSrc' => $logoSrc,
19
+      'codicePrenotazione' => strtoupper((string) ($riga['prenotazione']->codice ?? '')),
20
+    ];
21
+    $isFirst = $i === 0;
22
+  @endphp
23
+
24
+  @if($usaFaccePdf && ! empty($riga['foglioPdf']))
25
+    {{-- Una sola immagine a pagina intera: niente spezzature DomPDF --}}
26
+    <div class="foglio-page {{ $formato === 'orizzontale' ? 'foglio-page--orizzontale' : 'foglio-page--verticale' }} {{ $isFirst ? 'is-first' : '' }}">
27
+      <img class="foglio-img" src="{{ $riga['foglioPdf'] }}" alt="">
28
+    </div>
29
+  @elseif($formato === 'orizzontale')
30
+    <table class="pieghevole pieghevole--orizzontale {{ $isFirst ? 'is-first' : '' }}">
31
+      <tr>
32
+        <td class="panel panel--top panel--ruotato">
33
+          @include('prenotazione.report._segnaposto-faccia', $faccia)
34
+        </td>
35
+      </tr>
36
+      <tr>
37
+        <td class="panel panel--bottom">
38
+          @include('prenotazione.report._segnaposto-faccia', $faccia)
39
+        </td>
40
+      </tr>
41
+    </table>
42
+  @else
43
+    <table class="pieghevole pieghevole--verticale {{ $isFirst ? 'is-first' : '' }}">
44
+      <tr>
45
+        <td class="panel panel--left">
46
+          @include('prenotazione.report._segnaposto-faccia', $faccia)
47
+        </td>
48
+        <td class="panel panel--right">
49
+          @include('prenotazione.report._segnaposto-faccia', $faccia)
50
+        </td>
51
+      </tr>
52
+    </table>
53
+  @endif
54
+@endforeach

+ 551
- 0
resources/views/prenotazione/report/show.blade.php 查看文件

@@ -0,0 +1,551 @@
1
+@php
2
+  $isSegnaposto = ($preset['layout'] ?? '') === 'segnaposto';
3
+  $isPiantina = ($preset['layout'] ?? '') === 'piantina';
4
+  $segnapostoFormato = ($segnapostoFormato ?? 'verticale') === 'orizzontale' ? 'orizzontale' : 'verticale';
5
+  $brandAccent = ($accent ?? null) ?: ($evento->attivita?->colore ?: '#f58220');
6
+  $brandNavy = '#15265c';
7
+  $brandPurple = '#602d91';
8
+  $brandSurface = '#f0f2f8';
9
+  $brandBorder = '#e2e6ef';
10
+@endphp
11
+<!DOCTYPE html>
12
+<html lang="it">
13
+<head>
14
+  <meta charset="utf-8">
15
+  <meta name="viewport" content="width=device-width, initial-scale=1">
16
+  <title>{{ $reportTitle }} · {{ $evento->nome }}</title>
17
+  <style>
18
+    * { box-sizing: border-box; }
19
+    body {
20
+      font-family: "Segoe UI", Helvetica, Arial, sans-serif;
21
+      font-size: {{ max(12, ($fontSize ?? 10) + 2) }}px;
22
+      color: #111827;
23
+      margin: 0;
24
+      line-height: 1.4;
25
+      background: #f0f2f8;
26
+    }
27
+
28
+    .toolbar {
29
+      position: sticky;
30
+      top: 0;
31
+      z-index: 20;
32
+      display: flex;
33
+      flex-wrap: wrap;
34
+      align-items: center;
35
+      gap: .75rem 1rem;
36
+      padding: .75rem 1.25rem;
37
+      background: #0f172a;
38
+      color: #f8fafc;
39
+      box-shadow: 0 1px 0 rgba(15, 23, 42, .35);
40
+    }
41
+    .toolbar .meta { flex: 1 1 12rem; min-width: 0; }
42
+    .toolbar .meta strong { display: block; font-size: .95rem; }
43
+    .toolbar .meta span {
44
+      display: block;
45
+      font-size: .8rem;
46
+      color: #94a3b8;
47
+      margin-top: .15rem;
48
+    }
49
+    .toolbar .actions { display: flex; flex-wrap: wrap; gap: .5rem; }
50
+    .toolbar a,
51
+    .toolbar button {
52
+      appearance: none;
53
+      border: 0;
54
+      border-radius: 6px;
55
+      padding: .45rem .9rem;
56
+      font: inherit;
57
+      font-size: .875rem;
58
+      cursor: pointer;
59
+      text-decoration: none;
60
+      color: #0f172a;
61
+      background: #e2e8f0;
62
+    }
63
+    .toolbar a.primary,
64
+    .toolbar button.primary {
65
+      background: #3b82f6;
66
+      color: #fff;
67
+    }
68
+
69
+    .sheet {
70
+      max-width: {{ $isSegnaposto
71
+        ? ($segnapostoFormato === 'orizzontale' ? '520px' : '860px')
72
+        : ($isPiantina ? '1200px' : (($orientation ?? 'portrait') === 'landscape' ? '1100px' : '900px')) }};
73
+      margin: 1.25rem auto 2rem;
74
+      background: {{ $isSegnaposto ? 'transparent' : '#fff' }};
75
+      border: {{ $isSegnaposto ? '0' : '1px solid #e2e8f0' }};
76
+      border-radius: {{ $isSegnaposto ? '0' : '10px' }};
77
+      padding: {{ $isSegnaposto ? '0' : '1.5rem 1.25rem 2rem' }};
78
+      box-shadow: {{ $isSegnaposto ? 'none' : '0 8px 24px rgba(15, 23, 42, .06)' }};
79
+    }
80
+
81
+    .sheet-header {
82
+      display: flex;
83
+      justify-content: space-between;
84
+      align-items: flex-start;
85
+      gap: 1rem;
86
+      margin-bottom: 1rem;
87
+      padding-bottom: .75rem;
88
+      border-bottom: 1.5px solid #cbd5e1;
89
+    }
90
+    .sheet-header .left strong { display: block; color: #334155; font-size: .95rem; }
91
+    .sheet-header .left span { display: block; color: #64748b; font-size: .85rem; margin-top: .2rem; }
92
+    .sheet-header .right { text-align: right; color: #2563eb; font-weight: 700; font-size: .9rem; }
93
+    .sheet-footer {
94
+      margin-top: 1.25rem;
95
+      padding-top: .75rem;
96
+      border-top: 1px solid #e2e8f0;
97
+      color: #64748b;
98
+      font-size: .8rem;
99
+    }
100
+
101
+    table.lista {
102
+      width: 100%;
103
+      border-collapse: collapse;
104
+      table-layout: fixed;
105
+      background: #fff;
106
+    }
107
+    table.lista thead th {
108
+      background: #eef2f7;
109
+      color: #1e293b;
110
+      font-size: .85em;
111
+      font-weight: 700;
112
+      text-align: left;
113
+      padding: 8px 6px;
114
+      border-bottom: 1.5px solid #64748b;
115
+      word-wrap: break-word;
116
+    }
117
+    table.lista tbody td {
118
+      border-bottom: 1px solid #e2e8f0;
119
+      padding: 7px 6px;
120
+      vertical-align: top;
121
+      word-wrap: break-word;
122
+      overflow-wrap: break-word;
123
+      white-space: pre-line;
124
+      color: #0f172a;
125
+    }
126
+    table.lista tbody tr:nth-child(even) td { background: #f8fafc; }
127
+    .col-id,
128
+    .col-codice {
129
+      text-align: center;
130
+      vertical-align: middle;
131
+      color: #0f172a;
132
+      font-variant-numeric: tabular-nums;
133
+      letter-spacing: .06em;
134
+      font-weight: 800;
135
+      font-family: ui-monospace, Consolas, monospace;
136
+      font-size: 1.35em;
137
+      line-height: 1.2;
138
+    }
139
+    table.lista thead th.col-codice,
140
+    table.lista thead th.col-id {
141
+      text-align: center;
142
+      vertical-align: middle;
143
+      font-size: .9em;
144
+    }
145
+    table.lista tbody td.col-codice,
146
+    table.lista tbody td.col-id {
147
+      text-align: center;
148
+      vertical-align: middle;
149
+    }
150
+    .col-arrivato,
151
+    .col-confermata { text-align: center; }
152
+    .col-nome { font-size: 1.15em; font-weight: 700; }
153
+    .icon-conf {
154
+      display: inline-flex;
155
+      align-items: center;
156
+      justify-content: center;
157
+      width: 1.5em;
158
+      height: 1.5em;
159
+      border-radius: 50%;
160
+      font-weight: 700;
161
+      font-size: .9em;
162
+      color: #fff;
163
+      line-height: 1;
164
+    }
165
+    .icon-conf.is-yes { background: #16a34a; }
166
+    .icon-conf.is-no { background: #dc2626; }
167
+    .coperto-line { display: block; margin: 0 0 3px 0; line-height: 1.35; }
168
+    .coperto-ico {
169
+      display: inline-block;
170
+      width: 12px;
171
+      height: 12px;
172
+      margin-right: 4px;
173
+      vertical-align: -2px;
174
+    }
175
+    .empty {
176
+      color: #6b7280;
177
+      font-style: italic;
178
+      margin-top: 28px;
179
+      text-align: center;
180
+      background: #fff;
181
+      padding: 2rem;
182
+      border-radius: 10px;
183
+    }
184
+    .tavolo-block {
185
+      margin: 0 0 1.15rem;
186
+      page-break-inside: avoid;
187
+      border: 1px solid {{ $brandBorder }};
188
+      border-top: 3px solid {{ $brandAccent }};
189
+      border-radius: 0 0 10px 10px;
190
+      overflow: hidden;
191
+      background: #fff;
192
+      box-shadow: 0 1px 3px rgba(21, 38, 92, 0.05), 0 8px 18px rgba(21, 38, 92, 0.04);
193
+    }
194
+    .tavolo-gruppo-title {
195
+      margin: 1.1rem 0 .45rem;
196
+      padding-left: .55rem;
197
+      border-left: 3px solid {{ $brandAccent }};
198
+      font-size: .78rem;
199
+      font-weight: 800;
200
+      letter-spacing: .06em;
201
+      text-transform: uppercase;
202
+      color: {{ $brandPurple }};
203
+    }
204
+    .tavolo-gruppo-title:first-child,
205
+    .tavolo-gruppo-title.is-first { margin-top: 0; }
206
+    .tavolo-gruppo-title.is-page-break {
207
+      page-break-before: always;
208
+      break-before: page;
209
+      margin-top: 0;
210
+      padding-top: .25rem;
211
+    }
212
+    .tavolo-block.is-empty { opacity: 1; }
213
+    .tavolo-block table.lista tr.is-blank td {
214
+      height: 1.55em;
215
+      min-height: 22px;
216
+      border-bottom: 1px solid #c5cad6;
217
+      background: #fff;
218
+    }
219
+    .tavolo-block.is-unassigned { border-top-color: {{ $brandPurple }}; }
220
+    .tavolo-head {
221
+      padding: .6rem .75rem;
222
+      background: {{ $brandSurface }};
223
+      border-bottom: 1px solid {{ $brandBorder }};
224
+    }
225
+    .tavolo-head-table {
226
+      width: 100%;
227
+      border-collapse: collapse;
228
+    }
229
+    .tavolo-head-table td {
230
+      border: 0;
231
+      padding: 0;
232
+      vertical-align: middle;
233
+    }
234
+    .tavolo-head-main { min-width: 0; }
235
+    .tavolo-label {
236
+      font-size: 1.1em;
237
+      font-weight: 800;
238
+      color: {{ $brandNavy }};
239
+      letter-spacing: .01em;
240
+    }
241
+    .tavolo-gruppo {
242
+      display: inline-block;
243
+      margin-left: .4rem;
244
+      font-size: .75em;
245
+      color: #6b7280;
246
+      font-weight: 500;
247
+    }
248
+    .tavolo-meta { font-size: .82em; color: #6b7280; margin-top: .2rem; }
249
+    .tavolo-disponibili {
250
+      white-space: nowrap;
251
+      padding: 0 0 0 .75rem;
252
+      background: transparent;
253
+      border: 0;
254
+      color: {{ $brandNavy }};
255
+      line-height: 1.1;
256
+      text-align: right;
257
+    }
258
+    .tavolo-disponibili.is-zero {
259
+      background: transparent;
260
+      border: 0;
261
+      color: #94a3b8;
262
+    }
263
+    .tavolo-disponibili-n {
264
+      font-size: 1.65rem;
265
+      font-weight: 800;
266
+      font-variant-numeric: tabular-nums;
267
+      line-height: 1;
268
+      display: inline-block;
269
+      vertical-align: middle;
270
+      margin-right: .35rem;
271
+      letter-spacing: -.02em;
272
+      color: {{ $brandNavy }};
273
+    }
274
+    .tavolo-disponibili-l {
275
+      font-size: .65rem;
276
+      font-weight: 700;
277
+      text-transform: uppercase;
278
+      letter-spacing: .05em;
279
+      display: inline-block;
280
+      vertical-align: middle;
281
+      text-align: left;
282
+      color: {{ $brandAccent }};
283
+    }
284
+    .tavolo-disponibili.is-zero .tavolo-disponibili-n { color: #94a3b8; }
285
+    .tavolo-disponibili.is-zero .tavolo-disponibili-l { color: #94a3b8; }
286
+    .tavolo-block.is-full .tavolo-head { background: {{ $brandSurface }}; }
287
+    .tavolo-block table.lista thead th {
288
+      background: #fff;
289
+      color: {{ $brandNavy }};
290
+      border-bottom: 1px solid {{ $brandBorder }};
291
+    }
292
+    .tavolo-block table.lista tbody tr:nth-child(even) td {
293
+      background: #f8f9fc;
294
+    }
295
+    .tavolo-spezza-badge {
296
+      display: inline-block;
297
+      margin-left: .4rem;
298
+      padding: .1rem .4rem;
299
+      font-size: .72em;
300
+      font-weight: 700;
301
+      letter-spacing: .02em;
302
+      color: {{ $brandAccent }};
303
+      border: 1px solid {{ $brandAccent }};
304
+      border-radius: 4px;
305
+      vertical-align: middle;
306
+      white-space: nowrap;
307
+    }
308
+    table.lista tbody tr.is-spezzata td { background: #fffaf5; }
309
+    table.lista tbody tr.is-spezzata:nth-child(even) td { background: #fff6ee; }
310
+    .tavolo-empty {
311
+      margin: 0;
312
+      padding: .7rem .75rem;
313
+      color: #94a3b8;
314
+      font-size: .9em;
315
+    }
316
+    .tavolo-block table.lista { margin: 0; }
317
+
318
+    .piantina-report-wrap { margin-top: .25rem; }
319
+    .piantina-report-sala { margin-bottom: 1.25rem; }
320
+    .piantina-report-sala.is-next { margin-top: 1.5rem; }
321
+    .piantina-report-sala__title {
322
+      margin: 0 0 .55rem;
323
+      font-size: .95rem;
324
+      font-weight: 800;
325
+      color: #15265c;
326
+      letter-spacing: .01em;
327
+    }
328
+    .piantina-report-map {
329
+      position: relative;
330
+      width: 100%;
331
+      min-height: 240px;
332
+      background: #eef1f6;
333
+      border: 1px solid #d9dee3;
334
+      border-radius: .75rem;
335
+      overflow: hidden;
336
+    }
337
+    .piantina-report-img {
338
+      position: absolute;
339
+      inset: 0;
340
+      width: 100%;
341
+      height: 100%;
342
+      object-fit: fill;
343
+    }
344
+    .piantina-report-tavolo {
345
+      position: absolute;
346
+      display: flex;
347
+      flex-direction: column;
348
+      align-items: center;
349
+      justify-content: center;
350
+      padding: .15rem .2rem;
351
+      border: 2px solid #15265c;
352
+      color: #fff;
353
+      font-weight: 800;
354
+      font-size: clamp(.85rem, 1.85vw, 1.2rem);
355
+      line-height: 1.08;
356
+      text-align: center;
357
+      overflow: hidden;
358
+      box-shadow: 0 2px 8px rgba(21,38,92,.16);
359
+    }
360
+    .piantina-report-tavolo.is-rettangolo { border-radius: .35rem; }
361
+    .piantina-report-tavolo.is-cerchio { border-radius: 999px; }
362
+    .piantina-report-tavolo.is-libero { background: rgba(25, 135, 84, 0.88); border-color: #146c43; }
363
+    .piantina-report-tavolo.is-parziale { background: rgba(245, 130, 32, 0.92); }
364
+    .piantina-report-tavolo.is-pieno { background: rgba(220, 53, 69, 0.9); border-color: #b02a37; }
365
+    .piantina-report-tavolo__label { font-size: 1.35em; font-weight: 900; letter-spacing: .02em; }
366
+    .piantina-report-tavolo__posti { font-size: .88em; font-weight: 700; opacity: .96; }
367
+    .piantina-report-tavolo__ospiti {
368
+      margin-top: .15rem;
369
+      font-size: .82em;
370
+      font-weight: 650;
371
+      line-height: 1.12;
372
+      max-width: 100%;
373
+      overflow: hidden;
374
+    }
375
+    .piantina-report-legend { margin-top: 1.25rem; }
376
+    .piantina-report-legend__title {
377
+      font-size: .78rem;
378
+      font-weight: 800;
379
+      text-transform: uppercase;
380
+      letter-spacing: .05em;
381
+      color: #334155;
382
+      margin-bottom: .5rem;
383
+    }
384
+    .piantina-report-legend__empty { color: #94a3b8; font-style: italic; }
385
+
386
+    table.pieghevole {
387
+      width: 100%;
388
+      margin: 0 auto 1.25rem auto;
389
+      border-collapse: collapse;
390
+      table-layout: fixed;
391
+      background: #fff;
392
+      border: 1px solid #e2e8f0;
393
+      border-radius: 8px;
394
+      overflow: hidden;
395
+      box-shadow: 0 8px 24px rgba(15, 23, 42, .06);
396
+    }
397
+    table.pieghevole--verticale {
398
+      max-width: 210mm;
399
+      height: 148mm;
400
+    }
401
+    table.pieghevole--verticale td.panel {
402
+      width: 50%;
403
+      height: 148mm;
404
+      vertical-align: middle;
405
+      text-align: center;
406
+      padding: 28px 22px;
407
+    }
408
+    table.pieghevole--verticale td.panel--left {
409
+      border-right: 1px dashed #94a3b8;
410
+    }
411
+
412
+    table.pieghevole--orizzontale {
413
+      max-width: 148mm;
414
+      height: 210mm;
415
+    }
416
+    table.pieghevole--orizzontale td.panel {
417
+      width: 100%;
418
+      height: 50%;
419
+      vertical-align: middle;
420
+      text-align: center;
421
+      padding: 22px 28px;
422
+    }
423
+    table.pieghevole--orizzontale td.panel--top {
424
+      border-bottom: 1px dashed #94a3b8;
425
+    }
426
+    table.pieghevole--orizzontale td.panel--ruotato .faccia {
427
+      transform: rotate(180deg);
428
+    }
429
+
430
+    .faccia { width: 100%; }
431
+    .faccia-top { margin-bottom: 2.5rem; }
432
+    table.pieghevole--orizzontale .faccia-top { margin-bottom: 1.25rem; }
433
+    .faccia-logo {
434
+      max-width: 88px;
435
+      max-height: 52px;
436
+      margin: 0 auto .75rem auto;
437
+      display: block;
438
+      object-fit: contain;
439
+    }
440
+    .faccia-evento {
441
+      font-size: .78rem;
442
+      font-weight: 700;
443
+      color: #334155;
444
+      letter-spacing: .04em;
445
+      text-transform: uppercase;
446
+      margin: 0;
447
+    }
448
+    .faccia-nome {
449
+      font-size: 1.85rem;
450
+      font-weight: 700;
451
+      color: #0f172a;
452
+      line-height: 1.2;
453
+      word-wrap: break-word;
454
+    }
455
+    .faccia-codice {
456
+      margin-top: .5rem;
457
+      font-size: .9rem;
458
+      color: #64748b;
459
+      letter-spacing: .1em;
460
+      font-family: ui-monospace, Consolas, monospace;
461
+    }
462
+    table.pieghevole--orizzontale .faccia-nome { font-size: 2.1rem; }
463
+
464
+    @media (max-width: 700px) {
465
+      .sheet { margin: .75rem; }
466
+      table.pieghevole { height: auto; }
467
+      table.pieghevole td.panel { height: auto; min-height: 160px; padding: 1.25rem 1rem; }
468
+      .faccia-nome { font-size: 1.35rem !important; }
469
+    }
470
+
471
+    @media print {
472
+      body { background: #fff; }
473
+      .toolbar { display: none !important; }
474
+      .sheet {
475
+        max-width: none;
476
+        margin: 0;
477
+        border: 0;
478
+        border-radius: 0;
479
+        box-shadow: none;
480
+        padding: 0;
481
+        background: transparent;
482
+      }
483
+      .sheet-header,
484
+      .sheet-footer { display: none; }
485
+      table.pieghevole {
486
+        max-width: none;
487
+        margin: 0;
488
+        border: 0;
489
+        border-radius: 0;
490
+        box-shadow: none;
491
+        page-break-after: always;
492
+      }
493
+      table.pieghevole--verticale { width: 210mm; height: 148mm; }
494
+      table.pieghevole--verticale td.panel { width: 50%; height: 148mm; }
495
+      table.pieghevole--orizzontale { width: 148mm; height: 210mm; }
496
+      table.pieghevole--orizzontale td.panel { height: 50%; }
497
+      a { color: inherit; text-decoration: none; }
498
+    }
499
+  </style>
500
+</head>
501
+<body>
502
+  <div class="toolbar">
503
+    <div class="meta">
504
+      <strong>{{ $reportTitle }}{{ $isSegnaposto ? ' · '.ucfirst($segnapostoFormato) : '' }}</strong>
505
+      <span>{{ $attivitaNome }} · {{ $evento->nome }} · {{ $righe->count() }} prenotazioni · generato {{ $generatoIl }}</span>
506
+    </div>
507
+    <div class="actions">
508
+      <button type="button" class="primary" onclick="window.print()">Stampa</button>
509
+      <a class="primary" href="{{ $pdfUrl }}">PDF</a>
510
+      <button type="button" onclick="window.close()">Chiudi</button>
511
+    </div>
512
+  </div>
513
+
514
+  <div class="sheet">
515
+    @unless($isSegnaposto)
516
+      <div class="sheet-header">
517
+        <div class="left">
518
+          <strong>{{ $attivitaNome }}</strong>
519
+          <span>{{ $evento->nome }}</span>
520
+        </div>
521
+        <div class="right">{{ $reportTitle }}</div>
522
+      </div>
523
+    @endunless
524
+
525
+    @if($righe->isEmpty() && ! $isPiantina)
526
+      <p class="empty">Nessuna prenotazione da mostrare con i filtri selezionati.</p>
527
+    @elseif($isSegnaposto)
528
+      @include('prenotazione.report.segnaposto', ['outputHtml' => true])
529
+    @elseif($isPiantina)
530
+      @include('prenotazione.report.piantina')
531
+    @elseif(($preset['layout'] ?? '') === 'tavoli')
532
+      @include('prenotazione.report.tavoli')
533
+    @else
534
+      @include('prenotazione.report.lista')
535
+    @endif
536
+
537
+    <div class="sheet-footer">
538
+      @if($isSegnaposto)
539
+        @if($segnapostoFormato === 'orizzontale')
540
+          Formato orizzontale · A5 verticale, piega al centro · la faccia superiore è ruotata per leggersi dritta a piega chiusa.
541
+        @else
542
+          Formato verticale · A5 orizzontale, piega al centro (linea tratteggiata).
543
+        @endif
544
+        PDF consigliato per la stampa.
545
+      @else
546
+        Anteprima a schermo · stampa browser o apri il PDF per l’impaginazione definitiva.
547
+      @endif
548
+    </div>
549
+  </div>
550
+</body>
551
+</html>

+ 132
- 0
resources/views/prenotazione/report/tavoli.blade.php 查看文件

@@ -0,0 +1,132 @@
1
+@php
2
+  $gruppi = $gruppiTavolo ?? ['tavoli' => [], 'senza_assegnazione' => []];
3
+  $colonneVisibili = array_values(array_filter($colonne, fn ($c) => ($c['key'] ?? '') !== 'tavolo'));
4
+  $primaTestataGruppo = true;
5
+@endphp
6
+
7
+@foreach($gruppi['tavoli'] as $gruppo)
8
+  @if(!empty($gruppo['mostra_testata_gruppo']))
9
+    <div class="tavolo-gruppo-title {{ $primaTestataGruppo ? 'is-first' : 'is-page-break' }}">Colonna {{ $gruppo['gruppo_id'] }}</div>
10
+    @php $primaTestataGruppo = false; @endphp
11
+  @endif
12
+  <section class="tavolo-block {{ !empty($gruppo['vuoto']) ? 'is-empty' : '' }} {{ ($gruppo['liberi'] ?? 0) < 1 ? 'is-full' : 'has-free' }}">
13
+    <header class="tavolo-head">
14
+      <table class="tavolo-head-table">
15
+        <tr>
16
+          <td class="tavolo-head-main">
17
+            <strong class="tavolo-label">Tavolo {{ $gruppo['label'] }}</strong>
18
+            @if(!empty($gruppo['gruppo_id']))
19
+              <span class="tavolo-gruppo">{{ $gruppo['gruppo_id'] }} · riga {{ $gruppo['ordine_in_gruppo'] ?? '—' }}</span>
20
+            @endif
21
+            <div class="tavolo-meta">{{ $gruppo['occupati'] }}/{{ $gruppo['posti'] }} occupati</div>
22
+          </td>
23
+          <td class="tavolo-disponibili {{ ($gruppo['liberi'] ?? 0) < 1 ? 'is-zero' : '' }}" style="width: 1%;">
24
+            <span class="tavolo-disponibili-n">{{ (int) ($gruppo['liberi'] ?? 0) }}</span>
25
+            <span class="tavolo-disponibili-l">
26
+              @if(($gruppo['liberi'] ?? 0) === 1)
27
+                posto<br>disponibile
28
+              @else
29
+                posti<br>disponibili
30
+              @endif
31
+            </span>
32
+          </td>
33
+        </tr>
34
+      </table>
35
+    </header>
36
+
37
+    @php
38
+      $righeOccupate = $gruppo['righe'] ?? [];
39
+      $postiLiberi = max(0, (int) ($gruppo['liberi'] ?? 0));
40
+      // Righe bianche per scrittura a mano: tutti i posti se vuoto, altrimenti solo i liberi
41
+      $righeManuali = empty($righeOccupate)
42
+        ? max(2, (int) ($gruppo['posti'] ?? 4))
43
+        : $postiLiberi;
44
+    @endphp
45
+
46
+    <table class="lista">
47
+      <thead>
48
+        <tr>
49
+          @foreach($colonneVisibili as $col)
50
+            <th
51
+              class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif"
52
+              style="width: {{ $col['width'] ?? 'auto' }}%;"
53
+            >{{ $col['label_short'] ?? $col['label'] }}</th>
54
+          @endforeach
55
+        </tr>
56
+      </thead>
57
+      <tbody>
58
+        @foreach($righeOccupate as $riga)
59
+          <tr class="lista-row {{ !empty($riga['spezzata']['attiva']) ? 'is-spezzata' : '' }}">
60
+            @foreach($colonneVisibili as $col)
61
+              <td class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif">
62
+                @if($col['key'] === 'confermata')
63
+                  <span class="icon-conf {{ ($riga['celle'][$col['key']] ?? '') === '✓' ? 'is-yes' : 'is-no' }}">{{ $riga['celle'][$col['key']] ?? '—' }}</span>
64
+                @elseif($col['key'] === 'nome')
65
+                  {!! nl2br(e($riga['celle'][$col['key']] ?? '—')) !!}
66
+                  @if(!empty($riga['spezzata']['testo']))
67
+                    <span class="tavolo-spezza-badge">{{ $riga['spezzata']['testo'] }}</span>
68
+                  @endif
69
+                @elseif(($col['type'] ?? '') === 'form')
70
+                  {!! $riga['celle'][$col['key']] ?? '—' !!}
71
+                @else
72
+                  {!! nl2br(e($riga['celle'][$col['key']] ?? '—')) !!}
73
+                @endif
74
+              </td>
75
+            @endforeach
76
+          </tr>
77
+        @endforeach
78
+        @for($i = 0; $i < $righeManuali; $i++)
79
+          <tr class="lista-row is-blank">
80
+            @foreach($colonneVisibili as $col)
81
+              <td class="@if($col['key'] === 'arrivato') col-arrivato @endif">
82
+                @if($col['key'] === 'arrivato')
83
+                  ☐
84
+                @else
85
+                  &nbsp;
86
+                @endif
87
+              </td>
88
+            @endforeach
89
+          </tr>
90
+        @endfor
91
+      </tbody>
92
+    </table>
93
+  </section>
94
+@endforeach
95
+
96
+@if(!empty($gruppi['senza_assegnazione']))
97
+  <section class="tavolo-block is-unassigned">
98
+    <header class="tavolo-head">
99
+      <div><strong class="tavolo-label">Senza tavolo</strong></div>
100
+      <div class="tavolo-meta">{{ count($gruppi['senza_assegnazione']) }} prenotazioni</div>
101
+    </header>
102
+    <table class="lista">
103
+      <thead>
104
+        <tr>
105
+          @foreach($colonneVisibili as $col)
106
+            <th
107
+              class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif"
108
+              style="width: {{ $col['width'] ?? 'auto' }}%;"
109
+            >{{ $col['label_short'] ?? $col['label'] }}</th>
110
+          @endforeach
111
+        </tr>
112
+      </thead>
113
+      <tbody>
114
+        @foreach($gruppi['senza_assegnazione'] as $riga)
115
+          <tr class="lista-row">
116
+            @foreach($colonneVisibili as $col)
117
+              <td class="@if($col['key'] === 'codice' || $col['key'] === 'id') col-codice @elseif($col['key'] === 'arrivato') col-arrivato @elseif($col['key'] === 'confermata') col-confermata @elseif($col['key'] === 'nome') col-nome @endif">
118
+                @if($col['key'] === 'confermata')
119
+                  <span class="icon-conf {{ ($riga['celle'][$col['key']] ?? '') === '✓' ? 'is-yes' : 'is-no' }}">{{ $riga['celle'][$col['key']] ?? '—' }}</span>
120
+                @elseif(($col['type'] ?? '') === 'form')
121
+                  {!! $riga['celle'][$col['key']] ?? '—' !!}
122
+                @else
123
+                  {!! nl2br(e($riga['celle'][$col['key']] ?? '—')) !!}
124
+                @endif
125
+              </td>
126
+            @endforeach
127
+          </tr>
128
+        @endforeach
129
+      </tbody>
130
+    </table>
131
+  </section>
132
+@endif

正在加载...
取消
保存