|null */ private ?array $documentiColumnsCache = null; protected static ?string $navigationLabel = 'Ticket Gestione'; protected static ?string $title = 'Gestione Ticket'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list'; protected static UnitEnum|string|null $navigationGroup = 'Supporto'; protected static ?int $navigationSort = 26; protected static ?string $slug = 'supporto/ticket-gestione'; protected string $view = 'filament.pages.supporto.ticket-gestione'; public string $status = 'open'; public string $activeTab = 'elenco'; public ?int $selectedTicketId = null; public ?string $notaInterna = null; public ?int $fornitoreId = null; public string $fornitoreSearch = ''; public ?string $noteAssegnazione = null; public ?string $insurancePolicyReference = null; public ?int $insurancePolicyId = null; public ?string $insuranceClaimNumber = null; public ?string $insuranceStatus = null; public ?string $insuranceTakenInChargeAt = null; public ?string $insuranceEmailSentAt = null; public ?string $insuranceInsurerContactedAt = null; public ?string $insuranceDocumentsSentAt = null; public ?string $insuranceAppointmentAt = null; public ?string $insuranceClosedAt = null; public ?string $insuranceNextAction = null; public ?string $insuranceNotes = null; /** @var array */ public array $nuoviAllegati = []; /** @var array */ public array $nuoveFoto = []; public ?string $descrizioneAllegati = null; public ?string $lastGeneratedPassword = null; public ?int $selectedCategoriaId = null; public ?string $categoriaNome = null; public ?string $categoriaDescrizione = null; /** @var array */ public array $categorieOptions = []; /** @var array> */ public array $fornitoreMatches = []; /** @var array> */ public array $fornitoriAttiviRows = []; /** @var array|null */ public ?array $attachmentPreview = null; /** @var \Illuminate\Support\Collection */ public $tickets; /** @var array */ public array $ticketCounters = [ 'open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0, ]; public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void { $this->tickets = collect(); $this->selectedTicketId = (int) request()->query('ticket', 0) ?: null; $queryTab = (string) request()->query('tab', ''); if ($queryTab === 'scheda') { $this->activeTab = 'scheda'; } if (request()->query('status') === 'all') { $this->status = 'all'; } $this->loadCategorieTicketOptions(); $this->refreshFornitoriAttiviRows(); if ($this->selectedTicket) { $this->fornitoreId = (int) ($this->selectedTicket->assegnato_a_fornitore_id ?? 0) ?: null; } $this->refreshData(); } public function updatedSelectedCategoriaId($value): void { $id = (int) $value; if ($id <= 0) { $this->categoriaNome = null; $this->categoriaDescrizione = null; return; } $categoria = CategoriaTicket::query()->find($id); if (! $categoria instanceof CategoriaTicket) { return; } $this->categoriaNome = (string) $categoria->nome; $this->categoriaDescrizione = (string) ($categoria->descrizione ?? ''); } public function creaCategoriaTicket(): void { $this->validate([ 'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')], 'categoriaDescrizione' => ['nullable', 'string', 'max:2000'], ]); $categoria = CategoriaTicket::query()->create([ 'nome' => trim((string) $this->categoriaNome), 'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null, ]); $this->loadCategorieTicketOptions(); $this->selectedCategoriaId = (int) $categoria->id; $this->updatedSelectedCategoriaId($this->selectedCategoriaId); Notification::make()->title('Categoria creata')->success()->send(); } public function aggiornaCategoriaTicket(): void { $id = (int) ($this->selectedCategoriaId ?? 0); if ($id <= 0) { Notification::make()->title('Seleziona una categoria da modificare')->warning()->send(); return; } $this->validate([ 'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')->ignore($id)], 'categoriaDescrizione' => ['nullable', 'string', 'max:2000'], ]); $categoria = CategoriaTicket::query()->find($id); if (! $categoria instanceof CategoriaTicket) { Notification::make()->title('Categoria non trovata')->danger()->send(); return; } $categoria->update([ 'nome' => trim((string) $this->categoriaNome), 'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null, ]); $this->loadCategorieTicketOptions(); Notification::make()->title('Categoria aggiornata')->success()->send(); $this->refreshData(); } public function updatedStatus(): void { $this->refreshData(); } public function updatedFornitoreSearch(): void { $this->searchFornitori(); } public function refreshData(): void { $this->loadCounters(); $this->loadTickets(); $this->refreshFornitoriAttiviRows(); $this->syncSelectedTicketState(); } public function getTicketInserimentoUrl(): string { return TicketMobile::getUrl(panel: 'admin-filament'); } public function apriScheda(int $ticketId): void { $this->selectedTicketId = $ticketId; $this->activeTab = 'scheda'; $ticket = $this->selectedTicket; $this->fornitoreId = $ticket ? ((int) ($ticket->assegnato_a_fornitore_id ?? 0) ?: null): null; $this->syncSelectedTicketState(); } public function apriElenco(): void { $this->activeTab = 'elenco'; } public function getFornitoreOperativoUrl(?int $fornitoreId = null): string { $base = \App\Filament\Pages\Fornitore\TicketOperativi::getUrl(panel: 'admin-filament'); if ($fornitoreId) { return $base . '?fornitore=' . $fornitoreId; } return $base; } public function getFornitoreAnagraficaUrl(int $fornitoreId): string { return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament'); } public function getAttachmentPublicUrl(TicketAttachment $attachment): string { return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]); } public function openAttachmentPreview(int $attachmentId): void { $ticket = $this->selectedTicket; if (! $ticket) { return; } $attachment = $ticket->attachments->firstWhere('id', $attachmentId); if (! $attachment instanceof TicketAttachment) { return; } $name = (string) ($attachment->original_file_name ?? 'allegato'); $mime = $attachment->resolvedMimeType(); $isImage = $attachment->isImage(); $isPdf = $attachment->isPdf(); $this->attachmentPreview = [ 'id' => (int) $attachment->id, 'name' => $name, 'description' => (string) ($attachment->description ?? ''), 'url' => $this->getAttachmentPublicUrl($attachment), 'is_image' => $isImage, 'is_pdf' => $isPdf, 'mime' => $mime, ]; } public function closeAttachmentPreview(): void { $this->attachmentPreview = null; } public function getSelectedTicketProperty(): ?Ticket { if (! $this->selectedTicketId) { return null; } $user = Auth::user(); if (! $user instanceof User) { return null; } $stabileIds = $this->resolveTicketScopeStabileIds(true); if ($stabileIds === []) { return null; } return Ticket::query() ->with([ 'stabile:id,denominazione', 'categoriaTicket:id,nome', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome', 'insuranceClaim', 'attachments.user:id,name', 'messages.user:id,name', 'interventi.fornitore:id,ragione_sociale,nome,cognome', 'interventi.creatoDaUser:id,name', ]) ->whereIn('stabile_id', $stabileIds) ->find($this->selectedTicketId); } public function assegnaFornitore(): void { $ticket = $this->selectedTicket; if (! $ticket) { Notification::make()->title('Ticket non trovato')->danger()->send(); return; } $this->validate([ 'fornitoreId' => ['required', 'integer', 'exists:fornitori,id'], 'noteAssegnazione' => ['nullable', 'string', 'max:4000'], ]); $ticket->update([ 'assegnato_a_fornitore_id' => $this->fornitoreId, 'stato' => in_array($ticket->stato, ['Aperto'], true) ? 'Preso in Carico' : $ticket->stato, ]); if (Schema::hasTable('ticket_interventi')) { $ultimo = TicketIntervento::query() ->where('ticket_id', $ticket->id) ->where('fornitore_id', $this->fornitoreId) ->latest('id') ->first(); if (! $ultimo) { TicketIntervento::query()->create([ 'ticket_id' => $ticket->id, 'fornitore_id' => $this->fornitoreId, 'creato_da_user_id' => Auth::id(), 'stato' => 'assegnato', 'note_amministratore' => $this->noteAssegnazione, ]); } } if (filled($this->noteAssegnazione)) { $ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => 'Assegnazione fornitore: ' . trim((string) $this->noteAssegnazione), ]); } $this->noteAssegnazione = null; Notification::make()->title('Fornitore assegnato')->success()->send(); $this->refreshData(); } public function selezionaFornitore(int $fornitoreId): void { $match = collect($this->fornitoreMatches)->firstWhere('id', $fornitoreId); if (! is_array($match)) { $fornitore = $this->resolveFornitoriBaseQuery()->find($fornitoreId); if (! $fornitore instanceof Fornitore) { return; } $match = $this->mapFornitoreSearchRow($fornitore); } $this->fornitoreId = (int) $match['id']; $this->fornitoreSearch = (string) $match['nome']; $this->fornitoreMatches = []; } public function resetFornitoreSelection(): void { $this->fornitoreId = null; $this->fornitoreSearch = ''; $this->fornitoreMatches = []; } public function aggiungiNotaInterna(): void { $ticket = $this->selectedTicket; if (! $ticket) { Notification::make()->title('Ticket non trovato')->danger()->send(); return; } $this->validate([ 'notaInterna' => ['required', 'string', 'max:5000'], ]); $stamp = now()->format('d/m/Y H:i'); $ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => '[' . $stamp . '] ' . trim((string) $this->notaInterna), ]); $this->notaInterna = null; // Force refresh of selected ticket relations so the new note is visible immediately. $this->refreshData(); Notification::make()->title('Nota aggiunta')->success()->send(); } public function salvaSinistroAssicurativo(): void { $ticket = $this->selectedTicket; if (! $ticket) { Notification::make()->title('Ticket non trovato')->danger()->send(); return; } $this->validate([ 'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'], 'insurancePolicyReference' => ['nullable', 'string', 'max:255'], 'insuranceClaimNumber' => ['nullable', 'string', 'max:255'], 'insuranceStatus' => ['nullable', 'string', 'max:50'], 'insuranceTakenInChargeAt' => ['nullable', 'date'], 'insuranceEmailSentAt' => ['nullable', 'date'], 'insuranceInsurerContactedAt' => ['nullable', 'date'], 'insuranceDocumentsSentAt' => ['nullable', 'date'], 'insuranceAppointmentAt' => ['nullable', 'date'], 'insuranceClosedAt' => ['nullable', 'date'], 'insuranceNextAction' => ['nullable', 'string', 'max:1000'], 'insuranceNotes' => ['nullable', 'string', 'max:4000'], ]); $claimMetadata = array_merge((array) ($ticket->insuranceClaim?->metadata ?? []), [ 'taken_in_charge_at' => $this->insuranceTakenInChargeAt ?: null, 'email_sent_at' => $this->insuranceEmailSentAt ?: null, 'insurer_contacted_at' => $this->insuranceInsurerContactedAt ?: null, 'documents_sent_at' => $this->insuranceDocumentsSentAt ?: null, 'appointment_at' => $this->insuranceAppointmentAt ?: null, 'closed_at' => $this->insuranceClosedAt ?: null, 'next_action' => filled($this->insuranceNextAction) ? trim((string) $this->insuranceNextAction) : null, ]); $claim = InsuranceClaim::query()->updateOrCreate( ['ticket_id' => (int) $ticket->id], [ 'insurance_policy_id' => (int) ($this->insurancePolicyId ?? 0) ?: null, 'stabile_id' => (int) $ticket->stabile_id, 'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null, 'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null, 'status' => filled($this->insuranceStatus) ? trim((string) $this->insuranceStatus) : 'aperta', 'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(), 'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null, 'metadata' => $claimMetadata, ] ); $ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => 'Sinistro assicurativo aggiornato. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire') . ' · Stato: ' . ($claim->status ?: 'aperta'), 'canale' => 'assicurazione', 'direzione' => 'outbound', 'inviato_il' => now(), 'metadata' => [ 'insurance_claim_id' => (int) $claim->id, ], ]); $this->refreshData(); Notification::make()->title('Sinistro assicurativo aggiornato')->success()->send(); } public function caricaAllegati(): void { $ticket = $this->selectedTicket; if (! $ticket) { Notification::make()->title('Ticket non trovato')->danger()->send(); return; } if (! Schema::hasTable('ticket_attachments')) { Notification::make()->title('Tabella allegati non pronta')->danger()->send(); return; } $this->validate([ 'nuoviAllegati' => ['nullable', 'array', 'max:8'], 'nuoviAllegati.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'], 'nuoveFoto' => ['nullable', 'array', 'max:8'], 'nuoveFoto.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'], 'descrizioneAllegati' => ['nullable', 'string', 'max:500'], ]); $files = array_merge($this->nuoveFoto, $this->nuoviAllegati); if (count($files) === 0) { Notification::make()->title('Seleziona almeno un file o una foto')->warning()->send(); return; } $saved = 0; foreach ($files as $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } $stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-gestione/' . $ticket->id); $attachment = TicketAttachment::query()->create([ 'ticket_id' => $ticket->id, 'ticket_update_id' => null, 'user_id' => Auth::id(), 'file_path' => $stored['path'], 'original_file_name' => $stored['original_name'], 'mime_type' => $stored['mime'], 'size' => $stored['size'], 'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket', ]); if ($attachment instanceof TicketAttachment) { $this->archiveTicketAttachmentDocument($ticket, $attachment); } $saved++; } $this->nuoveFoto = []; $this->nuoviAllegati = []; $this->descrizioneAllegati = null; $this->refreshData(); Notification::make() ->title('Allegati caricati') ->body('Caricati ' . $saved . ' allegati.') ->success() ->send(); } public function prendiInCarico(int $ticketId): void { $this->aggiornaStatoTicket($ticketId, 'Preso in Carico', true); } public function avviaLavorazione(int $ticketId): void { $this->aggiornaStatoTicket($ticketId, 'In Lavorazione'); } public function risolviTicket(int $ticketId): void { $this->aggiornaStatoTicket($ticketId, 'Risolto'); } public function chiudiTicket(int $ticketId): void { $this->aggiornaStatoTicket($ticketId, 'Chiuso'); } public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Stabile attivo non disponibile')->warning()->send(); return; } $stabile = \App\Models\Stabile::query()->find($stabileId); $adminId = (int) ($stabile->amministratore_id ?? 0); $fornitore = Fornitore::query()->find($fornitoreId); if (! $fornitore instanceof Fornitore) { Notification::make()->title('Fornitore non trovato')->danger()->send(); return; } if ($adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $adminId) { Notification::make()->title('Fornitore non valido per questo stabile')->danger()->send(); return; } $email = trim((string) ($fornitore->email ?? '')); if ($email === '') { Notification::make()->title('Email fornitore mancante')->warning()->send(); return; } $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $fornitore->id) ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) ->first(); if (! $dipendente instanceof FornitoreDipendente) { $dipendente = FornitoreDipendente::query()->create([ 'fornitore_id' => (int) $fornitore->id, 'nome' => (string) ($fornitore->ragione_sociale ?: ($fornitore->nome ?: 'Referente fornitore')), 'cognome' => $fornitore->ragione_sociale ? null : (($fornitore->cognome ?? null) ?: null), 'email' => $email, 'telefono' => $fornitore->telefono ?: $fornitore->cellulare, 'attivo' => true, 'created_by_user_id' => Auth::id(), 'updated_by_user_id' => Auth::id(), ]); } $this->abilitaAccessoDipendente($dipendente); $this->refreshData(); } public function resetPasswordFornitoreUser(int $userId): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Stabile attivo non disponibile')->warning()->send(); return; } $stabile = \App\Models\Stabile::query()->find($stabileId); $adminId = (int) ($stabile->amministratore_id ?? 0); $canReset = FornitoreDipendente::query() ->where('user_id', $userId) ->whereHas('fornitore', function ($q) use ($adminId): void { if ($adminId > 0) { $q->where('amministratore_id', $adminId); } }) ->exists(); if (! $canReset) { Notification::make()->title('Utente non autorizzato per reset')->danger()->send(); return; } $target = User::query()->find($userId); if (! $target instanceof User) { Notification::make()->title('Utente non trovato')->danger()->send(); return; } $newPassword = Str::random(12); $target->password = Hash::make($newPassword); $target->save(); $this->lastGeneratedPassword = $newPassword; Notification::make() ->title('Password fornitore resettata') ->body('Nuova password temporanea: ' . $newPassword) ->success() ->send(); } private function loadTickets(): void { $user = Auth::user(); if (! $user instanceof User) { $this->tickets = collect(); return; } $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); if ($stabileIds === []) { $this->tickets = collect(); return; } $query = Ticket::query() ->with(['categoriaTicket', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome', 'assegnatoAUser:id,name']) ->withCount('attachments') ->whereIn('stabile_id', $stabileIds); if ($this->status === 'open') { $query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']); } elseif ($this->status === 'urgent') { $query->where('priorita', 'Urgente') ->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']); } elseif ($this->status === 'closed') { $query->whereIn('stato', ['Risolto', 'Chiuso']); } $this->tickets = $query->latest('data_apertura')->limit(50)->get(); } private function refreshFornitoriAttiviRows(): void { $user = Auth::user(); if (! $user instanceof User) { $this->fornitoriAttiviRows = []; return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { $this->fornitoriAttiviRows = []; return; } $ticketApertiStati = ['Aperto', 'Preso in Carico', 'In Lavorazione', 'In Attesa Approvazione', 'In Attesa Ricambi']; $assignedTickets = Ticket::query() ->where('stabile_id', $stabileId) ->whereIn('stato', $ticketApertiStati) ->whereNotNull('assegnato_a_fornitore_id') ->get(['id', 'assegnato_a_fornitore_id', 'stato', 'updated_at']); $assignedByFornitore = $assignedTickets ->groupBy('assegnato_a_fornitore_id') ->map(fn($rows) => $rows->count()); $ticketSummaries = $assignedTickets ->groupBy('assegnato_a_fornitore_id') ->map(function ($rows) { return $rows->sortByDesc('updated_at')->take(6)->map(function ($ticket): string { return '#' . ((int) $ticket->id) . ' (' . (string) $ticket->stato . ')'; })->values()->all(); }); $openInterventiByFornitore = collect(); $interventoStatiByFornitore = collect(); $lastInterventoAtByFornitore = collect(); if (Schema::hasTable('ticket_interventi')) { $openInterventi = TicketIntervento::query() ->whereIn('stato', ['assegnato', 'in_corso', 'in_verifica']) ->whereHas('ticket', function ($q) use ($stabileId, $ticketApertiStati): void { $q->where('stabile_id', $stabileId)->whereIn('stato', $ticketApertiStati); }) ->orderByDesc('updated_at') ->get(['fornitore_id', 'stato', 'updated_at']); $openInterventiByFornitore = $openInterventi ->groupBy('fornitore_id') ->map(fn($rows) => $rows->count()); $interventoStatiByFornitore = $openInterventi ->groupBy('fornitore_id') ->map(function ($rows): string { return $rows->pluck('stato') ->map(fn($stato) => str_replace('_', ' ', (string) $stato)) ->unique() ->take(3) ->implode(', '); }); $lastInterventoAtByFornitore = $openInterventi ->groupBy('fornitore_id') ->map(function ($rows): ?string { $last = $rows->sortByDesc('updated_at')->first(); return $last && $last->updated_at ? $last->updated_at->format('d/m/Y H:i') : null; }); } $fornitoriIds = collect($assignedByFornitore->keys()) ->merge($openInterventiByFornitore->keys()) ->map(fn($v) => (int) $v) ->filter(fn(int $v) => $v > 0) ->unique() ->values(); if ($fornitoriIds->isEmpty()) { $this->fornitoriAttiviRows = []; return; } $rows = []; $accessRows = FornitoreDipendente::query() ->whereIn('fornitore_id', $fornitoriIds->all()) ->get(['fornitore_id', 'user_id']); $dipCountByFornitore = $accessRows ->groupBy('fornitore_id') ->map(fn($r) => $r->count()); $linkedUserByFornitore = $accessRows ->filter(fn($r) => (int) ($r->user_id ?? 0) > 0) ->groupBy('fornitore_id') ->map(fn($r) => (int) ($r->first()->user_id ?? 0)); foreach (Fornitore::query()->whereIn('id', $fornitoriIds->all())->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome')->get() as $fornitore) { $rows[] = [ 'id' => (int) $fornitore->id, 'nome' => trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))), 'email' => (string) ($fornitore->email ?? ''), 'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare), 'ticket_attivi' => (int) ($assignedByFornitore->get((string) $fornitore->id) ?? $assignedByFornitore->get((int) $fornitore->id) ?? 0), 'interventi_aperti' => (int) ($openInterventiByFornitore->get((string) $fornitore->id) ?? $openInterventiByFornitore->get((int) $fornitore->id) ?? 0), 'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []), 'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''), 'ultimo_aggiornamento' => (string) ($lastInterventoAtByFornitore->get((string) $fornitore->id) ?? $lastInterventoAtByFornitore->get((int) $fornitore->id) ?? ''), 'dipendenti_count' => (int) ($dipCountByFornitore->get((string) $fornitore->id) ?? $dipCountByFornitore->get((int) $fornitore->id) ?? 0), 'linked_user_id' => (int) ($linkedUserByFornitore->get((string) $fornitore->id) ?? $linkedUserByFornitore->get((int) $fornitore->id) ?? 0), ]; } usort($rows, function (array $a, array $b): int { $lhs = ((int) $a['interventi_aperti']) * 1000 + (int) $a['ticket_attivi']; $rhs = ((int) $b['interventi_aperti']) * 1000 + (int) $b['ticket_attivi']; return $rhs <=> $lhs; }); $this->fornitoriAttiviRows = $rows; } private function abilitaAccessoDipendente(FornitoreDipendente $dipendente): void { $email = trim((string) ($dipendente->email ?? '')); if ($email === '') { Notification::make()->title('Email dipendente mancante')->warning()->send(); return; } $existingUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); $created = false; if (! $existingUser instanceof User) { $generatedPassword = Str::random(12); $existingUser = User::query()->create([ 'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')), 'email' => $email, 'password' => Hash::make($generatedPassword), 'email_verified_at' => now(), 'is_active' => true, ]); $this->lastGeneratedPassword = $generatedPassword; $created = true; } $existingUser->assignRole('fornitore'); $dipendente->user_id = (int) $existingUser->id; $dipendente->updated_by_user_id = Auth::id(); $dipendente->save(); $body = $created ? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)') : 'Accesso attivato/aggiornato per utente esistente.'; Notification::make()->title('Accesso fornitore abilitato')->body($body)->success()->send(); } private function searchFornitori(): void { $raw = trim($this->fornitoreSearch); if ($raw === '') { if ($this->fornitoreId) { $selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId); $this->fornitoreMatches = $selected instanceof Fornitore ? [$this->mapFornitoreSearchRow($selected)] : []; return; } $this->fornitoreMatches = []; return; } $digits = preg_replace('/\D+/', '', $raw) ?: ''; $needleText = '%' . mb_strtolower($raw) . '%'; $needleDigits = '%' . $digits . '%'; $canonicalNeedles = $this->normalizeSearchTags($raw); $matches = $this->resolveFornitoriBaseQuery() ->where(function ($query) use ($needleText, $needleDigits, $digits, $canonicalNeedles): void { $query->orWhereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(note, '')) LIKE ?", [$needleText]); foreach ($canonicalNeedles as $canonicalNeedle) { $query->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", ['%' . $canonicalNeedle . '%']); } if ($digits !== '') { $query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needleDigits]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needleDigits]); } }) ->limit(60) ->get(); $this->fornitoreMatches = $matches ->map(function (Fornitore $fornitore) use ($raw, $digits, $canonicalNeedles): array { $row = $this->mapFornitoreSearchRow($fornitore); $row['score'] = $this->scoreFornitoreMatch($row, $raw, $digits, $canonicalNeedles); return $row; }) ->sortByDesc('score') ->take(12) ->values() ->map(function (array $row): array { unset($row['score']); return $row; }) ->all(); if ($this->fornitoreId && ! collect($this->fornitoreMatches)->contains('id', $this->fornitoreId)) { $selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId); if ($selected instanceof Fornitore) { array_unshift($this->fornitoreMatches, $this->mapFornitoreSearchRow($selected)); $this->fornitoreMatches = array_slice($this->fornitoreMatches, 0, 12); } } } private function resolveFornitoriBaseQuery() { $user = Auth::user(); if (! $user instanceof User) { return Fornitore::query()->whereRaw('1 = 0'); } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return Fornitore::query()->whereRaw('1 = 0'); } $stabile = \App\Models\Stabile::query()->find($stabileId); $adminId = (int) ($stabile->amministratore_id ?? 0); $query = Fornitore::query()->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome'); if ($adminId > 0) { $query->where(function ($builder) use ($adminId): void { $builder->where('amministratore_id', $adminId)->orWhereNull('amministratore_id'); }); } return $query; } /** * @return array */ private function resolveTicketScopeStabileIds(bool $includeAllAccessible = false): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $accessibleIds = StabileContext::accessibleStabili($user) ->pluck('id') ->map(fn($value) => (int) $value) ->filter(fn(int $value) => $value > 0) ->values() ->all(); if ($accessibleIds === []) { return []; } if ($includeAllAccessible) { return $accessibleIds; } $activeId = StabileContext::resolveActiveStabileId($user); return $activeId ? [$activeId] : [(int) $accessibleIds[0]]; } /** * @return array */ private function mapFornitoreSearchRow(Fornitore $fornitore): array { $tags = $this->splitFornitoreTags((string) ($fornitore->tags ?? '')); $label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))); return [ 'id' => (int) $fornitore->id, 'nome' => $label !== '' ? $label : ('Fornitore #' . $fornitore->id), 'email' => (string) ($fornitore->email ?? ''), 'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare), 'tags' => $tags, 'tags_label' => $tags !== [] ? implode(', ', $tags) : '', 'note' => Str::limit(trim((string) ($fornitore->note ?? '')), 120), ]; } /** * @param array $row * @param array $canonicalNeedles */ private function scoreFornitoreMatch(array $row, string $raw, string $digits, array $canonicalNeedles): int { $score = (int) (($row['id'] ?? 0) === (int) ($this->fornitoreId ?? 0) ? 1000 : 0); $haystack = mb_strtolower(implode(' ', array_filter([ (string) ($row['nome'] ?? ''), (string) ($row['email'] ?? ''), (string) ($row['telefono'] ?? ''), (string) ($row['tags_label'] ?? ''), (string) ($row['note'] ?? ''), ]))); if ($raw !== '' && str_contains($haystack, mb_strtolower($raw))) { $score += 60; } if ($digits !== '') { $phoneDigits = preg_replace('/\D+/', '', (string) ($row['telefono'] ?? '')) ?: ''; if ($phoneDigits !== '' && str_contains($phoneDigits, $digits)) { $score += 45; } } $rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), (array) ($row['tags'] ?? [])); foreach ($canonicalNeedles as $canonicalNeedle) { foreach ($rowTags as $rowTag) { if ($rowTag === $canonicalNeedle) { $score += 90; } elseif (str_contains($rowTag, $canonicalNeedle) || str_contains($canonicalNeedle, $rowTag)) { $score += 55; } } } return $score; } /** * @return array */ private function normalizeSearchTags(string $input): array { $tags = []; foreach ($this->splitFornitoreTags($input) as $tag) { $normalized = $this->canonicalizeFornitoreTag($tag); if ($normalized !== null) { $tags[] = $normalized; } } return array_values(array_unique($tags)); } /** * @return array */ private function splitFornitoreTags(string $value): array { $parts = preg_split('/[,;|\n\r\/]+/', $value) ?: []; return array_values(array_filter(array_map(function (string $part): string { $clean = trim($part); $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; return $clean; }, $parts), fn(string $part): bool => $part !== '')); } private function canonicalizeFornitoreTag(string $raw): ?string { $clean = trim(mb_strtolower($raw)); if ($clean === '' || mb_strlen($clean) < 3) { return null; } $map = [ 'idr' => 'idraulico', 'idraul' => 'idraulico', 'elett' => 'elettricista', 'elettric' => 'elettricista', 'ascens' => 'ascensorista', 'puliz' => 'pulizie', 'giardin' => 'giardiniere', 'assicur' => 'assicurazione', 'manut' => 'manutenzione', 'spurgh' => 'spurghi', 'fogn' => 'spurghi', 'serr' => 'serrature', 'cald' => 'caldaia', ]; foreach ($map as $prefix => $normalized) { if (str_starts_with($clean, $prefix)) { return $normalized; } } return $clean; } private function loadCategorieTicketOptions(): void { if (! Schema::hasTable('categorie_ticket')) { $this->categorieOptions = []; return; } $this->categorieOptions = CategoriaTicket::query() ->orderBy('nome') ->get(['id', 'nome', 'descrizione']) ->map(fn(CategoriaTicket $c): array=> [ 'id' => (int) $c->id, 'nome' => (string) $c->nome, 'descrizione' => (string) ($c->descrizione ?? ''), ]) ->all(); } public function getTipoInterventoLabel(Ticket $ticket): string { $categoria = trim((string) optional($ticket->categoriaTicket)->nome); $titolo = trim((string) ($ticket->titolo ?? '')); if ($categoria !== '' && $titolo !== '') { return $categoria . ' - ' . $titolo; } if ($categoria !== '') { return $categoria; } if ($titolo !== '') { return $titolo; } return 'N/D'; } private function syncSelectedTicketState(): void { $ticket = $this->selectedTicket; if (! $ticket) { $this->fornitoreSearch = ''; $this->fornitoreMatches = []; $this->insurancePolicyId = null; $this->insurancePolicyReference = null; $this->insuranceClaimNumber = null; $this->insuranceStatus = null; $this->insuranceTakenInChargeAt = null; $this->insuranceEmailSentAt = null; $this->insuranceInsurerContactedAt = null; $this->insuranceDocumentsSentAt = null; $this->insuranceAppointmentAt = null; $this->insuranceClosedAt = null; $this->insuranceNextAction = null; $this->insuranceNotes = null; return; } $this->fornitoreId = (int) ($ticket->assegnato_a_fornitore_id ?? 0) ?: null; if ($this->fornitoreId) { $selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId); if ($selected instanceof Fornitore) { $this->fornitoreSearch = $this->mapFornitoreSearchRow($selected)['nome']; $this->fornitoreMatches = [$this->mapFornitoreSearchRow($selected)]; } } else { $this->fornitoreSearch = ''; $this->fornitoreMatches = []; } $this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null; $this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? ''); $this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? ''); $this->insuranceStatus = (string) ($ticket->insuranceClaim?->status ?? 'aperta'); $this->insuranceTakenInChargeAt = filled(data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at') : null; $this->insuranceEmailSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'email_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'email_sent_at') : null; $this->insuranceInsurerContactedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at') : null; $this->insuranceDocumentsSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at') : null; $this->insuranceAppointmentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'appointment_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'appointment_at') : null; $this->insuranceClosedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'closed_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'closed_at') : null; $this->insuranceNextAction = (string) (data_get($ticket->insuranceClaim?->metadata, 'next_action') ?? ''); $this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? ''); $this->syncTicketAttachmentsArchive($ticket); } /** * @return array */ public function getInsurancePolicyOptionsProperty(): array { $ticket = $this->selectedTicket; if (! $ticket || ! class_exists(InsurancePolicy::class)) { return []; } return InsurancePolicy::query() ->where('stabile_id', (int) $ticket->stabile_id) ->orderByDesc('renewal_at') ->orderByDesc('expires_at') ->orderBy('policy_number') ->get() ->mapWithKeys(function (InsurancePolicy $policy): array { $label = trim(implode(' · ', array_filter([ $policy->display_name, $policy->policy_number, $policy->expires_at?->format('d/m/Y'), ]))); return [(int) $policy->id => ($label !== '' ? $label : ('Polizza #' . (int) $policy->id))]; }) ->all(); } private function syncTicketAttachmentsArchive(Ticket $ticket): void { foreach ($ticket->attachments as $attachment) { if ($attachment instanceof TicketAttachment) { $this->archiveTicketAttachmentDocument($ticket, $attachment); } } } private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void { if (! Schema::hasTable('documenti')) { return; } $publicPath = trim((string) ($attachment->file_path ?? '')); if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) { return; } $extension = strtolower((string) pathinfo((string) ($attachment->original_file_name ?: $publicPath), PATHINFO_EXTENSION)); $safeName = Str::slug(pathinfo((string) ($attachment->original_file_name ?: 'allegato'), PATHINFO_FILENAME)); $safeName = $safeName !== '' ? $safeName : ('attachment-' . (int) $attachment->id); $archiveReference = $this->buildTicketArchiveReference($ticket, $attachment); $localPath = $this->buildTicketArchivePath($ticket, $attachment, $safeName, $extension, $archiveReference); if (! Storage::disk('local')->exists($localPath)) { Storage::disk('local')->put($localPath, Storage::disk('public')->get($publicPath)); } $contents = Storage::disk('local')->get($localPath); $payload = [ 'stabile_id' => (int) $ticket->stabile_id, 'utente_id' => Auth::id(), 'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)), 'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)), 'percorso_file' => $localPath, 'tipo_documento' => 'ticket_allegato', 'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'), 'mime_type' => $attachment->resolvedMimeType(), 'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id), 'note' => 'Ticket #' . (int) $ticket->id . ' · ' . (string) ($ticket->titolo ?: 'senza titolo'), 'dimensione_file' => (int) ($attachment->size ?? strlen($contents)), 'estensione' => $extension !== '' ? $extension : null, 'hash_file' => hash('sha256', $contents), 'xml_data' => [ 'ticket_id' => (int) $ticket->id, 'ticket_attachment_id' => (int) $attachment->id, 'source_public_path' => $publicPath, 'archive_reference' => $archiveReference, 'archive_year' => $this->resolveTicketArchiveYear($ticket, $attachment), 'archive_scope' => 'ticket', 'visibility_scope' => 'interno', 'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id], ], 'metadati_ocr' => $attachment->isImage() ? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment'] : ['source' => 'ticket_attachment'], 'data_upload' => now(), 'approvato' => true, 'archiviato' => true, 'urgente' => false, 'is_demo' => false, 'archive_reference' => $archiveReference, 'visibility_scope' => 'interno', 'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id], 'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'], ]; $documento = Documento::query()->updateOrCreate( [ 'documentable_type' => Ticket::class, 'documentable_id' => (int) $ticket->id, 'path_file' => $localPath, ], $this->filterDocumentoPayload($payload) ); if ($attachment->isPdf()) { app(PdfTextExtractionService::class)->extractAndStore($documento, false); } elseif ($attachment->isImage()) { app(ImageTextExtractionService::class)->extractAndStore($documento, false); } } /** * @return array */ private function documentiColumns(): array { if ($this->documentiColumnsCache === null) { $this->documentiColumnsCache = Schema::hasTable('documenti') ? Schema::getColumnListing('documenti') : []; } return $this->documentiColumnsCache; } private function filterDocumentoPayload(array $payload): array { $allowed = array_flip($this->documentiColumns()); return array_intersect_key($payload, $allowed); } private function buildTicketArchiveReference(Ticket $ticket, TicketAttachment $attachment): string { return sprintf('TKT-%06d-ATT-%06d', (int) $ticket->id, (int) $attachment->id); } private function buildTicketArchivePath(Ticket $ticket, TicketAttachment $attachment, string $safeName, string $extension, string $archiveReference): string { $year = $this->resolveTicketArchiveYear($ticket, $attachment); $suffix = $extension !== '' ? ('.' . $extension) : ''; return 'documenti/' . $year . '/stabili/stabile-' . (int) $ticket->stabile_id . '/ticket/ticket-' . str_pad((string) (int) $ticket->id, 6, '0', STR_PAD_LEFT) . '/' . Str::lower($archiveReference) . '-' . $safeName . $suffix; } private function resolveTicketArchiveYear(Ticket $ticket, TicketAttachment $attachment): string { $date = $attachment->created_at ?? $ticket->created_at ?? now(); return method_exists($date, 'format') ? $date->format('Y') : now()->format('Y'); } private function loadCounters(): void { $user = Auth::user(); if (! $user instanceof User) { $this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0]; return; } $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); if ($stabileIds === []) { $this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0]; return; } $base = Ticket::query()->whereIn('stabile_id', $stabileIds); $this->ticketCounters = [ 'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(), 'urgent' => (clone $base)->where('priorita', 'Urgente')->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(), 'closed' => (clone $base)->whereIn('stato', ['Risolto', 'Chiuso'])->count(), 'all' => (clone $base)->count(), ]; } private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $assegnaAUtente = false): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileIds = $this->resolveTicketScopeStabileIds(true); if ($stabileIds === []) { return; } $ticket = Ticket::query() ->where('id', $ticketId) ->whereIn('stabile_id', $stabileIds) ->first(); if (! $ticket) { Notification::make() ->title('Ticket non trovato o non accessibile') ->danger() ->send(); return; } $payload = ['stato' => $nuovoStato]; if ($assegnaAUtente) { $payload['assegnato_a_user_id'] = $user->id; } if ($nuovoStato === 'Risolto' && ! $ticket->data_risoluzione_effettiva) { $payload['data_risoluzione_effettiva'] = now()->toDateString(); } if ($nuovoStato === 'Chiuso' && ! $ticket->data_chiusura_effettiva) { $payload['data_chiusura_effettiva'] = now()->toDateString(); } $ticket->update($payload); Notification::make() ->title('Ticket #' . $ticket->id . ' aggiornato a ' . $nuovoStato) ->success() ->send(); $this->refreshData(); } }