fix(ticket-mobile): secure attachment preview and per-file upload UX
This commit is contained in:
parent
11f078b8b2
commit
90cb8514ff
|
|
@ -151,9 +151,9 @@ public function getFornitoreAnagraficaUrl(int $fornitoreId): string
|
|||
return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament');
|
||||
}
|
||||
|
||||
public function getAttachmentPublicUrl(string $filePath): string
|
||||
public function getAttachmentPublicUrl(TicketAttachment $attachment): string
|
||||
{
|
||||
return '/storage/' . ltrim($filePath, '/');
|
||||
return route('admin.tickets.attachments.view', ['attachment' => (int) $attachment->id]);
|
||||
}
|
||||
|
||||
public function openAttachmentPreview(int $attachmentId): void
|
||||
|
|
@ -179,7 +179,7 @@ public function openAttachmentPreview(int $attachmentId): void
|
|||
'id' => (int) $attachment->id,
|
||||
'name' => $name,
|
||||
'description' => (string) ($attachment->description ?? ''),
|
||||
'url' => $this->getAttachmentPublicUrl((string) $attachment->file_path),
|
||||
'url' => $this->getAttachmentPublicUrl($attachment),
|
||||
'is_image' => $isImage,
|
||||
'is_pdf' => $isPdf,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ class TicketMobile extends Page
|
|||
/** @var array<int, mixed> */
|
||||
public array $newTicketAttachments = [];
|
||||
|
||||
/** @var array<int, mixed> */
|
||||
public array $newTicketCameraShots = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $newTicketAttachmentDescriptions = [];
|
||||
|
||||
public ?string $newTicketFotoNote = null;
|
||||
|
||||
/** @var \Illuminate\Support\Collection<int, Ticket> */
|
||||
|
|
@ -291,8 +297,12 @@ public function creaTicketRapido(): void
|
|||
'newTicketCategoriaId' => ['nullable', 'integer', 'exists:categorie_ticket,id'],
|
||||
'newTicketTipoIntervento' => ['nullable', 'string', 'max:80'],
|
||||
'newTicketFotoNote' => ['nullable', 'string', 'max:2000'],
|
||||
'newTicketCameraShots' => ['nullable', 'array', 'max:8'],
|
||||
'newTicketCameraShots.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'],
|
||||
'newTicketAttachments' => ['nullable', 'array', 'max:8'],
|
||||
'newTicketAttachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'],
|
||||
'newTicketAttachmentDescriptions' => ['nullable', 'array'],
|
||||
'newTicketAttachmentDescriptions.*' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
|
@ -371,11 +381,78 @@ public function creaTicketRapido(): void
|
|||
$this->newTicketPriorita = 'Media';
|
||||
$this->newTicketCategoriaId = null;
|
||||
$this->newTicketTipoIntervento = 'altro';
|
||||
$this->newTicketCameraShots = [];
|
||||
$this->newTicketAttachments = [];
|
||||
$this->newTicketAttachmentDescriptions = [];
|
||||
$this->newTicketFotoNote = null;
|
||||
$this->refreshData();
|
||||
}
|
||||
|
||||
public function updatedNewTicketCameraShots(): void
|
||||
{
|
||||
$this->syncAttachmentDescriptionSlots();
|
||||
}
|
||||
|
||||
public function updatedNewTicketAttachments(): void
|
||||
{
|
||||
$this->syncAttachmentDescriptionSlots();
|
||||
}
|
||||
|
||||
public function removeNewTicketFile(int $index): void
|
||||
{
|
||||
$cameraCount = count($this->newTicketCameraShots);
|
||||
|
||||
if ($index < $cameraCount) {
|
||||
unset($this->newTicketCameraShots[$index]);
|
||||
$this->newTicketCameraShots = array_values($this->newTicketCameraShots);
|
||||
} else {
|
||||
$docIndex = $index - $cameraCount;
|
||||
unset($this->newTicketAttachments[$docIndex]);
|
||||
$this->newTicketAttachments = array_values($this->newTicketAttachments);
|
||||
}
|
||||
|
||||
$this->syncAttachmentDescriptionSlots();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{index:int,name:string,mime:string,is_image:bool,preview_url:?string,description:string}>
|
||||
*/
|
||||
public function getSelectedUploadsProperty(): array
|
||||
{
|
||||
$rows = [];
|
||||
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
||||
|
||||
foreach ($files as $index => $file) {
|
||||
if (! is_object($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
|
||||
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
|
||||
$isImage = str_starts_with($mime, 'image/');
|
||||
|
||||
$previewUrl = null;
|
||||
if ($isImage && method_exists($file, 'temporaryUrl')) {
|
||||
try {
|
||||
$previewUrl = (string) $file->temporaryUrl();
|
||||
} catch (Throwable) {
|
||||
$previewUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'index' => (int) $index,
|
||||
'name' => $name,
|
||||
'mime' => $mime,
|
||||
'is_image' => $isImage,
|
||||
'preview_url' => $previewUrl,
|
||||
'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function loadCategorieTicketOptions(): void
|
||||
{
|
||||
$this->categorieTicketOptions = CategoriaTicket::query()
|
||||
|
|
@ -397,7 +474,9 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
|
|||
|
||||
$saved = 0;
|
||||
|
||||
foreach ($this->newTicketAttachments as $file) {
|
||||
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
||||
|
||||
foreach ($files as $index => $file) {
|
||||
if (! is_object($file) || ! method_exists($file, 'store')) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -413,9 +492,7 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
|
|||
'original_file_name' => (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : basename($path)),
|
||||
'mime_type' => (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'),
|
||||
'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0),
|
||||
'description' => filled($this->newTicketFotoNote)
|
||||
? ('Nota foto: ' . trim((string) $this->newTicketFotoNote))
|
||||
: 'Allegato da Ticket Mobile',
|
||||
'description' => $this->resolveAttachmentDescription((int) $index),
|
||||
]);
|
||||
|
||||
$saved++;
|
||||
|
|
@ -430,6 +507,32 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
|
|||
return $saved;
|
||||
}
|
||||
|
||||
private function syncAttachmentDescriptionSlots(): void
|
||||
{
|
||||
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
||||
$next = [];
|
||||
|
||||
foreach ($files as $index => $_file) {
|
||||
$next[$index] = (string) ($this->newTicketAttachmentDescriptions[$index] ?? '');
|
||||
}
|
||||
|
||||
$this->newTicketAttachmentDescriptions = $next;
|
||||
}
|
||||
|
||||
private function resolveAttachmentDescription(int $index): string
|
||||
{
|
||||
$specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? ''));
|
||||
if ($specific !== '') {
|
||||
return $specific;
|
||||
}
|
||||
|
||||
if (filled($this->newTicketFotoNote)) {
|
||||
return 'Nota foto: ' . trim((string) $this->newTicketFotoNote);
|
||||
}
|
||||
|
||||
return 'Allegato da Ticket Mobile';
|
||||
}
|
||||
|
||||
public function excerpt(?string $text, int $limit = 160): string
|
||||
{
|
||||
return Str::limit((string) $text, $limit);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TicketAttachment;
|
||||
use App\Models\User;
|
||||
use App\Support\StabileContext;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TicketAttachmentViewController extends Controller
|
||||
{
|
||||
public function show(TicketAttachment $attachment): Response
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ticket = $attachment->ticket;
|
||||
if (! $ticket) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$stabileId = StabileContext::resolveActiveStabileId($user);
|
||||
if (! $stabileId || (int) $ticket->stabile_id !== (int) $stabileId) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$path = (string) $attachment->file_path;
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$absolutePath = $disk->path($path);
|
||||
$mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream');
|
||||
|
||||
return response()->file($absolutePath, [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => 'private, max-age=300',
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes((string) ($attachment->original_file_name ?: basename($path))) . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,19 @@
|
|||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PanasonicCstaController extends Controller
|
||||
{
|
||||
public function incoming(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->isAuthorized($request)) {
|
||||
$this->logUnauthorizedAttempt($request, 'incoming');
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$traceId = (string) Str::uuid();
|
||||
|
||||
$payload = $request->validate([
|
||||
'phone' => ['required', 'string', 'max:64'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
|
|
@ -29,6 +33,14 @@ public function incoming(Request $request): JsonResponse
|
|||
'called_extension' => ['nullable', 'string', 'max:30'],
|
||||
'note' => ['nullable', 'string'],
|
||||
'called_at' => ['nullable', 'date'],
|
||||
'is_test' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
[$isTest, $classification] = $this->classifyCall($payload);
|
||||
$this->logTrace('incoming.received', $request, $payload, [
|
||||
'trace_id' => $traceId,
|
||||
'classification' => $classification,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
$normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']);
|
||||
|
|
@ -57,9 +69,18 @@ public function incoming(Request $request): JsonResponse
|
|||
|
||||
$this->mirrorToPeerIfEnabled($request, 'incoming', $payload);
|
||||
|
||||
$this->logTrace('incoming.stored', $request, $payload, [
|
||||
'trace_id' => $traceId,
|
||||
'post_it_id' => $postIt?->id,
|
||||
'rubrica_id' => $rubrica?->id,
|
||||
'classification' => $classification,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'source' => 'panasonic_ns1000',
|
||||
'trace_id' => $traceId,
|
||||
'phone_normalized' => $normalized,
|
||||
'matched' => (bool) $rubrica,
|
||||
'rubrica' => $rubrica ? [
|
||||
|
|
@ -75,6 +96,7 @@ public function incoming(Request $request): JsonResponse
|
|||
public function lookup(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->isAuthorized($request)) {
|
||||
$this->logUnauthorizedAttempt($request, 'lookup');
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
|
|
@ -103,9 +125,12 @@ public function lookup(Request $request): JsonResponse
|
|||
public function callEnded(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->isAuthorized($request)) {
|
||||
$this->logUnauthorizedAttempt($request, 'call-ended');
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$traceId = (string) Str::uuid();
|
||||
|
||||
$payload = $request->validate([
|
||||
'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'],
|
||||
'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'],
|
||||
|
|
@ -115,6 +140,14 @@ public function callEnded(Request $request): JsonResponse
|
|||
'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'],
|
||||
'called_extension' => ['nullable', 'string', 'max:30'],
|
||||
'note' => ['nullable', 'string'],
|
||||
'is_test' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
[$isTest, $classification] = $this->classifyCall($payload);
|
||||
$this->logTrace('call-ended.received', $request, $payload, [
|
||||
'trace_id' => $traceId,
|
||||
'classification' => $classification,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
|
||||
|
|
@ -148,9 +181,16 @@ public function callEnded(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
if (! $postIt) {
|
||||
$this->logTrace('call-ended.not-found', $request, $payload, [
|
||||
'trace_id' => $traceId,
|
||||
'classification' => $classification,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'No matching Post-it and table unavailable',
|
||||
'trace_id' => $traceId,
|
||||
], 404);
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +215,18 @@ public function callEnded(Request $request): JsonResponse
|
|||
|
||||
$this->mirrorToPeerIfEnabled($request, 'call-ended', $payload);
|
||||
|
||||
$this->logTrace('call-ended.closed', $request, $payload, [
|
||||
'trace_id' => $traceId,
|
||||
'post_it_id' => $postIt->id,
|
||||
'created_new' => $created,
|
||||
'classification' => $classification,
|
||||
'is_test' => $isTest,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'source' => 'panasonic_ns1000',
|
||||
'trace_id' => $traceId,
|
||||
'closed' => true,
|
||||
'created_new' => $created,
|
||||
'post_it_id' => $postIt->id,
|
||||
|
|
@ -324,10 +373,17 @@ private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt
|
|||
|
||||
private function buildNote(array $payload): string
|
||||
{
|
||||
[$isTest, $classification] = $this->classifyCall($payload);
|
||||
|
||||
$lines = [
|
||||
'Origine: Panasonic NS1000 (CSTA)',
|
||||
'Classificazione: ' . $classification,
|
||||
];
|
||||
|
||||
if ($isTest) {
|
||||
$lines[] = 'Flag test: SI';
|
||||
}
|
||||
|
||||
if (! empty($payload['called_extension'])) {
|
||||
$lines[] = 'Interno chiamato: ' . $payload['called_extension'];
|
||||
}
|
||||
|
|
@ -342,4 +398,56 @@ private function buildNote(array $payload): string
|
|||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:bool,1:string}
|
||||
*/
|
||||
private function classifyCall(array $payload): array
|
||||
{
|
||||
if (array_key_exists('is_test', $payload)) {
|
||||
$isTest = (bool) $payload['is_test'];
|
||||
return [$isTest, $isTest ? 'test (flag esplicito)' : 'reale presunta (flag esplicito)'];
|
||||
}
|
||||
|
||||
$haystack = mb_strtolower(trim(implode(' ', [
|
||||
(string) ($payload['name'] ?? ''),
|
||||
(string) ($payload['note'] ?? ''),
|
||||
(string) ($payload['event_id'] ?? ''),
|
||||
])));
|
||||
|
||||
foreach (['test', 'prova', 'demo', 'simulazione'] as $marker) {
|
||||
if ($haystack !== '' && str_contains($haystack, $marker)) {
|
||||
return [true, 'test probabile (marker: ' . $marker . ')'];
|
||||
}
|
||||
}
|
||||
|
||||
return [false, 'reale presunta'];
|
||||
}
|
||||
|
||||
private function logUnauthorizedAttempt(Request $request, string $endpoint): void
|
||||
{
|
||||
Log::warning('CTI Panasonic unauthorized', [
|
||||
'endpoint' => $endpoint,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'has_token_header' => $request->headers->has('X-CTI-Token'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logTrace(string $event, Request $request, array $payload, array $extra = []): void
|
||||
{
|
||||
$phoneRaw = (string) ($payload['phone'] ?? '');
|
||||
$phoneDigits = PhoneNumber::normalizeDigits($phoneRaw);
|
||||
|
||||
Log::info('CTI Panasonic trace', array_merge([
|
||||
'event' => $event,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1',
|
||||
'event_id' => (string) ($payload['event_id'] ?? ''),
|
||||
'called_extension' => (string) ($payload['called_extension'] ?? ''),
|
||||
'direction' => (string) ($payload['direction'] ?? ''),
|
||||
'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '',
|
||||
], $extra));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ # Modifiche NetGescon
|
|||
| 11/03/2026 | 0.8.x-dev | Day-0 / Staging / Operativita | Handoff operativo completo, comandi diagnostica payload CSTA/HTTP e allineamento staging nginx su /var/www/netgescon:80 | [P] |
|
||||
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / DB condiviso | Verificato allineamento DB dev/staging su base unica, applicata migrazione campi `durata_secondi`/`chiusa_il`, fallback token CTI multi-chiave e test end-to-end incoming/call-ended/lookup con chiusura Post-it validata | [U] |
|
||||
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / Sync staging->sviluppo | Introdotti mirror payload CTI opzionale (solo staging) e comando `cti:sync-postit-from-staging` per riallineamento unidirezionale record `chiamate_post_it` Panasonic verso sviluppo | [U] |
|
||||
| 11/03/2026 | 0.8.x-dev | Ticket Mobile / Allegati | Fix accesso allegati con route protetta `admin.tickets.attachments.view` (stop 403 da URL `/storage`), anteprima in modal e lista allegati stile catalogo con descrizione per file in inserimento mobile (foto + documenti) | [U] |
|
||||
| 11/03/2026 | 0.8.x-dev | CTI Panasonic / Tracciamento | Aggiunto `trace_id` in risposta incoming/call-ended e log strutturato (`CTI Panasonic trace`) per audit chiamate reali/test, ricerca eventi mancanti e diagnosi `event_id`/telefono/canale mirror | [U] |
|
||||
|
|
|
|||
|
|
@ -199,12 +199,27 @@
|
|||
<x-filament::button color="success" wire:click="caricaAllegati">Carica allegati</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
@forelse($ticket->attachments as $a)
|
||||
<div class="rounded-md border p-2 text-xs">
|
||||
<div class="font-medium">{{ $a->original_file_name }}</div>
|
||||
<div class="text-gray-500">{{ $a->description }}</div>
|
||||
<button type="button" wire:click="openAttachmentPreview({{ (int) $a->id }})" class="text-primary-600 hover:underline">Apri allegato</button>
|
||||
<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(str_starts_with((string) $a->mime_type, 'image/'))
|
||||
<img src="{{ route('admin.tickets.attachments.view', ['attachment' => (int) $a->id]) }}" alt="{{ $a->original_file_name }}" class="h-full w-full rounded object-cover" />
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-center text-gray-500">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">{{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: 'FILE') }}</div>
|
||||
<div class="mt-1 text-[11px]">Anteprima disponibile in modal</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</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-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>
|
||||
@empty
|
||||
<div class="text-xs text-gray-500">Nessun allegato.</div>
|
||||
|
|
|
|||
|
|
@ -126,22 +126,57 @@
|
|||
</label>
|
||||
|
||||
<label class="block text-sm md:col-span-2">
|
||||
<span class="mb-1 block font-medium">Nota foto/documentazione (opzionale)</span>
|
||||
<span class="mb-1 block font-medium">Nota generale foto/documentazione (opzionale)</span>
|
||||
<textarea rows="2" wire:model.defer="newTicketFotoNote" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita visibile sotto il lavello, foto allegata" ></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block text-sm md:col-span-2">
|
||||
<span class="mb-1 block font-medium">Foto o documenti</span>
|
||||
<input
|
||||
type="file"
|
||||
wire:model="newTicketAttachments"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
capture="environment"
|
||||
class="w-full rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-gray-500">Puoi allegare fino a 8 file (max 10MB ciascuno). Da smartphone puoi scattare una foto direttamente.</div>
|
||||
<span class="mb-2 block font-medium">Acquisizione foto e allegati</span>
|
||||
<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="newTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" />
|
||||
</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="newTicketAttachments" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" class="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">Dopo ogni scatto puoi aggiungere subito la descrizione. Se servono altre foto usa di nuovo "Scatta foto".</div>
|
||||
</label>
|
||||
|
||||
@if(count($this->selectedUploads) > 0)
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-2 text-xs font-semibold text-gray-700">Anteprima allegati selezionati (stile catalogo)</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">
|
||||
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
|
||||
@if($upload['is_image'] && !empty($upload['preview_url']))
|
||||
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full rounded object-cover" />
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-center text-gray-500">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">{{ strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE') }}</div>
|
||||
<div class="mt-1 text-[11px]">{{ $upload['name'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 font-medium">{{ $upload['name'] }}</div>
|
||||
<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">Crea ticket</x-filament::button>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
use App\Http\Controllers\Admin\StabileAttivoController;
|
||||
use App\Http\Controllers\Admin\StabileController;
|
||||
use App\Http\Controllers\Admin\TicketController;
|
||||
use App\Http\Controllers\Admin\TicketAttachmentViewController;
|
||||
use App\Http\Controllers\Admin\TicketInterventoController;
|
||||
use App\Http\Controllers\Admin\UnitaImmobiliareController;
|
||||
use App\Http\Controllers\Admin\VoceSpesaController;
|
||||
|
|
@ -131,6 +132,7 @@
|
|||
Route::resource('tickets', TicketController::class);
|
||||
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
|
||||
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
|
||||
Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view');
|
||||
Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store');
|
||||
Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify');
|
||||
Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');
|
||||
|
|
@ -300,6 +302,7 @@
|
|||
Route::resource('tickets', TicketController::class);
|
||||
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
|
||||
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
|
||||
Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view');
|
||||
Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store');
|
||||
Route::post('tickets/{ticket}/interventi/{intervento}/verify', [TicketInterventoController::class, 'verify'])->name('tickets.interventi.verify');
|
||||
Route::post('tickets/{ticket}/interventi/{intervento}/invoice', [TicketInterventoController::class, 'invoice'])->name('tickets.interventi.invoice');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user