netgescon-day0/resources/views/filament/pages/supporto/ticket-acqua.blade.php

353 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<x-filament-panels::page>
<div class="space-y-4">
@if ($errors->any())
<div class="rounded-xl border border-rose-300 bg-rose-50 p-3 text-sm text-rose-800">
<div class="font-semibold">Controlla i dati prima di inviare</div>
<ul class="mt-2 list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">Ticket Acqua</h2>
<div class="mt-1 text-sm text-slate-600">Pagina mobile dedicata alle autoletture: valore del contatore in primo piano, foto nello stesso flusso operativo e salvataggio diretto su archivio letture acqua.</div>
</div>
<div class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-800">Protocollo bozza {{ $waterDraftProtocol }}</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Stabile</span>
<select wire:model.live="stabileId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona stabile</option>
@foreach($stabileOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Servizio acqua</span>
<select wire:model.live="servizioId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona servizio</option>
@foreach($servizioOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
<div class="mt-1 text-[11px] text-slate-500">Se lo stabile non ha ancora un servizio acqua attivo, preparo automaticamente un archivio letture dedicato.</div>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Cerca unità immobiliare</span>
<input type="text" wire:model.live.debounce.250ms="unitaSearch" class="w-full rounded-lg border-gray-300" placeholder="Es. Int. 10, Maggiore, B 4, codice unità..." />
<div class="mt-1 text-[11px] text-slate-500">Filtra l'elenco per interno, nominativo o codice unità.</div>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Unità immobiliare</span>
<select wire:model.live="unitaId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona unità</option>
@foreach($this->filteredUnitaOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
<div class="mt-1 text-[11px] text-slate-500">Risultati attuali: {{ count($this->filteredUnitaOptions) }}</div>
</label>
</div>
</div>
<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 shadow-sm">
<label class="block text-sm">
<span class="mb-1 block text-sm font-semibold text-slate-900">Lettura attuale del contatore</span>
<input type="number" step="0.001" min="0" wire:model.defer="letturaAttuale" class="w-full rounded-lg border-sky-300 bg-white text-2xl font-semibold text-slate-900" placeholder="Es. 1284.350" />
</label>
<div class="mt-2 text-xs text-slate-600">Inserisci solo il valore letto. Se alleghi foto, il sistema mantiene preview, metadata e riferimenti dello scatto dentro la lettura acqua.</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Nominativo letturista</span>
<input type="text" wire:model.defer="rilevatoreNome" class="w-full rounded-lg border-gray-300" placeholder="Nome operatore o condomino" />
@if(filled($suggestedReaderName))
<div class="mt-1 text-[11px] text-slate-500">Suggerimento unità: {{ $suggestedReaderName }}</div>
@endif
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Contatto</span>
<input type="text" wire:model.defer="rilevatoreContatto" class="w-full rounded-lg border-gray-300" placeholder="Telefono o email" />
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Data lettura</span>
<input type="date" wire:model.defer="dataLettura" class="w-full rounded-lg border-gray-300" />
</label>
</div>
</div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-sm font-semibold text-slate-900">Ultima lettura nota</div>
@if($previousReading)
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div><span class="font-medium">Valore:</span> {{ number_format((float) $previousReading['value'], 3, ',', '.') }}</div>
<div><span class="font-medium">Data:</span> {{ $previousReading['date'] ?: 'n/d' }}</div>
<div><span class="font-medium">Riferimento:</span> #{{ $previousReading['id'] }}</div>
</div>
@else
<div class="mt-3 text-sm text-slate-500">Nessuna lettura precedente trovata per questa combinazione servizio/unità.</div>
@endif
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<label class="block text-sm"
x-data="{
localWaterPreviews: [],
localWaterClientMetadata: [],
init() {
window.__ticketAcquaPreviewState = window.__ticketAcquaPreviewState || [];
window.__ticketAcquaClientMetadataState = window.__ticketAcquaClientMetadataState || [];
this.localWaterPreviews = [...window.__ticketAcquaPreviewState];
this.localWaterClientMetadata = [...window.__ticketAcquaClientMetadataState];
this.syncClientMetadataToWire();
},
persist() {
window.__ticketAcquaPreviewState = [...this.localWaterPreviews];
},
persistClientMetadata() {
window.__ticketAcquaClientMetadataState = [...this.localWaterClientMetadata];
this.syncClientMetadataToWire();
},
revoke(items) {
items.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
},
syncClientMetadataToWire() {
$wire.set('waterClientMetadata', this.localWaterClientMetadata.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);
},
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) {
const base = {
source: 'camera',
name: file?.name || '',
size: file?.size || 0,
captured_at: new Date(file?.lastModified || Date.now()).toISOString(),
device: this.getDeviceLabel(),
};
const geo = await this.readGeolocation();
return geo ? { ...base, ...geo } : base;
},
mergeClientMetadata(items) {
const existing = new Map(this.localWaterClientMetadata.map((item) => [`${item.name}|${item.size}`, item]));
items.forEach((item) => {
if (!item?.name) {
return;
}
existing.set(`${item.name}|${item.size}`, item);
});
this.localWaterClientMetadata = Array.from(existing.values());
this.persistClientMetadata();
},
async addFiles(event) {
const batchId = Date.now();
const files = Array.from(event?.target?.files || []);
const mapped = files.map((file, index) => ({
key: `${batchId}-${index}-${file.name}-${file.size}`,
name: file.name,
size: file.size || 0,
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
const existing = new Map(this.localWaterPreviews.map((item) => [item.key, item]));
mapped.forEach((item) => existing.set(item.key, item));
this.localWaterPreviews = Array.from(existing.values());
this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file))));
this.persist();
if (event?.target) {
event.target.value = null;
}
},
previewFor(name, size = 0) {
const exact = this.localWaterPreviews.find((item) => item.name === name && Number(item.size || 0) === Number(size || 0));
return exact?.url || null;
},
clearAll() {
this.revoke(this.localWaterPreviews);
this.localWaterPreviews = [];
this.localWaterClientMetadata = [];
this.persist();
this.persistClientMetadata();
},
}"
x-on:ticket-acqua-draft-reset.window="clearAll()"
>
<span class="mb-2 block font-medium">Foto contatore</span>
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta o aggiungi foto
<input type="file" wire:model="pendingWaterPhotos" multiple accept="image/*" capture="environment" class="hidden" x-on:change="addFiles($event)" />
</label>
<div class="mt-1 text-xs text-gray-500">Puoi accodare fino a 4 immagini. Ogni foto resta nello stesso riquadro con miniatura e nota.</div>
<div wire:loading wire:target="pendingWaterPhotos" class="mt-2 text-xs font-medium text-amber-700">Caricamento foto in corso: preparo la coda della lettura acqua.</div>
@if(count($this->selectedWaterUploads) > 0)
@php
$focusUpload = $this->selectedWaterUploads[0] ?? null;
@endphp
@if($focusUpload)
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 p-3">
<div class="mb-2 text-xs font-semibold text-sky-900">Chicca lettura rapida</div>
<div class="text-[11px] text-sky-800">Ti evidenzio la zona del display del contatore e ti lascio il campo lettura già dentro il riquadro operativo.</div>
<div class="mt-3 grid gap-3 lg:grid-cols-[1.1fr_0.9fr]">
<div class="relative overflow-hidden rounded-xl border bg-white">
<img src="{{ $focusUpload['preview_url'] ?: '' }}" x-bind:src="@js($focusUpload['preview_url']) || previewFor(@js($focusUpload['original_name']), @js($focusUpload['size'] ?? 0))" alt="{{ $focusUpload['name'] }}" class="h-full w-full object-cover" />
<div class="pointer-events-none absolute inset-y-[30%] right-[11%] w-[32%] rounded-xl border-2 border-amber-400 bg-amber-100/40 shadow-[0_0_0_9999px_rgba(15,23,42,0.10)]"></div>
</div>
<div class="rounded-xl border bg-white p-3">
<label class="block text-sm">
<span class="mb-1 block font-medium text-slate-900">Lettura attuale del contatore</span>
<input type="number" step="0.001" min="0" wire:model.defer="letturaAttuale" class="w-full rounded-lg border-amber-300 bg-amber-50 text-2xl font-semibold text-slate-900" placeholder="Es. 1284.350" />
</label>
<div class="mt-2 text-[11px] text-slate-600">Il riquadro giallo indica l'area delle cifre da leggere nella foto principale.</div>
</div>
</div>
</div>
@endif
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach($this->selectedWaterUploads as $upload)
<div class="rounded-xl border bg-slate-50 p-3 text-xs shadow-sm">
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-white" x-show="Boolean(@js($upload['preview_url']) || previewFor(@js($upload['original_name']), @js($upload['size'] ?? 0)))">
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['original_name']), @js($upload['size'] ?? 0))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-[11px] text-slate-700">
<div class="font-medium">{{ $upload['name'] }}</div>
<div class="mt-1 text-emerald-700">Protocollo bozza: {{ $upload['protocol'] }}</div>
<div class="mt-1 text-slate-500">Origine: {{ $upload['original_name'] }}</div>
@if(!empty($upload['metadata']['capture_source']))
<div class="mt-1 text-slate-500">Sorgente: {{ $upload['metadata']['capture_source'] }}</div>
@endif
</div>
@php
$metadata = is_array($upload['metadata'] ?? null) ? $upload['metadata'] : [];
$gpsLat = data_get($metadata, 'gps_decimal.lat');
$gpsLng = data_get($metadata, 'gps_decimal.lng');
$hasGps = is_numeric($gpsLat) && is_numeric($gpsLng);
$exifDate = trim((string) data_get($metadata, 'exif_datetime', ''));
$device = trim((string) data_get($metadata, 'device', ''));
$captureSource = trim((string) ($metadata['capture_source'] ?? ''));
$badgeRows = [];
$detailRows = [];
if ($captureSource === 'camera') {
$badgeRows[] = ['class' => 'bg-amber-100 text-amber-800', 'label' => 'GPS da browser'];
} elseif ($captureSource === 'attachment') {
$badgeRows[] = ['class' => 'bg-indigo-100 text-indigo-800', 'label' => 'EXIF da libreria'];
}
if ($hasGps) {
$detailRows[] = [
'label' => 'Coordinate',
'value' => number_format((float) $gpsLat, 5, '.', '') . ', ' . number_format((float) $gpsLng, 5, '.', ''),
];
}
if ($exifDate !== '') {
$detailRows[] = ['label' => 'Data EXIF', 'value' => $exifDate];
}
if ($device !== '') {
$detailRows[] = ['label' => 'Dispositivo', 'value' => $device];
}
if ($captureSource !== '') {
$detailRows[] = [
'label' => 'Sorgente metadati',
'value' => $captureSource === 'camera' ? 'browser/camera live' : $captureSource,
];
}
@endphp
@include('filament.pages.supporto.partials.photo-metadata', ['badgeRows' => $badgeRows, 'detailRows' => $detailRows])
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Nota sotto la foto</span>
<input type="text" wire:model.defer="waterPhotoDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. contatore cucina, cifra poco leggibile" />
</label>
<button type="button" wire:click="removeWaterPhoto({{ (int) $upload['index'] }})" class="mt-2 inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
@endforeach
</div>
@else
<div class="mt-3 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">Nessuna foto in coda. Aggiungi una o due foto del contatore per avere subito lanteprima prima dellinvio.</div>
@endif
</label>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<label class="block text-sm">
<span class="mb-1 block font-medium">Note generali</span>
<textarea rows="3" wire:model.defer="noteGenerali" class="w-full rounded-lg border-gray-300" placeholder="Es. locale contatori chiuso, seconda foto con riflesso, lettura confermata sul posto..."></textarea>
</label>
<div class="mt-3">
<x-filament::button wire:click="registraLetturaAcqua" wire:loading.attr="disabled" wire:target="registraLetturaAcqua,pendingWaterPhotos">
<span wire:loading.remove wire:target="registraLetturaAcqua">Invia ticket acqua</span>
<span wire:loading wire:target="registraLetturaAcqua">Invio lettura...</span>
</x-filament::button>
</div>
</div>
</div>
</div>
</div>
</x-filament-panels::page>