Browse Source

ore e allegati

Roberto Santini 1 month ago
parent
commit
484486f9af

+ 28
- 0
GestioneManutenzioni.skipper View File

@@ -161,6 +161,8 @@
161 161
           <attribute name="castFormat">d/m/Y H:i</attribute>
162 162
         </orm-attributes>
163 163
       </field>
164
+      <field name="tempo_inizio" type="string" uuid="a1c4e8f2-6b19-4d3a-9e77-2f8c1b0d4e61"/>
165
+      <field name="tempo_fine" type="string" uuid="b7d2c9a4-1e58-4f6b-8c33-9a0e5d7f2b14"/>
164 166
       <field name="costo" type="decimal" size="10" uuid="330f67c6-9da7-4130-8907-9c1339a62723">
165 167
         <orm-attributes>
166 168
           <attribute name="decimal">2</attribute>
@@ -3921,6 +3923,32 @@
3921 3923
         </element>
3922 3924
         <element action="reorder" uuid="91880b18-1a17-4b31-85e5-61ae81e6aba2" parent-uuid="b21d156e-828b-49fe-87a8-659eb8411896" sibling-uuid="04bc48b2-88ce-462f-920f-01ade337f877" previous-sibling-uuid="330da781-7ebb-4a50-b6fd-b626f954735f" type="field" helper="true"/>
3923 3925
       </revision>
3926
+      <revision uuid="2fbb45ea-7783-48cc-af41-21bf4e306a11" date="2026-09-22 17:04:37.965924" exportable="true">
3927
+        <element action="add" uuid="90dbff7d-2887-45ad-ba20-1c9ebaa9fc07" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="7b367191-cdfd-465f-8efe-ba0922fa32ff" type="field">
3928
+          <property name="name" value="firma_token"/>
3929
+          <property name="type" value="string"/>
3930
+        </element>
3931
+        <element action="add" uuid="ac5ef761-4617-4f2a-bb72-8d65cb949183" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="90dbff7d-2887-45ad-ba20-1c9ebaa9fc07" type="field">
3932
+          <property name="name" value="firma_short"/>
3933
+          <property name="type" value="string"/>
3934
+        </element>
3935
+        <element action="add" uuid="3961b35f-4db4-4bb1-aeff-1fcbd4027ae2" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="ac5ef761-4617-4f2a-bb72-8d65cb949183" type="field">
3936
+          <property name="name" value="firma_at"/>
3937
+          <property name="type" value="timestamp"/>
3938
+        </element>
3939
+        <element action="reorder" uuid="d0ae0044-ec8e-4aee-8cb5-84d5a663c57a" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="3961b35f-4db4-4bb1-aeff-1fcbd4027ae2" previous-sibling-uuid="7b367191-cdfd-465f-8efe-ba0922fa32ff" type="field" helper="true"/>
3940
+      </revision>
3941
+      <revision uuid="c3e91a70-5d24-4b8f-a612-7f0c4e8d1a93" date="2026-09-25 13:55:00.000000" exportable="false">
3942
+        <element action="add" uuid="a1c4e8f2-6b19-4d3a-9e77-2f8c1b0d4e61" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="f305a718-29d3-4bd5-8da9-619db3671251" type="field">
3943
+          <property name="name" value="tempo_inizio"/>
3944
+          <property name="type" value="string"/>
3945
+        </element>
3946
+        <element action="add" uuid="b7d2c9a4-1e58-4f6b-8c33-9a0e5d7f2b14" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="a1c4e8f2-6b19-4d3a-9e77-2f8c1b0d4e61" type="field">
3947
+          <property name="name" value="tempo_fine"/>
3948
+          <property name="type" value="string"/>
3949
+        </element>
3950
+        <element action="reorder" uuid="330f67c6-9da7-4130-8907-9c1339a62723" parent-uuid="43c9733c-5074-4c8d-9cb1-33867510c2fc" sibling-uuid="b7d2c9a4-1e58-4f6b-8c33-9a0e5d7f2b14" previous-sibling-uuid="f305a718-29d3-4bd5-8da9-619db3671251" type="field" helper="true"/>
3951
+      </revision>
3924 3952
     </migrations>
3925 3953
   </module>
3926 3954
   <visual-data>

+ 2
- 2
app/Http/Controllers/SegnalazioneClienteController.php View File

@@ -183,11 +183,11 @@ class SegnalazioneClienteController extends Controller implements HasMiddleware
183 183
             File::makeDirectory($directory, 0755, true);
184 184
         }
185 185
 
186
-        foreach ($request->file('allegato') as $file) {
186
+        foreach (array_values($request->file('allegato')) as $index => $file) {
187 187
             if (! $file) {
188 188
                 continue;
189 189
             }
190
-            $filename = time().'_'.$file->getClientOriginalName();
190
+            $filename = time().'_'.$index.'_'.$file->getClientOriginalName();
191 191
             $path = $file->storeAs((string) $segnalazione->id, $filename, 'allegatiAnnotazioni');
192 192
 
193 193
             $allegato = new AllegatoAnnotazione();

+ 5
- 2
app/Http/Controllers/SegnalazioneController.php View File

@@ -291,6 +291,9 @@ class SegnalazioneController extends Controller implements HasMiddleware
291 291
       'annotazione' => 'nullable|string',
292 292
       'tempo_stimato' => 'nullable|numeric|min:0',
293 293
       'tempo_ore' => 'nullable|string',
294
+      'tempo_modalita' => 'nullable|in:ore,intervallo',
295
+      'tempo_inizio' => ['nullable', 'regex:/^\d{1,2}:\d{2}(:\d{2})?$/'],
296
+      'tempo_fine' => ['nullable', 'regex:/^\d{1,2}:\d{2}(:\d{2})?$/'],
294 297
       'tempo_user_id' => 'nullable|exists:users,id',
295 298
       'slots' => 'nullable|array',
296 299
       'slots.*.data' => 'nullable|date_format:d/m/Y',
@@ -407,11 +410,11 @@ class SegnalazioneController extends Controller implements HasMiddleware
407 410
           \Illuminate\Support\Facades\File::makeDirectory($directory, 0755, true);
408 411
         }
409 412
 
410
-        foreach ($request->file('allegato') as $file) {
413
+        foreach (array_values($request->file('allegato')) as $index => $file) {
411 414
           if (!$file) {
412 415
             continue;
413 416
           }
414
-          $filename = time() . '_' . $file->getClientOriginalName();
417
+          $filename = time() . '_' . $index . '_' . $file->getClientOriginalName();
415 418
           $path = $file->storeAs($segnalazione->id, $filename, 'allegatiAnnotazioni');
416 419
 
417 420
           $allegato = new AllegatoAnnotazione();

+ 4
- 0
app/Models/AbstractModels/AbstractAnnotazione.php View File

@@ -48,6 +48,8 @@ abstract class AbstractAnnotazione extends Model
48 48
         'text' => 'string',
49 49
         'tempo_minuti' => 'integer',
50 50
         'tempo_data' => 'datetime:d/m/Y H:i',
51
+        'tempo_inizio' => 'string',
52
+        'tempo_fine' => 'string',
51 53
         'costo' => 'decimal:2',
52 54
         'costo_descrizione' => 'string',
53 55
         'non_fatturare' => 'boolean',
@@ -75,6 +77,8 @@ abstract class AbstractAnnotazione extends Model
75 77
         'text',
76 78
         'tempo_minuti',
77 79
         'tempo_data',
80
+        'tempo_inizio',
81
+        'tempo_fine',
78 82
         'costo',
79 83
         'costo_descrizione',
80 84
         'non_fatturare',

+ 27
- 7
app/Models/Annotazione.php View File

@@ -12,6 +12,33 @@ class Annotazione extends \App\Models\AbstractModels\AbstractAnnotazione
12 12
     $this->attributes['tempo_data'] = $value!=null?Carbon::createFromFormat('d/m/Y H:i', $value)->toDateTimeString():null;
13 13
   }
14 14
 
15
+  public function tempoInizioLabel(): ?string
16
+  {
17
+    return self::formatOra($this->tempo_inizio);
18
+  }
19
+
20
+  public function tempoFineLabel(): ?string
21
+  {
22
+    return self::formatOra($this->tempo_fine);
23
+  }
24
+
25
+  public static function formatOra(mixed $value): ?string
26
+  {
27
+    if ($value instanceof \Carbon\CarbonInterface) {
28
+      return $value->format('H:i');
29
+    }
30
+
31
+    $raw = trim((string) $value);
32
+    if ($raw === '') {
33
+      return null;
34
+    }
35
+    if (! preg_match('/^(\d{1,2}):(\d{2})/', $raw, $match)) {
36
+      return null;
37
+    }
38
+
39
+    return sprintf('%02d:%02d', (int) $match[1], (int) $match[2]);
40
+  }
41
+
15 42
   /**
16 43
    * Mappa cid/nome file → URL inline degli allegati (immagini da email).
17 44
    *
@@ -33,13 +60,6 @@ class Annotazione extends \App\Models\AbstractModels\AbstractAnnotazione
33 60
     return $map;
34 61
   }
35 62
 
36
-  public function getCasts()
37
-  {
38
-    return array_merge(parent::getCasts(), [
39
-      'firma_at' => 'datetime',
40
-    ]);
41
-  }
42
-
43 63
   public function getHasFirmaClienteAttribute(): bool
44 64
   {
45 65
     return filled($this->firma_at);

+ 80
- 9
app/Services/SegnalazioneAggiornaService.php View File

@@ -25,13 +25,7 @@ class SegnalazioneAggiornaService
25 25
         abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
26 26
         abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $assegnataAId, $segnalazione->user_id), 403);
27 27
 
28
-        $tempoMinuti = null;
29
-        if ($request->filled('tempo_ore')) {
30
-            $tempoMinuti = Config::parseTempoToMinuti($request->input('tempo_ore'));
31
-            if ($tempoMinuti === null) {
32
-                throw ValidationException::withMessages(['tempo_ore' => 'Formato tempo non valido']);
33
-            }
34
-        }
28
+        [$tempoMinuti, $tempoInizio, $tempoFine] = $this->resolveTempoImpiegato($request);
35 29
 
36 30
         $blockClose = $segnalazione->closingBlockedMessage($request->stato);
37 31
         if ($blockClose) {
@@ -72,6 +66,8 @@ class SegnalazioneAggiornaService
72 66
         if ($tempoMinuti !== null) {
73 67
             $annotazione->tempo_minuti = $tempoMinuti;
74 68
             $annotazione->tempo_data = $request->tempo_data;
69
+            $annotazione->tempo_inizio = $tempoInizio;
70
+            $annotazione->tempo_fine = $tempoFine;
75 71
         }
76 72
 
77 73
         if ($request->costo !== null) {
@@ -89,11 +85,11 @@ class SegnalazioneAggiornaService
89 85
                 File::makeDirectory($directory, 0755, true);
90 86
             }
91 87
 
92
-            foreach ($request->file('allegato') as $file) {
88
+            foreach (array_values($request->file('allegato')) as $index => $file) {
93 89
                 if (! $file) {
94 90
                     continue;
95 91
                 }
96
-                $filename = time().'_'.$file->getClientOriginalName();
92
+                $filename = time().'_'.$index.'_'.$file->getClientOriginalName();
97 93
                 $path = $file->storeAs($segnalazione->id, $filename, 'allegatiAnnotazioni');
98 94
 
99 95
                 $allegato = new AllegatoAnnotazione();
@@ -107,6 +103,81 @@ class SegnalazioneAggiornaService
107 103
         return $annotazione->fresh(['user', 'allegatiAnnotazione']);
108 104
     }
109 105
 
106
+    /**
107
+     * @return array{0: ?int, 1: ?string, 2: ?string}
108
+     */
109
+    private function resolveTempoImpiegato(Request $request): array
110
+    {
111
+        if ($request->input('tempo_modalita') === 'intervallo') {
112
+            $inizio = $this->normalizeOra($request->input('tempo_inizio'));
113
+            $fine = $this->normalizeOra($request->input('tempo_fine'));
114
+            if ($inizio === null && $fine === null) {
115
+                return [null, null, null];
116
+            }
117
+            if ($inizio === null || $fine === null) {
118
+                throw ValidationException::withMessages([
119
+                    'tempo_inizio' => 'Inserisci sia l\'ora di inizio sia l\'ora di fine.',
120
+                ]);
121
+            }
122
+
123
+            $start = $this->oraToMinuti($inizio);
124
+            $end = $this->oraToMinuti($fine);
125
+            if ($start === $end) {
126
+                throw ValidationException::withMessages([
127
+                    'tempo_fine' => 'L\'ora di fine deve essere diversa dall\'ora di inizio.',
128
+                ]);
129
+            }
130
+
131
+            $diff = $end - $start;
132
+            if ($diff < 0) {
133
+                $diff += 24 * 60;
134
+            }
135
+
136
+            return [$diff, $inizio, $fine];
137
+        }
138
+
139
+        if ($request->filled('tempo_ore')) {
140
+            $tempoMinuti = Config::parseTempoToMinuti($request->input('tempo_ore'));
141
+            if ($tempoMinuti === null) {
142
+                throw ValidationException::withMessages(['tempo_ore' => 'Formato tempo non valido']);
143
+            }
144
+
145
+            return [$tempoMinuti, null, null];
146
+        }
147
+
148
+        return [null, null, null];
149
+    }
150
+
151
+    private function normalizeOra(mixed $value): ?string
152
+    {
153
+        $raw = trim((string) $value);
154
+        if ($raw === '') {
155
+            return null;
156
+        }
157
+        if (! preg_match('/^(\d{1,2}):(\d{2})(?::\d{2})?$/', $raw, $match)) {
158
+            throw ValidationException::withMessages([
159
+                'tempo_inizio' => 'Orario non valido. Usa il formato HH:MM.',
160
+            ]);
161
+        }
162
+
163
+        $hours = (int) $match[1];
164
+        $minutes = (int) $match[2];
165
+        if ($hours > 23 || $minutes > 59) {
166
+            throw ValidationException::withMessages([
167
+                'tempo_inizio' => 'Orario non valido. Usa il formato HH:MM.',
168
+            ]);
169
+        }
170
+
171
+        return sprintf('%02d:%02d', $hours, $minutes);
172
+    }
173
+
174
+    private function oraToMinuti(string $ora): int
175
+    {
176
+        [$hours, $minutes] = array_map('intval', explode(':', $ora));
177
+
178
+        return ($hours * 60) + $minutes;
179
+    }
180
+
110 181
     /**
111 182
      * @return list<array{starts_at: \Carbon\Carbon, ends_at: \Carbon\Carbon}>
112 183
      */

+ 2
- 2
app/Services/SegnalazioneApiService.php View File

@@ -110,8 +110,8 @@ class SegnalazioneApiService
110 110
             File::makeDirectory($directory, 0755, true);
111 111
         }
112 112
 
113
-        foreach ($files as $file) {
114
-            $filename = time().'_'.$file->getClientOriginalName();
113
+        foreach (array_values($files) as $index => $file) {
114
+            $filename = time().'_'.$index.'_'.$file->getClientOriginalName();
115 115
             $path = $file->storeAs((string) $segnalazione->id, $filename, 'allegatiAnnotazioni');
116 116
 
117 117
             $allegato = new AllegatoAnnotazione();

+ 23
- 0
database/migrations/2026_09_25_135500_add_tempo_inizio_fine_to_annotazione.php View File

@@ -0,0 +1,23 @@
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('annotazione', function (Blueprint $table) {
12
+            $table->string('tempo_inizio', 5)->nullable()->after('tempo_data');
13
+            $table->string('tempo_fine', 5)->nullable()->after('tempo_inizio');
14
+        });
15
+    }
16
+
17
+    public function down(): void
18
+    {
19
+        Schema::table('annotazione', function (Blueprint $table) {
20
+            $table->dropColumn(['tempo_inizio', 'tempo_fine']);
21
+        });
22
+    }
23
+};

+ 112
- 6
resources/views/segnalazione/admin/_form_modifica_fields.blade.php View File

@@ -223,9 +223,35 @@
223 223
                     value="{{ old('tempo_stimato', $segnalazione->tempo_stimato !== null ? rtrim(rtrim(number_format((float) $segnalazione->tempo_stimato, 2, '.', ''), '0'), '.') : '') }}"
224 224
                     placeholder="Es. 1.5">
225 225
                 </div>
226
-                <div class="mb-3">
227
-                  <label for="ore_tempo_card" class="form-label">Registra ore</label>
228
-                  <input type="text" class="form-control" id="ore_tempo_card" name="tempo_ore" placeholder="Es. 1,5 o 1h30m">
226
+                @php
227
+                  $tempoModalita = old('tempo_modalita', 'ore') === 'intervallo' ? 'intervallo' : 'ore';
228
+                @endphp
229
+                <div class="mb-3" id="registraOreBlock">
230
+                  <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
231
+                    <label class="form-label mb-0" for="ore_tempo_card">Registra ore</label>
232
+                    <div class="btn-group btn-group-sm" role="group" aria-label="Modalità registrazione ore">
233
+                      <input type="radio" class="btn-check" name="tempo_modalita" id="tempo_modalita_ore" value="ore" autocomplete="off" @checked($tempoModalita === 'ore')>
234
+                      <label class="btn btn-outline-primary" for="tempo_modalita_ore">Ore</label>
235
+                      <input type="radio" class="btn-check" name="tempo_modalita" id="tempo_modalita_intervallo" value="intervallo" autocomplete="off" @checked($tempoModalita === 'intervallo')>
236
+                      <label class="btn btn-outline-primary" for="tempo_modalita_intervallo">Inizio / fine</label>
237
+                    </div>
238
+                  </div>
239
+                  <div id="tempoOreManualeWrap" @class(['d-none' => $tempoModalita === 'intervallo'])>
240
+                    <input type="text" class="form-control" id="ore_tempo_card" name="tempo_ore" value="{{ old('tempo_ore') }}" placeholder="Es. 1,5 o 1h30m" @disabled($tempoModalita === 'intervallo')>
241
+                  </div>
242
+                  <div id="tempoIntervalloWrap" @class(['d-none' => $tempoModalita !== 'intervallo'])>
243
+                    <div class="row g-2">
244
+                      <div class="col-6">
245
+                        <label for="tempo_inizio" class="form-label small mb-1">Inizio</label>
246
+                        <input type="time" class="form-control" id="tempo_inizio" name="tempo_inizio" value="{{ old('tempo_inizio') }}" @disabled($tempoModalita !== 'intervallo')>
247
+                      </div>
248
+                      <div class="col-6">
249
+                        <label for="tempo_fine" class="form-label small mb-1">Fine</label>
250
+                        <input type="time" class="form-control" id="tempo_fine" name="tempo_fine" value="{{ old('tempo_fine') }}" @disabled($tempoModalita !== 'intervallo')>
251
+                      </div>
252
+                    </div>
253
+                    <div class="form-text mt-1" id="tempoIntervalloCalcolato">Durata: —</div>
254
+                  </div>
229 255
                 </div>
230 256
                 <div class="mb-3">
231 257
                   <label for="tempo_data" class="form-label">Data e ora</label>
@@ -262,14 +288,94 @@
262 288
                 <div class="modifica-section-title"><i class="bx bx-paperclip me-1"></i>Allegati</div>
263 289
                 <div id="allegatoDropzoneAdmin" class="dropzone-box dropzone-box-compact border border-dashed rounded p-3 text-center">
264 290
                   <div class="dz-message">
265
-                    Trascina o clicca per caricare
266
-                    <span class="note">Max 10MB · PDF, immagini, documenti</span>
291
+                    Trascina o clicca per caricare più file
292
+                    <span class="note">Puoi aggiungerne altri. Max 10MB · PDF, immagini, documenti</span>
267 293
                   </div>
268 294
                 </div>
269
-                <input type="file" class="d-none" id="file_allegato_card" name="allegato[]" multiple
295
+                <input type="file" class="visually-hidden" id="file_allegato_card" name="allegato[]" multiple
270 296
                   accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx">
271 297
                 <div id="allegatoDropzoneInfoAdmin" class="small text-muted mt-2"></div>
272 298
                 <div id="allegatoPreviewAdmin" class="row g-2 mt-2"></div>
273 299
               </div>
274 300
             </div>
275 301
           </div>
302
+          <script>
303
+            (function () {
304
+              function parseTimeToMinutes(val) {
305
+                var m = String(val || '').trim().match(/^(\d{1,2}):(\d{2})/);
306
+                if (!m) return null;
307
+                var h = parseInt(m[1], 10);
308
+                var min = parseInt(m[2], 10);
309
+                if (h > 23 || min > 59) return null;
310
+                return h * 60 + min;
311
+              }
312
+
313
+              function formatDurata(minuti) {
314
+                var ore = Math.floor(minuti / 60);
315
+                var min = minuti % 60;
316
+                return ore + 'h ' + String(min).padStart(2, '0') + 'm';
317
+              }
318
+
319
+              function modalitaSelezionata() {
320
+                var checked = document.querySelector('#registraOreBlock input[name="tempo_modalita"]:checked');
321
+                return checked ? checked.value : 'ore';
322
+              }
323
+
324
+              function aggiornaDurataIntervallo() {
325
+                var el = document.getElementById('tempoIntervalloCalcolato');
326
+                var inizioEl = document.getElementById('tempo_inizio');
327
+                var fineEl = document.getElementById('tempo_fine');
328
+                if (!el || !inizioEl || !fineEl) return;
329
+                var inizio = parseTimeToMinutes(inizioEl.value);
330
+                var fine = parseTimeToMinutes(fineEl.value);
331
+                if (inizio === null || fine === null) {
332
+                  el.textContent = 'Durata: —';
333
+                  return;
334
+                }
335
+                if (fine === inizio) {
336
+                  el.textContent = 'L\'ora di fine deve essere diversa dall\'inizio.';
337
+                  return;
338
+                }
339
+                var diff = fine - inizio;
340
+                var overnight = diff < 0;
341
+                if (overnight) diff += 24 * 60;
342
+                el.textContent = 'Durata: ' + formatDurata(diff) + (overnight ? ' (giorno successivo)' : '');
343
+              }
344
+
345
+              window.syncTempoModalita = function () {
346
+                var intervallo = modalitaSelezionata() === 'intervallo';
347
+                var oreWrap = document.getElementById('tempoOreManualeWrap');
348
+                var intervalloWrap = document.getElementById('tempoIntervalloWrap');
349
+                var oreInput = document.getElementById('ore_tempo_card');
350
+                var inizio = document.getElementById('tempo_inizio');
351
+                var fine = document.getElementById('tempo_fine');
352
+                if (oreWrap) oreWrap.classList.toggle('d-none', intervallo);
353
+                if (intervalloWrap) intervalloWrap.classList.toggle('d-none', !intervallo);
354
+                if (oreInput) oreInput.disabled = intervallo;
355
+                if (inizio) inizio.disabled = !intervallo;
356
+                if (fine) fine.disabled = !intervallo;
357
+                aggiornaDurataIntervallo();
358
+              };
359
+
360
+              window.impostaTempoOreManuale = function (valore) {
361
+                var radio = document.getElementById('tempo_modalita_ore');
362
+                if (radio) radio.checked = true;
363
+                var oreInput = document.getElementById('ore_tempo_card');
364
+                if (oreInput) oreInput.value = valore || '';
365
+                window.syncTempoModalita();
366
+                if (oreInput) oreInput.focus();
367
+              };
368
+
369
+              document.querySelectorAll('#registraOreBlock input[name="tempo_modalita"]').forEach(function (radio) {
370
+                radio.addEventListener('change', window.syncTempoModalita);
371
+              });
372
+              ['tempo_inizio', 'tempo_fine'].forEach(function (id) {
373
+                var el = document.getElementById(id);
374
+                if (el) {
375
+                  el.addEventListener('input', aggiornaDurataIntervallo);
376
+                  el.addEventListener('change', aggiornaDurataIntervallo);
377
+                }
378
+              });
379
+              window.syncTempoModalita();
380
+            })();
381
+          </script>

+ 11
- 124
resources/views/segnalazione/admin/nuova.blade.php View File

@@ -243,14 +243,14 @@ $selectedContrattoId = old('contratto_servizio_id');
243 243
         </div>
244 244
 
245 245
         <div class="mb-3">
246
-          <label for="allegato" class="form-label">Allega file (opzionale)</label>
246
+          <label for="allegato" class="form-label">Allegati (opzionale)</label>
247 247
           <div id="allegatoDropzoneAdminNuova" class="dropzone-box border border-dashed rounded p-3 text-center">
248 248
             <div class="dz-message">
249
-              Trascina qui i file o clicca per caricare
250
-              <span class="note">Dimensione massima: 10MB per file. Formati accettati: PDF, immagini, documenti.</span>
249
+              Trascina qui i file o clicca per caricarne più di uno
250
+              <span class="note">Puoi aggiungerne altri in seguito. Max 10MB per file. PDF, immagini, documenti.</span>
251 251
             </div>
252 252
           </div>
253
-          <input class="d-none" type="file" id="allegato" name="allegato[]" multiple
253
+          <input class="visually-hidden" type="file" id="allegato" name="allegato[]" multiple
254 254
           accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx">
255 255
           <div id="allegatoDropzoneInfoAdminNuova" class="small text-muted mt-2"></div>
256 256
           <div id="allegatoPreviewAdminNuova" class="row g-2 mt-2"></div>
@@ -604,126 +604,13 @@ $selectedContrattoId = old('contratto_servizio_id');
604 604
       syncAlternativeVisibility();
605 605
     }
606 606
 
607
-    const dropzone = document.getElementById('allegatoDropzoneAdminNuova');
608
-    const inputAllegato = document.getElementById('allegato');
609
-    const dropzoneInfo = document.getElementById('allegatoDropzoneInfoAdminNuova');
610
-    const dropzonePreview = document.getElementById('allegatoPreviewAdminNuova');
611
-    const allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx'];
612
-    const objectUrls = [];
613
-
614
-    if (dropzone && inputAllegato) {
615
-      const clearPreviews = () => {
616
-        objectUrls.splice(0).forEach((url) => URL.revokeObjectURL(url));
617
-        if (dropzonePreview) {
618
-          dropzonePreview.innerHTML = '';
619
-        }
620
-      };
621
-
622
-      const renderPreviews = (files) => {
623
-        clearPreviews();
624
-        if (!dropzonePreview) return;
625
-
626
-        Array.from(files).forEach((file) => {
627
-          const isImage = file.type.startsWith('image/');
628
-          const col = document.createElement('div');
629
-          col.className = 'col-12 col-sm-6 col-lg-4';
630
-
631
-          const card = document.createElement('div');
632
-          card.className = 'border rounded p-2 h-100 d-flex align-items-center gap-2';
633
-
634
-          if (isImage) {
635
-            const img = document.createElement('img');
636
-            const url = URL.createObjectURL(file);
637
-            objectUrls.push(url);
638
-            img.src = url;
639
-            img.alt = file.name;
640
-            img.className = 'rounded';
641
-            img.style.width = '48px';
642
-            img.style.height = '48px';
643
-            img.style.objectFit = 'cover';
644
-            card.appendChild(img);
645
-          } else {
646
-            const icon = document.createElement('i');
647
-            icon.className = 'bx bx-file fs-3 text-primary';
648
-            card.appendChild(icon);
649
-          }
650
-
651
-          const info = document.createElement('div');
652
-          info.className = 'flex-grow-1';
653
-          const name = document.createElement('div');
654
-          name.className = 'small fw-semibold text-truncate';
655
-          name.textContent = file.name;
656
-          const size = document.createElement('div');
657
-          size.className = 'small text-muted';
658
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
659
-          size.textContent = `${sizeMb} MB`;
660
-          info.appendChild(name);
661
-          info.appendChild(size);
662
-          card.appendChild(info);
663
-
664
-          col.appendChild(card);
665
-          dropzonePreview.appendChild(col);
666
-        });
667
-      };
668
-
669
-      const filterFiles = (fileList) => {
670
-        const validFiles = [];
671
-        Array.from(fileList || []).forEach((file) => {
672
-          const extension = file.name.split('.').pop().toLowerCase();
673
-          if (allowedExtensions.includes(extension)) {
674
-            validFiles.push(file);
675
-          }
676
-        });
677
-        return validFiles;
678
-      };
679
-
680
-      const updateInfo = (files) => {
681
-        if (!dropzoneInfo) return;
682
-        if (!files || files.length === 0) {
683
-          dropzoneInfo.textContent = '';
684
-          return;
685
-        }
686
-        const lines = Array.from(files).map((file) => {
687
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
688
-          return `${file.name} (${sizeMb} MB)`;
689
-        });
690
-        dropzoneInfo.innerHTML = lines.join('<br>');
691
-      };
692
-
693
-      dropzone.addEventListener('click', () => inputAllegato.click());
694
-
695
-      inputAllegato.addEventListener('change', () => {
696
-        const validFiles = filterFiles(inputAllegato.files);
697
-        const dataTransfer = new DataTransfer();
698
-        validFiles.forEach((file) => dataTransfer.items.add(file));
699
-        inputAllegato.files = dataTransfer.files;
700
-        updateInfo(inputAllegato.files);
701
-        renderPreviews(inputAllegato.files);
702
-      });
703
-
704
-      dropzone.addEventListener('dragover', (event) => {
705
-        event.preventDefault();
706
-        dropzone.classList.add('border-primary');
707
-      });
708
-
709
-      dropzone.addEventListener('dragleave', () => {
710
-        dropzone.classList.remove('border-primary');
711
-      });
712
-
713
-      dropzone.addEventListener('drop', (event) => {
714
-        event.preventDefault();
715
-        dropzone.classList.remove('border-primary');
716
-        const files = filterFiles(event.dataTransfer.files);
717
-        if (!files || files.length === 0) return;
718
-        const dataTransfer = new DataTransfer();
719
-        files.forEach((file) => {
720
-          dataTransfer.items.add(file);
721
-        });
722
-        inputAllegato.files = dataTransfer.files;
723
-        updateInfo(inputAllegato.files);
724
-        renderPreviews(inputAllegato.files);
725
-      });
726
-    }
607
+    @include('segnalazione.partials.allegati_dropzone')
608
+    initSegnalazioneAllegatiDropzone(
609
+      document.getElementById('allegatoDropzoneAdminNuova'),
610
+      document.getElementById('allegato'),
611
+      document.getElementById('allegatoDropzoneInfoAdminNuova'),
612
+      document.getElementById('allegatoPreviewAdminNuova')
613
+    );
727 614
   });
728 615
 </script>
729 616
 @endsection

+ 7
- 92
resources/views/segnalazione/admin/rapporto.blade.php View File

@@ -408,98 +408,13 @@ use App\Models\Config;
408 408
     });
409 409
     syncOrdineContrattoModifica();
410 410
 
411
-    const dropzone = document.getElementById('allegatoDropzoneAdmin');
412
-    const inputAllegato = document.getElementById('file_allegato_card');
413
-    const dropzoneInfo = document.getElementById('allegatoDropzoneInfoAdmin');
414
-    const dropzonePreview = document.getElementById('allegatoPreviewAdmin');
415
-    const allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx'];
416
-    const objectUrls = [];
417
-    if (dropzone && inputAllegato) {
418
-      const clearPreviews = () => {
419
-        objectUrls.splice(0).forEach((url) => URL.revokeObjectURL(url));
420
-        if (dropzonePreview) dropzonePreview.innerHTML = '';
421
-      };
422
-      const renderPreviews = (files) => {
423
-        clearPreviews();
424
-        if (!dropzonePreview) return;
425
-        Array.from(files).forEach((file) => {
426
-          const isImage = file.type.startsWith('image/');
427
-          const col = document.createElement('div');
428
-          col.className = 'col-12 col-sm-6 col-lg-4';
429
-          const card = document.createElement('div');
430
-          card.className = 'border rounded p-2 h-100 d-flex align-items-center gap-2';
431
-          if (isImage) {
432
-            const img = document.createElement('img');
433
-            const url = URL.createObjectURL(file);
434
-            objectUrls.push(url);
435
-            img.src = url;
436
-            img.alt = file.name;
437
-            img.className = 'rounded';
438
-            img.style.width = '48px';
439
-            img.style.height = '48px';
440
-            img.style.objectFit = 'cover';
441
-            card.appendChild(img);
442
-          } else {
443
-            const icon = document.createElement('i');
444
-            icon.className = 'bx bx-file fs-3 text-primary';
445
-            card.appendChild(icon);
446
-          }
447
-          const info = document.createElement('div');
448
-          info.className = 'flex-grow-1';
449
-          const name = document.createElement('div');
450
-          name.className = 'small fw-semibold text-truncate';
451
-          name.textContent = file.name;
452
-          const size = document.createElement('div');
453
-          size.className = 'small text-muted';
454
-          size.textContent = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
455
-          info.appendChild(name);
456
-          info.appendChild(size);
457
-          card.appendChild(info);
458
-          col.appendChild(card);
459
-          dropzonePreview.appendChild(col);
460
-        });
461
-      };
462
-      const filterFiles = (fileList) => {
463
-        const validFiles = [];
464
-        Array.from(fileList || []).forEach((file) => {
465
-          const extension = file.name.split('.').pop().toLowerCase();
466
-          if (allowedExtensions.includes(extension)) validFiles.push(file);
467
-        });
468
-        return validFiles;
469
-      };
470
-      const updateInfo = (files) => {
471
-        if (!dropzoneInfo) return;
472
-        if (!files || files.length === 0) { dropzoneInfo.textContent = ''; return; }
473
-        dropzoneInfo.innerHTML = Array.from(files).map((file) => {
474
-          return file.name + ' (' + (file.size / (1024 * 1024)).toFixed(2) + ' MB)';
475
-        }).join('<br>');
476
-      };
477
-      dropzone.addEventListener('click', () => inputAllegato.click());
478
-      inputAllegato.addEventListener('change', () => {
479
-        const validFiles = filterFiles(inputAllegato.files);
480
-        const dataTransfer = new DataTransfer();
481
-        validFiles.forEach((file) => dataTransfer.items.add(file));
482
-        inputAllegato.files = dataTransfer.files;
483
-        updateInfo(inputAllegato.files);
484
-        renderPreviews(inputAllegato.files);
485
-      });
486
-      dropzone.addEventListener('dragover', (event) => {
487
-        event.preventDefault();
488
-        dropzone.classList.add('border-primary');
489
-      });
490
-      dropzone.addEventListener('dragleave', () => dropzone.classList.remove('border-primary'));
491
-      dropzone.addEventListener('drop', (event) => {
492
-        event.preventDefault();
493
-        dropzone.classList.remove('border-primary');
494
-        const files = filterFiles(event.dataTransfer.files);
495
-        if (!files || files.length === 0) return;
496
-        const dataTransfer = new DataTransfer();
497
-        files.forEach((file) => dataTransfer.items.add(file));
498
-        inputAllegato.files = dataTransfer.files;
499
-        updateInfo(inputAllegato.files);
500
-        renderPreviews(inputAllegato.files);
501
-      });
502
-    }
411
+    @include('segnalazione.partials.allegati_dropzone')
412
+    initSegnalazioneAllegatiDropzone(
413
+      document.getElementById('allegatoDropzoneAdmin'),
414
+      document.getElementById('file_allegato_card'),
415
+      document.getElementById('allegatoDropzoneInfoAdmin'),
416
+      document.getElementById('allegatoPreviewAdmin')
417
+    );
503 418
 
504 419
     const categoriaSicurezzaContainer = document.getElementById('categoria_sicurezza_container');
505 420
     const categoriaSicurezzaSelect = document.getElementById('segnalazione_categoria_sicurezza');

+ 21
- 125
resources/views/segnalazione/admin/show.blade.php View File

@@ -1084,7 +1084,12 @@ use App\Models\Config;
1084 1084
                     <div class="col-auto">
1085 1085
                       <small class="text-muted">Tempo impiegato:</small>
1086 1086
                       <span class="badge bg-label-secondary ms-2">
1087
-                        <i class="bx bx-time me-1"></i>{{ Config::formatTempo($evento['dati']['annotazione']->tempo_minuti) }}
1087
+                        <i class="bx bx-time me-1"></i>
1088
+                        @if($evento['dati']['annotazione']->tempoInizioLabel() || $evento['dati']['annotazione']->tempoFineLabel())
1089
+                        {{ $evento['dati']['annotazione']->tempoInizioLabel() ?: '—' }}–{{ $evento['dati']['annotazione']->tempoFineLabel() ?: '—' }}
1090
+                        ·
1091
+                        @endif
1092
+                        {{ Config::formatTempo($evento['dati']['annotazione']->tempo_minuti) }}
1088 1093
                       </span>
1089 1094
                     </div>
1090 1095
                     @endif
@@ -2015,126 +2020,13 @@ use App\Models\Config;
2015 2020
       });
2016 2021
     }
2017 2022
 
2018
-    const dropzone = document.getElementById('allegatoDropzoneAdmin');
2019
-    const inputAllegato = document.getElementById('file_allegato_card');
2020
-    const dropzoneInfo = document.getElementById('allegatoDropzoneInfoAdmin');
2021
-    const dropzonePreview = document.getElementById('allegatoPreviewAdmin');
2022
-    const allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx'];
2023
-    const objectUrls = [];
2024
-
2025
-    if (dropzone && inputAllegato) {
2026
-      const clearPreviews = () => {
2027
-        objectUrls.splice(0).forEach((url) => URL.revokeObjectURL(url));
2028
-        if (dropzonePreview) {
2029
-          dropzonePreview.innerHTML = '';
2030
-        }
2031
-      };
2032
-
2033
-      const renderPreviews = (files) => {
2034
-        clearPreviews();
2035
-        if (!dropzonePreview) return;
2036
-
2037
-        Array.from(files).forEach((file) => {
2038
-          const isImage = file.type.startsWith('image/');
2039
-          const col = document.createElement('div');
2040
-          col.className = 'col-12 col-sm-6 col-lg-4';
2041
-
2042
-          const card = document.createElement('div');
2043
-          card.className = 'border rounded p-2 h-100 d-flex align-items-center gap-2';
2044
-
2045
-          if (isImage) {
2046
-            const img = document.createElement('img');
2047
-            const url = URL.createObjectURL(file);
2048
-            objectUrls.push(url);
2049
-            img.src = url;
2050
-            img.alt = file.name;
2051
-            img.className = 'rounded';
2052
-            img.style.width = '48px';
2053
-            img.style.height = '48px';
2054
-            img.style.objectFit = 'cover';
2055
-            card.appendChild(img);
2056
-          } else {
2057
-            const icon = document.createElement('i');
2058
-            icon.className = 'bx bx-file fs-3 text-primary';
2059
-            card.appendChild(icon);
2060
-          }
2061
-
2062
-          const info = document.createElement('div');
2063
-          info.className = 'flex-grow-1';
2064
-          const name = document.createElement('div');
2065
-          name.className = 'small fw-semibold text-truncate';
2066
-          name.textContent = file.name;
2067
-          const size = document.createElement('div');
2068
-          size.className = 'small text-muted';
2069
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
2070
-          size.textContent = `${sizeMb} MB`;
2071
-          info.appendChild(name);
2072
-          info.appendChild(size);
2073
-          card.appendChild(info);
2074
-
2075
-          col.appendChild(card);
2076
-          dropzonePreview.appendChild(col);
2077
-        });
2078
-      };
2079
-
2080
-      const filterFiles = (fileList) => {
2081
-        const validFiles = [];
2082
-        Array.from(fileList || []).forEach((file) => {
2083
-          const extension = file.name.split('.').pop().toLowerCase();
2084
-          if (allowedExtensions.includes(extension)) {
2085
-            validFiles.push(file);
2086
-          }
2087
-        });
2088
-        return validFiles;
2089
-      };
2090
-
2091
-      const updateInfo = (files) => {
2092
-        if (!dropzoneInfo) return;
2093
-        if (!files || files.length === 0) {
2094
-          dropzoneInfo.textContent = '';
2095
-          return;
2096
-        }
2097
-        const lines = Array.from(files).map((file) => {
2098
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
2099
-          return `${file.name} (${sizeMb} MB)`;
2100
-        });
2101
-        dropzoneInfo.innerHTML = lines.join('<br>');
2102
-      };
2103
-
2104
-      dropzone.addEventListener('click', () => inputAllegato.click());
2105
-
2106
-      inputAllegato.addEventListener('change', () => {
2107
-        const validFiles = filterFiles(inputAllegato.files);
2108
-        const dataTransfer = new DataTransfer();
2109
-        validFiles.forEach((file) => dataTransfer.items.add(file));
2110
-        inputAllegato.files = dataTransfer.files;
2111
-        updateInfo(inputAllegato.files);
2112
-        renderPreviews(inputAllegato.files);
2113
-      });
2114
-
2115
-      dropzone.addEventListener('dragover', (event) => {
2116
-        event.preventDefault();
2117
-        dropzone.classList.add('border-primary');
2118
-      });
2119
-
2120
-      dropzone.addEventListener('dragleave', () => {
2121
-        dropzone.classList.remove('border-primary');
2122
-      });
2123
-
2124
-      dropzone.addEventListener('drop', (event) => {
2125
-        event.preventDefault();
2126
-        dropzone.classList.remove('border-primary');
2127
-        const files = filterFiles(event.dataTransfer.files);
2128
-        if (!files || files.length === 0) return;
2129
-        const dataTransfer = new DataTransfer();
2130
-        files.forEach((file) => {
2131
-          dataTransfer.items.add(file);
2132
-        });
2133
-        inputAllegato.files = dataTransfer.files;
2134
-        updateInfo(inputAllegato.files);
2135
-        renderPreviews(inputAllegato.files);
2136
-      });
2137
-    }
2023
+    @include('segnalazione.partials.allegati_dropzone')
2024
+    initSegnalazioneAllegatiDropzone(
2025
+      document.getElementById('allegatoDropzoneAdmin'),
2026
+      document.getElementById('file_allegato_card'),
2027
+      document.getElementById('allegatoDropzoneInfoAdmin'),
2028
+      document.getElementById('allegatoPreviewAdmin')
2029
+    );
2138 2030
 
2139 2031
     const categoriaSicurezzaContainer = document.getElementById('categoria_sicurezza_container');
2140 2032
     const categoriaSicurezzaSelect = document.getElementById('segnalazione_categoria_sicurezza');
@@ -2214,13 +2106,17 @@ use App\Models\Config;
2214 2106
     card.classList.remove('d-none');
2215 2107
     card.style.display = '';
2216 2108
     if (ore !== undefined && ore !== null && ore !== '') {
2217
-      var oreInput = document.getElementById('ore_tempo_card');
2218
-      if (oreInput) {
2219
-        oreInput.value = ore;
2109
+      if (typeof window.impostaTempoOreManuale === 'function') {
2110
+        window.impostaTempoOreManuale(ore);
2111
+      } else {
2112
+        var oreInput = document.getElementById('ore_tempo_card');
2113
+        if (oreInput) {
2114
+          oreInput.value = ore;
2115
+        }
2220 2116
       }
2221 2117
     }
2222 2118
     card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2223
-    if (ore) {
2119
+    if (ore && typeof window.impostaTempoOreManuale !== 'function') {
2224 2120
       var oreFocus = document.getElementById('ore_tempo_card');
2225 2121
       if (oreFocus) {
2226 2122
         setTimeout(function () { oreFocus.focus(); }, 300);

+ 10
- 123
resources/views/segnalazione/cliente/show.blade.php View File

@@ -1058,11 +1058,11 @@ use App\Models\Config;
1058 1058
             <div class="modifica-section-title"><i class="bx bx-paperclip me-1"></i>Allegati</div>
1059 1059
             <div id="allegatoDropzoneAdmin" class="dropzone-box dropzone-box-compact border border-dashed rounded p-3 text-center">
1060 1060
               <div class="dz-message">
1061
-                Trascina o clicca per caricare
1062
-                <span class="note">Max 10MB · PDF, immagini, documenti</span>
1061
+                Trascina o clicca per caricare più file
1062
+                <span class="note">Puoi aggiungerne altri. Max 10MB · PDF, immagini, documenti</span>
1063 1063
               </div>
1064 1064
             </div>
1065
-            <input type="file" class="d-none" id="file_allegato_card" name="allegato[]" multiple
1065
+            <input type="file" class="visually-hidden" id="file_allegato_card" name="allegato[]" multiple
1066 1066
               accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx">
1067 1067
             <div id="allegatoDropzoneInfoAdmin" class="small text-muted mt-2"></div>
1068 1068
             <div id="allegatoPreviewAdmin" class="row g-2 mt-2"></div>
@@ -1677,126 +1677,13 @@ use App\Models\Config;
1677 1677
       });
1678 1678
     }
1679 1679
 
1680
-    const dropzone = document.getElementById('allegatoDropzoneAdmin');
1681
-    const inputAllegato = document.getElementById('file_allegato_card');
1682
-    const dropzoneInfo = document.getElementById('allegatoDropzoneInfoAdmin');
1683
-    const dropzonePreview = document.getElementById('allegatoPreviewAdmin');
1684
-    const allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx'];
1685
-    const objectUrls = [];
1686
-
1687
-    if (dropzone && inputAllegato) {
1688
-      const clearPreviews = () => {
1689
-        objectUrls.splice(0).forEach((url) => URL.revokeObjectURL(url));
1690
-        if (dropzonePreview) {
1691
-          dropzonePreview.innerHTML = '';
1692
-        }
1693
-      };
1694
-
1695
-      const renderPreviews = (files) => {
1696
-        clearPreviews();
1697
-        if (!dropzonePreview) return;
1698
-
1699
-        Array.from(files).forEach((file) => {
1700
-          const isImage = file.type.startsWith('image/');
1701
-          const col = document.createElement('div');
1702
-          col.className = 'col-12 col-sm-6 col-lg-4';
1703
-
1704
-          const card = document.createElement('div');
1705
-          card.className = 'border rounded p-2 h-100 d-flex align-items-center gap-2';
1706
-
1707
-          if (isImage) {
1708
-            const img = document.createElement('img');
1709
-            const url = URL.createObjectURL(file);
1710
-            objectUrls.push(url);
1711
-            img.src = url;
1712
-            img.alt = file.name;
1713
-            img.className = 'rounded';
1714
-            img.style.width = '48px';
1715
-            img.style.height = '48px';
1716
-            img.style.objectFit = 'cover';
1717
-            card.appendChild(img);
1718
-          } else {
1719
-            const icon = document.createElement('i');
1720
-            icon.className = 'bx bx-file fs-3 text-primary';
1721
-            card.appendChild(icon);
1722
-          }
1723
-
1724
-          const info = document.createElement('div');
1725
-          info.className = 'flex-grow-1';
1726
-          const name = document.createElement('div');
1727
-          name.className = 'small fw-semibold text-truncate';
1728
-          name.textContent = file.name;
1729
-          const size = document.createElement('div');
1730
-          size.className = 'small text-muted';
1731
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
1732
-          size.textContent = `${sizeMb} MB`;
1733
-          info.appendChild(name);
1734
-          info.appendChild(size);
1735
-          card.appendChild(info);
1736
-
1737
-          col.appendChild(card);
1738
-          dropzonePreview.appendChild(col);
1739
-        });
1740
-      };
1741
-
1742
-      const filterFiles = (fileList) => {
1743
-        const validFiles = [];
1744
-        Array.from(fileList || []).forEach((file) => {
1745
-          const extension = file.name.split('.').pop().toLowerCase();
1746
-          if (allowedExtensions.includes(extension)) {
1747
-            validFiles.push(file);
1748
-          }
1749
-        });
1750
-        return validFiles;
1751
-      };
1752
-
1753
-      const updateInfo = (files) => {
1754
-        if (!dropzoneInfo) return;
1755
-        if (!files || files.length === 0) {
1756
-          dropzoneInfo.textContent = '';
1757
-          return;
1758
-        }
1759
-        const lines = Array.from(files).map((file) => {
1760
-          const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
1761
-          return `${file.name} (${sizeMb} MB)`;
1762
-        });
1763
-        dropzoneInfo.innerHTML = lines.join('<br>');
1764
-      };
1765
-
1766
-      dropzone.addEventListener('click', () => inputAllegato.click());
1767
-
1768
-      inputAllegato.addEventListener('change', () => {
1769
-        const validFiles = filterFiles(inputAllegato.files);
1770
-        const dataTransfer = new DataTransfer();
1771
-        validFiles.forEach((file) => dataTransfer.items.add(file));
1772
-        inputAllegato.files = dataTransfer.files;
1773
-        updateInfo(inputAllegato.files);
1774
-        renderPreviews(inputAllegato.files);
1775
-      });
1776
-
1777
-      dropzone.addEventListener('dragover', (event) => {
1778
-        event.preventDefault();
1779
-        dropzone.classList.add('border-primary');
1780
-      });
1781
-
1782
-      dropzone.addEventListener('dragleave', () => {
1783
-        dropzone.classList.remove('border-primary');
1784
-      });
1785
-
1786
-      dropzone.addEventListener('drop', (event) => {
1787
-        event.preventDefault();
1788
-        dropzone.classList.remove('border-primary');
1789
-        const files = filterFiles(event.dataTransfer.files);
1790
-        if (!files || files.length === 0) return;
1791
-        const dataTransfer = new DataTransfer();
1792
-        files.forEach((file) => {
1793
-          dataTransfer.items.add(file);
1794
-        });
1795
-        inputAllegato.files = dataTransfer.files;
1796
-        updateInfo(inputAllegato.files);
1797
-        renderPreviews(inputAllegato.files);
1798
-      });
1799
-    }
1680
+    @include('segnalazione.partials.allegati_dropzone')
1681
+    initSegnalazioneAllegatiDropzone(
1682
+      document.getElementById('allegatoDropzoneAdmin'),
1683
+      document.getElementById('file_allegato_card'),
1684
+      document.getElementById('allegatoDropzoneInfoAdmin'),
1685
+      document.getElementById('allegatoPreviewAdmin')
1686
+    );
1800 1687
 
1801 1688
     const categoriaSicurezzaContainer = document.getElementById('categoria_sicurezza_container');
1802 1689
     const categoriaSicurezzaSelect = document.getElementById('segnalazione_categoria_sicurezza');

+ 141
- 0
resources/views/segnalazione/partials/allegati_dropzone.blade.php View File

@@ -0,0 +1,141 @@
1
+function initSegnalazioneAllegatiDropzone(dropzone, inputAllegato, dropzoneInfo, dropzonePreview) {
2
+  if (!dropzone || !inputAllegato) return;
3
+
4
+  const allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx'];
5
+  const objectUrls = [];
6
+  let selectedFiles = [];
7
+
8
+  const fileKey = (file) => [file.name, file.size, file.lastModified].join('|');
9
+
10
+  const clearPreviews = () => {
11
+    objectUrls.splice(0).forEach((url) => URL.revokeObjectURL(url));
12
+    if (dropzonePreview) dropzonePreview.innerHTML = '';
13
+  };
14
+
15
+  const syncInput = () => {
16
+    const dataTransfer = new DataTransfer();
17
+    selectedFiles.forEach((file) => dataTransfer.items.add(file));
18
+    inputAllegato.files = dataTransfer.files;
19
+  };
20
+
21
+  const updateInfo = (rejectedCount) => {
22
+    if (!dropzoneInfo) return;
23
+    if (selectedFiles.length === 0 && !rejectedCount) {
24
+      dropzoneInfo.textContent = '';
25
+      return;
26
+    }
27
+    const lines = selectedFiles.map((file) => {
28
+      const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
29
+      return file.name + ' (' + sizeMb + ' MB)';
30
+    });
31
+    if (rejectedCount) {
32
+      lines.push('Alcuni file non sono stati aggiunti: formato non consentito.');
33
+    }
34
+    dropzoneInfo.innerHTML = lines.join('<br>');
35
+  };
36
+
37
+  const renderPreviews = () => {
38
+    clearPreviews();
39
+    if (!dropzonePreview) return;
40
+
41
+    selectedFiles.forEach((file, index) => {
42
+      const isImage = file.type.startsWith('image/');
43
+      const col = document.createElement('div');
44
+      col.className = 'col-12';
45
+
46
+      const card = document.createElement('div');
47
+      card.className = 'border rounded p-2 h-100 d-flex align-items-center gap-2';
48
+
49
+      if (isImage) {
50
+        const img = document.createElement('img');
51
+        const url = URL.createObjectURL(file);
52
+        objectUrls.push(url);
53
+        img.src = url;
54
+        img.alt = file.name;
55
+        img.className = 'rounded';
56
+        img.style.width = '48px';
57
+        img.style.height = '48px';
58
+        img.style.objectFit = 'cover';
59
+        card.appendChild(img);
60
+      } else {
61
+        const icon = document.createElement('i');
62
+        icon.className = 'bx bx-file fs-3 text-primary';
63
+        card.appendChild(icon);
64
+      }
65
+
66
+      const info = document.createElement('div');
67
+      info.className = 'flex-grow-1';
68
+      const name = document.createElement('div');
69
+      name.className = 'small fw-semibold text-truncate';
70
+      name.textContent = file.name;
71
+      const size = document.createElement('div');
72
+      size.className = 'small text-muted';
73
+      size.textContent = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
74
+      info.appendChild(name);
75
+      info.appendChild(size);
76
+      card.appendChild(info);
77
+
78
+      const remove = document.createElement('button');
79
+      remove.type = 'button';
80
+      remove.className = 'btn btn-sm btn-icon btn-text-danger';
81
+      remove.setAttribute('aria-label', 'Rimuovi ' + file.name);
82
+      remove.innerHTML = '<i class="bx bx-x"></i>';
83
+      remove.addEventListener('click', (event) => {
84
+        event.preventDefault();
85
+        event.stopPropagation();
86
+        selectedFiles.splice(index, 1);
87
+        refresh(0);
88
+      });
89
+      card.appendChild(remove);
90
+
91
+      col.appendChild(card);
92
+      dropzonePreview.appendChild(col);
93
+    });
94
+  };
95
+
96
+  const refresh = (rejectedCount) => {
97
+    syncInput();
98
+    updateInfo(rejectedCount);
99
+    renderPreviews();
100
+  };
101
+
102
+  const addFiles = (fileList) => {
103
+    const keys = new Set(selectedFiles.map(fileKey));
104
+    let rejected = 0;
105
+    Array.from(fileList || []).forEach((file) => {
106
+      const extension = String(file.name.split('.').pop() || '').toLowerCase();
107
+      if (!allowedExtensions.includes(extension)) {
108
+        rejected += 1;
109
+        return;
110
+      }
111
+      const key = fileKey(file);
112
+      if (keys.has(key)) return;
113
+      keys.add(key);
114
+      selectedFiles.push(file);
115
+    });
116
+    refresh(rejected);
117
+  };
118
+
119
+  dropzone.addEventListener('click', () => inputAllegato.click());
120
+
121
+  inputAllegato.addEventListener('change', () => {
122
+    const incoming = Array.from(inputAllegato.files || []);
123
+    inputAllegato.value = '';
124
+    addFiles(incoming);
125
+  });
126
+
127
+  dropzone.addEventListener('dragover', (event) => {
128
+    event.preventDefault();
129
+    dropzone.classList.add('border-primary');
130
+  });
131
+
132
+  dropzone.addEventListener('dragleave', () => {
133
+    dropzone.classList.remove('border-primary');
134
+  });
135
+
136
+  dropzone.addEventListener('drop', (event) => {
137
+    event.preventDefault();
138
+    dropzone.classList.remove('border-primary');
139
+    addFiles(event.dataTransfer ? event.dataTransfer.files : []);
140
+  });
141
+}

+ 11
- 1
resources/views/segnalazione/pdf-ticket.blade.php View File

@@ -544,6 +544,8 @@
544 544
       $tempoLabel = ((int) ($ann->tempo_minuti ?? 0) > 0)
545 545
         ? \App\Models\Config::formatTempo((int) $ann->tempo_minuti)
546 546
         : null;
547
+      $inizioLabel = $ann->tempoInizioLabel();
548
+      $fineLabel = $ann->tempoFineLabel();
547 549
       $costoLabel = filled($ann->costo)
548 550
         ? \App\Models\Config::formatCosto($ann->costo)
549 551
         : null;
@@ -559,9 +561,17 @@
559 561
         <td colspan="3">{{ $ann->user->full_name }}</td>
560 562
       </tr>
561 563
       @endif
564
+      @if($inizioLabel || $fineLabel)
565
+      <tr>
566
+        <th>Inizio</th>
567
+        <td class="field-value">{{ $inizioLabel ?: '—' }}</td>
568
+        <th>Fine</th>
569
+        <td class="field-value">{{ $fineLabel ?: '—' }}</td>
570
+      </tr>
571
+      @endif
562 572
       @if($tempoLabel)
563 573
       <tr>
564
-        <th>Tempo</th>
574
+        <th>Durata</th>
565 575
         <td class="field-value">{{ $tempoLabel }}</td>
566 576
         <th>Data</th>
567 577
         <td class="field-value">{{ $tempoDataLabel ?: '—' }}</td>

Loading…
Cancel
Save