*/ public array $newTicketAttachments = []; /** @var array */ public array $newTicketCameraShots = []; /** @var array */ public array $pendingTicketAttachments = []; /** @var array */ public array $pendingTicketCameraShots = []; /** @var array */ public array $newTicketAttachmentDescriptions = []; /** @var array */ public array $newTicketUploadCodes = []; public string $newTicketDraftProtocol = ''; public int $newTicketDraftSequence = 1; public ?string $newTicketFotoNote = null; /** @var \Illuminate\Support\Collection */ public $tickets; /** @var \Illuminate\Support\Collection */ public $callerMatches; /** @var array|null */ public ?array $liveIncomingCall = null; public bool $liveCallCanClickToCall = false; /** @var array */ public array $ticketCounters = [ 'open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0, ]; /** @var array */ public array $categorieTicketOptions = []; /** @var array */ public array $interventiPreimpostati = [ 'idraulico_perdita' => 'Idraulico - Perdita acqua', 'elettrico_luci' => 'Elettrico - Luci/Quadro', 'ascensore_bloccato' => 'Ascensore - Bloccato', 'citofono_guasto' => 'Citofono - Guasto', 'pulizia_straordinaria' => 'Pulizia - Straordinaria', 'infissi_serrature' => 'Infissi/Serrature', 'fognatura' => 'Fognatura/Odori', 'verde_potatura' => 'Verde - Potatura', 'altro' => 'Altro', ]; 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->callerMatches = collect(); $this->newTicketPriorita = 'Media'; $this->newTicketTipoIntervento = 'altro'; $this->resetDraftUploadState(); $this->loadCategorieTicketOptions(); if (request()->query('status') === 'all') { $this->status = 'all'; } $this->refreshLiveCallBanner(); $this->applyLiveCallQueryPrefill(); $this->refreshData(); } public function updatedStatus(): void { $this->refreshData(); } public function updatedCallerSearch(): void { $this->searchCaller(); } public function refreshData(): void { $this->loadCounters(); $this->loadTickets(); $this->searchCaller(); $this->refreshLiveCallBanner(); } public function refreshLiveCallBanner(): void { $authUser = Auth::user(); if (! $authUser instanceof User) { $this->liveIncomingCall = null; $this->liveCallCanClickToCall = false; return; } $this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser); $this->liveCallCanClickToCall = (bool) ($this->liveIncomingCall['can_click_to_call'] ?? false); } public function creaPostItDaChiamataInIngresso(): void { if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) { return; } $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make() ->title('Seleziona prima uno stabile attivo') ->warning() ->send(); return; } $phone = (string) $this->liveIncomingCall['phone']; $line = (string) ($this->liveIncomingCall['line'] ?? ''); $this->prefillTicketFromLiveCall($phone, $line); $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => (int) $stabileId, 'creato_da_user_id' => (int) $user->id, 'rubrica_id' => (int) ($this->selectedCallerId ?? 0) ?: null, 'telefono' => $phone, 'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . $phone)), 'direzione' => 'in_arrivo', 'origine' => 'smdr_live_banner', 'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''), 'oggetto' => 'Chiamata ricevuta da gestire', 'nota' => $this->buildLiveCallPostItNote($phone, $line), 'priorita' => 'Media', 'stato' => 'post_it', 'chiamata_il' => now(), ]); Notification::make() ->title('Post-it creato dalla chiamata in arrivo') ->success() ->send(); $this->redirect( PostItGestione::getUrl([ 'tab' => 'storico', 'focus_post_it' => (int) $postIt->id, ], panel: 'admin-filament'), navigate: true, ); } public function usaChiamataInIngresso(): void { if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) { return; } $phone = (string) $this->liveIncomingCall['phone']; $line = (string) ($this->liveIncomingCall['line'] ?? ''); $this->prefillTicketFromLiveCall($phone, $line); Notification::make() ->title('Ticket precompilato dalla chiamata in arrivo') ->success() ->send(); } public function richiediClickToCallDaInterno(): void { if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) { return; } $user = Auth::user(); if (! $user instanceof User) { return; } $extension = trim((string) ($user->pbx_extension ?? '')); if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') { Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send(); return; } $phone = $this->normalizePhoneDigits((string) $this->liveIncomingCall['phone']); if ($phone === '') { Notification::make()->title('Numero non valido')->warning()->send(); return; } $stabileId = StabileContext::resolveActiveStabileId($user); PbxClickToCallRequest::query()->create([ 'requested_by_user_id' => (int) $user->id, 'assigned_user_id' => (int) $user->id, 'stabile_id' => $stabileId, 'communication_message_id' => (int) ($this->liveIncomingCall['message_id'] ?? 0) ?: null, 'source_extension' => $extension, 'target_number' => $phone, 'status' => 'pending', 'note' => 'Richiesta da banner chiamata in arrivo', 'requested_at' => now(), 'metadata' => [ 'from_live_banner' => true, 'target_extension' => (string) ($this->liveIncomingCall['target_extension'] ?? ''), ], ]); Notification::make() ->title('Richiesta click-to-call registrata') ->body('Interno ' . $extension . ' -> ' . $phone) ->success() ->send(); } public function getLiveRubricaUrl(): ?string { if (! empty($this->liveIncomingCall['rubrica_url'])) { return (string) $this->liveIncomingCall['rubrica_url']; } $rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0); if ($rubricaId <= 0) { return null; } return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); } public function getLiveEstrattoContoUrl(): ?string { if (! empty($this->liveIncomingCall['estratto_conto_url'])) { return (string) $this->liveIncomingCall['estratto_conto_url']; } $soggettoId = (int) ($this->liveIncomingCall['soggetto_id'] ?? 0); if ($soggettoId <= 0) { return null; } return EstrattoContoSoggetto::getUrl(['record' => $soggettoId], panel: 'admin-filament'); } public function getRubricaFilamentUrl(): string { return route('filament.admin-filament.pages.gescon.anagrafica.rubrica-universale'); } public function getAdminMobileHubUrl(): string { return route('admin.mobile'); } public function getCondominoMobileTicketUrl(): string { return route('condomino.tickets.mobile'); } public function getDashboardFilamentUrl(): string { return route('filament.admin-filament.pages.dashboard'); } public function getTicketArchivioUrl(): string { return TicketGestione::getUrl(panel: 'admin-filament', parameters: ['status' => 'all']); } public function getHelpUrl(): string { return TicketMobileHelp::getUrl(panel: 'admin-filament'); } public function getTicketDettaglioUrl(int $ticketId): string { return TicketScheda::getUrl(panel: 'admin-filament') . '?ticket=' . $ticketId; } 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'); } 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']) ->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(30)->get(); } 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 searchCaller(): void { $raw = trim($this->callerSearch); if ($raw === '') { $this->callerMatches = collect(); $this->selectedCallerId = null; return; } $digits = preg_replace('/\D+/', '', $raw) ?: ''; $needleText = '%' . mb_strtolower($raw) . '%'; $needle = '%' . $digits . '%'; $matches = RubricaUniversale::query() ->where(function ($q) use ($needle, $needleText, $digits): void { $q->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText]); if ($digits !== '') { $q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]); } }) ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedCallerId ?? 0)]) ->orderBy('nome') ->orderBy('cognome') ->limit(50) ->get(); $selectedCallerId = (int) ($this->selectedCallerId ?? 0); $this->callerMatches = $matches ->groupBy(fn(RubricaUniversale $match): string => $this->buildCallerIdentityKey($match)) ->map(function (Collection $group) use ($selectedCallerId): RubricaUniversale { return $this->selectCallerRepresentative($group, $selectedCallerId); }) ->take(12) ->values(); if ($this->selectedCallerId && ! $this->callerMatches->contains('id', $this->selectedCallerId)) { $this->selectedCallerId = null; } } public function selezionaCaller(int $rubricaId): void { $match = $this->callerMatches->firstWhere('id', $rubricaId); if (! $match) { $match = RubricaUniversale::query()->find($rubricaId); } if (! $match) { return; } $this->selectedCallerId = (int) $match->id; $this->callerSearch = trim((string) ($match->nome_completo ?: $match->ragione_sociale ?: '')); if (blank($this->newTicketTitolo)) { $this->newTicketTitolo = 'Segnalazione da ' . ($match->nome_completo ?: $match->ragione_sociale ?: 'contatto'); } $this->searchCaller(); } public function creaTicketRapido(): void { $this->validate([ 'newTicketTitolo' => ['required', 'string', 'max:255'], 'newTicketDescrizione' => ['required', 'string'], 'newTicketLuogo' => ['nullable', 'string', 'max:255'], 'newTicketPriorita' => ['required', 'in:Bassa,Media,Alta,Urgente'], 'newTicketCategoriaId' => ['nullable', 'integer', 'exists:categorie_ticket,id'], 'newTicketTipoIntervento' => ['nullable', 'string', 'max:80'], 'newTicketFotoNote' => ['nullable', 'string', 'max:2000'], 'newTicketCameraShots' => ['nullable', 'array', 'max:10'], 'newTicketCameraShots.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'], 'newTicketAttachments' => ['nullable', 'array', 'max:10'], 'newTicketAttachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'], 'newTicketAttachmentDescriptions' => ['nullable', 'array'], 'newTicketAttachmentDescriptions.*' => ['nullable', 'string', 'max:255'], ], [ 'newTicketTitolo.required' => 'Inserisci il titolo del ticket.', 'newTicketTitolo.max' => 'Il titolo puo contenere al massimo 255 caratteri.', 'newTicketDescrizione.required' => 'Inserisci una descrizione del problema.', 'newTicketLuogo.max' => 'Il luogo intervento puo contenere al massimo 255 caratteri.', 'newTicketPriorita.in' => 'Seleziona una priorita valida.', 'newTicketCategoriaId.exists' => 'La categoria selezionata non e valida.', 'newTicketTipoIntervento.max' => 'Il tipo intervento selezionato non e valido.', 'newTicketFotoNote.max' => 'La nota generale foto puo contenere al massimo 2000 caratteri.', 'newTicketCameraShots.max' => 'Puoi caricare al massimo 10 foto per volta.', 'newTicketCameraShots.*.file' => 'Ogni foto deve essere un file valido.', 'newTicketCameraShots.*.max' => 'Ogni foto deve pesare al massimo 10 MB.', 'newTicketCameraShots.*.mimes' => 'Le foto devono essere JPG, PNG o WEBP.', 'newTicketAttachments.max' => 'Puoi caricare al massimo 10 allegati per volta.', 'newTicketAttachments.*.file' => 'Ogni allegato deve essere un file valido.', 'newTicketAttachments.*.max' => 'Ogni allegato deve pesare al massimo 10 MB.', 'newTicketAttachments.*.mimes' => 'Gli allegati devono essere immagini, PDF o documenti Office/testo.', 'newTicketAttachmentDescriptions.*.max' => 'Ogni descrizione allegato puo contenere al massimo 255 caratteri.', ], [ 'newTicketTitolo' => 'titolo', 'newTicketDescrizione' => 'descrizione', 'newTicketLuogo' => 'luogo intervento', 'newTicketPriorita' => 'priorita', 'newTicketCategoriaId' => 'categoria ticket', 'newTicketTipoIntervento' => 'tipo intervento', 'newTicketFotoNote' => 'nota generale foto', 'newTicketCameraShots' => 'foto', 'newTicketAttachments' => 'allegati', 'newTicketAttachmentDescriptions' => 'descrizioni allegati', ]); $totalUploads = count($this->newTicketCameraShots) + count($this->newTicketAttachments); if ($totalUploads > 10) { Notification::make() ->title('Troppi allegati selezionati') ->body('Puoi caricare al massimo 10 file totali (foto + documenti) per ticket.') ->warning() ->send(); return; } $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make() ->title('Seleziona prima uno stabile attivo') ->danger() ->send(); return; } $caller = null; if ($this->selectedCallerId) { $caller = RubricaUniversale::query()->find($this->selectedCallerId); } $descrizione = trim((string) $this->newTicketDescrizione); if ($caller) { $telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa); $descrizione .= "\n\nContatto associato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id)); if ($telefono) { $descrizione .= "\nTelefono richiamabile: {$telefono}"; } } if (filled($this->newTicketFotoNote)) { $descrizione .= "\n\nNota fotografica: " . trim((string) $this->newTicketFotoNote); } if (filled($this->newTicketTipoIntervento)) { $label = $this->interventiPreimpostati[(string) $this->newTicketTipoIntervento] ?? (string) $this->newTicketTipoIntervento; $descrizione .= "\nTipo intervento: " . $label; } $ticket = Ticket::query()->create([ 'stabile_id' => $stabileId, 'aperto_da_user_id' => $user->id, 'titolo' => (string) $this->newTicketTitolo, 'descrizione' => $descrizione, 'categoria_ticket_id' => $this->newTicketCategoriaId, 'luogo_intervento' => $this->newTicketLuogo, 'data_apertura' => now(), 'stato' => 'Aperto', 'priorita' => $this->newTicketPriorita, ]); $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); Notification::make() ->title('Ticket creato') ->body( $savedAttachments > 0 ? 'Ticket #' . $ticket->id . ' creato con successo con ' . $savedAttachments . ' allegati.' : 'Ticket #' . $ticket->id . ' creato con successo' ) ->success() ->send(); $this->newTicketTitolo = null; $this->newTicketDescrizione = null; $this->newTicketCallNote = null; $this->newTicketLuogo = null; $this->newTicketPriorita = 'Media'; $this->newTicketCategoriaId = null; $this->newTicketTipoIntervento = 'altro'; $this->newTicketFotoNote = null; $this->resetDraftUploadState(); $this->dispatch('ticket-mobile-draft-reset'); $this->refreshData(); } public function updatedPendingTicketCameraShots(): void { $this->appendPendingUploads('camera'); } public function updatedPendingTicketAttachments(): void { $this->appendPendingUploads('attachment'); } public function removeNewTicketFile(int $index): void { $file = null; $cameraCount = count($this->newTicketCameraShots); if ($index < $cameraCount) { $file = $this->newTicketCameraShots[$index] ?? null; unset($this->newTicketCameraShots[$index]); $this->newTicketCameraShots = array_values($this->newTicketCameraShots); } else { $docIndex = $index - $cameraCount; $file = $this->newTicketAttachments[$docIndex] ?? null; unset($this->newTicketAttachments[$docIndex]); $this->newTicketAttachments = array_values($this->newTicketAttachments); } if (is_object($file)) { unset($this->newTicketUploadCodes[$this->resolveUploadQueueKey($file, $index)]); } $this->syncAttachmentDescriptionSlots(); } /** * @return array */ 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/'); $protocol = $this->resolveDraftUploadCode($file, (int) $index, $isImage); $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { try { $previewUrl = (string) $file->temporaryUrl(); } catch (Throwable) { $previewUrl = null; } } $rows[] = [ 'index' => (int) $index, 'name' => $this->buildDraftUploadDisplayName($protocol, $name), 'original_name' => $name, 'protocol' => $protocol, 'mime' => $mime, 'is_image' => $isImage, 'preview_url' => $previewUrl, 'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''), ]; } return $rows; } private function loadCategorieTicketOptions(): void { $this->categorieTicketOptions = CategoriaTicket::query() ->orderBy('nome') ->limit(200) ->get(['id', 'nome']) ->map(fn(CategoriaTicket $cat): array=> [ 'id' => (int) $cat->id, 'nome' => (string) $cat->nome, ]) ->all(); } private function salvaAllegatiTicket(Ticket $ticket, int $userId): int { if (! Schema::hasTable('ticket_attachments')) { return 0; } $saved = 0; $files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments); foreach ($files as $index => $file) { if (! is_object($file) || ! method_exists($file, 'store')) { continue; } try { $displayName = $this->buildStoredUploadDisplayName($ticket, (int) $index, $file); $stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id, [ 'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME), 'display_name' => $displayName, ]); TicketAttachment::query()->create([ 'ticket_id' => (int) $ticket->id, 'ticket_update_id' => null, 'user_id' => $userId, 'file_path' => $stored['path'], 'original_file_name' => $stored['original_name'], 'mime_type' => $stored['mime'], 'size' => $stored['size'], 'description' => $this->resolveAttachmentDescription( (int) $index, is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], $stored['original_name'] ?? null, $stored['source_original_name'] ?? null, ), ]); $saved++; } catch (Throwable $e) { Log::warning('TicketMobile attachment save failed', [ 'ticket_id' => $ticket->id, 'error' => $e->getMessage(), ]); } } 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, array $metadata = [], ?string $displayName = null, ?string $sourceOriginalName = null): string { $parts = []; $specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? '')); if ($specific !== '') { $parts[] = $specific; } elseif (filled($this->newTicketFotoNote)) { $parts[] = 'Nota foto: ' . trim((string) $this->newTicketFotoNote); } else { $parts[] = 'Allegato da Ticket Mobile'; } if (filled($displayName)) { $parts[] = 'File: ' . trim((string) $displayName); } $sourceName = trim((string) $sourceOriginalName); if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) { $parts[] = 'Orig: ' . $sourceName; } $exifDate = trim((string) ($metadata['exif_datetime'] ?? '')); if ($exifDate !== '') { $parts[] = 'EXIF: ' . $exifDate; } $device = trim((string) ($metadata['device'] ?? '')); if ($device !== '') { $parts[] = 'Device: ' . $device; } $lat = $metadata['gps_decimal']['lat'] ?? null; $lng = $metadata['gps_decimal']['lng'] ?? null; if (is_numeric($lat) && is_numeric($lng)) { $parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', ''); $mapsUrl = trim((string) ($metadata['google_maps_url'] ?? '')); if ($mapsUrl !== '') { $parts[] = 'Maps: ' . $mapsUrl; } } return Str::limit(implode(' | ', array_filter($parts)), 255); } private function appendPendingUploads(string $source): void { $pendingProperty = $source === 'camera' ? 'pendingTicketCameraShots' : 'pendingTicketAttachments'; $targetProperty = $source === 'camera' ? 'newTicketCameraShots' : 'newTicketAttachments'; $pending = array_values(array_filter($this->{$pendingProperty}, fn($file) => is_object($file))); if ($pending === []) { return; } $currentTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments); $available = max(0, 10 - $currentTotal); if ($available <= 0) { $this->{$pendingProperty} = []; Notification::make() ->title('Limite allegati raggiunto') ->body('Puoi tenere in coda al massimo 10 file totali per ticket.') ->warning() ->send(); return; } $accepted = array_slice($pending, 0, $available); $this->{$targetProperty} = array_values(array_merge($this->{$targetProperty}, $accepted)); $this->{$pendingProperty} = []; $this->assignDraftUploadCodes($accepted, $currentTotal); $this->syncAttachmentDescriptionSlots(); $discarded = count($pending) - count($accepted); if (count($accepted) > 0) { $queueTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments); Notification::make() ->title('File accodati alla bozza ticket') ->body(count($accepted) . ' file aggiunti. Totale attuale in coda: ' . $queueTotal . '.') ->success() ->send(); } if ($discarded > 0) { Notification::make() ->title('Coda allegati ridotta') ->body('Sono stati accodati solo i primi ' . count($accepted) . ' file disponibili: limite massimo 10.') ->warning() ->send(); } } private function resetDraftUploadState(): void { $this->newTicketCameraShots = []; $this->newTicketAttachments = []; $this->pendingTicketCameraShots = []; $this->pendingTicketAttachments = []; $this->newTicketAttachmentDescriptions = []; $this->newTicketUploadCodes = []; $this->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His'); $this->newTicketDraftSequence = 1; } private function buildDraftProtocolCode(int $sequence, bool $isImage): string { return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . 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 : ''); } /** * @param array $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->newTicketUploadCodes[$key])) { continue; } $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $isImage = str_starts_with($mime, 'image/'); $this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage); $this->newTicketDraftSequence++; } } private function resolveDraftUploadCode(object $file, int $fallbackIndex, bool $isImage): string { $key = $this->resolveUploadQueueKey($file, $fallbackIndex); if (! isset($this->newTicketUploadCodes[$key])) { $this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage); $this->newTicketDraftSequence++; } return $this->newTicketUploadCodes[$key]; } 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) ?: 'upload-' . $fallbackIndex; } private function buildStoredUploadDisplayName(Ticket $ticket, int $index, object $file): string { $originalName = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $isImage = str_starts_with($mime, 'image/'); $baseName = 'ticket-' . str_pad((string) $ticket->id, 6, '0', STR_PAD_LEFT) . '-' . ($isImage ? 'foto-' : 'all-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT); return $baseName . ($extension !== '' ? '.' . $extension : ''); } /** * @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]]; } private function prefillTicketFromLiveCall(string $phone, string $line): void { $this->callerSearch = $phone; $this->searchCaller(); $this->newTicketCallNote = trim($line) !== '' ? trim($line) : null; $rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0); if ($rubricaId > 0) { $this->selezionaCaller($rubricaId); } elseif ($this->callerMatches->count() === 1) { $only = $this->callerMatches->first(); if ($only) { $this->selezionaCaller((int) $only->id); } } if (blank($this->newTicketTitolo)) { $this->newTicketTitolo = 'Segnalazione telefonica in arrivo'; } if (blank($this->newTicketDescrizione)) { $this->newTicketDescrizione = 'Segnalazione aperta durante chiamata ricevuta dal numero ' . $phone . '.'; } } /** * @return array{phone:?string,target_extension:?string,received_at:?string,caller_name:?string,caller_profile:?string,riferimento_stabile:?string,riferimento_unita:?string,stabile_label:?string,call_note:?string} */ public function getTicketCallContextProperty(): array { $caller = $this->selectedCallerId ? RubricaUniversale::query()->find($this->selectedCallerId) : null; $phone = null; if (! empty($this->liveIncomingCall['phone'])) { $phone = (string) $this->liveIncomingCall['phone']; } else { $digits = $this->normalizePhoneDigits((string) $this->callerSearch); $phone = $digits !== '' ? $digits : null; } return [ 'phone' => $phone, 'target_extension' => ! empty($this->liveIncomingCall['target_extension']) ? (string) $this->liveIncomingCall['target_extension'] : null, 'received_at' => ! empty($this->liveIncomingCall['received_at']) ? (string) $this->liveIncomingCall['received_at'] : null, 'caller_name' => $caller ? (string) ($caller->nome_completo ?: $caller->ragione_sociale ?: ''): (! empty($this->liveIncomingCall['rubrica_nome']) ? (string) $this->liveIncomingCall['rubrica_nome'] : null), 'caller_profile' => $caller ? ((string) ($caller->tipo_utenza_call ?: '') ?: null): null, 'riferimento_stabile' => $caller ? ((string) ($caller->riferimento_stabile ?: '') ?: null): null, 'riferimento_unita' => $caller ? ((string) ($caller->riferimento_unita ?: '') ?: null): null, 'stabile_label' => $caller ? $this->getCallerStabileLabel((int) $caller->id) : null, 'call_note' => $this->newTicketCallNote, ]; } private function buildLiveCallPostItNote(string $phone, string $line): string { $parts = [ 'Chiamata ricevuta dal numero ' . $phone, ]; $targetExtension = trim((string) ($this->liveIncomingCall['target_extension'] ?? '')); if ($targetExtension !== '') { $parts[] = 'Interno/gruppo chiamato: ' . $targetExtension; } $callerName = trim((string) ($this->liveIncomingCall['rubrica_nome'] ?? '')); if ($callerName !== '') { $parts[] = 'Contatto riconosciuto: ' . $callerName; } $cleanLine = trim($line); if ($cleanLine !== '') { $parts[] = 'Nota operativa: ' . $cleanLine; } return implode("\n", $parts); } private function normalizePhoneDigits(string $value): string { return preg_replace('/\D+/', '', $value) ?: ''; } private function buildCallerIdentityKey(RubricaUniversale $match): string { $fiscalCode = strtoupper(trim((string) ($match->codice_fiscale ?? ''))); if ($fiscalCode !== '') { return 'cf:' . $fiscalCode; } $vatNumber = strtoupper(trim((string) ($match->partita_iva ?? ''))); if ($vatNumber !== '') { return 'piva:' . $vatNumber; } $email = mb_strtolower(trim((string) ($match->email ?? ''))); if ($email !== '') { return 'email:' . $email; } $phone = $this->normalizePhoneDigits((string) ($match->telefono_cellulare ?: $match->telefono_ufficio ?: $match->telefono_casa ?: '')); $name = mb_strtolower(trim(preg_replace('/\s+/', ' ', (string) ($match->nome_completo ?: $match->ragione_sociale ?: '')))); if ($phone !== '') { return 'phone:' . $phone . '|name:' . $name; } if ($name !== '') { return 'name:' . $name; } return 'id:' . (string) $match->id; } private function selectCallerRepresentative(Collection $group, int $selectedCallerId): RubricaUniversale { $selected = $selectedCallerId > 0 ? $group->firstWhere('id', $selectedCallerId) : null; $representative = $selected instanceof RubricaUniversale ? $selected : $group ->sortByDesc(fn(RubricaUniversale $match): array=> [ (int) (! empty($match->amministratore_id)), (int) (! empty($match->riferimento_stabile)), (int) (! empty($match->riferimento_unita)), (int) (($match->categoria ?? '') === 'condomino'), -1 * (int) $match->id, ]) ->first(); $duplicateCategories = $group ->pluck('categoria') ->filter(fn($value) => is_string($value) && trim($value) !== '') ->unique() ->values() ->all(); $representative->setAttribute('duplicate_count', $group->count()); $representative->setAttribute('duplicate_categories', $duplicateCategories); return $representative; } private function applyLiveCallQueryPrefill(): void { $phone = $this->normalizePhoneDigits((string) request()->query('live_phone', '')); if ($phone === '') { return; } if (! $this->liveIncomingCall) { $this->liveIncomingCall = [ 'message_id' => (int) request()->query('live_message_id', 0), 'phone' => $phone, 'line' => (string) request()->query('live_note', ''), ]; } $this->prefillTicketFromLiveCall($phone, (string) request()->query('live_note', '')); } public function excerpt(?string $text, int $limit = 160): string { return Str::limit((string) $text, $limit); } public function getCallerStabileLabel(int $rubricaId): ?string { $stabile = Stabile::query()->where('rubrica_id', $rubricaId)->first(); if (! $stabile) { return null; } return $stabile->denominazione ?: ('Stabile #' . $stabile->id); } 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(); } }