From 755e3a5015cf55119e8fc8088b8dcf351d751925 Mon Sep 17 00:00:00 2001 From: michele Date: Sun, 12 Apr 2026 23:16:28 +0000 Subject: [PATCH] Add camera metadata fallback and refine ticket acqua --- app/Filament/Pages/Supporto/TicketAcqua.php | 284 +++++++++++++++++- app/Filament/Pages/Supporto/TicketMobile.php | 84 +++++- .../Support/TicketAttachmentUploadService.php | 70 +++++ .../pages/supporto/ticket-acqua.blade.php | 120 +++++++- .../pages/supporto/ticket-mobile.blade.php | 93 +++++- 5 files changed, 632 insertions(+), 19 deletions(-) diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php index 7a183de..ca005b8 100644 --- a/app/Filament/Pages/Supporto/TicketAcqua.php +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -79,6 +79,13 @@ class TicketAcqua extends Page /** @var array|null */ public ?array $previousReading = null; + public string $unitaSearch = ''; + + public ?string $suggestedReaderName = null; + + /** @var array> */ + public array $waterClientMetadata = []; + public static function canAccess(): bool { $user = Auth::user(); @@ -89,20 +96,25 @@ public static function canAccess(): bool public function mount(): void { + $user = Auth::user(); $this->dataLettura = now()->toDateString(); + $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; $this->resetWaterDraftUploadState(); $this->loadStabileOptions(); $this->bootstrapDefaultSelection(); $this->loadServiceOptions(); $this->loadUnitOptions(); $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); } public function updatedStabileId(): void { + $this->unitaSearch = ''; $this->loadServiceOptions(); $this->loadUnitOptions(); $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); } public function updatedServizioId(): void @@ -113,6 +125,7 @@ public function updatedServizioId(): void public function updatedUnitaId(): void { $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); } public function updatedPendingWaterPhotos(): void @@ -133,6 +146,34 @@ public function removeWaterPhoto(int $index): void $this->syncWaterDescriptionSlots(); } + /** + * @param array> $items + */ + public function updateWaterClientMetadata(array $items): void + { + $this->waterClientMetadata = array_values(array_filter(array_map(function (mixed $item): ?array { + if (! is_array($item)) { + return null; + } + + $name = trim((string) ($item['name'] ?? '')); + $size = (int) ($item['size'] ?? 0); + if ($name === '' || $size < 0) { + return null; + } + + return [ + 'source' => trim((string) ($item['source'] ?? '')), + 'name' => $name, + 'size' => $size, + 'captured_at' => trim((string) ($item['captured_at'] ?? '')), + 'device' => trim((string) ($item['device'] ?? '')), + 'gps_decimal' => is_array($item['gps_decimal'] ?? null) ? $item['gps_decimal'] : [], + 'gps_accuracy_meters' => $item['gps_accuracy_meters'] ?? null, + ]; + }, $items))); + } + /** * @return array}> */ @@ -148,7 +189,10 @@ public function getSelectedWaterUploadsProperty(): array $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('foto-' . $index)); $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $protocol = $this->resolveDraftUploadCode($file, $index); - $metadata = app(TicketAttachmentUploadService::class)->inspectUpload($file); + $metadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + app(TicketAttachmentUploadService::class)->inspectUpload($file), + $this->resolveClientCaptureMetadata($name, (int) (method_exists($file, 'getSize') ? $file->getSize() : 0)), + ); $preview = null; if (method_exists($file, 'temporaryUrl')) { @@ -182,6 +226,11 @@ public function registraLetturaAcqua(): void return; } + $this->ensureDefaultSelections(); + if (blank($this->rilevatoreNome)) { + $this->rilevatoreNome = trim((string) ($this->suggestedReaderName ?: $user->name)); + } + $validated = $this->validate([ 'stabileId' => ['required', 'integer'], 'servizioId' => ['required', 'integer'], @@ -192,6 +241,21 @@ public function registraLetturaAcqua(): void 'rilevatoreContatto' => ['nullable', 'string', 'max:255'], 'noteGenerali' => ['nullable', 'string', 'max:2000'], 'newWaterPhotos.*' => ['nullable', 'image', 'max:8192'], + ], [ + 'stabileId.required' => 'Seleziona lo stabile.', + 'servizioId.required' => 'Seleziona il servizio acqua.', + 'unitaId.required' => 'Seleziona o cerca l\'unità immobiliare.', + 'letturaAttuale.required' => 'Inserisci la lettura attuale del contatore.', + 'letturaAttuale.numeric' => 'La lettura attuale deve essere numerica.', + 'rilevatoreNome.required' => 'Inserisci il nominativo del letturista.', + 'newWaterPhotos.*.image' => 'Ogni allegato deve essere una foto valida.', + 'newWaterPhotos.*.max' => 'Ogni foto può pesare al massimo 8 MB.', + ], [ + 'stabileId' => 'stabile', + 'servizioId' => 'servizio acqua', + 'unitaId' => 'unità immobiliare', + 'letturaAttuale' => 'lettura attuale', + 'rilevatoreNome' => 'nominativo letturista', ]); $stabileIds = $this->resolveAccessibleStabileIds($user); @@ -203,6 +267,7 @@ public function registraLetturaAcqua(): void ->first(); $unita = UnitaImmobiliare::query() + ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) ->where('id', (int) $validated['unitaId']) ->where('stabile_id', (int) $validated['stabileId']) ->first(); @@ -228,6 +293,11 @@ public function registraLetturaAcqua(): void $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; $letturaFine = round((float) $validated['letturaAttuale'], 3); $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; + $readerName = trim((string) ($validated['rilevatoreNome'] ?? '')); + $readingDate = filled($validated['dataLettura'] ?? null) + ? Carbon::parse((string) $validated['dataLettura']) + : now(); + $storageDirectory = $this->buildWaterStorageDirectory($servizio, $readingDate); if ($consumo !== null && $consumo < 0) { $this->addError('letturaAttuale', 'La lettura attuale non può essere inferiore alla precedente.'); @@ -248,14 +318,28 @@ public function registraLetturaAcqua(): void } $protocol = $this->resolveDraftUploadCode($file, $index); + $naming = $this->buildWaterPhotoNaming( + $unita, + $this->resolveUnitReaderLabel($unita), + $protocol, + (string) $file->getClientOriginalName(), + $readingDate, + ); $stored = app(TicketAttachmentUploadService::class)->store( $file, - 'ticket-acqua/' . $servizio->id . '/' . $unita->id, + $storageDirectory, [ - 'stored_basename' => pathinfo($this->buildDraftUploadDisplayName($protocol, (string) $file->getClientOriginalName()), PATHINFO_FILENAME), - 'display_name' => $this->buildDraftUploadDisplayName($protocol, (string) $file->getClientOriginalName()), + 'stored_basename' => $naming['stored_basename'], + 'display_name' => $naming['display_name'], ] ); + $storedMetadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $this->resolveClientCaptureMetadata( + (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ''), + (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + ), + ); $photoAssets[] = [ 'index' => $index, @@ -265,8 +349,11 @@ public function registraLetturaAcqua(): void 'source_original_name' => $stored['source_original_name'] ?? null, 'mime' => $stored['mime'] ?? null, 'size' => $stored['size'] ?? null, - 'metadata' => is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + 'metadata' => $storedMetadata, 'description' => trim((string) ($this->waterPhotoDescriptions[$index] ?? '')), + 'drive_relative_directory' => $naming['drive_relative_directory'], + 'drive_display_name' => $naming['display_name'], + 'stored_basename' => $naming['stored_basename'], ]; } @@ -276,15 +363,13 @@ public function registraLetturaAcqua(): void 'unita_immobiliare_id' => (int) $unita->id, 'fornitore_id' => $servizio->fornitore_id, 'periodo_dal' => $previous?->periodo_al, - 'periodo_al' => filled($validated['dataLettura'] ?? null) - ? Carbon::parse((string) $validated['dataLettura'])->toDateString() - : now()->toDateString(), + 'periodo_al' => $readingDate->toDateString(), 'tipologia_lettura' => 'ticket_acqua_mobile', 'canale_acquisizione' => 'filament_ticket_acqua', 'workflow_stato' => 'ricevuta', 'riferimento_acquisizione' => 'TICKET-ACQUA:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis'), 'rilevatore_tipo' => 'operatore_filament', - 'rilevatore_nome' => trim((string) $validated['rilevatoreNome']), + 'rilevatore_nome' => $readerName, 'lettura_precedente_valore' => $letturaInizio, 'lettura_inizio' => $letturaInizio, 'lettura_fine' => $letturaFine, @@ -294,6 +379,7 @@ public function registraLetturaAcqua(): void 'photos' => $photoAssets, 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), 'note' => trim((string) ($validated['noteGenerali'] ?? '')), + 'drive_relative_directory' => $storageDirectory, ], 'consumo_valore' => $consumo, 'consumo_unita' => 'mc', @@ -302,6 +388,8 @@ public function registraLetturaAcqua(): void 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), 'note' => trim((string) ($validated['noteGenerali'] ?? '')), 'photo_assets' => $photoAssets, + 'drive_relative_directory' => $storageDirectory, + 'reader_label' => $this->resolveUnitReaderLabel($unita), ], 'created_by' => (int) $user->id, ]); @@ -311,6 +399,7 @@ public function registraLetturaAcqua(): void $this->dataLettura = now()->toDateString(); $this->resetWaterDraftUploadState(); $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); $this->dispatch('ticket-acqua-draft-reset'); Notification::make() @@ -403,6 +492,7 @@ private function loadUnitOptions(): void } $this->unitaOptions = UnitaImmobiliare::query() + ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) ->where('stabile_id', (int) $this->stabileId) ->where(function ($query): void { $query->whereNull('attiva')->orWhere('attiva', true); @@ -426,9 +516,18 @@ private function loadUnitOptions(): void $bits[] = implode(' ', $location); } + $readerLabel = $this->resolveUnitReaderLabel($unita); + $searchText = mb_strtolower(implode(' ', array_filter([ + implode(' ', array_filter($bits)), + $readerLabel, + ]))); + return [ - 'id' => (int) $unita->id, - 'label' => implode(' - ', array_filter($bits)), + 'id' => (int) $unita->id, + 'label' => implode(' - ', array_filter($bits)), + 'reader_label' => $readerLabel, + 'folder_prefix' => $this->buildUnitFolderPrefix($unita), + 'search_text' => $searchText, ]; }) ->all(); @@ -439,6 +538,19 @@ private function loadUnitOptions(): void } } + /** + * @return array + */ + public function getFilteredUnitaOptionsProperty(): array + { + $needle = mb_strtolower(trim($this->unitaSearch)); + if ($needle === '') { + return $this->unitaOptions; + } + + return array_values(array_filter($this->unitaOptions, fn(array $option): bool => str_contains((string) ($option['search_text'] ?? ''), $needle))); + } + private function loadPreviousReading(): void { $this->previousReading = null; @@ -500,6 +612,7 @@ private function resetWaterDraftUploadState(): void $this->pendingWaterPhotos = []; $this->waterPhotoDescriptions = []; $this->waterUploadCodes = []; + $this->waterClientMetadata = []; $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); $this->waterDraftSequence = 1; } @@ -571,6 +684,155 @@ private function resolveUploadQueueKey(object $file, int $fallbackIndex): string return spl_object_hash($file) ?: 'water-upload-' . $fallbackIndex; } + private function syncSuggestedReaderName(): void + { + $previous = $this->suggestedReaderName; + $current = $this->resolveCurrentUnitOption(); + + $this->suggestedReaderName = trim((string) ($current['reader_label'] ?? '')) ?: null; + + if (blank($this->rilevatoreNome) || ($previous !== null && $this->rilevatoreNome === $previous)) { + $this->rilevatoreNome = $this->suggestedReaderName; + } + } + + private function ensureDefaultSelections(): void + { + if (! $this->stabileId && $this->stabileOptions !== []) { + $this->stabileId = (int) ($this->stabileOptions[0]['id'] ?? 0) ?: null; + } + + if (! $this->servizioId && $this->servizioOptions !== []) { + $this->servizioId = (int) ($this->servizioOptions[0]['id'] ?? 0) ?: null; + } + + $filtered = $this->getFilteredUnitaOptionsProperty(); + if (! $this->unitaId && $filtered !== []) { + $this->unitaId = (int) ($filtered[0]['id'] ?? 0) ?: null; + } + } + + /** + * @return array|null + */ + private function resolveCurrentUnitOption(): ?array + { + foreach ($this->unitaOptions as $option) { + if ((int) ($option['id'] ?? 0) === (int) ($this->unitaId ?? 0)) { + return $option; + } + } + + return null; + } + + private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string + { + $role = $unita->rubricaRuoliAttivi + ?->sortByDesc(fn($item) => (bool) ($item->is_preferito ?? false)) + ->first(); + + if ($role?->contatto) { + $label = trim((string) ($role->contatto->nome_completo ?: $role->contatto->ragione_sociale)); + if ($label !== '') { + return $label; + } + } + + $historic = $unita->nominativiStorici + ?->sortByDesc(fn($item) => (string) ($item->data_fine ?? '9999-12-31')) + ->sortByDesc(fn($item) => (string) ($item->data_inizio ?? '')) + ->first(); + + $historicLabel = trim((string) ($historic->nominativo ?? '')); + if ($historicLabel !== '') { + return $historicLabel; + } + + return trim((string) ($unita->denominazione ?? '')); + } + + private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string + { + $parts = array_filter([ + filled($unita->scala) ? Str::upper(trim((string) $unita->scala)) : null, + filled($unita->interno) ? trim((string) $unita->interno) : null, + ! filled($unita->interno) && filled($unita->codice_unita) ? trim((string) $unita->codice_unita) : null, + ]); + + $raw = implode('_', $parts); + $normalized = preg_replace('/[^A-Za-z0-9_\-]+/', '_', $raw) ?? ''; + + return trim($normalized, '_-') !== '' ? trim($normalized, '_-') : ('UI_' . (int) $unita->id); + } + + private function buildWaterStorageDirectory(StabileServizio $servizio, Carbon $readingDate): string + { + $servizio->loadMissing('stabile:id,denominazione,codice_stabile'); + + $stabileLabel = trim((string) ($servizio->stabile?->denominazione ?: $servizio->stabile?->codice_stabile ?: ('stabile-' . $servizio->stabile_id))); + $stabileSlug = $this->normalizeLinuxPathSegment($stabileLabel); + + return 'condomini/' . $stabileSlug . '/utenze/acqua/letture/' . $readingDate->format('Y'); + } + + /** + * @return array{stored_basename:string,display_name:string,drive_relative_directory:string} + */ + private function buildWaterPhotoNaming(UnitaImmobiliare $unita, string $readerLabel, string $protocol, string $originalName, Carbon $readingDate): array + { + $unita->loadMissing('stabile:id,denominazione,codice_stabile'); + + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + $suffix = $extension !== '' ? '.' . $extension : ''; + $readerSafe = $this->normalizeLinuxPathSegment($readerLabel !== '' ? $readerLabel : ('ui-' . $unita->id)); + $folderPrefix = Str::lower($this->buildUnitFolderPrefix($unita)); + $storedBase = trim($folderPrefix . '_' . $readerSafe . '_' . Str::lower(str_replace(['.', ' '], '-', $protocol)), '_'); + + $displayParts = array_filter([ + filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, + $readerLabel !== '' ? $readerLabel : null, + ]); + $displayName = implode(' ', $displayParts); + if ($displayName === '') { + $displayName = trim((string) ($unita->codice_unita ?: ('UI ' . $unita->id))); + } + + return [ + 'stored_basename' => trim($storedBase, '_-') !== '' ? trim($storedBase, '_-') : ('water-photo-' . $unita->id . '-' . $readingDate->format('YmdHis')), + 'display_name' => $displayName . $suffix, + 'drive_relative_directory' => 'condomini/' . $this->normalizeLinuxPathSegment((string) ($unita->stabile?->denominazione ?? 'stabile')) . '/utenze/acqua/letture/' . $readingDate->format('Y'), + ]; + } + + private function normalizeLinuxPathSegment(string $value): string + { + $ascii = (string) Str::of($value)->ascii()->lower(); + $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; + + return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; + } + + /** + * @return array + */ + private function resolveClientCaptureMetadata(string $originalName, int $size): array + { + foreach ($this->waterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { + return $item; + } + } + + foreach ($this->waterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName) { + return $item; + } + } + + return []; + } + /** * @return array */ diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 57cef91..24fca1a 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -85,6 +85,9 @@ class TicketMobile extends Page /** @var array */ public array $newTicketUploadCodes = []; + /** @var array> */ + public array $newTicketClientMetadata = []; + public string $newTicketDraftProtocol = ''; public int $newTicketDraftSequence = 1; @@ -682,6 +685,34 @@ public function removeNewTicketFile(int $index): void $this->syncAttachmentDescriptionSlots(); } + /** + * @param array> $items + */ + public function updateClientCaptureMetadata(array $items): void + { + $this->newTicketClientMetadata = array_values(array_filter(array_map(function (mixed $item): ?array { + if (! is_array($item)) { + return null; + } + + $name = trim((string) ($item['name'] ?? '')); + $size = (int) ($item['size'] ?? 0); + if ($name === '' || $size < 0) { + return null; + } + + return [ + 'source' => trim((string) ($item['source'] ?? '')), + 'name' => $name, + 'size' => $size, + 'captured_at' => trim((string) ($item['captured_at'] ?? '')), + 'device' => trim((string) ($item['device'] ?? '')), + 'gps_decimal' => is_array($item['gps_decimal'] ?? null) ? $item['gps_decimal'] : [], + 'gps_accuracy_meters' => $item['gps_accuracy_meters'] ?? null, + ]; + }, $items))); + } + /** * @return array}> */ @@ -703,6 +734,15 @@ public function getSelectedUploadsProperty(): array $metadata = $isImage ? app(TicketAttachmentUploadService::class)->inspectUpload($file) : []; + $clientMetadata = $isImage + ? $this->resolveClientCaptureMetadata($index, $name, $size) + : []; + $metadata = $isImage + ? app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + is_array($metadata) ? $metadata : [], + $clientMetadata, + ) + : []; $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { @@ -765,6 +805,15 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int 'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME), 'display_name' => $displayName, ]); + $clientMetadata = $this->resolveClientCaptureMetadata( + (int) $index, + (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ''), + (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + ); + $storedMetadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $clientMetadata, + ); $payload = [ 'ticket_id' => (int) $ticket->id, @@ -776,14 +825,14 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int 'size' => $stored['size'], 'description' => $this->resolveAttachmentDescription( (int) $index, - is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $storedMetadata, $stored['original_name'] ?? null, $stored['source_original_name'] ?? null, ), ]; if ($supportsAttachmentMetadata) { - $payload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : []; + $payload['metadata'] = $storedMetadata; } TicketAttachment::query()->create($payload); @@ -916,6 +965,7 @@ private function resetDraftUploadState(): void $this->pendingTicketAttachments = []; $this->newTicketAttachmentDescriptions = []; $this->newTicketUploadCodes = []; + $this->newTicketClientMetadata = []; $this->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His'); $this->newTicketDraftSequence = 1; } @@ -979,6 +1029,36 @@ private function resolveUploadQueueKey(object $file, int $fallbackIndex): string return spl_object_hash($file) ?: 'upload-' . $fallbackIndex; } + /** + * @return array + */ + private function resolveClientCaptureMetadata(int $index, string $originalName, int $size): array + { + $source = $index < count($this->newTicketCameraShots) ? 'camera' : 'attachment'; + + foreach ($this->newTicketClientMetadata as $item) { + if (($item['source'] ?? '') !== $source) { + continue; + } + + if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { + return $item; + } + } + + foreach ($this->newTicketClientMetadata as $item) { + if (($item['source'] ?? '') !== $source) { + continue; + } + + if (($item['name'] ?? '') === $originalName) { + return $item; + } + } + + return []; + } + private function buildStoredUploadDisplayName(Ticket $ticket, int $index, object $file): string { $originalName = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); diff --git a/app/Services/Support/TicketAttachmentUploadService.php b/app/Services/Support/TicketAttachmentUploadService.php index 6e1b475..c6df7ad 100644 --- a/app/Services/Support/TicketAttachmentUploadService.php +++ b/app/Services/Support/TicketAttachmentUploadService.php @@ -26,6 +26,60 @@ public function inspectStoredPath(string $path): array return $this->extractImageMetadata($path); } + /** + * @param array $metadata + * @param array $clientMetadata + * @return array + */ + public function mergeClientCaptureMetadata(array $metadata, array $clientMetadata): array + { + if ($clientMetadata === []) { + return $metadata; + } + + $merged = $metadata; + + $captureSource = trim((string) ($clientMetadata['source'] ?? '')); + if ($captureSource !== '') { + $merged['capture_source'] = $captureSource; + } + + $capturedAt = $this->normalizeClientCapturedAt($clientMetadata['captured_at'] ?? null); + if (! filled($merged['exif_datetime'] ?? null) && $capturedAt !== null) { + $merged['exif_datetime'] = $capturedAt; + } + + $device = trim((string) ($clientMetadata['device'] ?? '')); + if (! filled($merged['device'] ?? null) && $device !== '') { + $merged['device'] = $device; + } + + $lat = data_get($clientMetadata, 'gps_decimal.lat'); + $lng = data_get($clientMetadata, 'gps_decimal.lng'); + if (! isset($merged['gps_decimal']) && is_numeric($lat) && is_numeric($lng)) { + $merged['gps_decimal'] = [ + 'lat' => round((float) $lat, 6), + 'lng' => round((float) $lng, 6), + ]; + $merged['google_maps_url'] = 'https://maps.google.com/?q=' . $merged['gps_decimal']['lat'] . ',' . $merged['gps_decimal']['lng']; + } + + $accuracy = $clientMetadata['gps_accuracy_meters'] ?? null; + if (is_numeric($accuracy)) { + $merged['gps_accuracy_meters'] = (int) round((float) $accuracy); + } + + $merged['client_capture'] = array_filter([ + 'source' => $captureSource !== '' ? $captureSource : null, + 'captured_at' => $capturedAt, + 'device' => $device !== '' ? $device : null, + 'gps_decimal' => is_array($merged['gps_decimal'] ?? null) ? $merged['gps_decimal'] : null, + 'gps_accuracy_meters' => is_numeric($accuracy) ? (int) round((float) $accuracy) : null, + ], fn($value) => $value !== null && $value !== ''); + + return $merged; + } + /** * @param UploadedFile|TemporaryUploadedFile $file * @return array{path:string,mime:string,size:int,original_name:string} @@ -330,6 +384,22 @@ private function extractImageMetadataFromAbsolutePath(string $absolutePath): arr return $metadata; } + private function normalizeClientCapturedAt(mixed $value): ?string + { + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + try { + $date = new \DateTimeImmutable($raw); + + return $date->format('Y:m:d H:i:s'); + } catch (\Throwable) { + return null; + } + } + private function gpsCoordinateToDecimal(mixed $coordinate, mixed $reference): ?float { if (! is_array($coordinate) || count($coordinate) < 3) { diff --git a/resources/views/filament/pages/supporto/ticket-acqua.blade.php b/resources/views/filament/pages/supporto/ticket-acqua.blade.php index dfc44fd..7261597 100644 --- a/resources/views/filament/pages/supporto/ticket-acqua.blade.php +++ b/resources/views/filament/pages/supporto/ticket-acqua.blade.php @@ -45,14 +45,21 @@ + + @@ -68,8 +75,11 @@
@if(count($this->selectedWaterUploads) > 0) + @php($focusUpload = $this->selectedWaterUploads[0] ?? null) + @if($focusUpload) +
+
Chicca lettura rapida
+
Ti evidenzio la zona del display del contatore e ti lascio il campo lettura già dentro il riquadro operativo.
+
+
+ {{ $focusUpload['name'] }} +
+
+
+ +
Il riquadro giallo indica l'area delle cifre da leggere nella foto principale.
+
+
+
+ @endif +
@foreach($this->selectedWaterUploads as $upload)
@@ -165,6 +274,9 @@
{{ $upload['name'] }}
Protocollo bozza: {{ $upload['protocol'] }}
Origine: {{ $upload['original_name'] }}
+ @if(!empty($upload['metadata']['capture_source'])) +
Sorgente: {{ $upload['metadata']['capture_source'] }}
+ @endif
@php $metadata = is_array($upload['metadata'] ?? null) ? $upload['metadata'] : []; diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index 18fe9ee..25ff29d 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -239,10 +239,14 @@ draftSequence: {{ (int) $newTicketDraftSequence }}, localCameraPreviews: [], localAttachmentPreviews: [], + localClientMetadata: [], init() { window.__ticketMobilePreviewState = window.__ticketMobilePreviewState || { camera: [], attachment: [] }; + window.__ticketMobileClientMetadataState = window.__ticketMobileClientMetadataState || []; this.localCameraPreviews = [...(window.__ticketMobilePreviewState.camera || [])]; this.localAttachmentPreviews = [...(window.__ticketMobilePreviewState.attachment || [])]; + this.localClientMetadata = [...window.__ticketMobileClientMetadataState]; + this.syncClientMetadataToWire(); }, persist() { window.__ticketMobilePreviewState = { @@ -250,6 +254,10 @@ attachment: [...this.localAttachmentPreviews], }; }, + persistClientMetadata() { + window.__ticketMobileClientMetadataState = [...this.localClientMetadata]; + this.syncClientMetadataToWire(); + }, revoke(items) { items.forEach((item) => { if (item.url) { @@ -257,6 +265,17 @@ } }); }, + syncClientMetadataToWire() { + $wire.set('newTicketClientMetadata', this.localClientMetadata.map((item) => ({ + source: item.source, + name: item.name, + size: item.size, + captured_at: item.captured_at || null, + device: item.device || null, + gps_decimal: item.gps_decimal || null, + gps_accuracy_meters: item.gps_accuracy_meters || null, + })), false); + }, buildDraftName(file, sequence) { const sourceName = file?.name || 'file'; const extension = sourceName.includes('.') ? sourceName.split('.').pop().toLowerCase() : ''; @@ -284,10 +303,72 @@ this[target] = Array.from(existing.values()); }, - setPreviews(event, kind) { + mergeClientMetadata(items) { + const existing = new Map(this.localClientMetadata.map((item) => [`${item.source}|${item.name}|${item.size}`, item])); + + items.forEach((item) => { + if (!item?.name) { + return; + } + + existing.set(`${item.source}|${item.name}|${item.size}`, item); + }); + + this.localClientMetadata = Array.from(existing.values()); + this.persistClientMetadata(); + }, + getDeviceLabel() { + if (navigator.userAgentData?.platform) { + return navigator.userAgentData.platform; + } + + return navigator.platform || navigator.userAgent || ''; + }, + async readGeolocation() { + if (!navigator.geolocation) { + return null; + } + + return await new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (position) => resolve({ + gps_decimal: { + lat: position.coords.latitude, + lng: position.coords.longitude, + }, + gps_accuracy_meters: position.coords.accuracy || null, + }), + () => resolve(null), + { + enableHighAccuracy: true, + maximumAge: 30000, + timeout: 8000, + }, + ); + }); + }, + async buildClientMetadata(file, kind) { + const base = { + source: kind, + name: file?.name || '', + size: file?.size || 0, + captured_at: new Date(file?.lastModified || Date.now()).toISOString(), + device: this.getDeviceLabel(), + }; + + if (kind !== 'camera') { + return base; + } + + const geo = await this.readGeolocation(); + + return geo ? { ...base, ...geo } : base; + }, + async setPreviews(event, kind) { const target = kind === 'camera' ? 'localCameraPreviews' : 'localAttachmentPreviews'; const batchId = Date.now(); - const mapped = Array.from(event?.target?.files || []).map((file, index) => ({ + const files = Array.from(event?.target?.files || []); + const mapped = files.map((file, index) => ({ key: `${kind}-${batchId}-${index}-${file.name}-${file.size}`, name: file.name, draftName: this.buildDraftName(file, this.draftSequence + index), @@ -296,6 +377,7 @@ url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null, })); this.appendPreviews(target, mapped); + this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file, kind)))); this.persist(); if (event?.target) { event.target.value = null; @@ -305,12 +387,19 @@ if (kind === null || kind === 'camera') { this.revoke(this.localCameraPreviews); this.localCameraPreviews = []; + this.localClientMetadata = kind === null + ? [] + : this.localClientMetadata.filter((item) => item.source !== 'camera'); } if (kind === null || kind === 'attachment') { this.revoke(this.localAttachmentPreviews); this.localAttachmentPreviews = []; + if (kind !== null) { + this.localClientMetadata = this.localClientMetadata.filter((item) => item.source !== 'attachment'); + } } this.persist(); + this.persistClientMetadata(); }, previewFor(draftName, originalName, size = 0) { const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews];