Browse Source

modifiche

Roberto Santini 1 month ago
parent
commit
59242ca906

+ 3
- 0
GestioneManutenzioni.skipper View File

@@ -173,6 +173,9 @@
173 173
       <field name="riferimento" type="string" uuid="dca8d620-9b45-4a1f-96e4-0d76392ac410"/>
174 174
       <field name="email" type="string" uuid="e2ca0f54-426d-4977-b729-fd007dee6f64"/>
175 175
       <field name="telefono" type="string" uuid="7b367191-cdfd-465f-8efe-ba0922fa32ff"/>
176
+      <field name="firma_token" type="string" uuid="90dbff7d-2887-45ad-ba20-1c9ebaa9fc07"/>
177
+      <field name="firma_short" type="string" uuid="ac5ef761-4617-4f2a-bb72-8d65cb949183"/>
178
+      <field name="firma_at" type="timestamp" uuid="3961b35f-4db4-4bb1-aeff-1fcbd4027ae2"/>
176 179
       <field name="created_at" type="timestamp" uuid="d0ae0044-ec8e-4aee-8cb5-84d5a663c57a"/>
177 180
       <field name="updated_at" type="timestamp" uuid="80c6b3cc-d201-4972-8b42-e25cbee8b615"/>
178 181
       <orm-attributes>

+ 192
- 0
app/Http/Controllers/HomePageController.php View File

@@ -130,9 +130,201 @@ class HomePageController extends Controller
130 130
       'segnalazioniInLavoro' => $segnalazioniInLavoro,
131 131
       'timerUsers' => $timerUsers,
132 132
       'slotPrenotazioni' => $slotPrenotazioni,
133
+      'tempoLavorato' => $this->getTempoLavoratoStats(),
133 134
     ];
134 135
   }
135 136
 
137
+  /**
138
+   * Tempo registrato (annotazioni) per giorno/settimana/mese/anno,
139
+   * confrontato col periodo precedente equivalente e col riferimento 8h × lun–ven.
140
+   * Sabato e domenica restano nel totale se presenti, ma non aumentano il target.
141
+   *
142
+   * @return list<array<string, mixed>>
143
+   */
144
+  private function getTempoLavoratoStats(): array
145
+  {
146
+    $now = Carbon::now();
147
+    $today = $now->copy()->startOfDay();
148
+    $byDay = $this->tempoMinutiByDay(
149
+      $now->copy()->subYear()->startOfYear(),
150
+      $now->copy()->endOfDay()
151
+    );
152
+
153
+    $weekStart = $now->copy()->startOfWeek(Carbon::MONDAY)->startOfDay();
154
+    $weekOffsetDays = $now->isoWeekday() - 1;
155
+    $prevWeekStart = $weekStart->copy()->subWeek();
156
+    $prevWeekEnd = $prevWeekStart->copy()->addDays($weekOffsetDays)->endOfDay();
157
+
158
+    $monthStart = $now->copy()->startOfMonth();
159
+    $prevMonthStart = $monthStart->copy()->subMonth();
160
+    $prevMonthSameDay = $prevMonthStart->copy()->day(min((int) $now->day, (int) $prevMonthStart->copy()->endOfMonth()->day))->endOfDay();
161
+
162
+    $yearStart = $now->copy()->startOfYear();
163
+    $prevYearEnd = $now->copy()->subYear()->endOfDay();
164
+
165
+    return [
166
+      $this->buildTempoPeriodo(
167
+        'Oggi',
168
+        $today->format('d/m/Y'),
169
+        'bx-time',
170
+        'primary',
171
+        'ieri',
172
+        $byDay,
173
+        $today,
174
+        $now,
175
+        $today->copy()->subDay(),
176
+        $today->copy()->subDay()->endOfDay(),
177
+      ),
178
+      $this->buildTempoPeriodo(
179
+        'Settimana',
180
+        $weekStart->format('d/m').' – '.$today->format('d/m'),
181
+        'bx-calendar-event',
182
+        'info',
183
+        'sett. scorsa',
184
+        $byDay,
185
+        $weekStart,
186
+        $now,
187
+        $prevWeekStart,
188
+        $prevWeekEnd,
189
+      ),
190
+      $this->buildTempoPeriodo(
191
+        'Mese',
192
+        $this->italianMonthYear($now),
193
+        'bx-calendar',
194
+        'warning',
195
+        'mese scorso',
196
+        $byDay,
197
+        $monthStart,
198
+        $now,
199
+        $prevMonthStart,
200
+        $prevMonthSameDay,
201
+      ),
202
+      $this->buildTempoPeriodo(
203
+        'Anno',
204
+        $now->format('Y'),
205
+        'bx-bar-chart-alt-2',
206
+        'success',
207
+        'anno scorso',
208
+        $byDay,
209
+        $yearStart,
210
+        $now,
211
+        $yearStart->copy()->subYear(),
212
+        $prevYearEnd,
213
+      ),
214
+    ];
215
+  }
216
+
217
+  /**
218
+   * @param  array<string, int>  $byDay
219
+   * @return array<string, mixed>
220
+   */
221
+  private function buildTempoPeriodo(
222
+    string $label,
223
+    string $subtitle,
224
+    string $icon,
225
+    string $color,
226
+    string $previousLabel,
227
+    array $byDay,
228
+    Carbon $currentFrom,
229
+    Carbon $currentTo,
230
+    Carbon $previousFrom,
231
+    Carbon $previousTo,
232
+  ): array {
233
+    $minuti = $this->sumMinutiInRange($byDay, $currentFrom, $currentTo);
234
+    $precedente = $this->sumMinutiInRange($byDay, $previousFrom, $previousTo);
235
+    $giorniLavorativi = $this->countWeekdays($currentFrom, $currentTo);
236
+    $atteso = $giorniLavorativi * 8 * 60;
237
+    $delta = $minuti - $precedente;
238
+    $pctPrecedente = $precedente > 0
239
+      ? round(($delta / $precedente) * 100, 1)
240
+      : ($minuti > 0 ? 100.0 : 0.0);
241
+    $pctAtteso = $atteso > 0
242
+      ? (int) round(($minuti / $atteso) * 100)
243
+      : ($minuti > 0 ? 100 : 0);
244
+
245
+    $deltaSign = $delta > 0 ? '+' : ($delta < 0 ? '−' : '');
246
+
247
+    return [
248
+      'label' => $label,
249
+      'subtitle' => $subtitle,
250
+      'icon' => $icon,
251
+      'color' => $color,
252
+      'minuti' => $minuti,
253
+      'minuti_label' => Config::formatTempo($minuti),
254
+      'atteso_minuti' => $atteso,
255
+      'atteso_label' => Config::formatTempo($atteso),
256
+      'giorni_lavorativi' => $giorniLavorativi,
257
+      'percentuale_atteso' => $pctAtteso,
258
+      'progress_width' => min(100, $pctAtteso),
259
+      'has_target' => $atteso > 0,
260
+      'vs_precedente_minuti' => $delta,
261
+      'vs_precedente_label' => $deltaSign.Config::formatTempo(abs($delta)),
262
+      'vs_precedente_percentuale' => $pctPrecedente,
263
+      'precedente_label' => $previousLabel,
264
+      'precedente_minuti_label' => Config::formatTempo($precedente),
265
+    ];
266
+  }
267
+
268
+  /**
269
+   * @return array<string, int>
270
+   */
271
+  private function tempoMinutiByDay(Carbon $from, Carbon $to): array
272
+  {
273
+    $rows = $this->annotazioniDashboardQuery()
274
+      ->where('tempo_minuti', '>', 0)
275
+      ->where(function (Builder $q) use ($from, $to) {
276
+        $q->where(function (Builder $q1) use ($from, $to) {
277
+          $q1->whereNotNull('tempo_data')->whereBetween('tempo_data', [$from, $to]);
278
+        })->orWhere(function (Builder $q2) use ($from, $to) {
279
+          $q2->whereNull('tempo_data')->whereBetween('created_at', [$from, $to]);
280
+        });
281
+      })
282
+      ->selectRaw('DATE(COALESCE(tempo_data, created_at)) as giorno')
283
+      ->selectRaw('SUM(tempo_minuti) as minuti')
284
+      ->groupByRaw('DATE(COALESCE(tempo_data, created_at))')
285
+      ->pluck('minuti', 'giorno');
286
+
287
+    return $rows->map(fn ($v) => (int) $v)->all();
288
+  }
289
+
290
+  /**
291
+   * @param  array<string, int>  $byDay
292
+   */
293
+  private function sumMinutiInRange(array $byDay, Carbon $from, Carbon $to): int
294
+  {
295
+    $sum = 0;
296
+    $end = $to->copy()->startOfDay();
297
+    for ($d = $from->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
298
+      $sum += $byDay[$d->toDateString()] ?? 0;
299
+    }
300
+
301
+    return $sum;
302
+  }
303
+
304
+  private function countWeekdays(Carbon $from, Carbon $to): int
305
+  {
306
+    $count = 0;
307
+    $end = $to->copy()->startOfDay();
308
+    for ($d = $from->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
309
+      if ($d->isWeekday()) {
310
+        $count++;
311
+      }
312
+    }
313
+
314
+    return $count;
315
+  }
316
+
317
+  private function italianMonthYear(Carbon $date): string
318
+  {
319
+    $mesi = [
320
+      1 => 'gennaio', 2 => 'febbraio', 3 => 'marzo', 4 => 'aprile',
321
+      5 => 'maggio', 6 => 'giugno', 7 => 'luglio', 8 => 'agosto',
322
+      9 => 'settembre', 10 => 'ottobre', 11 => 'novembre', 12 => 'dicembre',
323
+    ];
324
+
325
+    return ($mesi[(int) $date->month] ?? $date->format('m')).' '.$date->format('Y');
326
+  }
327
+
136 328
   /**
137 329
    * Dati grafici e KPI performance per la pagina Statistiche.
138 330
    *

+ 155
- 131
app/Http/Controllers/SegnalazioneController.php View File

@@ -20,6 +20,8 @@ use App\DataTables\SegnalazioneDataTable;
20 20
 use Illuminate\Support\Facades\Auth;
21 21
 use App\DataTables\SegnalazioneDataTableEditor;
22 22
 use App\Services\SegnalazioneTicketPdfService;
23
+use App\Services\SegnalazioneAggiornaService;
24
+use App\Services\SegnalazioneRapportoService;
23 25
 use Illuminate\Support\Str;
24 26
 use Illuminate\Support\Facades\File;
25 27
 use Illuminate\Validation\Rule;
@@ -43,6 +45,8 @@ class SegnalazioneController extends Controller implements HasMiddleware
43 45
       new Middleware('permission:edit-segnalazione', only: [
44 46
         'updateStato',
45 47
         'admin_aggiorna',
48
+        'admin_rapporto',
49
+        'admin_rapporto_store',
46 50
         'admin_batch_update',
47 51
         'admin_toggle_todo',
48 52
         'admin_update_todo_note',
@@ -141,169 +145,189 @@ class SegnalazioneController extends Controller implements HasMiddleware
141 145
     ]);
142 146
   }
143 147
 
144
-  public function admin_aggiorna(Request $request, $id)
148
+  public function admin_aggiorna(Request $request, $id, SegnalazioneAggiornaService $aggiorna)
145 149
   {
146
-    $request->validate([
147
-      'titolo' => 'required|string|max:255',
148
-      'azienda_id' => ['nullable', Rule::exists('users', 'id')->where('is_azienda', 1)],
149
-      'user_id' => 'required|exists:users,id', // utente che ha aperto la segnalazione
150
-      'assegnata_a_id' => 'nullable|exists:users,id',
151
-      'ordine_lavoro_id' => 'nullable|exists:ordine_lavoro,id',
152
-      'contratto_servizio_id' => 'nullable|exists:contratto_servizio,id',
153
-      'stato' => 'nullable|string|max:255',
154
-      'priorita' => 'nullable|string|max:255',
155
-      'tipo' => 'nullable|string|max:255',
156
-      'categoria_sicurezza' => 'nullable|string|max:255',
157
-      'annotazione_user_id' => 'required|exists:users,id',
158
-      'annotazione' => 'nullable|string',
159
-      'tempo_stimato' => 'nullable|numeric|min:0',
160
-      'tempo_ore' => 'nullable|string',
161
-      'tempo_user_id' => 'nullable|exists:users,id',
162
-      'slots' => 'nullable|array',
163
-      'slots.*.data' => 'nullable|date_format:d/m/Y',
164
-      'slots.*.ora_inizio' => 'nullable|date_format:H:i',
165
-      'slots.*.ora_fine' => 'nullable|date_format:H:i',
166
-      'allegato' => 'nullable|array',
167
-      'allegato.*' => 'file|max:10240', // max 10MB per file
168
-      'cc' => 'nullable|string',
169
-      'bcc' => 'nullable|string',
170
-    ]);
171
-
172
-    if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
173
-      return redirect()->back()
174
-        ->withInput()
175
-        ->withErrors(['contratto_servizio_id' => 'Ordine di lavoro e contratto sono alternativi: scegline solo uno.']);
176
-    }
150
+    $this->validateAggiornamento($request);
177 151
 
178 152
     $segnalazione = Segnalazione::find($id);
179 153
     if (!$segnalazione) {
180 154
       return redirect()->back()->with('error', 'Segnalazione non trovata');
181 155
     }
182 156
 
183
-    $assegnataAId = $request->filled('assegnata_a_id') ? (int) $request->assegnata_a_id : null;
157
+    $annotazione = $aggiorna->apply($request, $segnalazione);
184 158
 
185
-    // Se non c’è assegnazione, assegna a chi sta rispondendo (utente annotazione).
186
-    if ($assegnataAId === null && ! $segnalazione->assegnata_a_id) {
187
-      $assegnataAId = (int) ($request->annotazione_user_id ?: Auth::id());
159
+    if (! $annotazione->riservata && $segnalazione->user_id) {
160
+      $segnalazione->notifyApertaDaAnnotazionePubblica($annotazione);
188 161
     }
189 162
 
190
-    abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
191
-    abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $assegnataAId, $segnalazione->user_id), 403);
163
+    return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
164
+    ->with('success', 'Segnalazione aggiornata con successo');
165
+  }
192 166
 
193
-    $tempoMinuti = null;
194
-    if ($request->filled('tempo_ore')) {
195
-      $tempoMinuti = Config::parseTempoToMinuti($request->input('tempo_ore'));
196
-      if ($tempoMinuti === null) {
197
-        return redirect()->back()->withInput()->with('error', 'Formato tempo non valido');
198
-      }
167
+  public function admin_rapporto(Request $request, $id)
168
+  {
169
+    $segnalazione = $this->loadSegnalazioneAdmin($id);
170
+    if (! $segnalazione) {
171
+      return redirect()->route('admin.segnalazione.index')->with('error', 'Segnalazione non trovata');
199 172
     }
200 173
 
201
-    $blockClose = $segnalazione->closingBlockedMessage($request->stato);
202
-    if ($blockClose) {
203
-      return redirect()->back()->withInput()->with('error', $blockClose);
204
-    }
174
+    abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
205 175
 
206
-    $parsedSlots = [];
207
-    if ($request->stato === 'pianificata') {
208
-      foreach ($request->input('slots', []) as $slotInput) {
209
-        $data = trim((string) ($slotInput['data'] ?? ''));
210
-        $oraInizio = trim((string) ($slotInput['ora_inizio'] ?? ''));
211
-        $oraFine = trim((string) ($slotInput['ora_fine'] ?? ''));
212
-        if ($data === '' && $oraInizio === '' && $oraFine === '') {
213
-          continue;
214
-        }
215
-        if ($data === '' || $oraInizio === '' || $oraFine === '') {
216
-          return redirect()->back()->withInput()->with('error', 'Completa data, ora inizio e ora fine per ogni slot di prenotazione.');
217
-        }
218
-        try {
219
-          $startsAt = \Carbon\Carbon::createFromFormat('d/m/Y H:i', $data.' '.$oraInizio);
220
-          $endsAt = \Carbon\Carbon::createFromFormat('d/m/Y H:i', $data.' '.$oraFine);
221
-        } catch (\Throwable $e) {
222
-          return redirect()->back()->withInput()->with('error', 'Formato data/ora slot non valido.');
223
-        }
224
-        if ($endsAt->lte($startsAt)) {
225
-          return redirect()->back()->withInput()->with('error', 'L\'ora di fine deve essere successiva all\'ora di inizio.');
226
-        }
227
-        $parsedSlots[] = [
228
-          'starts_at' => $startsAt,
229
-          'ends_at' => $endsAt,
230
-        ];
231
-      }
176
+    if (Segnalazione::isClienteVista(Auth::user())) {
177
+      return redirect()->to($segnalazione->urlCliente());
232 178
     }
233 179
 
234
-    $segnalazione->titolo = $request->titolo;
235
-    $segnalazione->azienda_id = $request->filled('azienda_id') ? $request->azienda_id : null;
236
-    $segnalazione->user_id = $request->user_id;
237
-    $segnalazione->assegnata_a_id = $assegnataAId;
238
-    $segnalazione->ordine_lavoro_id = $request->filled('ordine_lavoro_id') ? $request->ordine_lavoro_id : null;
239
-    $segnalazione->contratto_servizio_id = $request->filled('contratto_servizio_id') ? $request->contratto_servizio_id : null;
240
-    $segnalazione->stato = $request->stato;
241
-    $segnalazione->priorita = $request->priorita;
242
-    $segnalazione->tipo = $request->tipo;
243
-    $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
244
-      ? round((float) $request->input('tempo_stimato'), 2)
245
-      : null;
246
-    $this->applyCcBccFromRequest($segnalazione, $request);
247
-    $segnalazione->save();
248
-
249
-    // Sync slot prenotazione (solo in stato pianificata; altrimenti svuota)
250
-    $segnalazione->slots()->delete();
251
-    if ($request->stato === 'pianificata') {
252
-      foreach ($parsedSlots as $slotData) {
253
-        $segnalazione->slots()->create($slotData);
254
-      }
255
-    }
180
+    return view('segnalazione.admin.rapporto', [
181
+      'segnalazione' => $segnalazione,
182
+    ]);
183
+  }
256 184
 
257
-    $annotazione = new Annotazione();
258
-    $annotazione->text = $request->text;
259
-    $annotazione->segnalazione_id = $segnalazione->id;
260
-    $annotazione->user_id = $request->annotazione_user_id;
261
-    $annotazione->riservata = $request->boolean('annotazione_riservata');
262
-    $annotazione->save();
185
+  public function admin_rapporto_store(
186
+    Request $request,
187
+    $id,
188
+    SegnalazioneAggiornaService $aggiorna,
189
+    SegnalazioneRapportoService $rapporto
190
+  ) {
191
+    $validatedAzione = $request->validate([
192
+      'azione' => 'required|in:salva,invia_firma',
193
+      'canale_firma' => 'nullable|in:email,sms',
194
+      'firma_email' => [
195
+        'nullable',
196
+        'required_if:canale_firma,email',
197
+        'email',
198
+        'max:255',
199
+        Rule::unique('users', 'email')->ignore($request->input('user_id')),
200
+      ],
201
+      'firma_telefono' => 'nullable|required_if:canale_firma,sms|string|max:255',
202
+    ], [
203
+      'firma_email.required_if' => 'Indica l\'indirizzo email del destinatario.',
204
+      'firma_email.email' => 'Indirizzo email non valido.',
205
+      'firma_email.unique' => 'Questa email è già usata da un altro utente in anagrafica.',
206
+      'firma_telefono.required_if' => 'Indica il numero di telefono del destinatario.',
207
+    ]);
208
+    $this->validateAggiornamento($request);
263 209
 
264
-    if ($tempoMinuti !== null) {
265
-      $annotazione->tempo_minuti = $tempoMinuti;
266
-      $annotazione->tempo_data = $request->tempo_data;
210
+    $segnalazione = Segnalazione::find($id);
211
+    if (! $segnalazione) {
212
+      return redirect()->back()->with('error', 'Segnalazione non trovata');
267 213
     }
268 214
 
269
-    if($request->costo !== null) {
270
-      $annotazione->costo = $request->costo;
271
-      $annotazione->costo_descrizione = $request->costo_descrizione;
215
+    $request->merge(['annotazione_riservata' => 0]);
216
+    $annotazione = $aggiorna->apply($request, $segnalazione);
217
+    $segnalazione->refresh()->load(['user', 'azienda']);
218
+
219
+    $azione = $validatedAzione['azione'];
220
+    if ($azione === 'salva') {
221
+      $inviato = $rapporto->sendRapportinoEmail($segnalazione, $annotazione, false);
222
+      $msg = 'Rapporto d\'intervento salvato.';
223
+      if ($inviato) {
224
+        $msg .= ' Rapportino inviato via email all\'utente della segnalazione.';
225
+      } else {
226
+        $msg .= ' Nessuna email utente: PDF non inviato.';
227
+      }
228
+
229
+      return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
230
+        ->with('success', $msg);
272 231
     }
273 232
 
274
-    $annotazione->non_fatturare = $request->boolean('non_fatturare');
275
-    $annotazione->costo_uscita = $request->boolean('costo_uscita');
233
+    $canale = $validatedAzione['canale_firma'] ?? null;
234
+    if (! in_array($canale, ['email', 'sms'], true)) {
235
+      return redirect()->back()->withInput()->with('error', 'Scegli se inviare il link per email o SMS.');
236
+    }
276 237
 
277
-    $annotazione->save();
238
+    $annotazione->ensureFirmaShortCode();
278 239
 
279
-    if ($request->hasFile('allegato')) {
280
-      $directory = storage_path('app/public/allegatiAnnotazioni/' . $segnalazione->id);
281
-      if (!File::exists($directory)) {
282
-        File::makeDirectory($directory, 0755, true);
283
-      }
240
+    $destinatario = $segnalazione->user;
241
+    if (! $destinatario) {
242
+      return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
243
+        ->with('error', 'Rapporto salvato, ma manca l\'utente destinatario.');
244
+    }
284 245
 
285
-      foreach ($request->file('allegato') as $file) {
286
-        if (!$file) {
287
-          continue;
288
-        }
289
-        $filename = time() . '_' . $file->getClientOriginalName();
290
-        $path = $file->storeAs($segnalazione->id, $filename, 'allegatiAnnotazioni');
246
+    if ($canale === 'sms') {
247
+      $telefono = trim((string) ($validatedAzione['firma_telefono'] ?? ''));
248
+      $destinatario->telefono = $telefono !== '' ? $telefono : null;
249
+      $destinatario->save();
250
+      $segnalazione->unsetRelation('user');
291 251
 
292
-        $allegato = new AllegatoAnnotazione();
293
-          $allegato->annotazione()->associate($annotazione);
294
-          $allegato->nome = $file->getClientOriginalName();
295
-          $allegato->path_file = $path;
296
-          $allegato->save();
252
+      $result = $rapporto->sendFirmaLinkSms($annotazione->fresh());
253
+      if (! $result['ok']) {
254
+        return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
255
+          ->with('error', 'Rapporto salvato, ma invio SMS non riuscito: '.($result['error'] ?? 'errore sconosciuto'));
297 256
       }
257
+
258
+      return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
259
+        ->with('success', 'Rapporto salvato. Link per la firma inviato via SMS.');
298 260
     }
299 261
 
300
-    // Annotazione pubblica: notifica anche chi ha aperto la segnalazione
301
-    if (! $annotazione->riservata && $segnalazione->user_id) {
302
-      $segnalazione->notifyApertaDaAnnotazionePubblica($annotazione);
262
+    $email = trim((string) ($validatedAzione['firma_email'] ?? ''));
263
+    $destinatario->email = $email !== '' ? $email : null;
264
+    $destinatario->save();
265
+    $segnalazione->unsetRelation('user');
266
+
267
+    $inviato = $rapporto->sendFirmaLinkEmail($segnalazione->fresh(['user', 'azienda']), $annotazione);
268
+    if (! $inviato) {
269
+      return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
270
+        ->with('error', 'Rapporto salvato, ma l\'utente della segnalazione non ha un\'email valida.');
303 271
     }
304 272
 
305 273
     return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
306
-    ->with('success', 'Segnalazione aggiornata con successo');
274
+      ->with('success', 'Rapporto salvato. Link per la firma inviato via email.');
275
+  }
276
+
277
+  private function validateAggiornamento(Request $request): void
278
+  {
279
+    $request->validate([
280
+      'titolo' => 'required|string|max:255',
281
+      'azienda_id' => ['nullable', Rule::exists('users', 'id')->where('is_azienda', 1)],
282
+      'user_id' => 'required|exists:users,id',
283
+      'assegnata_a_id' => 'nullable|exists:users,id',
284
+      'ordine_lavoro_id' => 'nullable|exists:ordine_lavoro,id',
285
+      'contratto_servizio_id' => 'nullable|exists:contratto_servizio,id',
286
+      'stato' => 'nullable|string|max:255',
287
+      'priorita' => 'nullable|string|max:255',
288
+      'tipo' => 'nullable|string|max:255',
289
+      'categoria_sicurezza' => 'nullable|string|max:255',
290
+      'annotazione_user_id' => 'required|exists:users,id',
291
+      'annotazione' => 'nullable|string',
292
+      'tempo_stimato' => 'nullable|numeric|min:0',
293
+      'tempo_ore' => 'nullable|string',
294
+      'tempo_user_id' => 'nullable|exists:users,id',
295
+      'slots' => 'nullable|array',
296
+      'slots.*.data' => 'nullable|date_format:d/m/Y',
297
+      'slots.*.ora_inizio' => 'nullable|date_format:H:i',
298
+      'slots.*.ora_fine' => 'nullable|date_format:H:i',
299
+      'allegato' => 'nullable|array',
300
+      'allegato.*' => 'file|max:10240',
301
+      'cc' => 'nullable|string',
302
+      'bcc' => 'nullable|string',
303
+    ]);
304
+
305
+    if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
306
+      throw \Illuminate\Validation\ValidationException::withMessages([
307
+        'contratto_servizio_id' => 'Ordine di lavoro e contratto sono alternativi: scegline solo uno.',
308
+      ]);
309
+    }
310
+  }
311
+
312
+  private function loadSegnalazioneAdmin($id): ?Segnalazione
313
+  {
314
+    return Segnalazione::with([
315
+      'azienda',
316
+      'user',
317
+      'assegnataA',
318
+      'ordineLavoro.offerte.righe',
319
+      'ordineLavoro.righe',
320
+      'contrattoServizio.contrattoTemplate',
321
+      'contrattoServizio.righe.prodotto',
322
+      'contrattoServizioTodo',
323
+      'prodotto',
324
+      'acquistoServizio.prodotto',
325
+      'acquistoServizios.prodotto',
326
+      'todos.completatoDa',
327
+      'slots',
328
+      'annotazioni.allegatiAnnotazione',
329
+      'annotazioni.user',
330
+    ])->find($id);
307 331
   }
308 332
 
309 333
   public function admin_crea(Request $request)

+ 104
- 0
app/Http/Controllers/SegnalazioneRapportoFirmaController.php View File

@@ -0,0 +1,104 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\Annotazione;
6
+use App\Services\SegnalazioneRapportoService;
7
+use Illuminate\Http\Request;
8
+
9
+class SegnalazioneRapportoFirmaController extends Controller
10
+{
11
+    public function show(string $token)
12
+    {
13
+        $annotazione = $this->findByToken($token);
14
+
15
+        return view('segnalazione.public.firma-rapporto', [
16
+            'annotazione' => $annotazione,
17
+            'segnalazione' => $annotazione->segnalazione,
18
+            'alreadySigned' => $annotazione->has_firma_cliente,
19
+            'token' => $token,
20
+            'pdfUrl' => route('segnalazione.rapporto_firma.pdf', $token),
21
+        ]);
22
+    }
23
+
24
+    public function pdf(string $token, SegnalazioneRapportoService $rapporto)
25
+    {
26
+        $annotazione = $this->findByToken($token);
27
+        $segnalazione = $annotazione->segnalazione;
28
+        $filename = $rapporto->rapportinoFilename($segnalazione, $annotazione->has_firma_cliente);
29
+        $binary = $rapporto->generateRapportinoBinary($segnalazione, $annotazione);
30
+
31
+        return response()->stream(function () use ($binary) {
32
+            echo $binary;
33
+        }, 200, [
34
+            'Content-Type' => 'application/pdf',
35
+            'Content-Disposition' => 'inline; filename="'.$filename.'"',
36
+        ]);
37
+    }
38
+
39
+    public function store(Request $request, string $token, SegnalazioneRapportoService $rapporto)
40
+    {
41
+        $annotazione = $this->findByToken($token);
42
+
43
+        if ($annotazione->has_firma_cliente) {
44
+            return redirect()->route('segnalazione.rapporto_firma.show', $token)
45
+                ->with('success', 'Hai già firmato questo rapportino.');
46
+        }
47
+
48
+        $binary = $this->decodeFirmaPng($request);
49
+        if ($binary instanceof \Illuminate\Http\RedirectResponse) {
50
+            return $binary;
51
+        }
52
+
53
+        $rapporto->appendFirmaCliente($annotazione, $binary);
54
+        $annotazione->refresh();
55
+        $segnalazione = $annotazione->segnalazione()->with(['user', 'azienda'])->first();
56
+        $inviato = $rapporto->sendRapportinoEmail($segnalazione, $annotazione, true);
57
+
58
+        $msg = 'Grazie: firma registrata. Puoi chiudere questa pagina.';
59
+        if ($inviato) {
60
+            $msg = 'Grazie: firma registrata. Il rapportino firmato è stato inviato via email. Puoi chiudere questa pagina.';
61
+        }
62
+
63
+        return redirect()->route('segnalazione.rapporto_firma.show', $token)
64
+            ->with('success', $msg);
65
+    }
66
+
67
+    /**
68
+     * @return string|\Illuminate\Http\RedirectResponse
69
+     */
70
+    private function decodeFirmaPng(Request $request)
71
+    {
72
+        $validated = $request->validate([
73
+            'firma' => 'required|string',
74
+            'privacy_firma' => 'accepted',
75
+        ], [
76
+            'privacy_firma.accepted' => 'Per salvare la firma è necessario accettare l’informativa privacy.',
77
+        ]);
78
+
79
+        $dataUrl = $validated['firma'];
80
+        if (! preg_match('#^data:image/(png|jpeg|jpg);base64,#i', $dataUrl)) {
81
+            return back()->withErrors(['firma' => 'Firma non valida. Disegna la firma nel riquadro.']);
82
+        }
83
+
84
+        $base64 = preg_replace('#^data:image/\w+;base64,#i', '', $dataUrl);
85
+        $binary = base64_decode($base64, true);
86
+        if ($binary === false || strlen($binary) < 100) {
87
+            return back()->withErrors(['firma' => 'Firma non valida o troppo corta.']);
88
+        }
89
+
90
+        return $binary;
91
+    }
92
+
93
+    private function findByToken(string $token): Annotazione
94
+    {
95
+        $annotazione = Annotazione::query()
96
+            ->with(['segnalazione.user', 'segnalazione.azienda', 'user', 'allegatiAnnotazione'])
97
+            ->where('firma_token', $token)
98
+            ->first();
99
+
100
+        abort_if(! $annotazione || ! $annotazione->segnalazione, 404);
101
+
102
+        return $annotazione;
103
+    }
104
+}

+ 13
- 0
app/Http/Controllers/ShortUrlController.php View File

@@ -3,6 +3,7 @@
3 3
 namespace App\Http\Controllers;
4 4
 
5 5
 use App\Models\AcquistoServizio;
6
+use App\Models\Annotazione;
6 7
 use Illuminate\Http\RedirectResponse;
7 8
 
8 9
 class ShortUrlController extends Controller
@@ -12,6 +13,18 @@ class ShortUrlController extends Controller
12 13
         $code = trim($code);
13 14
         abort_if($code === '' || strlen($code) > 32, 404);
14 15
 
16
+        $annotazione = Annotazione::query()
17
+            ->where('firma_short', $code)
18
+            ->first();
19
+
20
+        if ($annotazione) {
21
+            $annotazione->ensureFirmaToken();
22
+            $url = $annotazione->firma_url;
23
+            abort_unless($url, 404);
24
+
25
+            return redirect()->away($url);
26
+        }
27
+
15 28
         $acquisto = AcquistoServizio::query()
16 29
             ->where('meta->firma_consegna_short', $code)
17 30
             ->first();

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

@@ -56,6 +56,9 @@ abstract class AbstractAnnotazione extends Model
56 56
         'riferimento' => 'string',
57 57
         'email' => 'string',
58 58
         'telefono' => 'string',
59
+        'firma_token' => 'string',
60
+        'firma_short' => 'string',
61
+        'firma_at' => 'datetime',
59 62
         'created_at' => 'datetime',
60 63
         'updated_at' => 'datetime'
61 64
     ];
@@ -80,6 +83,9 @@ abstract class AbstractAnnotazione extends Model
80 83
         'riferimento',
81 84
         'email',
82 85
         'telefono',
86
+        'firma_token',
87
+        'firma_short',
88
+        'firma_at',
83 89
         'created_at',
84 90
         'updated_at'
85 91
     ];

+ 1
- 8
app/Models/AcquistoServizio.php View File

@@ -594,14 +594,7 @@ class AcquistoServizio extends \App\Models\AbstractModels\AbstractAcquistoServiz
594 594
 
595 595
     private function generateUniqueShortCode(int $length = 8): string
596 596
     {
597
-        do {
598
-            $code = Str::lower(Str::random($length));
599
-        } while (
600
-            static::query()->where('meta->firma_consegna_short', $code)->exists()
601
-            || static::query()->where('meta->firma_riconsegna_short', $code)->exists()
602
-        );
603
-
604
-        return $code;
597
+        return \App\Support\ShortUrlCode::generate($length);
605 598
     }
606 599
 
607 600
     public function getTrackingUrlAttribute(): ?string

+ 100
- 0
app/Models/Annotazione.php View File

@@ -1,7 +1,10 @@
1 1
 <?php
2 2
 namespace App\Models;
3 3
 
4
+use App\Support\ShortUrlCode;
4 5
 use Carbon\Carbon;
6
+use Illuminate\Support\Str;
7
+use Propaganistas\LaravelPhone\PhoneNumber;
5 8
 
6 9
 class Annotazione extends \App\Models\AbstractModels\AbstractAnnotazione
7 10
 {
@@ -29,4 +32,101 @@ class Annotazione extends \App\Models\AbstractModels\AbstractAnnotazione
29 32
 
30 33
     return $map;
31 34
   }
35
+
36
+  public function getCasts()
37
+  {
38
+    return array_merge(parent::getCasts(), [
39
+      'firma_at' => 'datetime',
40
+    ]);
41
+  }
42
+
43
+  public function getHasFirmaClienteAttribute(): bool
44
+  {
45
+    return filled($this->firma_at);
46
+  }
47
+
48
+  public function ensureFirmaToken(): string
49
+  {
50
+    $token = (string) ($this->firma_token ?? '');
51
+    if ($token !== '') {
52
+      return $token;
53
+    }
54
+
55
+    do {
56
+      $token = Str::random(48);
57
+    } while (static::query()->where('firma_token', $token)->exists());
58
+
59
+    $this->firma_token = $token;
60
+    $this->save();
61
+
62
+    return $token;
63
+  }
64
+
65
+  public function ensureFirmaShortCode(): string
66
+  {
67
+    $this->ensureFirmaToken();
68
+    $code = (string) ($this->firma_short ?? '');
69
+    if ($code !== '') {
70
+      return $code;
71
+    }
72
+
73
+    $code = ShortUrlCode::generate();
74
+    $this->firma_short = $code;
75
+    $this->save();
76
+
77
+    return $code;
78
+  }
79
+
80
+  public function getFirmaUrlAttribute(): ?string
81
+  {
82
+    $token = (string) ($this->firma_token ?? '');
83
+    if ($token === '') {
84
+      return null;
85
+    }
86
+
87
+    return route('segnalazione.rapporto_firma.show', $token);
88
+  }
89
+
90
+  public function getFirmaShortUrlAttribute(): ?string
91
+  {
92
+    $code = (string) ($this->firma_short ?? '');
93
+    if ($code === '') {
94
+      return null;
95
+    }
96
+
97
+    return route('short.redirect', $code);
98
+  }
99
+
100
+  public function smsFirmaRapportoMessage(): string
101
+  {
102
+    $this->ensureFirmaShortCode();
103
+    $this->refresh();
104
+    $url = $this->firma_short_url ?: ($this->firma_url ?: '');
105
+
106
+    return 'ElephanTech: firma il rapporto d\'intervento '.$url;
107
+  }
108
+
109
+  public function firmaSmsPhoneDigits(): ?string
110
+  {
111
+    $this->loadMissing(['segnalazione.user', 'segnalazione.azienda']);
112
+    $raw = trim((string) (
113
+      $this->segnalazione?->user?->telefono
114
+      ?: $this->segnalazione?->azienda?->telefono
115
+      ?: ''
116
+    ));
117
+    if ($raw === '') {
118
+      return null;
119
+    }
120
+
121
+    try {
122
+      $phone = new PhoneNumber($raw, config('app.phone_locale', 'IT'));
123
+      if (! $phone->isValid()) {
124
+        return null;
125
+      }
126
+
127
+      return preg_replace('/\D+/', '', $phone->formatE164()) ?: null;
128
+    } catch (\Throwable $e) {
129
+      return null;
130
+    }
131
+  }
32 132
 }

+ 50
- 0
app/Notifications/RapportinoFirmaLink.php View File

@@ -0,0 +1,50 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Segnalazione;
7
+use Illuminate\Bus\Queueable;
8
+use Illuminate\Contracts\Queue\ShouldQueue;
9
+use Illuminate\Notifications\Messages\MailMessage;
10
+use Illuminate\Notifications\Notification;
11
+use Illuminate\Queue\InteractsWithQueue;
12
+use Illuminate\Queue\SerializesModels;
13
+
14
+class RapportinoFirmaLink extends Notification implements ShouldQueue
15
+{
16
+    use Queueable, InteractsWithQueue, SerializesModels;
17
+
18
+    public function __construct(
19
+        protected Segnalazione $segnalazione,
20
+        protected Annotazione $annotazione,
21
+    ) {
22
+    }
23
+
24
+    /**
25
+     * @return array<int, string>
26
+     */
27
+    public function via(object $notifiable): array
28
+    {
29
+        return ['mail'];
30
+    }
31
+
32
+    public function toMail(object $notifiable): MailMessage
33
+    {
34
+        $this->segnalazione->loadMissing(['azienda']);
35
+        $this->annotazione->ensureFirmaShortCode();
36
+        $url = $this->annotazione->firma_short_url ?: $this->annotazione->firma_url;
37
+
38
+        $mail = (new MailMessage)
39
+            ->subject($this->segnalazione->mailSubject(true).' — Firma il rapporto d\'intervento')
40
+            ->markdown('mail.rapportino-firma-link', [
41
+                'segnalazione' => $this->segnalazione,
42
+                'annotazione' => $this->annotazione,
43
+                'firmaUrl' => $url,
44
+            ]);
45
+
46
+        return $this->segnalazione->applyEmailDestinatari($mail, [
47
+            strtolower((string) ($notifiable->email ?? '')),
48
+        ]);
49
+    }
50
+}

+ 64
- 0
app/Notifications/RapportinoFirmato.php View File

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Segnalazione;
7
+use App\Services\SegnalazioneRapportoService;
8
+use App\Support\MailAnnotazioneHtml;
9
+use Illuminate\Bus\Queueable;
10
+use Illuminate\Contracts\Queue\ShouldQueue;
11
+use Illuminate\Notifications\Messages\MailMessage;
12
+use Illuminate\Notifications\Notification;
13
+use Illuminate\Queue\InteractsWithQueue;
14
+use Illuminate\Queue\SerializesModels;
15
+
16
+class RapportinoFirmato extends Notification implements ShouldQueue
17
+{
18
+    use Queueable, InteractsWithQueue, SerializesModels;
19
+
20
+    public function __construct(
21
+        protected Segnalazione $segnalazione,
22
+        protected Annotazione $annotazione,
23
+    ) {
24
+    }
25
+
26
+    /**
27
+     * @return array<int, string>
28
+     */
29
+    public function via(object $notifiable): array
30
+    {
31
+        return ['mail'];
32
+    }
33
+
34
+    public function toMail(object $notifiable): MailMessage
35
+    {
36
+        $this->segnalazione->loadMissing(['azienda']);
37
+        $this->annotazione->loadMissing(['user', 'allegatiAnnotazione']);
38
+        $annotazioneHtml = MailAnnotazioneHtml::fromAnnotazione($this->annotazione);
39
+        $rapporto = app(SegnalazioneRapportoService::class);
40
+
41
+        $mail = (new MailMessage)
42
+            ->subject($this->segnalazione->mailSubject(true).' — Rapportino firmato')
43
+            ->markdown('mail.rapportino-segnalazione', [
44
+                'segnalazione' => $this->segnalazione,
45
+                'annotazione' => $this->annotazione,
46
+                'annotazioneHtml' => $annotazioneHtml->html,
47
+                'firmato' => true,
48
+            ]);
49
+
50
+        try {
51
+            $mail->attachData(
52
+                $rapporto->generateRapportinoBinary($this->segnalazione, $this->annotazione),
53
+                $rapporto->rapportinoFilename($this->segnalazione, true),
54
+                ['mime' => 'application/pdf']
55
+            );
56
+        } catch (\Throwable $e) {
57
+            report($e);
58
+        }
59
+
60
+        return $annotazioneHtml->bind($this->segnalazione->applyEmailDestinatari($mail, [
61
+            strtolower((string) ($notifiable->email ?? '')),
62
+        ]));
63
+    }
64
+}

+ 64
- 0
app/Notifications/RapportinoSegnalazione.php View File

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Segnalazione;
7
+use App\Services\SegnalazioneRapportoService;
8
+use App\Support\MailAnnotazioneHtml;
9
+use Illuminate\Bus\Queueable;
10
+use Illuminate\Contracts\Queue\ShouldQueue;
11
+use Illuminate\Notifications\Messages\MailMessage;
12
+use Illuminate\Notifications\Notification;
13
+use Illuminate\Queue\InteractsWithQueue;
14
+use Illuminate\Queue\SerializesModels;
15
+
16
+class RapportinoSegnalazione extends Notification implements ShouldQueue
17
+{
18
+    use Queueable, InteractsWithQueue, SerializesModels;
19
+
20
+    public function __construct(
21
+        protected Segnalazione $segnalazione,
22
+        protected Annotazione $annotazione,
23
+    ) {
24
+    }
25
+
26
+    /**
27
+     * @return array<int, string>
28
+     */
29
+    public function via(object $notifiable): array
30
+    {
31
+        return ['mail'];
32
+    }
33
+
34
+    public function toMail(object $notifiable): MailMessage
35
+    {
36
+        $this->segnalazione->loadMissing(['azienda']);
37
+        $this->annotazione->loadMissing(['user', 'allegatiAnnotazione']);
38
+        $annotazioneHtml = MailAnnotazioneHtml::fromAnnotazione($this->annotazione);
39
+        $rapporto = app(SegnalazioneRapportoService::class);
40
+
41
+        $mail = (new MailMessage)
42
+            ->subject($this->segnalazione->mailSubject(true).' — Rapportino d\'intervento')
43
+            ->markdown('mail.rapportino-segnalazione', [
44
+                'segnalazione' => $this->segnalazione,
45
+                'annotazione' => $this->annotazione,
46
+                'annotazioneHtml' => $annotazioneHtml->html,
47
+                'firmato' => false,
48
+            ]);
49
+
50
+        try {
51
+            $mail->attachData(
52
+                $rapporto->generateRapportinoBinary($this->segnalazione, $this->annotazione),
53
+                $rapporto->rapportinoFilename($this->segnalazione, false),
54
+                ['mime' => 'application/pdf']
55
+            );
56
+        } catch (\Throwable $e) {
57
+            report($e);
58
+        }
59
+
60
+        return $annotazioneHtml->bind($this->segnalazione->applyEmailDestinatari($mail, [
61
+            strtolower((string) ($notifiable->email ?? '')),
62
+        ]));
63
+    }
64
+}

+ 160
- 0
app/Services/SegnalazioneAggiornaService.php View File

@@ -0,0 +1,160 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\AllegatoAnnotazione;
6
+use App\Models\Annotazione;
7
+use App\Models\Segnalazione;
8
+use App\Models\User;
9
+use App\Models\Config;
10
+use Illuminate\Http\Request;
11
+use Illuminate\Support\Facades\Auth;
12
+use Illuminate\Support\Facades\File;
13
+use Illuminate\Validation\ValidationException;
14
+
15
+class SegnalazioneAggiornaService
16
+{
17
+    public function apply(Request $request, Segnalazione $segnalazione): Annotazione
18
+    {
19
+        $assegnataAId = $request->filled('assegnata_a_id') ? (int) $request->assegnata_a_id : null;
20
+
21
+        if ($assegnataAId === null && ! $segnalazione->assegnata_a_id) {
22
+            $assegnataAId = (int) ($request->annotazione_user_id ?: Auth::id());
23
+        }
24
+
25
+        abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
26
+        abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $assegnataAId, $segnalazione->user_id), 403);
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
+        }
35
+
36
+        $blockClose = $segnalazione->closingBlockedMessage($request->stato);
37
+        if ($blockClose) {
38
+            throw ValidationException::withMessages(['stato' => $blockClose]);
39
+        }
40
+
41
+        $parsedSlots = $this->parseSlots($request);
42
+
43
+        $segnalazione->titolo = $request->titolo;
44
+        $segnalazione->azienda_id = $request->filled('azienda_id') ? $request->azienda_id : null;
45
+        $segnalazione->user_id = $request->user_id;
46
+        $segnalazione->assegnata_a_id = $assegnataAId;
47
+        $segnalazione->ordine_lavoro_id = $request->filled('ordine_lavoro_id') ? $request->ordine_lavoro_id : null;
48
+        $segnalazione->contratto_servizio_id = $request->filled('contratto_servizio_id') ? $request->contratto_servizio_id : null;
49
+        $segnalazione->stato = $request->stato;
50
+        $segnalazione->priorita = $request->priorita;
51
+        $segnalazione->tipo = $request->tipo;
52
+        $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
53
+            ? round((float) $request->input('tempo_stimato'), 2)
54
+            : null;
55
+        $this->applyCcBccFromRequest($segnalazione, $request);
56
+        $segnalazione->save();
57
+
58
+        $segnalazione->slots()->delete();
59
+        if ($request->stato === 'pianificata') {
60
+            foreach ($parsedSlots as $slotData) {
61
+                $segnalazione->slots()->create($slotData);
62
+            }
63
+        }
64
+
65
+        $annotazione = new Annotazione();
66
+        $annotazione->text = $request->text;
67
+        $annotazione->segnalazione_id = $segnalazione->id;
68
+        $annotazione->user_id = $request->annotazione_user_id;
69
+        $annotazione->riservata = $request->boolean('annotazione_riservata');
70
+        $annotazione->save();
71
+
72
+        if ($tempoMinuti !== null) {
73
+            $annotazione->tempo_minuti = $tempoMinuti;
74
+            $annotazione->tempo_data = $request->tempo_data;
75
+        }
76
+
77
+        if ($request->costo !== null) {
78
+            $annotazione->costo = $request->costo;
79
+            $annotazione->costo_descrizione = $request->costo_descrizione;
80
+        }
81
+
82
+        $annotazione->non_fatturare = $request->boolean('non_fatturare');
83
+        $annotazione->costo_uscita = $request->boolean('costo_uscita');
84
+        $annotazione->save();
85
+
86
+        if ($request->hasFile('allegato')) {
87
+            $directory = storage_path('app/public/allegatiAnnotazioni/'.$segnalazione->id);
88
+            if (! File::exists($directory)) {
89
+                File::makeDirectory($directory, 0755, true);
90
+            }
91
+
92
+            foreach ($request->file('allegato') as $file) {
93
+                if (! $file) {
94
+                    continue;
95
+                }
96
+                $filename = time().'_'.$file->getClientOriginalName();
97
+                $path = $file->storeAs($segnalazione->id, $filename, 'allegatiAnnotazioni');
98
+
99
+                $allegato = new AllegatoAnnotazione();
100
+                $allegato->annotazione()->associate($annotazione);
101
+                $allegato->nome = $file->getClientOriginalName();
102
+                $allegato->path_file = $path;
103
+                $allegato->save();
104
+            }
105
+        }
106
+
107
+        return $annotazione->fresh(['user', 'allegatiAnnotazione']);
108
+    }
109
+
110
+    /**
111
+     * @return list<array{starts_at: \Carbon\Carbon, ends_at: \Carbon\Carbon}>
112
+     */
113
+    private function parseSlots(Request $request): array
114
+    {
115
+        $parsedSlots = [];
116
+        if ($request->stato !== 'pianificata') {
117
+            return $parsedSlots;
118
+        }
119
+
120
+        foreach ($request->input('slots', []) as $slotInput) {
121
+            $data = trim((string) ($slotInput['data'] ?? ''));
122
+            $oraInizio = trim((string) ($slotInput['ora_inizio'] ?? ''));
123
+            $oraFine = trim((string) ($slotInput['ora_fine'] ?? ''));
124
+            if ($data === '' && $oraInizio === '' && $oraFine === '') {
125
+                continue;
126
+            }
127
+            if ($data === '' || $oraInizio === '' || $oraFine === '') {
128
+                throw ValidationException::withMessages(['slots' => 'Completa data, ora inizio e ora fine per ogni slot di prenotazione.']);
129
+            }
130
+            try {
131
+                $startsAt = \Carbon\Carbon::createFromFormat('d/m/Y H:i', $data.' '.$oraInizio);
132
+                $endsAt = \Carbon\Carbon::createFromFormat('d/m/Y H:i', $data.' '.$oraFine);
133
+            } catch (\Throwable $e) {
134
+                throw ValidationException::withMessages(['slots' => 'Formato data/ora slot non valido.']);
135
+            }
136
+            if ($endsAt->lte($startsAt)) {
137
+                throw ValidationException::withMessages(['slots' => 'L\'ora di fine deve essere successiva all\'ora di inizio.']);
138
+            }
139
+            $parsedSlots[] = [
140
+                'starts_at' => $startsAt,
141
+                'ends_at' => $endsAt,
142
+            ];
143
+        }
144
+
145
+        return $parsedSlots;
146
+    }
147
+
148
+    private function applyCcBccFromRequest(Segnalazione $segnalazione, Request $request): void
149
+    {
150
+        $exclude = [];
151
+        $userId = $request->filled('user_id') ? (int) $request->user_id : (int) $segnalazione->user_id;
152
+        if ($userId > 0) {
153
+            $email = User::query()->where('id', $userId)->value('email');
154
+            if ($email) {
155
+                $exclude[] = (string) $email;
156
+            }
157
+        }
158
+        $segnalazione->applyDestinatariFromRequest($request->input('cc'), $request->input('bcc'), $exclude);
159
+    }
160
+}

+ 122
- 0
app/Services/SegnalazioneRapportoService.php View File

@@ -0,0 +1,122 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\AllegatoAnnotazione;
6
+use App\Models\Annotazione;
7
+use App\Models\Segnalazione;
8
+use App\Models\User;
9
+use App\Notifications\RapportinoFirmaLink;
10
+use App\Notifications\RapportinoFirmato;
11
+use App\Notifications\RapportinoSegnalazione;
12
+use Illuminate\Support\Facades\File;
13
+use Illuminate\Support\Facades\Notification;
14
+use Illuminate\Support\Facades\Storage;
15
+use Illuminate\Support\Str;
16
+
17
+class SegnalazioneRapportoService
18
+{
19
+    public const FIRMA_CID = 'firma-cliente.png';
20
+
21
+    public function __construct(
22
+        private SegnalazioneTicketPdfService $pdfService,
23
+        private SmsService $sms,
24
+    ) {}
25
+
26
+    public function sendRapportinoEmail(Segnalazione $segnalazione, Annotazione $annotazione, bool $firmato = false): bool
27
+    {
28
+        $destinatario = $this->destinatarioUtente($segnalazione);
29
+        if (! $destinatario) {
30
+            return false;
31
+        }
32
+
33
+        $notification = $firmato
34
+            ? new RapportinoFirmato($segnalazione, $annotazione)
35
+            : new RapportinoSegnalazione($segnalazione, $annotazione);
36
+
37
+        Notification::send(collect([$destinatario]), $notification);
38
+
39
+        return true;
40
+    }
41
+
42
+    public function sendFirmaLinkEmail(Segnalazione $segnalazione, Annotazione $annotazione): bool
43
+    {
44
+        $destinatario = $this->destinatarioUtente($segnalazione);
45
+        if (! $destinatario) {
46
+            return false;
47
+        }
48
+
49
+        $annotazione->ensureFirmaShortCode();
50
+        Notification::send(collect([$destinatario]), new RapportinoFirmaLink($segnalazione, $annotazione->fresh()));
51
+
52
+        return true;
53
+    }
54
+
55
+    /**
56
+     * @return array{ok: bool, error?: string}
57
+     */
58
+    public function sendFirmaLinkSms(Annotazione $annotazione): array
59
+    {
60
+        return $this->sms->sendRapportoFirmaLink($annotazione);
61
+    }
62
+
63
+    public function appendFirmaCliente(Annotazione $annotazione, string $pngBinary): void
64
+    {
65
+        if ($annotazione->has_firma_cliente) {
66
+            return;
67
+        }
68
+
69
+        $segnalazioneId = (int) $annotazione->segnalazione_id;
70
+        $directory = storage_path('app/public/allegatiAnnotazioni/'.$segnalazioneId);
71
+        if (! File::exists($directory)) {
72
+            File::makeDirectory($directory, 0755, true);
73
+        }
74
+
75
+        $filename = time().'_firma-cliente.png';
76
+        $relativePath = $segnalazioneId.'/'.$filename;
77
+        Storage::disk('allegatiAnnotazioni')->put($relativePath, $pngBinary);
78
+
79
+        $allegato = new AllegatoAnnotazione();
80
+        $allegato->annotazione()->associate($annotazione);
81
+        $allegato->nome = 'firma-cliente.png';
82
+        $allegato->path_file = $relativePath;
83
+        $allegato->descrizione = 'cid:'.self::FIRMA_CID;
84
+        $allegato->save();
85
+
86
+        $htmlFirma = '<p><strong>Firma cliente</strong></p>'
87
+            .'<p><img src="cid:'.self::FIRMA_CID.'" alt="Firma cliente" style="max-width:280px;max-height:90px;"></p>';
88
+
89
+        $text = trim((string) $annotazione->text);
90
+        if ($text !== '' && ! str_ends_with($text, '</p>') && ! str_ends_with($text, '>')) {
91
+            $text .= '<br>';
92
+        }
93
+        $annotazione->text = $text.$htmlFirma;
94
+        $annotazione->firma_at = now();
95
+        $annotazione->save();
96
+    }
97
+
98
+    public function destinatarioUtente(Segnalazione $segnalazione): ?User
99
+    {
100
+        if (! $segnalazione->user_id) {
101
+            return null;
102
+        }
103
+
104
+        return User::soloUtenti()
105
+            ->where('id', $segnalazione->user_id)
106
+            ->whereNotNull('email')
107
+            ->where('email', '!=', '')
108
+            ->first();
109
+    }
110
+
111
+    public function generateRapportinoBinary(Segnalazione $segnalazione, Annotazione $annotazione): string
112
+    {
113
+        return $this->pdfService->generateRapportinoBinary($segnalazione, $annotazione);
114
+    }
115
+
116
+    public function rapportinoFilename(Segnalazione $segnalazione, bool $firmato = false): string
117
+    {
118
+        $suffix = $firmato ? '_firmato' : '';
119
+
120
+        return 'rapportino_'.$segnalazione->id.$suffix.'_'.Str::slug($segnalazione->titolo).'.pdf';
121
+    }
122
+}

+ 36
- 0
app/Services/SegnalazioneTicketPdfService.php View File

@@ -120,4 +120,40 @@ class SegnalazioneTicketPdfService
120 120
 
121 121
         return $pdf->output();
122 122
     }
123
+
124
+    public function rapportinoFilename(Segnalazione $segnalazione, bool $firmato = false): string
125
+    {
126
+        $suffix = $firmato ? '_firmato' : '';
127
+
128
+        return 'rapportino_'.$segnalazione->id.$suffix.'_'.Str::slug($segnalazione->titolo).'.pdf';
129
+    }
130
+
131
+    public function generateRapportinoBinary(Segnalazione $segnalazione, $annotazione): string
132
+    {
133
+        $segnalazione = $this->loadSegnalazioneForPdf($segnalazione);
134
+        $annotazione->loadMissing(['user', 'allegatiAnnotazione']);
135
+
136
+        $configStato = $segnalazione->getConfiguraStato();
137
+        $configPriorita = $segnalazione->getConfiguraPriorita();
138
+
139
+        $pdf = Pdf::loadView('segnalazione.pdf-ticket', [
140
+            'segnalazione' => $segnalazione,
141
+            'configStato' => $configStato,
142
+            'configPriorita' => $configPriorita,
143
+            'primaAnnotazione' => $annotazione,
144
+            'annotazioniPdf' => collect([$annotazione]),
145
+            'todosPdf' => collect(),
146
+            'destinatario' => self::DEST_CLIENTE,
147
+            'isClientePdf' => true,
148
+            'isRapportino' => true,
149
+            'rapportinoAnnotazione' => $annotazione,
150
+            'acquistoServizio' => $this->linkedAcquistoServizio($segnalazione),
151
+        ]);
152
+        $pdf->setPaper('a4', 'portrait');
153
+        $pdf->setOption('defaultFont', 'DejaVu Sans');
154
+        $pdf->setOption('isHtml5ParserEnabled', true);
155
+        $pdf->setOption('isPhpEnabled', true);
156
+
157
+        return $pdf->output();
158
+    }
123 159
 }

+ 69
- 0
app/Services/SmsService.php View File

@@ -205,4 +205,73 @@ class SmsService
205 205
             return ['ok' => false, 'error' => $friendly, 'provider' => $provider];
206 206
         }
207 207
     }
208
+
209
+    /**
210
+     * Invia il link di firma del rapportino via SMS.
211
+     *
212
+     * @return array{ok: bool, error?: string, message_id?: string|null, provider?: string}
213
+     */
214
+    public function sendRapportoFirmaLink(\App\Models\Annotazione $annotazione): array
215
+    {
216
+        $provider = $this->provider();
217
+
218
+        if (! in_array($provider, ['aimon', 'skebby'], true)) {
219
+            return ['ok' => false, 'error' => 'SMS_PROVIDER non valido (usa aimon o skebby).'];
220
+        }
221
+
222
+        if (! $this->isConfigured()) {
223
+            $hint = $provider === 'skebby'
224
+                ? 'SKEBBY_USERNAME/SKEBBY_PASSWORD oppure SKEBBY_USER_KEY/SKEBBY_ACCESS_TOKEN'
225
+                : 'AIMON_LOGIN / AIMON_PASSWORD / AIMON_ID_API';
226
+
227
+            return ['ok' => false, 'error' => "SMS {$provider} non configurato ({$hint})."];
228
+        }
229
+
230
+        $to = $annotazione->firmaSmsPhoneDigits();
231
+        if (! $to) {
232
+            return ['ok' => false, 'error' => 'Numero di telefono cliente mancante o non valido.'];
233
+        }
234
+
235
+        $annotazione->ensureFirmaShortCode();
236
+        $annotazione->refresh();
237
+
238
+        if (! $annotazione->firma_url) {
239
+            return ['ok' => false, 'error' => 'Link firma non disponibile.'];
240
+        }
241
+
242
+        $body = $annotazione->smsFirmaRapportoMessage();
243
+
244
+        \Illuminate\Support\Facades\Log::info('SMS firma rapportino: messaggio da inviare', [
245
+            'annotazione_id' => $annotazione->id,
246
+            'segnalazione_id' => $annotazione->segnalazione_id,
247
+            'provider' => $provider,
248
+            'to' => $to,
249
+            'message' => $body,
250
+            'firma_url' => $annotazione->firma_url,
251
+            'firma_short_url' => $annotazione->firma_short_url,
252
+        ]);
253
+
254
+        try {
255
+            $messageId = match ($provider) {
256
+                'skebby' => $this->skebby->sendSms($to, $body),
257
+                default => $this->aimon->sendSms($to, $body),
258
+            };
259
+
260
+            return ['ok' => true, 'message_id' => $messageId, 'provider' => $provider];
261
+        } catch (\Throwable $e) {
262
+            $friendly = $e->getMessage();
263
+            if (str_contains($friendly, 'Resolving timed out') || str_contains($friendly, 'cURL error 28')) {
264
+                $friendly = 'Timeout di rete verso il provider SMS (DNS). Verifica connettività del server verso api.skebby.it / secure.apisms.it.';
265
+            }
266
+
267
+            \Illuminate\Support\Facades\Log::error('SMS: invio firma rapportino fallito', [
268
+                'annotazione_id' => $annotazione->id,
269
+                'provider' => $provider,
270
+                'to' => $to,
271
+                'error' => $e->getMessage(),
272
+            ]);
273
+
274
+            return ['ok' => false, 'error' => $friendly, 'provider' => $provider];
275
+        }
276
+    }
208 277
 }

+ 37
- 0
app/Support/ShortUrlCode.php View File

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+use App\Models\AcquistoServizio;
6
+use App\Models\Annotazione;
7
+use Illuminate\Support\Str;
8
+
9
+class ShortUrlCode
10
+{
11
+    public static function exists(string $code): bool
12
+    {
13
+        if ($code === '') {
14
+            return true;
15
+        }
16
+
17
+        if (Annotazione::query()->where('firma_short', $code)->exists()) {
18
+            return true;
19
+        }
20
+
21
+        return AcquistoServizio::query()
22
+            ->where(function ($q) use ($code) {
23
+                $q->where('meta->firma_consegna_short', $code)
24
+                    ->orWhere('meta->firma_riconsegna_short', $code);
25
+            })
26
+            ->exists();
27
+    }
28
+
29
+    public static function generate(int $length = 8): string
30
+    {
31
+        do {
32
+            $code = Str::lower(Str::random($length));
33
+        } while (self::exists($code));
34
+
35
+        return $code;
36
+    }
37
+}

+ 48
- 0
database/migrations/2026_09_22_170437_skipper_migrations_2026092217043796.php View File

@@ -0,0 +1,48 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 2fbb45ea-7783-48cc-af41-21bf4e306a11
5
+ * Migration datetime: 2026-09-22 17:04:37.965924
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026092217043796 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::table('annotazione', function (Blueprint $table) {
22
+            $table->string('firma_token')->nullable(true)->after('telefono');
23
+        });
24
+        Schema::table('annotazione', function (Blueprint $table) {
25
+            $table->string('firma_short')->nullable(true)->after('firma_token');
26
+        });
27
+        Schema::table('annotazione', function (Blueprint $table) {
28
+            $table->timestamp('firma_at')->nullable(true)->after('firma_short');
29
+        });
30
+    }
31
+    /**
32
+     * Reverse the migrations.
33
+     *
34
+     * @return void
35
+     */
36
+    public function down()
37
+    {
38
+        Schema::table('annotazione', function (Blueprint $table) {
39
+            $table->dropColumn('firma_at');
40
+        });
41
+        Schema::table('annotazione', function (Blueprint $table) {
42
+            $table->dropColumn('firma_short');
43
+        });
44
+        Schema::table('annotazione', function (Blueprint $table) {
45
+            $table->dropColumn('firma_token');
46
+        });
47
+    }
48
+}

+ 75
- 0
resources/views/dashboard/dashboards-manutenzioni.blade.php View File

@@ -193,6 +193,81 @@ $configData = Helper::appClasses();
193 193
   </div>
194 194
 </div>
195 195
 
196
+<div class="card mb-4">
197
+  <div class="card-header border-bottom">
198
+    <div class="d-flex align-items-center">
199
+      <div class="avatar avatar-sm me-2">
200
+        <span class="avatar-initial rounded bg-label-info">
201
+          <i class="bx bx-time-five fs-4"></i>
202
+        </span>
203
+      </div>
204
+      <div>
205
+        <h5 class="mb-0">Tempo lavorato</h5>
206
+        <p class="text-body-secondary mb-0">Riferimento 8 ore × 5 giorni (lun–ven). Sabato e domenica restano nel totale se registrati.</p>
207
+      </div>
208
+    </div>
209
+  </div>
210
+  <div class="card-body">
211
+    <div class="row mb-0">
212
+      @foreach($tempoLavorato as $periodo)
213
+      @php
214
+        $overTarget = $periodo['has_target'] && $periodo['percentuale_atteso'] >= 100;
215
+        $underTarget = $periodo['has_target'] && $periodo['percentuale_atteso'] < 80;
216
+        $barColor = ! $periodo['has_target'] ? 'secondary' : ($overTarget ? 'success' : ($underTarget ? 'warning' : 'info'));
217
+        $delta = $periodo['vs_precedente_minuti'];
218
+        $deltaClass = $delta > 0 ? 'success' : ($delta < 0 ? 'warning' : 'muted');
219
+        $deltaIcon = $delta > 0 ? 'bx-trending-up' : ($delta < 0 ? 'bx-trending-down' : 'bx-minus');
220
+      @endphp
221
+      <div class="col-lg-3 col-md-6 col-12 mb-4 mb-lg-0">
222
+        <div class="card h-100">
223
+          <div class="card-body">
224
+            <div class="d-flex justify-content-between align-items-start mb-2">
225
+              <div>
226
+                <span class="badge bg-label-{{ $periodo['color'] }} mb-2">
227
+                  <i class="bx {{ $periodo['icon'] }} me-1"></i>{{ $periodo['label'] }}
228
+                </span>
229
+                <h4 class="card-title mb-0">{{ $periodo['minuti_label'] }}</h4>
230
+                <p class="text-body-secondary mb-0">{{ $periodo['subtitle'] }}</p>
231
+              </div>
232
+              <div class="avatar flex-shrink-0">
233
+                <span class="avatar-initial rounded bg-label-{{ $periodo['color'] }}">
234
+                  <i class="bx {{ $periodo['icon'] }} fs-4"></i>
235
+                </span>
236
+              </div>
237
+            </div>
238
+            <div class="mt-3">
239
+              <div class="d-flex justify-content-between small mb-1">
240
+                @if($periodo['has_target'])
241
+                <span class="text-muted">Rif. {{ $periodo['atteso_label'] }} · {{ $periodo['giorni_lavorativi'] }} gg lav.</span>
242
+                <span class="fw-semibold text-{{ $barColor }}">{{ $periodo['percentuale_atteso'] }}%</span>
243
+                @else
244
+                <span class="text-muted">Nessun target (weekend)</span>
245
+                <span class="fw-semibold text-muted">—</span>
246
+                @endif
247
+              </div>
248
+              <div class="progress" style="height: 6px;">
249
+                <div class="progress-bar bg-{{ $barColor }}" role="progressbar"
250
+                  style="width: {{ $periodo['has_target'] ? $periodo['progress_width'] : 0 }}%;"
251
+                  aria-valuenow="{{ $periodo['percentuale_atteso'] }}" aria-valuemin="0" aria-valuemax="100"></div>
252
+              </div>
253
+            </div>
254
+            <div class="small mt-3 text-{{ $deltaClass }}">
255
+              <i class="bx {{ $deltaIcon }} me-1"></i>
256
+              {{ $periodo['vs_precedente_label'] }}
257
+              @if($periodo['vs_precedente_minuti'] != 0)
258
+                ({{ $periodo['vs_precedente_percentuale'] > 0 ? '+' : '' }}{{ rtrim(rtrim(number_format($periodo['vs_precedente_percentuale'], 1, ',', ''), '0'), ',') }}%)
259
+              @endif
260
+              vs {{ $periodo['precedente_label'] }}
261
+              <span class="text-muted">({{ $periodo['precedente_minuti_label'] }})</span>
262
+            </div>
263
+          </div>
264
+        </div>
265
+      </div>
266
+      @endforeach
267
+    </div>
268
+  </div>
269
+</div>
270
+
196 271
 <div class="card mb-4">
197 272
   <div class="card-header border-bottom">
198 273
     <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">

+ 22
- 0
resources/views/mail/rapportino-firma-link.blade.php View File

@@ -0,0 +1,22 @@
1
+@component('mail::message')
2
+# Firma il rapporto d'intervento
3
+
4
+ti chiediamo di leggere e firmare il rapportino relativo alla segnalazione **{{ $segnalazione->getLabel() }}**.
5
+
6
+Apri il link (funziona anche da telefono, senza accesso): poi potrai leggere il PDF e apporre la firma.
7
+
8
+@component('mail::panel')
9
+**Segnalazione:** {{ $segnalazione->getNumeroLabel() }}  
10
+**Titolo:** {{ $segnalazione->titolo }}
11
+@endcomponent
12
+
13
+@component('mail::button', ['url' => $firmaUrl])
14
+Apri e firma
15
+@endcomponent
16
+
17
+Se il pulsante non funziona, copia questo indirizzo nel browser:  
18
+{{ $firmaUrl }}
19
+
20
+Grazie,  
21
+**ElephanTech**
22
+@endcomponent

+ 30
- 0
resources/views/mail/rapportino-segnalazione.blade.php View File

@@ -0,0 +1,30 @@
1
+@component('mail::message')
2
+@if($firmato ?? false)
3
+# Rapportino d'intervento firmato
4
+
5
+in allegato trovi il **rapportino** dell'intervento relativo alla segnalazione **{{ $segnalazione->getLabel() }}**, con la firma del cliente.
6
+@else
7
+# Rapportino d'intervento
8
+
9
+in allegato trovi il **rapportino** dell'intervento relativo alla segnalazione **{{ $segnalazione->getLabel() }}**.
10
+@endif
11
+
12
+@component('mail::panel')
13
+**Segnalazione:** {{ $segnalazione->getNumeroLabel() }}  
14
+**Titolo:** {{ $segnalazione->titolo }}  
15
+@if($segnalazione->azienda)
16
+**Azienda:** {{ $segnalazione->azienda->full_name }}
17
+@endif
18
+@endcomponent
19
+
20
+@if(\App\Support\MailAnnotazioneHtml::hasVisibleContent($annotazioneHtml ?? ''))
21
+<div class="annotazione-mail">{!! $annotazioneHtml !!}</div>
22
+@endif
23
+
24
+@component('mail::button', ['url' => $segnalazione->urlCliente()])
25
+Apri segnalazione
26
+@endcomponent
27
+
28
+Grazie,  
29
+**ElephanTech**
30
+@endcomponent

+ 275
- 0
resources/views/segnalazione/admin/_form_modifica_fields.blade.php View File

@@ -0,0 +1,275 @@
1
+          <?php
2
+          use Illuminate\Support\Facades\Auth;
3
+          use App\Models\Config;
4
+          ?>
5
+          {{-- 1. Anagrafica --}}
6
+          <div class="modifica-section mb-4">
7
+            <div class="modifica-section-title">Anagrafica</div>
8
+            <div class="row g-3">
9
+              <div class="col-12">
10
+                <label for="titolo_segnalazione" class="form-label">Titolo <span class="text-danger">*</span></label>
11
+                <input type="text" class="form-control" id="titolo_segnalazione" name="titolo" value="{{ $segnalazione->titolo }}" required>
12
+              </div>
13
+              <div class="col-md-4">
14
+                <label for="azienda_id" class="form-label">Azienda</label>
15
+                <select class="form-select" id="azienda_id" name="azienda_id">
16
+                  <option value="">-- Nessuna azienda --</option>
17
+                  @foreach(\App\Models\Azienda::orderBy('nome')->get() as $azienda)
18
+                  <option value="{{ $azienda->id }}" data-telefono="{{ $azienda->telefono }}" @if($segnalazione->azienda_id == $azienda->id) selected @endif>
19
+                    {{ $azienda->full_name }}
20
+                  </option>
21
+                  @endforeach
22
+                </select>
23
+              </div>
24
+              <div class="col-md-4">
25
+                <label for="user_id" class="form-label">Aperta da <span class="text-danger">*</span></label>
26
+                <select class="form-select" id="user_id" name="user_id" required>
27
+                  <option value="" disabled @selected(! filled($segnalazione->user_id))>-- Seleziona utente --</option>
28
+                  @foreach(App\Models\User::soloUtenti()->orderBy('cognome')->orderBy('nome')->get() as $user)
29
+                  <option value="{{ $user->id }}" data-email="{{ $user->email }}" data-telefono="{{ $user->telefono }}" @selected(filled($segnalazione->user_id) && (int) $segnalazione->user_id === (int) $user->id)>{{ $user->full_name }}</option>
30
+                  @endforeach
31
+                </select>
32
+              </div>
33
+              <div class="col-md-4">
34
+                <label for="assegnata_a_id" class="form-label">Assegnata a</label>
35
+                <select class="form-select" id="assegnata_a_id" name="assegnata_a_id">
36
+                  <option value="">-- Nessuna Assegnazione --</option>
37
+                  @foreach($assegnatariSegnalazione as $user)
38
+                  <option value="{{ $user->id }}" @selected((int) ($segnalazione->assegnata_a_id ?: Auth::id()) === (int) $user->id)>{{ $user->full_name }}</option>
39
+                  @endforeach
40
+                </select>
41
+              </div>
42
+            </div>
43
+          </div>
44
+
45
+          {{-- 2. Classificazione + collegamenti --}}
46
+          <div class="row g-3 mb-4">
47
+            <div class="col-lg-7">
48
+              <div class="modifica-section h-100">
49
+                <div class="modifica-section-title">Classificazione</div>
50
+                <div class="row g-3">
51
+                  <div class="col-sm-6 col-xl-3">
52
+                    <label for="segnalazione_stato" class="form-label">Stato</label>
53
+                    <select class="form-select" id="segnalazione_stato" name="stato">
54
+                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'stato')->orderBy('order')->get() as $statoOption)
55
+                      <option value="{{ $statoOption->value }}" @if($segnalazione->stato == $statoOption->value) selected @endif>
56
+                        {{ $statoOption->label }}
57
+                      </option>
58
+                      @endforeach
59
+                    </select>
60
+                  </div>
61
+                  <div class="col-sm-6 col-xl-3">
62
+                    <label for="segnalazione_priorita" class="form-label">Priorità</label>
63
+                    <select class="form-select" id="segnalazione_priorita" name="priorita">
64
+                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'Priorità')->orderBy('order')->get() as $prioritaOption)
65
+                      <option value="{{ $prioritaOption->value }}" @if($segnalazione->priorita == $prioritaOption->value) selected @endif>
66
+                        {{ $prioritaOption->label }}
67
+                      </option>
68
+                      @endforeach
69
+                    </select>
70
+                  </div>
71
+                  <div class="col-sm-6 col-xl-3">
72
+                    <label for="segnalazione_tipo" class="form-label">Tipo</label>
73
+                    <select class="form-select" id="segnalazione_tipo" name="tipo">
74
+                      <option value="">-- Nessuno --</option>
75
+                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'Tipo')->orderBy('order')->get() as $tipoOption)
76
+                      <option value="{{ $tipoOption->value }}" @if($segnalazione->tipo == $tipoOption->value) selected @endif>
77
+                        {{ $tipoOption->label }}
78
+                      </option>
79
+                      @endforeach
80
+                    </select>
81
+                  </div>
82
+                  <div class="col-sm-6 col-xl-3">
83
+                    <div id="categoria_sicurezza_container" style="{{ empty($categoriaSicurezzaOptions) ? 'display:none;' : '' }}">
84
+                      <label for="segnalazione_categoria_sicurezza" class="form-label">Cat. sicurezza</label>
85
+                      <select class="form-select" id="segnalazione_categoria_sicurezza" name="categoria_sicurezza">
86
+                        @foreach($categoriaSicurezzaOptions as $categoriaSicurezzaOption)
87
+                          @php
88
+                            $optionValue = is_array($categoriaSicurezzaOption)
89
+                              ? ($categoriaSicurezzaOption['value'] ?? $categoriaSicurezzaOption['label'] ?? $categoriaSicurezzaOption['name'] ?? null)
90
+                              : $categoriaSicurezzaOption;
91
+                            $optionLabel = is_array($categoriaSicurezzaOption)
92
+                              ? ($categoriaSicurezzaOption['label'] ?? $categoriaSicurezzaOption['value'] ?? $categoriaSicurezzaOption['name'] ?? $optionValue)
93
+                              : $categoriaSicurezzaOption;
94
+                          @endphp
95
+                          @if($optionValue !== null)
96
+                            <option value="{{ $optionValue }}" @if($categoriaSicurezzaValue == $optionValue) selected @endif>
97
+                              {{ $optionLabel }}
98
+                            </option>
99
+                          @endif
100
+                        @endforeach
101
+                      </select>
102
+                    </div>
103
+                  </div>
104
+                </div>
105
+
106
+                <div id="slotPianificazioneBox" class="mt-3 {{ $segnalazione->stato === 'pianificata' ? '' : 'd-none' }}">
107
+                  <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
108
+                    <div>
109
+                      <div class="fw-semibold">Slot di prenotazione</div>
110
+                      <div class="small text-muted">Es. 26/08/2026 · 12:00 → 15:00</div>
111
+                    </div>
112
+                    <button type="button" class="btn btn-sm btn-label-primary" id="btnAggiungiSlotPrenotazione">
113
+                      <i class="bx bx-plus me-1"></i>Aggiungi slot
114
+                    </button>
115
+                  </div>
116
+                  <div id="slotPrenotazioneList" class="d-flex flex-column gap-2">
117
+                    @forelse($segnalazione->slots as $slotIndex => $slot)
118
+                      <div class="row g-2 align-items-end slot-prenotazione-row" data-slot-row>
119
+                        <div class="col-md-4">
120
+                          <label class="form-label small mb-1">Data</label>
121
+                          <input type="text" class="form-control slot-data" name="slots[{{ $slotIndex }}][data]"
122
+                            value="{{ $slot->starts_at->format('d/m/Y') }}" placeholder="dd/mm/yyyy" autocomplete="off">
123
+                        </div>
124
+                        <div class="col-md-3">
125
+                          <label class="form-label small mb-1">Dalle</label>
126
+                          <input type="text" class="form-control slot-ora-inizio" name="slots[{{ $slotIndex }}][ora_inizio]"
127
+                            value="{{ $slot->starts_at->format('H:i') }}" placeholder="HH:mm" autocomplete="off">
128
+                        </div>
129
+                        <div class="col-md-3">
130
+                          <label class="form-label small mb-1">Alle</label>
131
+                          <input type="text" class="form-control slot-ora-fine" name="slots[{{ $slotIndex }}][ora_fine]"
132
+                            value="{{ $slot->ends_at->format('H:i') }}" placeholder="HH:mm" autocomplete="off">
133
+                        </div>
134
+                        <div class="col-md-2">
135
+                          <button type="button" class="btn btn-label-danger w-100 btn-rimuovi-slot" title="Rimuovi">
136
+                            <i class="bx bx-trash"></i>
137
+                          </button>
138
+                        </div>
139
+                      </div>
140
+                    @empty
141
+                    @endforelse
142
+                  </div>
143
+                  <div class="form-text mt-2">
144
+                    Totale slot: <span id="slotPrenotazioneTotaleOre">0</span> ore
145
+                  </div>
146
+                </div>
147
+              </div>
148
+            </div>
149
+            <div class="col-lg-5">
150
+              <div class="modifica-section h-100">
151
+                <div class="modifica-section-title">Collegamento <span class="fw-normal text-muted">(ordine o contratto)</span></div>
152
+                <div class="row g-3" id="rowOrdineContrattoModifica">
153
+                  <div class="col-12" id="wrapOrdineLavoroModifica">
154
+                    <label for="ordine_lavoro_id" class="form-label">Ordine di lavoro</label>
155
+                    <select class="form-select" id="ordine_lavoro_id" name="ordine_lavoro_id">
156
+                      <option value="">-- Nessuno --</option>
157
+                      @foreach(\App\Models\OrdineLavoro::when($segnalazione->azienda_id, fn($q) => $q->where('user_id', $segnalazione->azienda_id))->orderByDesc('numero')->limit(100)->get() as $odl)
158
+                      <option value="{{ $odl->id }}" @selected((int)$segnalazione->ordine_lavoro_id === (int)$odl->id)>{{ $odl->label }}</option>
159
+                      @endforeach
160
+                    </select>
161
+                  </div>
162
+                  <div class="col-12" id="wrapContrattoServizioModifica">
163
+                    <label for="contratto_servizio_id" class="form-label">Contratto (scala ore)</label>
164
+                    <select class="form-select" id="contratto_servizio_id" name="contratto_servizio_id">
165
+                      <option value="">-- Nessuno (a fatturare) --</option>
166
+                      @foreach($contrattiCliente as $cs)
167
+                      <option value="{{ $cs->id }}" @selected((int)$segnalazione->contratto_servizio_id === (int)$cs->id)>{{ $cs->label }}</option>
168
+                      @endforeach
169
+                    </select>
170
+                  </div>
171
+                </div>
172
+              </div>
173
+            </div>
174
+          </div>
175
+
176
+          {{-- 3. Annotazione --}}
177
+          <div class="modifica-section mb-4">
178
+            <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
179
+              <div class="modifica-section-title mb-0">Annotazione</div>
180
+              <div class="d-flex flex-wrap align-items-center gap-3">
181
+                <div>
182
+                  <label for="annotazione_user_id" class="form-label mb-1 small text-muted">Autore</label>
183
+                  <select class="form-select form-select-sm" id="annotazione_user_id" name="annotazione_user_id" required style="min-width: 180px;">
184
+                    @foreach(App\Models\User::soloUtenti()->orderBy('cognome')->orderBy('nome')->get() as $user)
185
+                    <option value="{{ $user->id }}" @selected((int) Auth::id() === (int) $user->id)>{{ $user->full_name }}</option>
186
+                    @endforeach
187
+                  </select>
188
+                </div>
189
+                @if($hideRiservata ?? false)
190
+                <input type="hidden" name="annotazione_riservata" value="0">
191
+                @else
192
+                <div class="form-check form-switch mt-3 mt-sm-4 mb-0">
193
+                  <input type="hidden" name="annotazione_riservata" value="0">
194
+                  <input class="form-check-input" type="checkbox" name="annotazione_riservata" id="annotazione_riservata" value="1">
195
+                  <label class="form-check-label" for="annotazione_riservata">Riservata / interna</label>
196
+                </div>
197
+                @endif
198
+              </div>
199
+            </div>
200
+            <input type="hidden" id="text" name="text" value="{{ old('text', '') }}">
201
+            <div id="quill_annotazione_toolbar">
202
+              @include('content.form-elements.quill_toolbar')
203
+            </div>
204
+            <div id="quill_annotazione" class="quill-annotazione-editor"></div>
205
+            <div class="form-text mt-1 d-none" id="annotazioneQuoteHint">Citazione stile email già inserita (modificabile). Scrivi la nuova risposta sopra.</div>
206
+            <div class="mt-3">
207
+              @include('segnalazione.admin._cc_bcc_fields', [
208
+                'ccValue' => old('cc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->cc ?? []))),
209
+                'bccValue' => old('bcc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->bcc ?? []))),
210
+              ])
211
+            </div>
212
+          </div>
213
+
214
+          {{-- 4. Tempo / costo / allegati --}}
215
+          <div class="row g-3 mb-2">
216
+            <div class="col-md-4">
217
+              <div class="modifica-section h-100">
218
+                <div class="modifica-section-title"><i class="bx bx-time-five me-1"></i>Tempo</div>
219
+                <div class="mb-3">
220
+                  <label for="tempo_stimato" class="form-label">Tempo stimato (ore)</label>
221
+                  <input type="number" class="form-control" id="tempo_stimato" name="tempo_stimato"
222
+                    step="0.25" min="0"
223
+                    value="{{ old('tempo_stimato', $segnalazione->tempo_stimato !== null ? rtrim(rtrim(number_format((float) $segnalazione->tempo_stimato, 2, '.', ''), '0'), '.') : '') }}"
224
+                    placeholder="Es. 1.5">
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">
229
+                </div>
230
+                <div class="mb-3">
231
+                  <label for="tempo_data" class="form-label">Data e ora</label>
232
+                  <input type="text" class="form-control flatpickr-input" id="tempo_data" name="tempo_data" value="{{ \Carbon\Carbon::now()->format('d/m/Y H:i') }}">
233
+                </div>
234
+                <div class="form-check mb-2">
235
+                  <input type="hidden" name="non_fatturare" value="0">
236
+                  <input class="form-check-input" type="checkbox" name="non_fatturare" id="non_fatturare" value="1" @checked(old('non_fatturare'))>
237
+                  <label class="form-check-label" for="non_fatturare">Non fatturare</label>
238
+                  <div class="form-text">Questo tempo non entra in fattura.</div>
239
+                </div>
240
+                <div class="form-check mb-0">
241
+                  <input type="hidden" name="costo_uscita" value="0">
242
+                  <input class="form-check-input" type="checkbox" name="costo_uscita" id="costo_uscita" value="1" @checked(old('costo_uscita'))>
243
+                  <label class="form-check-label" for="costo_uscita">Costo d'uscita ({{ Config::formatCosto(Config::costoUscitaDefault()) }})</label>
244
+                </div>
245
+              </div>
246
+            </div>
247
+            <div class="col-md-4">
248
+              <div class="modifica-section h-100">
249
+                <div class="modifica-section-title"><i class="bx bx-money me-1"></i>Costo</div>
250
+                <div class="mb-3">
251
+                  <label for="costo_importo_card" class="form-label">Importo (€)</label>
252
+                  <input type="number" class="form-control" id="costo_importo_card" name="costo" step="0.01" min="0">
253
+                </div>
254
+                <div class="mb-0">
255
+                  <label for="descrizione_costo_card" class="form-label">Descrizione</label>
256
+                  <textarea class="form-control" id="descrizione_costo_card" name="costo_descrizione" rows="2"></textarea>
257
+                </div>
258
+              </div>
259
+            </div>
260
+            <div class="col-md-4">
261
+              <div class="modifica-section h-100">
262
+                <div class="modifica-section-title"><i class="bx bx-paperclip me-1"></i>Allegati</div>
263
+                <div id="allegatoDropzoneAdmin" class="dropzone-box dropzone-box-compact border border-dashed rounded p-3 text-center">
264
+                  <div class="dz-message">
265
+                    Trascina o clicca per caricare
266
+                    <span class="note">Max 10MB · PDF, immagini, documenti</span>
267
+                  </div>
268
+                </div>
269
+                <input type="file" class="d-none" id="file_allegato_card" name="allegato[]" multiple
270
+                  accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx">
271
+                <div id="allegatoDropzoneInfoAdmin" class="small text-muted mt-2"></div>
272
+                <div id="allegatoPreviewAdmin" class="row g-2 mt-2"></div>
273
+              </div>
274
+            </div>
275
+          </div>

+ 635
- 0
resources/views/segnalazione/admin/rapporto.blade.php View File

@@ -0,0 +1,635 @@
1
+<?php
2
+
3
+use Illuminate\Support\Facades\Auth;
4
+use App\Models\Config;
5
+?>
6
+
7
+@extends('layouts/layoutMaster')
8
+
9
+@section('title', "Rapporto d'intervento")
10
+
11
+@section('vendor-script')
12
+@vite([
13
+'resources/assets/vendor/libs/quill/katex.js',
14
+'resources/assets/vendor/libs/quill/quill.js',
15
+'resources/assets/vendor/libs/tagify/tagify.js'
16
+])
17
+@endsection
18
+
19
+@section('vendor-style')
20
+@vite([
21
+'resources/assets/vendor/libs/quill/typography.scss',
22
+'resources/assets/vendor/libs/quill/katex.scss',
23
+'resources/assets/vendor/libs/quill/editor.scss',
24
+'resources/assets/vendor/libs/tagify/tagify.scss'
25
+])
26
+@endsection
27
+
28
+@section('pageTitle')
29
+<div class="d-flex flex-column">
30
+  <h4 class="mb-1">Compila rapporto d'intervento</h4>
31
+  <a href="{{ route('admin.segnalazione.show', $segnalazione->id) }}" class="small">← Segnalazione #{{ $segnalazione->id }}</a>
32
+</div>
33
+@endsection
34
+
35
+@section('content')
36
+@php
37
+  $assegnatariSegnalazione = \App\Models\User::assegnatariSegnalazione()->get();
38
+  if ($segnalazione->assegnata_a_id && ! $assegnatariSegnalazione->contains('id', (int) $segnalazione->assegnata_a_id)) {
39
+    $assegnatarioCorrente = \App\Models\User::find($segnalazione->assegnata_a_id);
40
+    if ($assegnatarioCorrente) {
41
+      $assegnatariSegnalazione->push($assegnatarioCorrente);
42
+    }
43
+  }
44
+  $contrattiCliente = $segnalazione->azienda_id
45
+    ? \App\Models\ContrattoServizio::where('user_id', $segnalazione->azienda_id)->attivi()->get()
46
+    : collect();
47
+  $tipoConfigs = \App\Models\ConfiguraSegnalazione::where('gruppo', 'Tipo')->orderBy('order')->get();
48
+  $tipoOptionsMap = $tipoConfigs->mapWithKeys(fn ($item) => [$item->value => ($item->opzioni ?? [])]);
49
+  $tipoIdMap = $tipoConfigs->mapWithKeys(fn ($item) => [$item->value => $item->id]);
50
+  $categoriaSicurezzaValue = null;
51
+  $categoriaSicurezzaMap = is_array($segnalazione->categoria_sicurezza ?? null)
52
+    ? $segnalazione->categoria_sicurezza
53
+    : [];
54
+  $currentTipoId = $tipoIdMap[$segnalazione->tipo] ?? null;
55
+  if ($currentTipoId !== null && array_key_exists($currentTipoId, $categoriaSicurezzaMap)) {
56
+    $categoriaSicurezzaValue = $categoriaSicurezzaMap[$currentTipoId];
57
+  }
58
+  $categoriaSicurezzaOptions = $tipoOptionsMap[$segnalazione->tipo] ?? [];
59
+  $utenteSegnalazione = $segnalazione->user;
60
+  $hasUserEmail = filled($utenteSegnalazione?->email);
61
+  $hasUserPhone = filled($utenteSegnalazione?->telefono) || filled($segnalazione->azienda?->telefono);
62
+@endphp
63
+
64
+<div class="row justify-content-center mt-3">
65
+  <div class="col-lg-12">
66
+    @if(session('success'))
67
+    <div class="alert alert-success alert-dismissible fade show" role="alert">
68
+      <i class="bx bx-check-circle me-2"></i>{{ session('success') }}
69
+      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
70
+    </div>
71
+    @endif
72
+    @if(session('error'))
73
+    <div class="alert alert-danger alert-dismissible fade show" role="alert">
74
+      <i class="bx bx-error-circle me-2"></i>{{ session('error') }}
75
+      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
76
+    </div>
77
+    @endif
78
+    @if($errors->any())
79
+    <div class="alert alert-danger alert-dismissible fade show" role="alert">
80
+      <i class="bx bx-error-circle me-2"></i>
81
+      <ul class="mb-0">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
82
+      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
83
+    </div>
84
+    @endif
85
+
86
+    <div class="card mb-4">
87
+      <div class="card-header">
88
+        <h5 class="card-title mb-0">
89
+          <i class="bx bx-file-blank me-2"></i>Rapporto d'intervento — {{ $segnalazione->getLabel() }}
90
+        </h5>
91
+        <div class="small text-muted mt-1">
92
+          Il rapportino (PDF) contiene l'intestazione della segnalazione e solo questa annotazione, con tempi e costi di questo intervento.
93
+          Destinatario: {{ $utenteSegnalazione?->full_name ?: '—' }}
94
+          @if($hasUserEmail)
95
+            &lt;{{ $utenteSegnalazione->email }}&gt;
96
+          @else
97
+            <span class="text-danger">(nessuna email)</span>
98
+          @endif
99
+        </div>
100
+      </div>
101
+      <div class="card-body">
102
+        <form id="formModificaSegnalazione" action="{{ route('admin.segnalazione.rapporto.store', $segnalazione->id) }}" method="POST" enctype="multipart/form-data">
103
+          @csrf
104
+          <input type="hidden" name="segnalazione_id" id="segnalazione_id" value="{{ $segnalazione->id }}">
105
+          <input type="hidden" name="azione" id="rapportoAzione" value="salva">
106
+          <input type="hidden" name="canale_firma" id="rapportoCanaleFirma" value="">
107
+          <input type="hidden" name="firma_email" id="rapportoFirmaEmail" value="">
108
+          <input type="hidden" name="firma_telefono" id="rapportoFirmaTelefono" value="">
109
+
110
+          @include('segnalazione.admin._form_modifica_fields', ['hideRiservata' => true])
111
+
112
+          <div class="d-flex flex-wrap justify-content-end gap-2 mt-4 pt-3 border-top">
113
+            <a href="{{ route('admin.segnalazione.show', $segnalazione->id) }}" class="btn btn-label-secondary">Annulla</a>
114
+            <button type="submit" class="btn btn-primary" id="btnSalvaRapporto">
115
+              <i class="bx bx-check me-1"></i>Salva
116
+            </button>
117
+            <button type="button" class="btn btn-success" id="btnInviaLinkFirma">
118
+              <i class="bx bx-link me-1"></i>Invia link per la firma
119
+            </button>
120
+          </div>
121
+        </form>
122
+      </div>
123
+    </div>
124
+  </div>
125
+</div>
126
+@endsection
127
+
128
+@section('page-style')
129
+<style>
130
+  .dropzone-box {
131
+    min-height: 180px;
132
+    display: flex;
133
+    align-items: center;
134
+    justify-content: center;
135
+    background-color: rgba(105, 108, 255, 0.06);
136
+    border-width: 2px;
137
+    transition: border-color 0.2s ease, background-color 0.2s ease;
138
+  }
139
+  .dropzone-box-compact { min-height: 120px; }
140
+  .dropzone-box .dz-message { margin: 0; font-size: 0.95rem; color: #6c757d; }
141
+  .dropzone-box .note { display: block; margin-top: 0.35rem; font-size: 0.8rem; }
142
+  .dropzone-box.border-primary { background-color: rgba(105, 108, 255, 0.12); }
143
+  .modifica-section {
144
+    border: 1px solid rgba(67, 89, 113, 0.12);
145
+    border-radius: 0.5rem;
146
+    padding: 1rem 1.15rem;
147
+    background: rgba(67, 89, 113, 0.02);
148
+  }
149
+  .modifica-section-title {
150
+    font-size: 0.78rem;
151
+    font-weight: 700;
152
+    letter-spacing: 0.04em;
153
+    text-transform: uppercase;
154
+    color: #697a8d;
155
+    margin-bottom: 0.85rem;
156
+  }
157
+  #quill_annotazione.quill-annotazione-editor { min-height: 180px; background: #fff; }
158
+  #quill_annotazione .ql-editor p, .ql-editor p { margin-top: 0; margin-bottom: 0.25rem; }
159
+</style>
160
+@endsection
161
+
162
+@section('page-script')
163
+<script type="module">
164
+  $(document).ready(function () {
165
+    $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } });
166
+
167
+    function initSegnalazioneCcBccTagify() {
168
+      if (typeof window.Tagify !== 'function') return;
169
+      ['#segnalazione_cc', '#segnalazione_bcc'].forEach(function (sel) {
170
+        const el = document.querySelector(sel);
171
+        if (!el || el.dataset.tagifyReady) return;
172
+        el.dataset.tagifyReady = '1';
173
+        new window.Tagify(el, {
174
+          delimiters: ',|;',
175
+          duplicates: false,
176
+          pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
177
+          dropdown: { enabled: 0 },
178
+          originalInputValueFormat: (values) => values.map((v) => v.value).join(',')
179
+        });
180
+      });
181
+    }
182
+    initSegnalazioneCcBccTagify();
183
+
184
+    const statiChiusura = @json(\App\Models\Config::getStatiSegnalazione('chiuse'));
185
+    let slotFormAllowSubmit = false;
186
+
187
+    $('#formModificaSegnalazione').on('submit', function (e) {
188
+      const stato = $('#segnalazione_stato').val();
189
+      if (statiChiusura.includes(stato)) {
190
+        // i task obbligatori sono validati lato server
191
+      }
192
+      if (slotFormAllowSubmit || stato !== 'pianificata') {
193
+        return;
194
+      }
195
+      const oreSlot = calcolaOreSlotPrenotazione();
196
+      if (oreSlot <= 0) return;
197
+      const $tempo = $('#tempo_stimato');
198
+      const corrente = parseFloat(String($tempo.val() || '').replace(',', '.'));
199
+      const hasTempo = Number.isFinite(corrente) && corrente > 0;
200
+      const oreLabel = formatOreSlot(oreSlot);
201
+      if (!hasTempo) {
202
+        $tempo.val(oreLabel);
203
+        return;
204
+      }
205
+      e.preventDefault();
206
+      const form = this;
207
+      Swal.fire({
208
+        title: 'Aggiornare il tempo stimato?',
209
+        html: 'Il tempo stimato attuale è <strong>' + formatOreSlot(corrente) + ' ore</strong>.<br>'
210
+          + 'La somma degli slot è <strong>' + oreLabel + ' ore</strong>.<br>'
211
+          + 'Vuoi aggiornare il tempo stimato con la somma degli slot?',
212
+        icon: 'question',
213
+        showCancelButton: true,
214
+        showDenyButton: true,
215
+        confirmButtonText: 'Sì, aggiorna',
216
+        denyButtonText: 'No, mantieni',
217
+        cancelButtonText: 'Annulla'
218
+      }).then(function (result) {
219
+        if (result.isDismissed) return;
220
+        if (result.isConfirmed) $tempo.val(oreLabel);
221
+        slotFormAllowSubmit = true;
222
+        form.requestSubmit ? form.requestSubmit() : $(form).trigger('submit');
223
+      });
224
+    });
225
+
226
+    function formatOreSlot(n) {
227
+      const v = Math.round(n * 100) / 100;
228
+      return String(v).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
229
+    }
230
+    function parseTimeToMinutes(val) {
231
+      const m = String(val || '').trim().match(/^(\d{1,2}):(\d{2})$/);
232
+      if (!m) return null;
233
+      const h = parseInt(m[1], 10);
234
+      const min = parseInt(m[2], 10);
235
+      if (h > 23 || min > 59) return null;
236
+      return h * 60 + min;
237
+    }
238
+    function calcolaOreSlotPrenotazione() {
239
+      let totaleMin = 0;
240
+      $('#slotPrenotazioneList [data-slot-row]').each(function () {
241
+        const data = $(this).find('.slot-data').val();
242
+        const inizio = parseTimeToMinutes($(this).find('.slot-ora-inizio').val());
243
+        const fine = parseTimeToMinutes($(this).find('.slot-ora-fine').val());
244
+        if (!data || inizio === null || fine === null || fine <= inizio) return;
245
+        totaleMin += (fine - inizio);
246
+      });
247
+      return totaleMin / 60;
248
+    }
249
+    function aggiornaTotaleOreSlot() {
250
+      $('#slotPrenotazioneTotaleOre').text(formatOreSlot(calcolaOreSlotPrenotazione()));
251
+    }
252
+    function initSlotRowFlatpickr($row) {
253
+      const $data = $row.find('.slot-data');
254
+      const $inizio = $row.find('.slot-ora-inizio');
255
+      const $fine = $row.find('.slot-ora-fine');
256
+      if ($data.length && !$data[0]._flatpickr) {
257
+        flatpickr($data[0], { dateFormat: 'd/m/Y', locale: 'it', allowInput: true });
258
+      }
259
+      if ($inizio.length && !$inizio[0]._flatpickr) {
260
+        flatpickr($inizio[0], { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, locale: 'it', allowInput: true });
261
+      }
262
+      if ($fine.length && !$fine[0]._flatpickr) {
263
+        flatpickr($fine[0], { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, locale: 'it', allowInput: true });
264
+      }
265
+    }
266
+    function reindexSlotRows() {
267
+      $('#slotPrenotazioneList [data-slot-row]').each(function (idx) {
268
+        $(this).find('.slot-data').attr('name', 'slots[' + idx + '][data]');
269
+        $(this).find('.slot-ora-inizio').attr('name', 'slots[' + idx + '][ora_inizio]');
270
+        $(this).find('.slot-ora-fine').attr('name', 'slots[' + idx + '][ora_fine]');
271
+      });
272
+    }
273
+    function escapeHtml(str) {
274
+      return String(str == null ? '' : str)
275
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
276
+        .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
277
+    }
278
+    function addSlotPrenotazioneRow(prefill) {
279
+      const idx = $('#slotPrenotazioneList [data-slot-row]').length;
280
+      const data = (prefill && prefill.data) || '';
281
+      const inizio = (prefill && typeof prefill.ora_inizio !== 'undefined') ? prefill.ora_inizio : '';
282
+      const fine = (prefill && typeof prefill.ora_fine !== 'undefined') ? prefill.ora_fine : '';
283
+      const $row = $(
284
+        '<div class="row g-2 align-items-end slot-prenotazione-row" data-slot-row>'
285
+        + '<div class="col-md-4"><label class="form-label small mb-1">Data</label>'
286
+        + '<input type="text" class="form-control slot-data" name="slots[' + idx + '][data]" value="' + escapeHtml(data) + '" placeholder="dd/mm/yyyy" autocomplete="off"></div>'
287
+        + '<div class="col-md-3"><label class="form-label small mb-1">Dalle</label>'
288
+        + '<input type="text" class="form-control slot-ora-inizio" name="slots[' + idx + '][ora_inizio]" value="' + escapeHtml(inizio) + '" placeholder="HH:mm" autocomplete="off"></div>'
289
+        + '<div class="col-md-3"><label class="form-label small mb-1">Alle</label>'
290
+        + '<input type="text" class="form-control slot-ora-fine" name="slots[' + idx + '][ora_fine]" value="' + escapeHtml(fine) + '" placeholder="HH:mm" autocomplete="off"></div>'
291
+        + '<div class="col-md-2"><button type="button" class="btn btn-label-danger w-100 btn-rimuovi-slot" title="Rimuovi"><i class="bx bx-trash"></i></button></div>'
292
+        + '</div>'
293
+      );
294
+      $('#slotPrenotazioneList').append($row);
295
+      initSlotRowFlatpickr($row);
296
+      aggiornaTotaleOreSlot();
297
+    }
298
+    function syncSlotPianificazioneVisibility() {
299
+      const isPianificata = $('#segnalazione_stato').val() === 'pianificata';
300
+      $('#slotPianificazioneBox').toggleClass('d-none', !isPianificata);
301
+      aggiornaTotaleOreSlot();
302
+    }
303
+    $('#segnalazione_stato').on('change', syncSlotPianificazioneVisibility);
304
+    $('#btnAggiungiSlotPrenotazione').on('click', function () { addSlotPrenotazioneRow(); });
305
+    $('#slotPrenotazioneList').on('click', '.btn-rimuovi-slot', function () {
306
+      $(this).closest('[data-slot-row]').remove();
307
+      reindexSlotRows();
308
+      aggiornaTotaleOreSlot();
309
+    });
310
+    $('#slotPrenotazioneList').on('change input', '.slot-data, .slot-ora-inizio, .slot-ora-fine', aggiornaTotaleOreSlot);
311
+    $('#slotPrenotazioneList [data-slot-row]').each(function () { initSlotRowFlatpickr($(this)); });
312
+    syncSlotPianificazioneVisibility();
313
+
314
+    flatpickr('#tempo_data', {
315
+      dateFormat: 'd/m/Y H:i',
316
+      enableTime: true,
317
+      time_24hr: true,
318
+      locale: 'it'
319
+    });
320
+
321
+    const quillEl = document.getElementById('quill_annotazione');
322
+    let quill = null;
323
+    if (quillEl) {
324
+      quill = new Quill('#quill_annotazione', {
325
+        bounds: '#quill_annotazione',
326
+        modules: { toolbar: '#quill_annotazione_toolbar' },
327
+        theme: 'snow'
328
+      });
329
+      quill.on('text-change', function () {
330
+        $('#text').val(quill.root.innerHTML);
331
+      });
332
+      const existingText = $('#text').val();
333
+      if (existingText) {
334
+        quill.root.innerHTML = existingText;
335
+      }
336
+    }
337
+
338
+    function fillSelectSimple(select, items, emptyLabel, selectedId) {
339
+      const $el = $(select);
340
+      if (!$el.length) return;
341
+      const current = selectedId != null ? String(selectedId) : '';
342
+      $el.empty();
343
+      $el.append($('<option>', { value: '', text: emptyLabel }));
344
+      (items || []).forEach(function (item) {
345
+        $el.append($('<option>', {
346
+          value: item.id,
347
+          text: item.label,
348
+          selected: current !== '' && current === String(item.id)
349
+        }));
350
+      });
351
+    }
352
+    function reloadOptionsByAziendaModifica(aziendaId, preferredContrattoId, preferredOrdineId) {
353
+      const $contratto = $('#contratto_servizio_id');
354
+      const $ordine = $('#ordine_lavoro_id');
355
+      if (!$contratto.length && !$ordine.length) return;
356
+      $contratto.prop('disabled', true);
357
+      $ordine.prop('disabled', true);
358
+      $.ajax({
359
+        url: '{{ route('admin.segnalazione.options_by_azienda') }}',
360
+        method: 'GET',
361
+        data: { azienda_id: aziendaId || '' },
362
+        headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
363
+      }).done(function (res) {
364
+        fillSelectSimple('#contratto_servizio_id', res.contratti || [], '-- Nessuno (a fatturare) --', preferredContrattoId);
365
+        fillSelectSimple('#ordine_lavoro_id', res.ordini || [], '-- Nessuno --', preferredOrdineId);
366
+        syncOrdineContrattoModifica();
367
+      }).fail(function () {
368
+        fillSelectSimple('#contratto_servizio_id', [], '-- Nessuno (a fatturare) --', null);
369
+        fillSelectSimple('#ordine_lavoro_id', [], '-- Nessuno --', null);
370
+        syncOrdineContrattoModifica();
371
+      }).always(function () {
372
+        $contratto.prop('disabled', false);
373
+        $ordine.prop('disabled', false);
374
+      });
375
+    }
376
+    $('#azienda_id').on('change', function () {
377
+      reloadOptionsByAziendaModifica($(this).val(), null, null);
378
+    });
379
+    function syncOrdineContrattoModifica() {
380
+      const hasOrdine = !!$('#ordine_lavoro_id').val();
381
+      const hasContratto = !!$('#contratto_servizio_id').val();
382
+      if (hasOrdine && !hasContratto) {
383
+        $('#wrapContrattoServizioModifica').addClass('d-none');
384
+        $('#wrapOrdineLavoroModifica').removeClass('d-none');
385
+      } else if (hasContratto && !hasOrdine) {
386
+        $('#wrapOrdineLavoroModifica').addClass('d-none');
387
+        $('#wrapContrattoServizioModifica').removeClass('d-none');
388
+      } else {
389
+        $('#wrapOrdineLavoroModifica, #wrapContrattoServizioModifica').removeClass('d-none');
390
+      }
391
+    }
392
+    let syncingAltModifica = false;
393
+    $('#ordine_lavoro_id').on('change', function () {
394
+      if (!syncingAltModifica && $(this).val()) {
395
+        syncingAltModifica = true;
396
+        $('#contratto_servizio_id').val('');
397
+        syncingAltModifica = false;
398
+      }
399
+      syncOrdineContrattoModifica();
400
+    });
401
+    $('#contratto_servizio_id').on('change', function () {
402
+      if (!syncingAltModifica && $(this).val()) {
403
+        syncingAltModifica = true;
404
+        $('#ordine_lavoro_id').val('');
405
+        syncingAltModifica = false;
406
+      }
407
+      syncOrdineContrattoModifica();
408
+    });
409
+    syncOrdineContrattoModifica();
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
+    }
503
+
504
+    const categoriaSicurezzaContainer = document.getElementById('categoria_sicurezza_container');
505
+    const categoriaSicurezzaSelect = document.getElementById('segnalazione_categoria_sicurezza');
506
+    const tipoSelect = document.getElementById('segnalazione_tipo');
507
+    const categoriaSicurezzaOptionsByTipo = @json($tipoOptionsMap);
508
+    const initialCategoriaValue = @json($categoriaSicurezzaValue);
509
+    function normalizeOption(option) {
510
+      if (option && typeof option === 'object') {
511
+        const value = option.value ?? option.label ?? option.name ?? null;
512
+        const label = option.label ?? option.value ?? option.name ?? value;
513
+        if (value === null) return null;
514
+        return { value, label };
515
+      }
516
+      if (option === null || option === undefined) return null;
517
+      return { value: option, label: option };
518
+    }
519
+    function renderCategoriaSicurezzaOptions(tipoValue) {
520
+      if (!categoriaSicurezzaContainer || !categoriaSicurezzaSelect) return;
521
+      const options = categoriaSicurezzaOptionsByTipo[tipoValue] || [];
522
+      categoriaSicurezzaSelect.innerHTML = '';
523
+      if (!options || options.length === 0) {
524
+        categoriaSicurezzaSelect.value = '';
525
+        categoriaSicurezzaContainer.style.display = 'none';
526
+        return;
527
+      }
528
+      const selectedValue = categoriaSicurezzaSelect.value || initialCategoriaValue;
529
+      options.forEach((option) => {
530
+        const normalized = normalizeOption(option);
531
+        if (!normalized) return;
532
+        const opt = document.createElement('option');
533
+        opt.value = normalized.value;
534
+        opt.textContent = normalized.label;
535
+        if (selectedValue && String(selectedValue) === String(normalized.value)) opt.selected = true;
536
+        categoriaSicurezzaSelect.appendChild(opt);
537
+      });
538
+      categoriaSicurezzaContainer.style.display = '';
539
+    }
540
+    if (tipoSelect) {
541
+      tipoSelect.addEventListener('change', function (e) {
542
+        renderCategoriaSicurezzaOptions(e.target.value);
543
+      });
544
+      renderCategoriaSicurezzaOptions(tipoSelect.value);
545
+    }
546
+
547
+    function selectedDestinatarioContact() {
548
+      const $opt = $('#user_id option:selected');
549
+      return {
550
+        email: String($opt.attr('data-email') || '').trim(),
551
+        telefono: String($opt.attr('data-telefono') || '').trim()
552
+      };
553
+    }
554
+    function selectedAziendaPhone() {
555
+      return String($('#azienda_id option:selected').attr('data-telefono') || '').trim();
556
+    }
557
+    function submitInviaFirma(canale, email, telefono) {
558
+      $('#rapportoAzione').val('invia_firma');
559
+      $('#rapportoCanaleFirma').val(canale);
560
+      $('#rapportoFirmaEmail').val(email || '');
561
+      $('#rapportoFirmaTelefono').val(telefono || '');
562
+      document.getElementById('formModificaSegnalazione').requestSubmit();
563
+    }
564
+    function promptEmailThenSubmit() {
565
+      const email = selectedDestinatarioContact().email;
566
+      Swal.fire({
567
+        title: 'Indirizzo email',
568
+        html: 'Il link verrà inviato a questo indirizzo.<br><span class="small text-muted">Se diverso da quello in anagrafica, verrà aggiornato sul destinatario.</span>',
569
+        input: 'email',
570
+        inputValue: email,
571
+        inputPlaceholder: 'email@esempio.it',
572
+        inputAttributes: { autocapitalize: 'off', autocomplete: 'off' },
573
+        showCancelButton: true,
574
+        confirmButtonText: 'Invia',
575
+        cancelButtonText: 'Annulla',
576
+        confirmButtonColor: '#696cff',
577
+        inputValidator: function (value) {
578
+          const v = String(value || '').trim();
579
+          if (!v) return 'Inserisci un indirizzo email';
580
+          if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Indirizzo email non valido';
581
+        }
582
+      }).then(function (res) {
583
+        if (!res.isConfirmed) return;
584
+        submitInviaFirma('email', String(res.value || '').trim(), '');
585
+      });
586
+    }
587
+    function promptTelefonoThenSubmit() {
588
+      const phone = selectedDestinatarioContact().telefono || selectedAziendaPhone();
589
+      Swal.fire({
590
+        title: 'Numero di telefono',
591
+        html: 'Il link verrà inviato via SMS a questo numero.<br><span class="small text-muted">Se diverso da quello in anagrafica, verrà aggiornato sul destinatario.</span>',
592
+        input: 'tel',
593
+        inputValue: phone,
594
+        inputPlaceholder: 'Es. 333 1234567',
595
+        inputAttributes: { autocomplete: 'off' },
596
+        showCancelButton: true,
597
+        confirmButtonText: 'Invia',
598
+        cancelButtonText: 'Annulla',
599
+        confirmButtonColor: '#696cff',
600
+        inputValidator: function (value) {
601
+          if (!String(value || '').trim()) return 'Inserisci un numero di telefono';
602
+        }
603
+      }).then(function (res) {
604
+        if (!res.isConfirmed) return;
605
+        submitInviaFirma('sms', '', String(res.value || '').trim());
606
+      });
607
+    }
608
+    $('#btnSalvaRapporto').on('click', function () {
609
+      $('#rapportoAzione').val('salva');
610
+      $('#rapportoCanaleFirma').val('');
611
+      $('#rapportoFirmaEmail').val('');
612
+      $('#rapportoFirmaTelefono').val('');
613
+    });
614
+    $('#btnInviaLinkFirma').on('click', function () {
615
+      Swal.fire({
616
+        title: 'Invia link per la firma',
617
+        html: 'Come vuoi inviare il link al cliente?<br><span class="small text-muted">Il rapportino viene salvato e il cliente potrà leggerlo e firmare senza autenticazione.</span>',
618
+        icon: 'question',
619
+        showDenyButton: true,
620
+        showCancelButton: true,
621
+        confirmButtonText: 'Email',
622
+        denyButtonText: 'SMS',
623
+        cancelButtonText: 'Annulla',
624
+        confirmButtonColor: '#696cff'
625
+      }).then(function (result) {
626
+        if (result.isConfirmed) {
627
+          promptEmailThenSubmit();
628
+        } else if (result.isDenied) {
629
+          promptTelefonoThenSubmit();
630
+        }
631
+      });
632
+    });
633
+  });
634
+</script>
635
+@endsection

+ 4
- 267
resources/views/segnalazione/admin/show.blade.php View File

@@ -708,6 +708,9 @@ use App\Models\Config;
708 708
         <button type="button" class="btn btn-label-primary btn-sm" onclick="showCardModificaSegnalazione('rispondi')">
709 709
           <i class="bx bx-reply me-1"></i>Rispondi
710 710
         </button>
711
+        <a href="{{ route('admin.segnalazione.rapporto', $segnalazione->id) }}" class="btn btn-label-success btn-sm">
712
+          <i class="bx bx-file-blank me-1"></i>Compila rapporto d'intervento
713
+        </a>
711 714
         @elseif($isVistaCliente)
712 715
         <button type="button" class="btn btn-primary btn-sm" onclick="document.getElementById('cardRispostaCliente')?.classList.remove('d-none'); document.getElementById('cardRispostaCliente')?.scrollIntoView({behavior:'smooth'});">
713 716
           <i class="bx bx-reply me-1"></i>Rispondi
@@ -1232,273 +1235,7 @@ use App\Models\Config;
1232 1235
           @csrf
1233 1236
           <input type="hidden" name="segnalazione_id" id="segnalazione_id" value="{{ $segnalazione->id }}">
1234 1237
 
1235
-          {{-- 1. Anagrafica --}}
1236
-          <div class="modifica-section mb-4">
1237
-            <div class="modifica-section-title">Anagrafica</div>
1238
-            <div class="row g-3">
1239
-              <div class="col-12">
1240
-                <label for="titolo_segnalazione" class="form-label">Titolo <span class="text-danger">*</span></label>
1241
-                <input type="text" class="form-control" id="titolo_segnalazione" name="titolo" value="{{ $segnalazione->titolo }}" required>
1242
-              </div>
1243
-              <div class="col-md-4">
1244
-                <label for="azienda_id" class="form-label">Azienda</label>
1245
-                <select class="form-select" id="azienda_id" name="azienda_id">
1246
-                  <option value="">-- Nessuna azienda --</option>
1247
-                  @foreach(\App\Models\Azienda::orderBy('nome')->get() as $azienda)
1248
-                  <option value="{{ $azienda->id }}" @if($segnalazione->azienda_id == $azienda->id) selected @endif>
1249
-                    {{ $azienda->full_name }}
1250
-                  </option>
1251
-                  @endforeach
1252
-                </select>
1253
-              </div>
1254
-              <div class="col-md-4">
1255
-                <label for="user_id" class="form-label">Aperta da <span class="text-danger">*</span></label>
1256
-                <select class="form-select" id="user_id" name="user_id" required>
1257
-                  <option value="" disabled @selected(! filled($segnalazione->user_id))>-- Seleziona utente --</option>
1258
-                  @foreach(App\Models\User::soloUtenti()->orderBy('cognome')->orderBy('nome')->get() as $user)
1259
-                  <option value="{{ $user->id }}" @selected(filled($segnalazione->user_id) && (int) $segnalazione->user_id === (int) $user->id)>{{ $user->full_name }}</option>
1260
-                  @endforeach
1261
-                </select>
1262
-              </div>
1263
-              <div class="col-md-4">
1264
-                <label for="assegnata_a_id" class="form-label">Assegnata a</label>
1265
-                <select class="form-select" id="assegnata_a_id" name="assegnata_a_id">
1266
-                  <option value="">-- Nessuna Assegnazione --</option>
1267
-                  @foreach($assegnatariSegnalazione as $user)
1268
-                  <option value="{{ $user->id }}" @selected((int) ($segnalazione->assegnata_a_id ?: Auth::id()) === (int) $user->id)>{{ $user->full_name }}</option>
1269
-                  @endforeach
1270
-                </select>
1271
-              </div>
1272
-            </div>
1273
-          </div>
1274
-
1275
-          {{-- 2. Classificazione + collegamenti --}}
1276
-          <div class="row g-3 mb-4">
1277
-            <div class="col-lg-7">
1278
-              <div class="modifica-section h-100">
1279
-                <div class="modifica-section-title">Classificazione</div>
1280
-                <div class="row g-3">
1281
-                  <div class="col-sm-6 col-xl-3">
1282
-                    <label for="segnalazione_stato" class="form-label">Stato</label>
1283
-                    <select class="form-select" id="segnalazione_stato" name="stato">
1284
-                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'stato')->orderBy('order')->get() as $statoOption)
1285
-                      <option value="{{ $statoOption->value }}" @if($segnalazione->stato == $statoOption->value) selected @endif>
1286
-                        {{ $statoOption->label }}
1287
-                      </option>
1288
-                      @endforeach
1289
-                    </select>
1290
-                  </div>
1291
-                  <div class="col-sm-6 col-xl-3">
1292
-                    <label for="segnalazione_priorita" class="form-label">Priorità</label>
1293
-                    <select class="form-select" id="segnalazione_priorita" name="priorita">
1294
-                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'Priorità')->orderBy('order')->get() as $prioritaOption)
1295
-                      <option value="{{ $prioritaOption->value }}" @if($segnalazione->priorita == $prioritaOption->value) selected @endif>
1296
-                        {{ $prioritaOption->label }}
1297
-                      </option>
1298
-                      @endforeach
1299
-                    </select>
1300
-                  </div>
1301
-                  <div class="col-sm-6 col-xl-3">
1302
-                    <label for="segnalazione_tipo" class="form-label">Tipo</label>
1303
-                    <select class="form-select" id="segnalazione_tipo" name="tipo">
1304
-                      <option value="">-- Nessuno --</option>
1305
-                      @foreach(\App\Models\ConfiguraSegnalazione::where('gruppo', 'Tipo')->orderBy('order')->get() as $tipoOption)
1306
-                      <option value="{{ $tipoOption->value }}" @if($segnalazione->tipo == $tipoOption->value) selected @endif>
1307
-                        {{ $tipoOption->label }}
1308
-                      </option>
1309
-                      @endforeach
1310
-                    </select>
1311
-                  </div>
1312
-                  <div class="col-sm-6 col-xl-3">
1313
-                    <div id="categoria_sicurezza_container" style="{{ empty($categoriaSicurezzaOptions) ? 'display:none;' : '' }}">
1314
-                      <label for="segnalazione_categoria_sicurezza" class="form-label">Cat. sicurezza</label>
1315
-                      <select class="form-select" id="segnalazione_categoria_sicurezza" name="categoria_sicurezza">
1316
-                        @foreach($categoriaSicurezzaOptions as $categoriaSicurezzaOption)
1317
-                          @php
1318
-                            $optionValue = is_array($categoriaSicurezzaOption)
1319
-                              ? ($categoriaSicurezzaOption['value'] ?? $categoriaSicurezzaOption['label'] ?? $categoriaSicurezzaOption['name'] ?? null)
1320
-                              : $categoriaSicurezzaOption;
1321
-                            $optionLabel = is_array($categoriaSicurezzaOption)
1322
-                              ? ($categoriaSicurezzaOption['label'] ?? $categoriaSicurezzaOption['value'] ?? $categoriaSicurezzaOption['name'] ?? $optionValue)
1323
-                              : $categoriaSicurezzaOption;
1324
-                          @endphp
1325
-                          @if($optionValue !== null)
1326
-                            <option value="{{ $optionValue }}" @if($categoriaSicurezzaValue == $optionValue) selected @endif>
1327
-                              {{ $optionLabel }}
1328
-                            </option>
1329
-                          @endif
1330
-                        @endforeach
1331
-                      </select>
1332
-                    </div>
1333
-                  </div>
1334
-                </div>
1335
-
1336
-                <div id="slotPianificazioneBox" class="mt-3 {{ $segnalazione->stato === 'pianificata' ? '' : 'd-none' }}">
1337
-                  <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
1338
-                    <div>
1339
-                      <div class="fw-semibold">Slot di prenotazione</div>
1340
-                      <div class="small text-muted">Es. 26/08/2026 · 12:00 → 15:00</div>
1341
-                    </div>
1342
-                    <button type="button" class="btn btn-sm btn-label-primary" id="btnAggiungiSlotPrenotazione">
1343
-                      <i class="bx bx-plus me-1"></i>Aggiungi slot
1344
-                    </button>
1345
-                  </div>
1346
-                  <div id="slotPrenotazioneList" class="d-flex flex-column gap-2">
1347
-                    @forelse($segnalazione->slots as $slotIndex => $slot)
1348
-                      <div class="row g-2 align-items-end slot-prenotazione-row" data-slot-row>
1349
-                        <div class="col-md-4">
1350
-                          <label class="form-label small mb-1">Data</label>
1351
-                          <input type="text" class="form-control slot-data" name="slots[{{ $slotIndex }}][data]"
1352
-                            value="{{ $slot->starts_at->format('d/m/Y') }}" placeholder="dd/mm/yyyy" autocomplete="off">
1353
-                        </div>
1354
-                        <div class="col-md-3">
1355
-                          <label class="form-label small mb-1">Dalle</label>
1356
-                          <input type="text" class="form-control slot-ora-inizio" name="slots[{{ $slotIndex }}][ora_inizio]"
1357
-                            value="{{ $slot->starts_at->format('H:i') }}" placeholder="HH:mm" autocomplete="off">
1358
-                        </div>
1359
-                        <div class="col-md-3">
1360
-                          <label class="form-label small mb-1">Alle</label>
1361
-                          <input type="text" class="form-control slot-ora-fine" name="slots[{{ $slotIndex }}][ora_fine]"
1362
-                            value="{{ $slot->ends_at->format('H:i') }}" placeholder="HH:mm" autocomplete="off">
1363
-                        </div>
1364
-                        <div class="col-md-2">
1365
-                          <button type="button" class="btn btn-label-danger w-100 btn-rimuovi-slot" title="Rimuovi">
1366
-                            <i class="bx bx-trash"></i>
1367
-                          </button>
1368
-                        </div>
1369
-                      </div>
1370
-                    @empty
1371
-                    @endforelse
1372
-                  </div>
1373
-                  <div class="form-text mt-2">
1374
-                    Totale slot: <span id="slotPrenotazioneTotaleOre">0</span> ore
1375
-                  </div>
1376
-                </div>
1377
-              </div>
1378
-            </div>
1379
-            <div class="col-lg-5">
1380
-              <div class="modifica-section h-100">
1381
-                <div class="modifica-section-title">Collegamento <span class="fw-normal text-muted">(ordine o contratto)</span></div>
1382
-                <div class="row g-3" id="rowOrdineContrattoModifica">
1383
-                  <div class="col-12" id="wrapOrdineLavoroModifica">
1384
-                    <label for="ordine_lavoro_id" class="form-label">Ordine di lavoro</label>
1385
-                    <select class="form-select" id="ordine_lavoro_id" name="ordine_lavoro_id">
1386
-                      <option value="">-- Nessuno --</option>
1387
-                      @foreach(\App\Models\OrdineLavoro::when($segnalazione->azienda_id, fn($q) => $q->where('user_id', $segnalazione->azienda_id))->orderByDesc('numero')->limit(100)->get() as $odl)
1388
-                      <option value="{{ $odl->id }}" @selected((int)$segnalazione->ordine_lavoro_id === (int)$odl->id)>{{ $odl->label }}</option>
1389
-                      @endforeach
1390
-                    </select>
1391
-                  </div>
1392
-                  <div class="col-12" id="wrapContrattoServizioModifica">
1393
-                    <label for="contratto_servizio_id" class="form-label">Contratto (scala ore)</label>
1394
-                    <select class="form-select" id="contratto_servizio_id" name="contratto_servizio_id">
1395
-                      <option value="">-- Nessuno (a fatturare) --</option>
1396
-                      @foreach($contrattiCliente as $cs)
1397
-                      <option value="{{ $cs->id }}" @selected((int)$segnalazione->contratto_servizio_id === (int)$cs->id)>{{ $cs->label }}</option>
1398
-                      @endforeach
1399
-                    </select>
1400
-                  </div>
1401
-                </div>
1402
-              </div>
1403
-            </div>
1404
-          </div>
1405
-
1406
-          {{-- 3. Annotazione --}}
1407
-          <div class="modifica-section mb-4">
1408
-            <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
1409
-              <div class="modifica-section-title mb-0">Annotazione</div>
1410
-              <div class="d-flex flex-wrap align-items-center gap-3">
1411
-                <div>
1412
-                  <label for="annotazione_user_id" class="form-label mb-1 small text-muted">Autore</label>
1413
-                  <select class="form-select form-select-sm" id="annotazione_user_id" name="annotazione_user_id" required style="min-width: 180px;">
1414
-                    @foreach(App\Models\User::soloUtenti()->orderBy('cognome')->orderBy('nome')->get() as $user)
1415
-                    <option value="{{ $user->id }}" @selected((int) Auth::id() === (int) $user->id)>{{ $user->full_name }}</option>
1416
-                    @endforeach
1417
-                  </select>
1418
-                </div>
1419
-                <div class="form-check form-switch mt-3 mt-sm-4 mb-0">
1420
-                  <input type="hidden" name="annotazione_riservata" value="0">
1421
-                  <input class="form-check-input" type="checkbox" name="annotazione_riservata" id="annotazione_riservata" value="1">
1422
-                  <label class="form-check-label" for="annotazione_riservata">Riservata / interna</label>
1423
-                </div>
1424
-              </div>
1425
-            </div>
1426
-            <input type="hidden" id="text" name="text" value="">
1427
-            <div id="quill_annotazione_toolbar">
1428
-              @include('content.form-elements.quill_toolbar')
1429
-            </div>
1430
-            <div id="quill_annotazione" class="quill-annotazione-editor"></div>
1431
-            <div class="form-text mt-1 d-none" id="annotazioneQuoteHint">Citazione stile email già inserita (modificabile). Scrivi la nuova risposta sopra.</div>
1432
-            <div class="mt-3">
1433
-              @include('segnalazione.admin._cc_bcc_fields', [
1434
-                'ccValue' => old('cc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->cc ?? []))),
1435
-                'bccValue' => old('bcc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->bcc ?? []))),
1436
-              ])
1437
-            </div>
1438
-          </div>
1439
-
1440
-          {{-- 4. Tempo / costo / allegati --}}
1441
-          <div class="row g-3 mb-2">
1442
-            <div class="col-md-4">
1443
-              <div class="modifica-section h-100">
1444
-                <div class="modifica-section-title"><i class="bx bx-time-five me-1"></i>Tempo</div>
1445
-                <div class="mb-3">
1446
-                  <label for="tempo_stimato" class="form-label">Tempo stimato (ore)</label>
1447
-                  <input type="number" class="form-control" id="tempo_stimato" name="tempo_stimato"
1448
-                    step="0.25" min="0"
1449
-                    value="{{ old('tempo_stimato', $segnalazione->tempo_stimato !== null ? rtrim(rtrim(number_format((float) $segnalazione->tempo_stimato, 2, '.', ''), '0'), '.') : '') }}"
1450
-                    placeholder="Es. 1.5">
1451
-                </div>
1452
-                <div class="mb-3">
1453
-                  <label for="ore_tempo_card" class="form-label">Registra ore</label>
1454
-                  <input type="text" class="form-control" id="ore_tempo_card" name="tempo_ore" placeholder="Es. 1,5 o 1h30m">
1455
-                </div>
1456
-                <div class="mb-3">
1457
-                  <label for="tempo_data" class="form-label">Data e ora</label>
1458
-                  <input type="text" class="form-control flatpickr-input" id="tempo_data" name="tempo_data" value="{{ \Carbon\Carbon::now()->format('d/m/Y H:i') }}">
1459
-                </div>
1460
-                <div class="form-check mb-2">
1461
-                  <input type="hidden" name="non_fatturare" value="0">
1462
-                  <input class="form-check-input" type="checkbox" name="non_fatturare" id="non_fatturare" value="1" @checked(old('non_fatturare'))>
1463
-                  <label class="form-check-label" for="non_fatturare">Non fatturare</label>
1464
-                  <div class="form-text">Questo tempo non entra in fattura.</div>
1465
-                </div>
1466
-                <div class="form-check mb-0">
1467
-                  <input type="hidden" name="costo_uscita" value="0">
1468
-                  <input class="form-check-input" type="checkbox" name="costo_uscita" id="costo_uscita" value="1" @checked(old('costo_uscita'))>
1469
-                  <label class="form-check-label" for="costo_uscita">Costo d'uscita ({{ Config::formatCosto(Config::costoUscitaDefault()) }})</label>
1470
-                </div>
1471
-              </div>
1472
-            </div>
1473
-            <div class="col-md-4">
1474
-              <div class="modifica-section h-100">
1475
-                <div class="modifica-section-title"><i class="bx bx-money me-1"></i>Costo</div>
1476
-                <div class="mb-3">
1477
-                  <label for="costo_importo_card" class="form-label">Importo (€)</label>
1478
-                  <input type="number" class="form-control" id="costo_importo_card" name="costo" step="0.01" min="0">
1479
-                </div>
1480
-                <div class="mb-0">
1481
-                  <label for="descrizione_costo_card" class="form-label">Descrizione</label>
1482
-                  <textarea class="form-control" id="descrizione_costo_card" name="costo_descrizione" rows="2"></textarea>
1483
-                </div>
1484
-              </div>
1485
-            </div>
1486
-            <div class="col-md-4">
1487
-              <div class="modifica-section h-100">
1488
-                <div class="modifica-section-title"><i class="bx bx-paperclip me-1"></i>Allegati</div>
1489
-                <div id="allegatoDropzoneAdmin" class="dropzone-box dropzone-box-compact border border-dashed rounded p-3 text-center">
1490
-                  <div class="dz-message">
1491
-                    Trascina o clicca per caricare
1492
-                    <span class="note">Max 10MB · PDF, immagini, documenti</span>
1493
-                  </div>
1494
-                </div>
1495
-                <input type="file" class="d-none" id="file_allegato_card" name="allegato[]" multiple
1496
-                  accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx">
1497
-                <div id="allegatoDropzoneInfoAdmin" class="small text-muted mt-2"></div>
1498
-                <div id="allegatoPreviewAdmin" class="row g-2 mt-2"></div>
1499
-              </div>
1500
-            </div>
1501
-          </div>
1238
+          @include('segnalazione.admin._form_modifica_fields')
1502 1239
 
1503 1240
           <div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
1504 1241
             <button type="button" class="btn btn-label-secondary" id="btnAnnullaModificaSegnalazione">Annulla</button>

+ 80
- 5
resources/views/segnalazione/pdf-ticket.blade.php View File

@@ -462,6 +462,19 @@
462 462
     $annotazioniPdf = $annotazioniPdf ?? collect();
463 463
     $isClientePdf = $isClientePdf ?? false;
464 464
     $acquistoServizio = $acquistoServizio ?? null;
465
+    $isRapportino = $isRapportino ?? false;
466
+    $rapportinoAnnotazione = $rapportinoAnnotazione ?? null;
467
+    if ($isRapportino && $rapportinoAnnotazione) {
468
+      $primaAnnotazione = $rapportinoAnnotazione;
469
+      $annotazioneAllegati = $rapportinoAnnotazione;
470
+      $immaginiAllegati = $annotazioneAllegati
471
+        ? $annotazioneAllegati->allegatiAnnotazione
472
+          ->filter($pdfIsImageAllegato)
473
+          ->reject(fn ($a) => str_contains(strtolower((string) ($a->nome ?? '')), 'firma-cliente')
474
+            || str_contains(strtolower((string) ($a->nome ?? '')), 'firma cliente'))
475
+          ->values()
476
+        : collect();
477
+    }
465 478
   @endphp
466 479
 
467 480
   <div class="header">
@@ -483,8 +496,14 @@
483 496
     </table>
484 497
   </div>
485 498
 
486
-  <p class="ticket-title">Ticket di segnalazione</p>
487
-  <p class="pdf-variant">{{ $isClientePdf ? 'Documento per il cliente' : 'Documento operatore' }}</p>
499
+  <p class="ticket-title">{{ $isRapportino ? "Rapportino d'intervento" : 'Ticket di segnalazione' }}</p>
500
+  <p class="pdf-variant">
501
+    @if($isRapportino)
502
+      Rapportino dell'intervento
503
+    @else
504
+      {{ $isClientePdf ? 'Documento per il cliente' : 'Documento operatore' }}
505
+    @endif
506
+  </p>
488 507
 
489 508
   <table class="details-table">
490 509
     <tr>
@@ -509,7 +528,7 @@
509 528
     </tr>
510 529
   </table>
511 530
 
512
-  <h2 class="section-title">Descrizione segnalazione</h2>
531
+  <h2 class="section-title">{{ $isRapportino ? "Descrizione intervento" : 'Descrizione segnalazione' }}</h2>
513 532
   <div class="annotation-box">
514 533
     @php $testoAnnotazione = $primaAnnotazione ? $pdfAnnotazioneHtml($primaAnnotazione->text, $primaAnnotazione) : null; @endphp
515 534
     @if($testoAnnotazione)
@@ -519,6 +538,62 @@
519 538
     @endif
520 539
   </div>
521 540
 
541
+  @if($isRapportino && $rapportinoAnnotazione)
542
+    @php
543
+      $ann = $rapportinoAnnotazione;
544
+      $tempoLabel = ((int) ($ann->tempo_minuti ?? 0) > 0)
545
+        ? \App\Models\Config::formatTempo((int) $ann->tempo_minuti)
546
+        : null;
547
+      $costoLabel = filled($ann->costo)
548
+        ? \App\Models\Config::formatCosto($ann->costo)
549
+        : null;
550
+      $tempoDataLabel = $ann->tempo_data
551
+        ? (is_object($ann->tempo_data) ? $ann->tempo_data->format('d/m/Y H:i') : (string) $ann->tempo_data)
552
+        : null;
553
+    @endphp
554
+    <h2 class="section-title" style="margin-top: 18px;">Dettagli di questo intervento</h2>
555
+    <table class="details-table">
556
+      @if($ann->user)
557
+      <tr>
558
+        <th>Operatore</th>
559
+        <td colspan="3">{{ $ann->user->full_name }}</td>
560
+      </tr>
561
+      @endif
562
+      @if($tempoLabel)
563
+      <tr>
564
+        <th>Tempo</th>
565
+        <td class="field-value">{{ $tempoLabel }}</td>
566
+        <th>Data</th>
567
+        <td class="field-value">{{ $tempoDataLabel ?: '—' }}</td>
568
+      </tr>
569
+      @endif
570
+      @if($costoLabel)
571
+      <tr>
572
+        <th>Costo</th>
573
+        <td class="field-value">{{ $costoLabel }}</td>
574
+        <th>Descrizione costo</th>
575
+        <td class="field-value">{{ filled($ann->costo_descrizione) ? $ann->costo_descrizione : '—' }}</td>
576
+      </tr>
577
+      @endif
578
+      @if($ann->non_fatturare || $ann->costo_uscita)
579
+      <tr>
580
+        <th>Note economiche</th>
581
+        <td colspan="3">
582
+          @if($ann->non_fatturare) Non fatturare @endif
583
+          @if($ann->non_fatturare && $ann->costo_uscita) — @endif
584
+          @if($ann->costo_uscita) Costo d'uscita @endif
585
+        </td>
586
+      </tr>
587
+      @endif
588
+      @if($ann->created_at)
589
+      <tr>
590
+        <th>Compilato il</th>
591
+        <td colspan="3">{{ $ann->created_at->format('d/m/Y H:i') }}</td>
592
+      </tr>
593
+      @endif
594
+    </table>
595
+  @endif
596
+
522 597
   @if($immaginiAllegati->isNotEmpty())
523 598
   <div class="attachments-images">
524 599
     @foreach($immaginiAllegati as $allegato)
@@ -535,7 +610,7 @@
535 610
   </div>
536 611
   @endif
537 612
 
538
-  @if($todosPdf->isNotEmpty())
613
+  @if(! $isRapportino && $todosPdf->isNotEmpty())
539 614
   <h2 class="section-title" style="margin-top: 22px;">Checklist / Task</h2>
540 615
   <table class="tasks-table">
541 616
     <thead>
@@ -568,7 +643,7 @@
568 643
       ->when($primaAnnotazione, fn ($c) => $c->reject(fn ($a) => (int) $a->id === (int) $primaAnnotazione->id))
569 644
       ->values();
570 645
   @endphp
571
-  @if($altreAnnotazioni->isNotEmpty())
646
+  @if(! $isRapportino && $altreAnnotazioni->isNotEmpty())
572 647
   <h2 class="section-title" style="margin-top: 10px;">Annotazioni</h2>
573 648
   @foreach($altreAnnotazioni as $annotazione)
574 649
     @php $testoAltra = $pdfAnnotazioneHtml($annotazione->text, $annotazione); @endphp

+ 271
- 0
resources/views/segnalazione/public/firma-rapporto.blade.php View File

@@ -0,0 +1,271 @@
1
+@extends('layouts/guest')
2
+
3
+@section('title', "Firma rapporto d'intervento")
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=Fraunces:opsz,wght@9..144,600&family=Source+Sans+3:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+<style>
10
+  :root {
11
+    --ink: #0f172a;
12
+    --muted: #5b6777;
13
+    --teal: #0f766e;
14
+    --line: #d8e2e8;
15
+    --sand: #f3f6f8;
16
+  }
17
+  .firma-page {
18
+    font-family: 'Source Sans 3', system-ui, sans-serif;
19
+    color: var(--ink);
20
+    max-width: 920px;
21
+    margin: 0 auto;
22
+    padding: 0 1rem 3rem;
23
+    text-align: left;
24
+  }
25
+  .firma-page h1 {
26
+    font-family: Fraunces, Georgia, serif;
27
+    font-size: clamp(1.35rem, 4vw, 1.7rem);
28
+    margin: 0 0 .35rem;
29
+  }
30
+  .firma-card {
31
+    background: #fff;
32
+    border: 1px solid var(--line);
33
+    border-radius: 1.1rem;
34
+    padding: 1.1rem 1.2rem;
35
+    margin-bottom: 1rem;
36
+    box-shadow: 0 8px 24px rgba(15,23,42,.04);
37
+  }
38
+  .firma-card h2 {
39
+    font-size: .95rem;
40
+    margin: 0 0 .75rem;
41
+    color: var(--teal);
42
+  }
43
+  .firma-meta { font-size: .95rem; line-height: 1.5; color: var(--muted); }
44
+  .firma-meta strong { color: var(--ink); }
45
+  .pdf-frame-wrap {
46
+    width: 100%;
47
+    border: 1px solid var(--line);
48
+    border-radius: .85rem;
49
+    overflow: hidden;
50
+    background: var(--sand);
51
+    min-height: 55vh;
52
+  }
53
+  .pdf-frame-wrap iframe {
54
+    width: 100%;
55
+    height: min(70vh, 720px);
56
+    min-height: 360px;
57
+    border: 0;
58
+    display: block;
59
+    background: #fff;
60
+  }
61
+  .firma-pad-wrap {
62
+    border: 2px dashed var(--line);
63
+    border-radius: .85rem;
64
+    background: var(--sand);
65
+    touch-action: none;
66
+  }
67
+  #firmaCanvas {
68
+    width: 100%;
69
+    height: 180px;
70
+    display: block;
71
+    cursor: crosshair;
72
+    border-radius: .75rem;
73
+  }
74
+  .firma-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1rem; }
75
+  .btn-firma {
76
+    border: 0;
77
+    border-radius: 999px;
78
+    padding: .7rem 1.25rem;
79
+    font-weight: 600;
80
+    cursor: pointer;
81
+    font-size: 1rem;
82
+  }
83
+  .btn-firma-primary { background: var(--teal); color: #fff; }
84
+  .btn-firma-secondary { background: #e8eef2; color: var(--ink); }
85
+  .alert-ok {
86
+    background: #e6f4f2;
87
+    color: #0d5f59;
88
+    border-radius: .85rem;
89
+    padding: .9rem 1rem;
90
+    margin-bottom: 1rem;
91
+  }
92
+  .alert-err {
93
+    background: #fde8e8;
94
+    color: #9b1c1c;
95
+    border-radius: .85rem;
96
+    padding: .9rem 1rem;
97
+    margin-bottom: 1rem;
98
+  }
99
+  .firma-privacy {
100
+    font-size: .85rem;
101
+    line-height: 1.45;
102
+    color: var(--muted);
103
+  }
104
+  .firma-privacy strong { color: var(--ink); }
105
+  .firma-privacy ul { padding-left: 1.1rem; margin: 0 0 .75rem; }
106
+  .firma-check {
107
+    display: flex;
108
+    gap: .55rem;
109
+    align-items: flex-start;
110
+    font-size: .9rem;
111
+    margin-top: 1rem;
112
+    color: var(--ink);
113
+  }
114
+  .firma-check input { margin-top: .25rem; }
115
+  @media (max-width: 576px) {
116
+    .firma-card { padding: .9rem; }
117
+    .pdf-frame-wrap iframe { min-height: 280px; height: 50vh; }
118
+    #firmaCanvas { height: 160px; }
119
+  }
120
+</style>
121
+@endsection
122
+
123
+@section('content')
124
+<div class="firma-page">
125
+  <h1>Firma rapporto d'intervento</h1>
126
+  <p class="firma-meta mb-3">
127
+    {{ $segnalazione->getNumeroLabel() }} · {{ $segnalazione->titolo }}
128
+  </p>
129
+
130
+  @if(session('success'))
131
+  <div class="alert-ok">{{ session('success') }}</div>
132
+  @endif
133
+  @if($errors->any())
134
+  <div class="alert-err">
135
+    <ul class="mb-0">@foreach($errors->all() as $e)<li>{{ $e }}</li>@endforeach</ul>
136
+  </div>
137
+  @endif
138
+
139
+  <div class="firma-card">
140
+    <h2>Rapportino</h2>
141
+    <p class="firma-meta mb-2">
142
+      Leggi il documento compilato. Se il PDF non si apre nel riquadro,
143
+      <a href="{{ $pdfUrl }}" target="_blank" rel="noopener">aprilo in una nuova scheda</a>.
144
+    </p>
145
+    <div class="pdf-frame-wrap">
146
+      <iframe src="{{ $pdfUrl }}" title="Rapportino d'intervento"></iframe>
147
+    </div>
148
+  </div>
149
+
150
+  @if($alreadySigned)
151
+  <div class="firma-card">
152
+    <h2>Firma già registrata</h2>
153
+    <p class="firma-meta mb-0">
154
+      Hai firmato
155
+      @if($annotazione->firma_at)
156
+      il {{ $annotazione->firma_at->format('d/m/Y H:i') }}.
157
+      @endif
158
+      Grazie, puoi chiudere la pagina.
159
+    </p>
160
+  </div>
161
+  @else
162
+  <div class="firma-card">
163
+    <h2>La tua firma</h2>
164
+    <p class="firma-meta mb-2">
165
+      Conferma di aver letto il rapportino e firma nello spazio sottostante (usa il dito sul telefono).
166
+    </p>
167
+    <form method="POST" action="{{ route('segnalazione.rapporto_firma.store', $token) }}" id="formFirma">
168
+      @csrf
169
+      <input type="hidden" name="firma" id="firmaInput">
170
+      <div class="firma-pad-wrap">
171
+        <canvas id="firmaCanvas" width="600" height="180"></canvas>
172
+      </div>
173
+
174
+      <div class="firma-privacy mt-3">
175
+        @include('acquisto_servizio.partials.privacy-firma')
176
+      </div>
177
+      <label class="firma-check">
178
+        <input type="checkbox" name="privacy_firma" value="1" id="privacy_firma" required @checked(old('privacy_firma'))>
179
+        <span>Ho letto l’informativa e autorizzo il salvataggio della firma per il rapportino d’intervento.</span>
180
+      </label>
181
+
182
+      <div class="firma-actions">
183
+        <button type="button" class="btn-firma btn-firma-secondary" id="btnClearFirma">Cancella</button>
184
+        <button type="submit" class="btn-firma btn-firma-primary" id="btnSalvaFirma">Conferma firma</button>
185
+      </div>
186
+    </form>
187
+  </div>
188
+  @endif
189
+</div>
190
+@endsection
191
+
192
+@section('page-script')
193
+@unless($alreadySigned)
194
+<script>
195
+(function () {
196
+  const canvas = document.getElementById('firmaCanvas');
197
+  if (!canvas) return;
198
+  const ctx = canvas.getContext('2d');
199
+  const input = document.getElementById('firmaInput');
200
+  const form = document.getElementById('formFirma');
201
+  let drawing = false;
202
+  let hasInk = false;
203
+
204
+  function resize() {
205
+    const ratio = window.devicePixelRatio || 1;
206
+    const rect = canvas.getBoundingClientRect();
207
+    const data = hasInk ? canvas.toDataURL() : null;
208
+    canvas.width = Math.floor(rect.width * ratio);
209
+    canvas.height = Math.floor(180 * ratio);
210
+    ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
211
+    ctx.lineWidth = 2.2;
212
+    ctx.lineCap = 'round';
213
+    ctx.lineJoin = 'round';
214
+    ctx.strokeStyle = '#0f172a';
215
+    if (data) {
216
+      const img = new Image();
217
+      img.onload = function () { ctx.drawImage(img, 0, 0, rect.width, 180); };
218
+      img.src = data;
219
+    }
220
+  }
221
+
222
+  function pos(e) {
223
+    const rect = canvas.getBoundingClientRect();
224
+    const t = e.touches ? e.touches[0] : e;
225
+    return { x: t.clientX - rect.left, y: t.clientY - rect.top };
226
+  }
227
+
228
+  function start(e) {
229
+    e.preventDefault();
230
+    drawing = true;
231
+    const p = pos(e);
232
+    ctx.beginPath();
233
+    ctx.moveTo(p.x, p.y);
234
+  }
235
+  function move(e) {
236
+    if (!drawing) return;
237
+    e.preventDefault();
238
+    const p = pos(e);
239
+    ctx.lineTo(p.x, p.y);
240
+    ctx.stroke();
241
+    hasInk = true;
242
+  }
243
+  function end() { drawing = false; }
244
+
245
+  canvas.addEventListener('mousedown', start);
246
+  canvas.addEventListener('mousemove', move);
247
+  window.addEventListener('mouseup', end);
248
+  canvas.addEventListener('touchstart', start, { passive: false });
249
+  canvas.addEventListener('touchmove', move, { passive: false });
250
+  canvas.addEventListener('touchend', end);
251
+
252
+  document.getElementById('btnClearFirma').addEventListener('click', function () {
253
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
254
+    hasInk = false;
255
+  });
256
+
257
+  form.addEventListener('submit', function (e) {
258
+    if (!hasInk) {
259
+      e.preventDefault();
260
+      alert('Disegna la firma prima di confermare.');
261
+      return;
262
+    }
263
+    input.value = canvas.toDataURL('image/png');
264
+  });
265
+
266
+  window.addEventListener('resize', resize);
267
+  resize();
268
+})();
269
+</script>
270
+@endunless
271
+@endsection

+ 6
- 0
routes/web.php View File

@@ -28,6 +28,7 @@ use App\Http\Controllers\PromoCodeController;
28 28
 use App\Http\Controllers\AcquistoServizioPublicController;
29 29
 use App\Http\Controllers\AcquistoServizioOperatoreController;
30 30
 use App\Http\Controllers\AcquistoServizioFirmaController;
31
+use App\Http\Controllers\SegnalazioneRapportoFirmaController;
31 32
 use App\Http\Controllers\ShortUrlController;
32 33
 use Illuminate\Support\Facades\Route;
33 34
 
@@ -151,6 +152,8 @@ Route::middleware(['auth:sanctum', config('jetstream.auth_session')])->prefix('a
151 152
     Route::post('segnalazione/batch-update', [SegnalazioneController::class, 'admin_batch_update'])->name('admin.segnalazione.batch_update');
152 153
     Route::post('segnalazione/batch-delete', [SegnalazioneController::class, 'admin_batch_delete'])->name('admin.segnalazione.batch_delete');
153 154
     Route::get('segnalazione/{id}/show', [SegnalazioneController::class, 'admin_show'])->name('admin.segnalazione.show');
155
+    Route::get('segnalazione/{id}/rapporto', [SegnalazioneController::class, 'admin_rapporto'])->name('admin.segnalazione.rapporto');
156
+    Route::post('segnalazione/{id}/rapporto', [SegnalazioneController::class, 'admin_rapporto_store'])->name('admin.segnalazione.rapporto.store');
154 157
     Route::post('segnalazione/{id}/todo/{todoId}/toggle', [SegnalazioneController::class, 'admin_toggle_todo'])->name('admin.segnalazione.todo.toggle');
155 158
     Route::post('segnalazione/{id}/todo/{todoId}/note', [SegnalazioneController::class, 'admin_update_todo_note'])->name('admin.segnalazione.todo.note');
156 159
     Route::post('segnalazione/{id}/todo', [SegnalazioneController::class, 'admin_store_todo'])->name('admin.segnalazione.todo.store');
@@ -276,6 +279,9 @@ Route::post('webhook/stripe/acquisto-servizio', [AcquistoServizioPublicControlle
276 279
 Route::post('webhook/paypal/acquisto-servizio', [AcquistoServizioPublicController::class, 'paypalWebhook']);
277 280
 
278 281
 Route::get('segnalazione/show/{token}', [SegnalazioneController::class, 'show'])->name('segnalazione.show');
282
+Route::get('segnalazione/rapporto-firma/{token}', [SegnalazioneRapportoFirmaController::class, 'show'])->name('segnalazione.rapporto_firma.show');
283
+Route::post('segnalazione/rapporto-firma/{token}', [SegnalazioneRapportoFirmaController::class, 'store'])->name('segnalazione.rapporto_firma.store');
284
+Route::get('segnalazione/rapporto-firma/{token}/pdf', [SegnalazioneRapportoFirmaController::class, 'pdf'])->name('segnalazione.rapporto_firma.pdf');
279 285
 
280 286
 Route::get('allegato_annotazione/show', [AllegatoAnnotazioneController::class, 'show'])->name('allegato_annotazione.show');
281 287
 Route::delete('allegato_annotazione/delete', [AllegatoAnnotazioneController::class, 'delete'])->name('allegato_annotazione.delete');

Loading…
Cancel
Save