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

459 lines
32 KiB
PHP

<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
Il riepilogo operativo Ticket Mobile ora e nella dashboard principale, cosi i link rapidi e lo stato ticket restano visibili appena entri in Filament.
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Ricerca chiamante</div>
<div class="mt-2 text-xs text-gray-500">Ricerca per telefono, nome, cognome, ragione sociale o email e seleziona il contatto con un click.</div>
<input type="text" wire:model.live.debounce.300ms="callerSearch" placeholder="Es. +39 333 1234567 oppure Mario Rossi" class="mt-3 w-full rounded-lg border-gray-300 text-sm" />
@if($selectedCallerId)
<div class="mt-2 text-xs text-emerald-700">Contatto selezionato: #{{ $selectedCallerId }}</div>
@endif
@if($callerMatches->count() > 0)
<div class="mt-3 space-y-2">
@foreach($callerMatches as $match)
@php
$stabileLabel = $this->getCallerStabileLabel((int) $match->id);
@endphp
<button type="button" wire:click="selezionaCaller({{ (int) $match->id }})" class="w-full rounded-lg border p-3 text-left {{ (int) $selectedCallerId === (int) $match->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'bg-white hover:bg-gray-50' }}">
<div class="text-sm font-medium">{{ $match->nome_completo ?: ($match->ragione_sociale ?: 'Contatto') }}</div>
<div class="text-xs text-gray-600">{{ $match->categoria ?: 'altro' }} @if($match->email) · {{ $match->email }} @endif</div>
@if(($match->duplicate_count ?? 1) > 1)
<div class="mt-1 text-xs text-amber-700">
Raggruppati {{ $match->duplicate_count }} record equivalenti
@if(!empty($match->duplicate_categories))
· categorie: {{ implode(', ', $match->duplicate_categories) }}
@endif
</div>
@endif
@if($match->tipo_utenza_call)
<div class="text-xs text-indigo-700">Profilo: {{ $match->tipo_utenza_call }}</div>
@endif
@if($match->riferimento_stabile)
<div class="text-xs text-indigo-700">Rif. stabile: {{ $match->riferimento_stabile }}</div>
@endif
@if($match->riferimento_unita)
<div class="text-xs text-indigo-700">Rif. unità: {{ $match->riferimento_unita }}</div>
@endif
@if($stabileLabel)
<div class="text-xs text-emerald-700">Stabile: {{ $stabileLabel }}</div>
@endif
<div class="mt-1 text-xs text-gray-700">
{{ $match->telefono_cellulare ?: $match->telefono_ufficio ?: $match->telefono_casa ?: 'Telefono non valorizzato' }}
</div>
@if($match->note_segreteria)
<div class="mt-1 text-xs text-gray-500">{{ $match->note_segreteria }}</div>
@endif
</button>
@endforeach
</div>
@elseif(filled($callerSearch))
<div class="mt-3 text-xs text-gray-500">Nessun contatto trovato con questa ricerca.</div>
@endif
<div class="mt-4 rounded-lg border border-dashed p-3">
<div class="text-sm font-semibold">Nuovo ticket rapido</div>
<div class="mt-1 text-xs text-gray-500">Dopo aver selezionato il chiamante, compila e crea il ticket.</div>
@if($errors->any())
<div class="mt-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-800">
<div class="font-semibold">Il ticket non e stato inviato.</div>
<ul class="mt-1 list-disc ps-4">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="mt-3 grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Titolo operativo</span>
<input type="text" wire:model.defer="newTicketTitolo" class="w-full rounded-lg border-gray-300" placeholder="Es. Guasto ascensore scala B" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Priorita</span>
<select wire:model.defer="newTicketPriorita" class="w-full rounded-lg border-gray-300">
<option value="Bassa">Bassa</option>
<option value="Media">Media</option>
<option value="Alta">Alta</option>
<option value="Urgente">Urgente</option>
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Categoria ticket</span>
<select wire:model.defer="newTicketCategoriaId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona categoria</option>
@foreach($categorieTicketOptions as $cat)
<option value="{{ (int) $cat['id'] }}">{{ $cat['nome'] }}</option>
@endforeach
</select>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Tipo intervento preimpostato</span>
<select wire:model.defer="newTicketTipoIntervento" class="w-full rounded-lg border-gray-300">
@foreach($interventiPreimpostati as $code => $label)
<option value="{{ $code }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Luogo intervento</span>
<input type="text" wire:model.defer="newTicketLuogo" class="w-full rounded-lg border-gray-300" placeholder="Es. Scala B - Piano 2" />
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Descrizione</span>
<textarea rows="3" wire:model.defer="newTicketDescrizione" class="w-full rounded-lg border-gray-300" placeholder="Descrivi il problema senza ripetere i dati tecnici della chiamata..." ></textarea>
</label>
<label class="block text-sm md:col-span-2"
x-data="{
draftProtocol: @js($newTicketDraftProtocol),
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 = {
camera: [...this.localCameraPreviews],
attachment: [...this.localAttachmentPreviews],
};
},
persistClientMetadata() {
window.__ticketMobileClientMetadataState = [...this.localClientMetadata];
this.syncClientMetadataToWire();
},
revoke(items) {
items.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
},
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() : '';
const isImage = (file?.type || '').startsWith('image/');
const code = `${this.draftProtocol}-${isImage ? 'F' : 'A'}${String(sequence).padStart(2, '0')}`;
return extension ? `${code}.${extension}` : code;
},
mapFiles(fileList, kind) {
return Array.from(fileList || []).map((file, index) => ({
key: `${kind}-${index}-${file.name}-${file.size}`,
name: file.name,
draftName: this.buildDraftName(file, this.draftSequence + index),
size: file.size || 0,
isImage: (file.type || '').startsWith('image/'),
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
},
appendPreviews(target, items) {
const existing = new Map((this[target] || []).map((item) => [item.key, item]));
items.forEach((item) => {
existing.set(item.key, item);
});
this[target] = Array.from(existing.values());
},
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 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),
size: file.size || 0,
isImage: (file.type || '').startsWith('image/'),
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.draftSequence += files.length;
this.persist();
if (event?.target) {
event.target.value = null;
}
},
clearPreviews(kind = null) {
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, source = null) {
const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews];
const exact = pool.find((item) => {
if (source === 'camera' && !String(item.key || '').startsWith('camera-')) {
return false;
}
if (source === 'attachment' && !String(item.key || '').startsWith('attachment-')) {
return false;
}
return item.name === originalName && Number(item.size || 0) === Number(size || 0);
});
if (exact?.url) {
return exact.url;
}
const match = pool.find((item) => {
if (source === 'camera' && !String(item.key || '').startsWith('camera-')) {
return false;
}
if (source === 'attachment' && !String(item.key || '').startsWith('attachment-')) {
return false;
}
return item.draftName === draftName || item.name === originalName;
});
return match?.url || null;
},
formatSize(bytes) {
if (!bytes) {
return '0 KB';
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1048576) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / 1048576).toFixed(1)} MB`;
},
}"
x-on:ticket-mobile-local-previews-clear.window="clearPreviews($event.detail?.source || null)"
x-on:ticket-mobile-draft-reset.window="clearPreviews()"
>
<span class="mb-2 block font-medium">Acquisizione foto e allegati</span>
@if(count($this->selectedUploads) > 0)
<div class="mb-2 inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-[11px] font-semibold text-emerald-800">
{{ count($this->selectedUploads) }} file gi\u00e0 in coda per questo ticket
</div>
@endif
<div class="flex flex-wrap gap-2">
<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 foto
<input type="file" wire:model="pendingTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" x-on:change="setPreviews($event, 'camera')" />
</label>
<label class="inline-flex cursor-pointer items-center rounded-md bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">
Aggiungi allegati
<input type="file" wire:model="pendingTicketAttachments" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" x-on:change="setPreviews($event, 'attachment')" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare fino a 10 file totali. Ogni nuovo scatto viene accodato senza sostituire i precedenti.</div>
<div class="mt-1 text-xs text-gray-500">Bozza protocollo corrente: <span class="font-semibold">{{ $newTicketDraftProtocol }}</span></div>
<div class="mt-1 text-xs text-gray-500">iPhone: su "Aggiungi allegati" scegli "Sfoglia" per aprire l'app File (iCloud Drive/On My iPhone).</div>
<div wire:loading wire:target="pendingTicketCameraShots,pendingTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: sto accodando foto e allegati alla bozza ticket.</div>
<div x-show="localCameraPreviews.length || localAttachmentPreviews.length" x-cloak class="mt-3 rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="text-xs font-semibold text-sky-900">Preparazione allegati in corso</div>
<div class="mt-1 text-[11px] text-sky-800">Le foto e i file stanno entrando nella coda operativa qui sotto. Mantengo un solo box finale per ogni allegato, con miniatura e descrizione nello stesso riquadro.</div>
</div>
</label>
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Coda allegati pronta per il ticket</div>
<div class="mb-2 text-[11px] text-gray-500">Ogni allegato resta nello stesso riquadro con miniatura, protocollo e descrizione subito sotto. I dati EXIF e GPS non vengono rimossi dal salvataggio.</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
@if($upload['is_image'])
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50" x-show="Boolean(@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0), @js($upload['metadata']['capture_source'] ?? null)))">
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0), @js($upload['metadata']['capture_source'] ?? null))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold {{ $upload['is_image'] ? 'bg-sky-100 text-sky-700' : 'bg-slate-200 text-slate-700' }}">
{{ $upload['is_image'] ? 'Immagine' : (strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE')) }}
</span>
<div class="text-[11px] text-gray-600">
{{ $upload['is_image'] ? 'Miniatura agganciata a questa descrizione' : 'Allegato pronto per l\'invio' }}
</div>
</div>
<div class="mt-2 font-medium">{{ $upload['name'] }}</div>
<div class="mt-1 text-[11px] text-emerald-700">Protocollo bozza: {{ $upload['protocol'] }}</div>
@if($upload['original_name'] !== $upload['name'])
<div class="mt-1 text-[11px] text-gray-500">File origine: {{ $upload['original_name'] }}</div>
@endif
<div class="mt-1 text-[11px] text-gray-500">{{ number_format(((int) ($upload['size'] ?? 0)) / 1024, 1, ',', '.') }} KB</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', ''));
$mapsUrl = trim((string) data_get($metadata, 'google_maps_url', ''));
@endphp
@if($hasGps || $exifDate !== '' || $device !== '')
<div class="mt-2 flex flex-wrap gap-1">
@if($hasGps)
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-800">GPS</span>
@endif
@if(($metadata['capture_source'] ?? null) === 'camera')
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-800">GPS da browser</span>
@elseif(($metadata['capture_source'] ?? null) === 'attachment')
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-[10px] font-semibold text-indigo-800">EXIF da libreria</span>
@endif
@if($exifDate !== '')
<span class="inline-flex items-center rounded-full bg-sky-100 px-2 py-0.5 text-[10px] font-semibold text-sky-800">EXIF</span>
@endif
@if($device !== '')
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-700">{{ $device }}</span>
@endif
</div>
<div class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-2 text-[11px] text-slate-700">
@if($hasGps)
<div><span class="font-medium">Coordinate:</span> {{ number_format((float) $gpsLat, 5, '.', '') }}, {{ number_format((float) $gpsLng, 5, '.', '') }}</div>
@endif
@if($mapsUrl !== '')
<div class="mt-1 break-all"><span class="font-medium">Maps:</span> {{ $mapsUrl }}</div>
@endif
@if($exifDate !== '')
<div><span class="font-medium">Data EXIF:</span> {{ $exifDate }}</div>
@endif
@if($device !== '')
<div><span class="font-medium">Dispositivo:</span> {{ $device }}</div>
@endif
@if(!empty($metadata['capture_source']))
<div><span class="font-medium">Sorgente metadati:</span> {{ $metadata['capture_source'] === 'camera' ? 'browser/camera live' : $metadata['capture_source'] }}</div>
@endif
</div>
@endif
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Descrizione foto/allegato</span>
<input type="text" wire:model.defer="newTicketAttachmentDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita in cucina lato sifone" />
</label>
<button type="button" wire:click="removeNewTicketFile({{ (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>
</div>
@endif
</div>
<div class="mt-3">
<x-filament::button wire:click="creaTicketRapido" wire:loading.attr="disabled" wire:target="creaTicketRapido,pendingTicketCameraShots,pendingTicketAttachments">
<span wire:loading.remove wire:target="creaTicketRapido">Crea ticket</span>
<span wire:loading wire:target="creaTicketRapido">Invio ticket...</span>
</x-filament::button>
</div>
</div>
</div>
<div class="text-xs text-gray-500">
Suggerimento: aggiungi questa pagina ai preferiti del browser sul telefono per usarla come webapp rapida.
</div>
</div>
</x-filament-panels::page>