Fix ticket mobile previews and add ticket acqua page
This commit is contained in:
parent
1276cc7128
commit
ab2b3af2fa
586
app/Filament/Pages/Supporto/TicketAcqua.php
Normal file
586
app/Filament/Pages/Supporto/TicketAcqua.php
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
<?php
|
||||
namespace App\Filament\Pages\Supporto;
|
||||
|
||||
use App\Models\StabileServizio;
|
||||
use App\Models\StabileServizioLettura;
|
||||
use App\Models\UnitaImmobiliare;
|
||||
use App\Models\User;
|
||||
use App\Services\Support\TicketAttachmentUploadService;
|
||||
use App\Support\StabileContext;
|
||||
use BackedEnum;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\WithFileUploads;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
|
||||
class TicketAcqua extends Page
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
protected static ?string $navigationLabel = 'Ticket Acqua';
|
||||
|
||||
protected static ?string $title = 'Ticket Acqua';
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-beaker';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
|
||||
|
||||
protected static ?int $navigationSort = 26;
|
||||
|
||||
protected static ?string $slug = 'supporto/ticket-acqua';
|
||||
|
||||
protected string $view = 'filament.pages.supporto.ticket-acqua';
|
||||
|
||||
public ?int $stabileId = null;
|
||||
|
||||
public ?int $servizioId = null;
|
||||
|
||||
public ?int $unitaId = null;
|
||||
|
||||
public ?string $letturaAttuale = null;
|
||||
|
||||
public ?string $dataLettura = null;
|
||||
|
||||
public ?string $rilevatoreNome = null;
|
||||
|
||||
public ?string $rilevatoreContatto = null;
|
||||
|
||||
public ?string $noteGenerali = null;
|
||||
|
||||
/** @var array<int,mixed> */
|
||||
public array $newWaterPhotos = [];
|
||||
|
||||
/** @var array<int,mixed> */
|
||||
public array $pendingWaterPhotos = [];
|
||||
|
||||
/** @var array<int,string> */
|
||||
public array $waterPhotoDescriptions = [];
|
||||
|
||||
/** @var array<string,string> */
|
||||
public array $waterUploadCodes = [];
|
||||
|
||||
public string $waterDraftProtocol = '';
|
||||
|
||||
public int $waterDraftSequence = 1;
|
||||
|
||||
/** @var array<int,array{id:int,label:string}> */
|
||||
public array $stabileOptions = [];
|
||||
|
||||
/** @var array<int,array{id:int,label:string}> */
|
||||
public array $servizioOptions = [];
|
||||
|
||||
/** @var array<int,array{id:int,label:string}> */
|
||||
public array $unitaOptions = [];
|
||||
|
||||
/** @var array<string,mixed>|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<int,array{index:int,name:string,original_name:string,protocol:string,mime:string,is_image:bool,preview_url:?string,size:int,metadata:array<string,mixed>}>
|
||||
*/
|
||||
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<int,mixed> $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<int,int>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string,mixed> $metadata
|
||||
* @param array<string,mixed> $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<string,mixed> $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) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ public function inspectUpload(object $file): array
|
|||
return $this->extractImageMetadataFromFileObject($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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}
|
||||
|
|
|
|||
219
resources/views/filament/pages/supporto/ticket-acqua.blade.php
Normal file
219
resources/views/filament/pages/supporto/ticket-acqua.blade.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<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>
|
||||
</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($unitaOptions as $option)
|
||||
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</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">Rilevatore</span>
|
||||
<input type="text" wire:model.defer="rilevatoreNome" class="w-full rounded-lg border-gray-300" placeholder="Nome operatore o condomino" />
|
||||
</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: [],
|
||||
init() {
|
||||
window.__ticketAcquaPreviewState = window.__ticketAcquaPreviewState || [];
|
||||
this.localWaterPreviews = [...window.__ticketAcquaPreviewState];
|
||||
},
|
||||
persist() {
|
||||
window.__ticketAcquaPreviewState = [...this.localWaterPreviews];
|
||||
},
|
||||
revoke(items) {
|
||||
items.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
});
|
||||
},
|
||||
addFiles(event) {
|
||||
const batchId = Date.now();
|
||||
const mapped = Array.from(event?.target?.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.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.persist();
|
||||
},
|
||||
}"
|
||||
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)
|
||||
<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>
|
||||
</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', ''));
|
||||
@endphp
|
||||
@if($hasGps || $exifDate !== '' || $device !== '')
|
||||
<div class="mt-2 rounded-lg border border-slate-200 bg-white 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($exifDate !== '')
|
||||
<div><span class="font-medium">Data EXIF:</span> {{ $exifDate }}</div>
|
||||
@endif
|
||||
@if($device !== '')
|
||||
<div><span class="font-medium">Dispositivo:</span> {{ $device }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<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>
|
||||
|
|
@ -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)
|
||||
<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'])))">
|
||||
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
|
||||
<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)))">
|
||||
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0))" 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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user