356 lines
24 KiB
PHP
Executable File
356 lines
24 KiB
PHP
Executable File
<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($unitaContatoreSeriale)
|
||
<div class="mt-1 text-xs font-semibold text-cyan-600 dark:text-cyan-400">Matricola Contatore UI: {{ $unitaContatoreSeriale }}</div>
|
||
@endif
|
||
@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 l’anteprima prima dell’invio.</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> |