From ab2b3af2fac04e219f3345dc70c25a3e33960bb6 Mon Sep 17 00:00:00 2001 From: michele Date: Sun, 12 Apr 2026 22:09:09 +0000 Subject: [PATCH] Fix ticket mobile previews and add ticket acqua page --- app/Filament/Pages/Supporto/TicketAcqua.php | 586 ++++++++++++++++++ .../Pages/Supporto/TicketGestione.php | 53 ++ .../Support/TicketAttachmentUploadService.php | 8 + .../pages/supporto/ticket-acqua.blade.php | 219 +++++++ .../pages/supporto/ticket-mobile.blade.php | 11 +- 5 files changed, 874 insertions(+), 3 deletions(-) create mode 100644 app/Filament/Pages/Supporto/TicketAcqua.php create mode 100644 resources/views/filament/pages/supporto/ticket-acqua.blade.php diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php new file mode 100644 index 0000000..7a183de --- /dev/null +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -0,0 +1,586 @@ + */ + public array $newWaterPhotos = []; + + /** @var array */ + public array $pendingWaterPhotos = []; + + /** @var array */ + public array $waterPhotoDescriptions = []; + + /** @var array */ + public array $waterUploadCodes = []; + + public string $waterDraftProtocol = ''; + + public int $waterDraftSequence = 1; + + /** @var array */ + public array $stabileOptions = []; + + /** @var array */ + public array $servizioOptions = []; + + /** @var array */ + public array $unitaOptions = []; + + /** @var array|null */ + public ?array $previousReading = null; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + } + + public function mount(): void + { + $this->dataLettura = now()->toDateString(); + $this->resetWaterDraftUploadState(); + $this->loadStabileOptions(); + $this->bootstrapDefaultSelection(); + $this->loadServiceOptions(); + $this->loadUnitOptions(); + $this->loadPreviousReading(); + } + + public function updatedStabileId(): void + { + $this->loadServiceOptions(); + $this->loadUnitOptions(); + $this->loadPreviousReading(); + } + + public function updatedServizioId(): void + { + $this->loadPreviousReading(); + } + + public function updatedUnitaId(): void + { + $this->loadPreviousReading(); + } + + public function updatedPendingWaterPhotos(): void + { + $this->appendPendingWaterPhotos(); + } + + public function removeWaterPhoto(int $index): void + { + $file = $this->newWaterPhotos[$index] ?? null; + unset($this->newWaterPhotos[$index]); + $this->newWaterPhotos = array_values($this->newWaterPhotos); + + if (is_object($file)) { + unset($this->waterUploadCodes[$this->resolveUploadQueueKey($file, $index)]); + } + + $this->syncWaterDescriptionSlots(); + } + + /** + * @return array}> + */ + public function getSelectedWaterUploadsProperty(): array + { + $rows = []; + + foreach ($this->newWaterPhotos as $index => $file) { + if (! is_object($file)) { + continue; + } + + $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); + $preview = null; + + if (method_exists($file, 'temporaryUrl')) { + try { + $preview = (string) $file->temporaryUrl(); + } catch (Throwable) { + $preview = null; + } + } + + $rows[] = [ + 'index' => (int) $index, + 'name' => $this->buildDraftUploadDisplayName($protocol, $name), + 'original_name' => $name, + 'protocol' => $protocol, + 'mime' => $mime, + 'is_image' => str_starts_with($mime, 'image/'), + 'preview_url' => $preview, + 'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + 'metadata' => is_array($metadata) ? $metadata : [], + ]; + } + + return $rows; + } + + public function registraLetturaAcqua(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $validated = $this->validate([ + 'stabileId' => ['required', 'integer'], + 'servizioId' => ['required', 'integer'], + 'unitaId' => ['required', 'integer'], + 'letturaAttuale' => ['required', 'numeric', 'min:0'], + 'dataLettura' => ['nullable', 'date'], + 'rilevatoreNome' => ['required', 'string', 'max:255'], + 'rilevatoreContatto' => ['nullable', 'string', 'max:255'], + 'noteGenerali' => ['nullable', 'string', 'max:2000'], + 'newWaterPhotos.*' => ['nullable', 'image', 'max:8192'], + ]); + + $stabileIds = $this->resolveAccessibleStabileIds($user); + $servizio = StabileServizio::query() + ->where('id', (int) $validated['servizioId']) + ->where('stabile_id', (int) $validated['stabileId']) + ->whereIn('stabile_id', $stabileIds) + ->where('tipo', 'acqua') + ->first(); + + $unita = UnitaImmobiliare::query() + ->where('id', (int) $validated['unitaId']) + ->where('stabile_id', (int) $validated['stabileId']) + ->first(); + + if (! $servizio || ! $unita) { + Notification::make() + ->title('Selezione acqua non valida') + ->body('Controlla stabile, servizio e unità prima di inviare la lettura.') + ->warning() + ->send(); + + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unita->id) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; + $letturaFine = round((float) $validated['letturaAttuale'], 3); + $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; + + if ($consumo !== null && $consumo < 0) { + $this->addError('letturaAttuale', 'La lettura attuale non può essere inferiore alla precedente.'); + + Notification::make() + ->title('Lettura non coerente') + ->body('Il valore inserito è inferiore alla lettura precedente memorizzata.') + ->warning() + ->send(); + + return; + } + + $photoAssets = []; + foreach ($this->newWaterPhotos as $index => $file) { + if (! is_object($file) || ! method_exists($file, 'store')) { + continue; + } + + $protocol = $this->resolveDraftUploadCode($file, $index); + $stored = app(TicketAttachmentUploadService::class)->store( + $file, + 'ticket-acqua/' . $servizio->id . '/' . $unita->id, + [ + 'stored_basename' => pathinfo($this->buildDraftUploadDisplayName($protocol, (string) $file->getClientOriginalName()), PATHINFO_FILENAME), + 'display_name' => $this->buildDraftUploadDisplayName($protocol, (string) $file->getClientOriginalName()), + ] + ); + + $photoAssets[] = [ + 'index' => $index, + 'protocol' => $protocol, + 'path' => $stored['path'], + 'original_name' => $stored['original_name'] ?? null, + 'source_original_name' => $stored['source_original_name'] ?? null, + 'mime' => $stored['mime'] ?? null, + 'size' => $stored['size'] ?? null, + 'metadata' => is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + 'description' => trim((string) ($this->waterPhotoDescriptions[$index] ?? '')), + ]; + } + + $row = StabileServizioLettura::query()->create([ + 'stabile_id' => (int) $servizio->stabile_id, + 'stabile_servizio_id' => (int) $servizio->id, + '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(), + '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']), + 'lettura_precedente_valore' => $letturaInizio, + 'lettura_inizio' => $letturaInizio, + 'lettura_fine' => $letturaFine, + 'lettura_foto_path' => $photoAssets[0]['path'] ?? null, + 'lettura_foto_original_name'=> $photoAssets[0]['original_name'] ?? null, + 'lettura_foto_metadata' => [ + 'photos' => $photoAssets, + 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), + 'note' => trim((string) ($validated['noteGenerali'] ?? '')), + ], + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => [ + 'source' => 'ticket_acqua_filament', + 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), + 'note' => trim((string) ($validated['noteGenerali'] ?? '')), + 'photo_assets' => $photoAssets, + ], + 'created_by' => (int) $user->id, + ]); + + $this->letturaAttuale = null; + $this->noteGenerali = null; + $this->dataLettura = now()->toDateString(); + $this->resetWaterDraftUploadState(); + $this->loadPreviousReading(); + $this->dispatch('ticket-acqua-draft-reset'); + + Notification::make() + ->title('Ticket acqua registrato') + ->body('Lettura salvata per unità ' . ((string) ($unita->codice_unita ?: ('#' . $unita->id))) . ' con riferimento #' . $row->id . '.') + ->success() + ->send(); + } + + private function loadStabileOptions(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + $this->stabileOptions = []; + return; + } + + $this->stabileOptions = StabileContext::accessibleStabili($user) + ->sortBy(fn($stabile) => mb_strtolower((string) ($stabile->denominazione ?? ''))) + ->map(fn($stabile): array => [ + 'id' => (int) $stabile->id, + 'label' => trim((string) (($stabile->codice_stabile ?? '') !== '' + ? ($stabile->codice_stabile . ' - ' . $stabile->denominazione) + : ($stabile->denominazione ?? ('Stabile #' . $stabile->id)))), + ]) + ->values() + ->all(); + } + + private function bootstrapDefaultSelection(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $activeId = StabileContext::resolveActiveStabileId($user); + $optionIds = array_column($this->stabileOptions, 'id'); + + if ($activeId && in_array((int) $activeId, $optionIds, true)) { + $this->stabileId = (int) $activeId; + return; + } + + $this->stabileId = $optionIds[0] ?? null; + } + + private function loadServiceOptions(): void + { + if (! $this->stabileId) { + $this->servizioOptions = []; + $this->servizioId = null; + return; + } + + $this->servizioOptions = StabileServizio::query() + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'acqua') + ->where('attivo', true) + ->orderBy('nome') + ->get(['id', 'nome', 'codice_utenza', 'contatore_matricola']) + ->map(function (StabileServizio $servizio): array { + $bits = [trim((string) ($servizio->nome ?: 'Servizio acqua #' . $servizio->id))]; + if (filled($servizio->codice_utenza)) { + $bits[] = 'Utenza ' . trim((string) $servizio->codice_utenza); + } + if (filled($servizio->contatore_matricola)) { + $bits[] = 'Contatore ' . trim((string) $servizio->contatore_matricola); + } + + return [ + 'id' => (int) $servizio->id, + 'label' => implode(' - ', array_filter($bits)), + ]; + }) + ->all(); + + $serviceIds = array_column($this->servizioOptions, 'id'); + if (! in_array((int) $this->servizioId, $serviceIds, true)) { + $this->servizioId = $serviceIds[0] ?? null; + } + } + + private function loadUnitOptions(): void + { + if (! $this->stabileId) { + $this->unitaOptions = []; + $this->unitaId = null; + return; + } + + $this->unitaOptions = UnitaImmobiliare::query() + ->where('stabile_id', (int) $this->stabileId) + ->where(function ($query): void { + $query->whereNull('attiva')->orWhere('attiva', true); + }) + ->orderBy('codice_unita') + ->orderBy('id') + ->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno']) + ->map(function (UnitaImmobiliare $unita): array { + $bits = [trim((string) ($unita->codice_unita ?: ('UI #' . $unita->id)))]; + if (filled($unita->denominazione)) { + $bits[] = trim((string) $unita->denominazione); + } + + $location = array_filter([ + filled($unita->scala) ? 'Scala ' . trim((string) $unita->scala) : null, + filled($unita->piano) ? 'Piano ' . trim((string) $unita->piano) : null, + filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, + ]); + + if ($location !== []) { + $bits[] = implode(' ', $location); + } + + return [ + 'id' => (int) $unita->id, + 'label' => implode(' - ', array_filter($bits)), + ]; + }) + ->all(); + + $unitIds = array_column($this->unitaOptions, 'id'); + if (! in_array((int) $this->unitaId, $unitIds, true)) { + $this->unitaId = $unitIds[0] ?? null; + } + } + + private function loadPreviousReading(): void + { + $this->previousReading = null; + + if (! $this->servizioId || ! $this->unitaId) { + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $this->servizioId) + ->where('unita_immobiliare_id', (int) $this->unitaId) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + if (! $previous) { + return; + } + + $this->previousReading = [ + 'value' => $previous->lettura_fine, + 'date' => $previous->periodo_al?->format('d/m/Y'), + 'id' => (int) $previous->id, + ]; + } + + private function appendPendingWaterPhotos(): void + { + $pending = array_values(array_filter($this->pendingWaterPhotos, fn($file) => is_object($file))); + if ($pending === []) { + return; + } + + $available = max(0, 4 - count($this->newWaterPhotos)); + if ($available <= 0) { + $this->pendingWaterPhotos = []; + + Notification::make() + ->title('Limite foto raggiunto') + ->body('Puoi tenere in coda al massimo 4 foto per una lettura acqua.') + ->warning() + ->send(); + + return; + } + + $accepted = array_slice($pending, 0, $available); + $currentTotal = count($this->newWaterPhotos); + $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); + $this->pendingWaterPhotos = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); + $this->syncWaterDescriptionSlots(); + } + + private function resetWaterDraftUploadState(): void + { + $this->newWaterPhotos = []; + $this->pendingWaterPhotos = []; + $this->waterPhotoDescriptions = []; + $this->waterUploadCodes = []; + $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); + $this->waterDraftSequence = 1; + } + + private function syncWaterDescriptionSlots(): void + { + $next = []; + + foreach ($this->newWaterPhotos as $index => $_file) { + $next[$index] = (string) ($this->waterPhotoDescriptions[$index] ?? ''); + } + + $this->waterPhotoDescriptions = $next; + } + + /** + * @param array $files + */ + private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void + { + foreach ($files as $offset => $file) { + if (! is_object($file)) { + continue; + } + + $key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset); + if (isset($this->waterUploadCodes[$key])) { + continue; + } + + $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); + $this->waterDraftSequence++; + } + } + + private function resolveDraftUploadCode(object $file, int $fallbackIndex): string + { + $key = $this->resolveUploadQueueKey($file, $fallbackIndex); + + if (! isset($this->waterUploadCodes[$key])) { + $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); + $this->waterDraftSequence++; + } + + return $this->waterUploadCodes[$key]; + } + + private function buildDraftProtocolCode(int $sequence): string + { + return $this->waterDraftProtocol . '-F' . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + } + + private function buildDraftUploadDisplayName(string $protocol, string $originalName): string + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + + return $protocol . ($extension !== '' ? '.' . $extension : ''); + } + + private function resolveUploadQueueKey(object $file, int $fallbackIndex): string + { + if (method_exists($file, 'getFilename')) { + $filename = trim((string) $file->getFilename()); + if ($filename !== '') { + return $filename; + } + } + + return spl_object_hash($file) ?: 'water-upload-' . $fallbackIndex; + } + + /** + * @return array + */ + private function resolveAccessibleStabileIds(User $user): array + { + return StabileContext::accessibleStabili($user) + ->pluck('id') + ->map(fn($value) => (int) $value) + ->filter(fn(int $value) => $value > 0) + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index f9f999e..47ac2b4 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -387,6 +387,14 @@ public function getAttachmentDetails(TicketAttachment $attachment): array $details = $this->parseAttachmentDescription((string) ($attachment->description ?? '')); $metadata = is_array($attachment->metadata ?? null) ? $attachment->metadata : []; + if ($this->shouldHydrateAttachmentMetadata($attachment, $metadata, $details)) { + $storedMetadata = app(TicketAttachmentUploadService::class)->inspectStoredPath((string) $attachment->file_path); + if ($storedMetadata !== []) { + $metadata = array_replace_recursive($storedMetadata, $metadata); + $this->persistHydratedAttachmentMetadata($attachment, $metadata); + } + } + $lat = $metadata['gps_decimal']['lat'] ?? null; $lng = $metadata['gps_decimal']['lng'] ?? null; if ($details['gps'] === null && is_numeric($lat) && is_numeric($lng)) { @@ -417,6 +425,51 @@ public function getAttachmentDetails(TicketAttachment $attachment): array return $details; } + /** + * @param array $metadata + * @param array $details + */ + private function shouldHydrateAttachmentMetadata(TicketAttachment $attachment, array $metadata, array $details): bool + { + if (! str_starts_with((string) ($attachment->mime_type ?? ''), 'image/')) { + return false; + } + + if (! filled($attachment->file_path) || ! Storage::disk('public')->exists((string) $attachment->file_path)) { + return false; + } + + if ($metadata === []) { + return true; + } + + return empty($details['gps']) + || empty($details['exif_datetime']) + || empty($details['device']); + } + + /** + * @param array $metadata + */ + private function persistHydratedAttachmentMetadata(TicketAttachment $attachment, array $metadata): void + { + static $supportsAttachmentMetadata = null; + + if ($supportsAttachmentMetadata === null) { + $supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata'); + } + + if (! $supportsAttachmentMetadata) { + return; + } + + $attachment->metadata = $metadata; + + if ($attachment->isDirty('metadata')) { + $attachment->save(); + } + } + public function getSelectedTicketProperty(): ?Ticket { if (! $this->selectedTicketId) { diff --git a/app/Services/Support/TicketAttachmentUploadService.php b/app/Services/Support/TicketAttachmentUploadService.php index 5b6f8ad..6e1b475 100644 --- a/app/Services/Support/TicketAttachmentUploadService.php +++ b/app/Services/Support/TicketAttachmentUploadService.php @@ -18,6 +18,14 @@ public function inspectUpload(object $file): array return $this->extractImageMetadataFromFileObject($file); } + /** + * @return array + */ + public function inspectStoredPath(string $path): array + { + return $this->extractImageMetadata($path); + } + /** * @param UploadedFile|TemporaryUploadedFile $file * @return array{path:string,mime:string,size:int,original_name:string} diff --git a/resources/views/filament/pages/supporto/ticket-acqua.blade.php b/resources/views/filament/pages/supporto/ticket-acqua.blade.php new file mode 100644 index 0000000..dfc44fd --- /dev/null +++ b/resources/views/filament/pages/supporto/ticket-acqua.blade.php @@ -0,0 +1,219 @@ + +
+ @if ($errors->any()) +
+
Controlla i dati prima di inviare
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+
+

Ticket Acqua

+
Pagina mobile dedicata alle autoletture: valore del contatore in primo piano, foto nello stesso flusso operativo e salvataggio diretto su archivio letture acqua.
+
+
Protocollo bozza {{ $waterDraftProtocol }}
+
+
+ +
+
+
+
+ + + + + +
+
+ +
+ +
Inserisci solo il valore letto. Se alleghi foto, il sistema mantiene preview, metadata e riferimenti dello scatto dentro la lettura acqua.
+
+ +
+
+ + + +
+
+
+ +
+
+
Ultima lettura nota
+ @if($previousReading) +
+
Valore: {{ number_format((float) $previousReading['value'], 3, ',', '.') }}
+
Data: {{ $previousReading['date'] ?: 'n/d' }}
+
Riferimento: #{{ $previousReading['id'] }}
+
+ @else +
Nessuna lettura precedente trovata per questa combinazione servizio/unità.
+ @endif +
+ +
+ +
+ +
+ +
+ + Invia ticket acqua + Invio lettura... + +
+
+
+
+
+
\ No newline at end of file diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index 5e2fd45..18fe9ee 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -312,8 +312,13 @@ } this.persist(); }, - previewFor(draftName, originalName) { + previewFor(draftName, originalName, size = 0) { const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews]; + const exact = pool.find((item) => item.name === originalName && Number(item.size || 0) === Number(size || 0)); + if (exact?.url) { + return exact.url; + } + const match = pool.find((item) => item.draftName === draftName || item.name === originalName); return match?.url || null; @@ -371,8 +376,8 @@ @foreach($this->selectedUploads as $upload)
@if($upload['is_image']) -
- {{ $upload['name'] }} +
+ {{ $upload['name'] }}
@endif