Ticket visibility and dashboard cleanup

This commit is contained in:
michele 2026-04-08 19:57:14 +00:00
parent dba907a85f
commit c079737b5b
10 changed files with 275 additions and 25 deletions

View File

@ -5,6 +5,9 @@ # Changelog
## [Unreleased]
- Initial open-source release prep.
- Removed active-stabile filtering from topbar avvisi/urgenze counters and from the Google ticket-operativi box so cross-stabile operational tickets stay visible.
- Removed the Google Workspace widget from the main/support dashboard, leaving the full panel only under Impostazioni > Google.
- Tightened Filament sidebar navigation spacing between main menu groups for denser top-level navigation.
- Added immediate local preview for Ticket Mobile photos and attachments before final send on phone.
- Split insurance handling out of Ticket Gestione into dedicated Tab 4 and added centralized Supporto claims list.
- Fixed supplier ticket-detail access by resolving both intervention-id and ticket-id safely in supplier context.

View File

@ -114,6 +114,9 @@ class TicketGestione extends Page
/** @var array<string,mixed>|null */
public ?array $attachmentPreview = null;
/** @var array<string,mixed>|null */
public ?array $attachmentMapPreview = null;
/** @var \Illuminate\Support\Collection<int, Ticket> */
public $tickets;
@ -320,6 +323,7 @@ public function openAttachmentPreview(int $attachmentId): void
$isImage = $attachment->isImage();
$isPdf = $attachment->isPdf();
$details = $this->getAttachmentDetails($attachment);
$this->attachmentPreview = [
'id' => (int) $attachment->id,
@ -329,12 +333,58 @@ public function openAttachmentPreview(int $attachmentId): void
'is_image' => $isImage,
'is_pdf' => $isPdf,
'mime' => $mime,
'details' => $details,
];
}
public function openAttachmentMapPreview(int $attachmentId): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
return;
}
$attachment = $ticket->attachments->firstWhere('id', $attachmentId);
if (! $attachment instanceof TicketAttachment) {
return;
}
$details = $this->getAttachmentDetails($attachment);
$gps = $details['gps'] ?? [];
$lat = $gps['lat'] ?? null;
$lng = $gps['lng'] ?? null;
if (! is_numeric($lat) || ! is_numeric($lng)) {
Notification::make()->title('Coordinate GPS non disponibili per questo allegato')->warning()->send();
return;
}
$this->attachmentMapPreview = [
'attachment_id' => (int) $attachment->id,
'name' => (string) ($attachment->original_file_name ?? 'Allegato'),
'label' => (string) ($gps['label'] ?? (number_format((float) $lat, 5, '.', '') . ', ' . number_format((float) $lng, 5, '.', ''))),
'maps_url' => (string) ($details['maps_url'] ?? ('https://maps.google.com/?q=' . $lat . ',' . $lng)),
'embed_url' => (string) ($details['maps_embed_url'] ?? ('https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&z=17&output=embed')),
];
}
public function closeAttachmentPreview(): void
{
$this->attachmentPreview = null;
$this->attachmentMapPreview = null;
}
public function closeAttachmentMapPreview(): void
{
$this->attachmentMapPreview = null;
}
/**
* @return array<string,mixed>
*/
public function getAttachmentDetails(TicketAttachment $attachment): array
{
return $this->parseAttachmentDescription((string) ($attachment->description ?? ''));
}
public function getSelectedTicketProperty(): ?Ticket
@ -1276,6 +1326,95 @@ private function syncTicketAttachmentsArchive(Ticket $ticket): void
}
}
/**
* @return array<string,mixed>
*/
private function parseAttachmentDescription(string $description): array
{
$parts = preg_split('/\s*\|\s*/', trim($description)) ?: [];
$details = [
'primary_description' => '',
'gps' => null,
'maps_url' => null,
'maps_embed_url' => null,
'exif_datetime' => null,
'device' => null,
'file_label' => null,
'original_label' => null,
'other' => [],
'full_description' => trim($description),
];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '') {
continue;
}
if (str_starts_with($part, 'GPS:')) {
$gpsValue = trim((string) Str::after($part, 'GPS:'));
if (preg_match('/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/', $gpsValue, $matches) === 1) {
$lat = (float) $matches[1];
$lng = (float) $matches[2];
$details['gps'] = [
'lat' => $lat,
'lng' => $lng,
'label' => number_format($lat, 5, '.', '') . ', ' . number_format($lng, 5, '.', ''),
];
$details['maps_embed_url'] = 'https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&z=17&output=embed';
}
continue;
}
if (str_starts_with($part, 'Maps:')) {
$details['maps_url'] = trim((string) Str::after($part, 'Maps:'));
continue;
}
if (str_starts_with($part, 'EXIF:')) {
$details['exif_datetime'] = trim((string) Str::after($part, 'EXIF:'));
continue;
}
if (str_starts_with($part, 'Device:')) {
$details['device'] = trim((string) Str::after($part, 'Device:'));
continue;
}
if (str_starts_with($part, 'File:')) {
$details['file_label'] = trim((string) Str::after($part, 'File:'));
continue;
}
if (str_starts_with($part, 'Orig:')) {
$details['original_label'] = trim((string) Str::after($part, 'Orig:'));
continue;
}
if ($details['primary_description'] === '') {
$details['primary_description'] = $part;
continue;
}
$details['other'][] = $part;
}
if ($details['maps_url'] === null && is_array($details['gps'])) {
$details['maps_url'] = 'https://maps.google.com/?q=' . $details['gps']['lat'] . ',' . $details['gps']['lng'];
}
if ($details['primary_description'] === '') {
$details['primary_description'] = $details['full_description'] !== ''
? $details['full_description']
: 'Allegato ticket';
}
return $details;
}
private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void
{
if (! Schema::hasTable('documenti')) {

View File

@ -673,15 +673,21 @@ private function loadOpenTickets(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($stabileIds === []) {
$this->openTickets = [];
return;
}
$items = Ticket::query()
->with('stabile:id,denominazione')
->where('stabile_id', $stabileId)
->whereIn('stabile_id', $stabileIds)
->aperti()
->orderByDesc('created_at')
->limit(8)

View File

@ -10,7 +10,6 @@
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
use App\Filament\Pages\Supporto\SupportoDashboard;
use App\Filament\Pages\Supporto\TicketMobile;
use App\Filament\Widgets\GoogleScadenziarioOverview;
use App\Filament\Widgets\TicketMobileOverview;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
@ -116,7 +115,6 @@ public function panel(Panel $panel): Panel
->discoverWidgets(in: app_path('Filament/Widgets'), for : 'App\\Filament\\Widgets')
->widgets([
AccountWidget::class,
GoogleScadenziarioOverview::class,
TicketMobileOverview::class,
])
->middleware([

View File

@ -35,4 +35,18 @@ .netgescon-footer {
width: 100% !important;
flex: 0 0 auto;
margin-top: auto;
}
.fi-sidebar-nav,
.fi-sidebar-nav-groups {
row-gap: calc(var(--spacing) * 4);
}
.fi-sidebar-group-btn,
.fi-sidebar-group-dropdown-trigger-btn {
padding-block: calc(var(--spacing) * 1.25);
}
.fi-sidebar-group-label {
line-height: calc(var(--spacing) * 5);
}

View File

@ -1,17 +1,22 @@
@php
$user = \Illuminate\Support\Facades\Auth::user();
$stabileId = null;
$stabileIds = [];
if ($user instanceof \App\Models\User) {
$stabileId = \App\Support\StabileContext::resolveActiveStabileId($user);
$stabileIds = \App\Support\StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
}
$avvisiCount = 0;
$urgenzeCount = 0;
if ($stabileId) {
if ($stabileIds !== []) {
$baseQuery = \App\Models\Ticket::query()
->where('stabile_id', $stabileId)
->whereIn('stabile_id', $stabileIds)
->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
$urgenzeCount = (clone $baseQuery)->where('priorita', 'Urgente')->count();

View File

@ -5,11 +5,17 @@
$avvisi = 0;
if ($user instanceof \App\Models\User) {
$stabileId = \App\Support\StabileContext::resolveActiveStabileId($user);
if ($stabileId) {
$stabileIds = \App\Support\StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($stabileIds !== []) {
$base = \App\Models\Ticket::query()
->where('stabile_id', $stabileId)
->where('stato', 'Aperto');
->whereIn('stabile_id', $stabileIds)
->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
$urgenti = (clone $base)->where('priorita', 'Urgente')->count();
$avvisi = (clone $base)->where('priorita', '!=', 'Urgente')->count();

View File

@ -16,7 +16,5 @@
</a>
</div>
</div>
@livewire(\App\Filament\Widgets\GoogleScadenziarioOverview::class)
</div>
</x-filament-panels::page>

View File

@ -332,6 +332,7 @@
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@forelse($ticket->attachments as $a)
@php($details = $this->getAttachmentDetails($a))
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($a->isImage())
@ -347,10 +348,48 @@
</div>
<div class="mt-2 font-medium">{{ $a->original_file_name }}</div>
<div class="mt-1 text-gray-600">{{ $a->description ?: 'Nessuna descrizione' }}</div>
<div class="mt-1 text-gray-700">{{ $details['primary_description'] ?: 'Nessuna descrizione' }}</div>
<div class="mt-2 flex flex-wrap gap-1">
@if(!empty($details['gps']))
<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(!empty($details['exif_datetime']))
<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(!empty($details['device']))
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-700">{{ $details['device'] }}</span>
@endif
</div>
@if(!empty($details['gps']) || !empty($details['exif_datetime']) || !empty($details['file_label']) || !empty($details['original_label']) || !empty($details['other']))
<div class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-2 text-[11px] text-slate-700">
@if(!empty($details['gps']))
<div><span class="font-medium">Coordinate:</span> {{ $details['gps']['label'] }}</div>
@endif
@if(!empty($details['exif_datetime']))
<div><span class="font-medium">Data EXIF:</span> {{ $details['exif_datetime'] }}</div>
@endif
@if(!empty($details['device']))
<div><span class="font-medium">Dispositivo:</span> {{ $details['device'] }}</div>
@endif
@if(!empty($details['file_label']))
<div><span class="font-medium">Nome file:</span> {{ $details['file_label'] }}</div>
@endif
@if(!empty($details['original_label']))
<div><span class="font-medium">Origine:</span> {{ $details['original_label'] }}</div>
@endif
@foreach($details['other'] as $otherPart)
<div>{{ $otherPart }}</div>
@endforeach
</div>
@endif
<div class="mt-2 text-[11px] text-gray-500">{{ optional($a->created_at)->format('d/m/Y H:i') }}</div>
<button type="button" wire:click="openAttachmentPreview({{ (int) $a->id }})" class="mt-2 inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500">Visualizza in modal</button>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" wire:click="openAttachmentPreview({{ (int) $a->id }})" class="inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500">Visualizza allegato</button>
@if(!empty($details['gps']))
<button type="button" wire:click="openAttachmentMapPreview({{ (int) $a->id }})" class="inline-flex items-center rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-emerald-500">Apri mappa</button>
@endif
</div>
</div>
@empty
<div class="text-xs text-gray-500">Nessun allegato.</div>
@ -545,8 +584,21 @@
<div class="flex items-center justify-between border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
@if(!empty($attachmentPreview['description']))
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
@if(!empty($attachmentPreview['details']['primary_description']))
<div class="text-xs text-gray-500">{{ $attachmentPreview['details']['primary_description'] }}</div>
@endif
@if(!empty($attachmentPreview['details']['gps']) || !empty($attachmentPreview['details']['exif_datetime']) || !empty($attachmentPreview['details']['device']))
<div class="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-600">
@if(!empty($attachmentPreview['details']['gps']))
<span>GPS {{ $attachmentPreview['details']['gps']['label'] }}</span>
@endif
@if(!empty($attachmentPreview['details']['exif_datetime']))
<span>EXIF {{ $attachmentPreview['details']['exif_datetime'] }}</span>
@endif
@if(!empty($attachmentPreview['details']['device']))
<span>{{ $attachmentPreview['details']['device'] }}</span>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-2">
@ -560,12 +612,21 @@
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
<button type="button" x-on:click="printPdf()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Stampa PDF</button>
@endif
@if(!empty($attachmentPreview['details']['gps']))
<button type="button" wire:click="openAttachmentMapPreview({{ (int) ($attachmentPreview['id'] ?? 0) }})" class="rounded-md bg-emerald-100 px-2 py-1 text-xs text-emerald-800 hover:bg-emerald-200">Google Maps</button>
@endif
<a href="{{ $attachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri in nuova scheda</a>
<button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
@if(!empty($attachmentPreview['details']['full_description']))
<div class="mb-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700">
<div class="font-medium text-slate-900">Dettagli allegato</div>
<div class="mt-1 whitespace-pre-wrap">{{ $attachmentPreview['details']['full_description'] }}</div>
</div>
@endif
@if($attachmentPreview['is_image'] ?? false)
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
<div class="flex min-h-full min-w-full items-start justify-center">
@ -584,4 +645,24 @@
</div>
</div>
@endif
@if($attachmentMapPreview)
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
<div class="h-[88vh] w-full max-w-5xl rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">Mappa allegato</div>
<div class="text-xs text-slate-500">{{ $attachmentMapPreview['name'] ?? 'Allegato' }} · {{ $attachmentMapPreview['label'] ?? '' }}</div>
</div>
<div class="flex items-center gap-2">
<a href="{{ $attachmentMapPreview['maps_url'] }}" target="_blank" rel="noopener noreferrer" class="rounded-md bg-emerald-100 px-2 py-1 text-xs text-emerald-800 hover:bg-emerald-200">Apri Google Maps</a>
<button type="button" wire:click="closeAttachmentMapPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="h-[calc(88vh-64px)] p-4">
<iframe src="{{ $attachmentMapPreview['embed_url'] }}" class="h-full w-full rounded-lg border" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>
</div>
@endif
</x-filament-panels::page>

View File

@ -364,7 +364,7 @@
<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">Anteprima immediata sul telefono</div>
<div class="mt-1 text-[11px] text-sky-800">Le foto e gli allegati selezionati si vedono subito qui, prima dell'accodamento definitivo nella bozza ticket.</div>
<div class="mt-1 text-[11px] text-sky-800">Le foto e gli allegati selezionati si vedono subito qui mentre entrano in coda. Nella griglia sotto ogni miniatura resta nello stesso box della sua descrizione.</div>
<template x-if="localCameraPreviews.length">
<div class="mt-3">
@ -419,13 +419,13 @@
@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">Qui sotto restano solo i dati operativi del file. L'anteprima immagine resta nel riquadro sopra fino all'invio del ticket.</div>
<div class="mb-2 text-[11px] text-gray-500">Ogni allegato mantiene qui miniatura, protocollo e descrizione nello stesso riquadro, cosi puoi descrivere correttamente piu foto consecutive.</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'] && $upload['preview_url'])
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50">
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
@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>
@endif
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">
@ -433,7 +433,7 @@
{{ $upload['is_image'] ? 'Immagine' : (strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE')) }}
</span>
<div class="text-[11px] text-gray-600">
{{ $upload['is_image'] ? 'Anteprima visibile sopra' : 'Allegato pronto per l\'invio' }}
{{ $upload['is_image'] ? 'Miniatura agganciata a questa descrizione' : 'Allegato pronto per l\'invio' }}
</div>
</div>