From fd3904e0d2df0fcb8596fb1acd38d859cd3022df Mon Sep 17 00:00:00 2001 From: michele Date: Sat, 4 Apr 2026 22:54:05 +0000 Subject: [PATCH] feat: allinea day0 supporto ticket e import catasto --- .../Commands/GesconQaRubricaLegamiCommand.php | 138 ++-- app/Console/Commands/SmdrListenCommand.php | 60 +- .../Pages/Gescon/RubricaUniversaleScheda.php | 475 ++++++++++++- .../Impostazioni/SchedaAmministratore.php | 50 +- app/Filament/Pages/Strumenti/PostIt.php | 68 +- .../Pages/Strumenti/PostItGestione.php | 300 ++++++++- app/Filament/Pages/Supporto/Modifiche.php | 622 +++++++++++++++++- .../Pages/Supporto/TicketGestione.php | 2 +- app/Filament/Pages/Supporto/TicketMobile.php | 38 +- app/Models/ChiamataPostIt.php | 8 +- app/Models/SupportBugRegistry.php | 18 + app/Models/SupportUpdateEntry.php | 28 + app/Providers/AppServiceProvider.php | 47 ++ app/Services/Cti/LiveIncomingCallService.php | 32 +- app/Services/Cti/PbxRoutingService.php | 153 +++++ .../GesconImport/StabileEnrichmentService.php | 2 +- bootstrap/app.php | 2 +- ...nment_fields_to_chiamate_post_it_table.php | 2 +- ...port_update_entries_and_bug_registries.php | 48 ++ docs/CTI-WINDOWS-TAPI-PROBE.md | 2 +- docs/support/CRM-ANAGRAFICA-LEGAMI.md | 2 +- .../rubrica-universale-scheda.blade.php | 81 +++ .../strumenti/post-it-gestione.blade.php | 37 +- .../pages/supporto/modifiche.blade.php | 293 ++++++++- .../pages/supporto/ticket-mobile.blade.php | 5 + .../filament/topbar-live-call.blade.php | 2 +- 26 files changed, 2267 insertions(+), 248 deletions(-) create mode 100644 app/Models/SupportBugRegistry.php create mode 100644 app/Models/SupportUpdateEntry.php create mode 100644 database/migrations/2026_04_04_120000_create_support_update_entries_and_bug_registries.php diff --git a/app/Console/Commands/GesconQaRubricaLegamiCommand.php b/app/Console/Commands/GesconQaRubricaLegamiCommand.php index a0faf0e..49d8539 100644 --- a/app/Console/Commands/GesconQaRubricaLegamiCommand.php +++ b/app/Console/Commands/GesconQaRubricaLegamiCommand.php @@ -48,11 +48,11 @@ public function handle(): int return self::SUCCESS; } - $applySafe = (bool) $this->option('apply-safe'); + $applySafe = (bool) $this->option('apply-safe'); $updatedReferences = 0; - $createdRuoli = 0; - $ambiguous = 0; - $manualReview = 0; + $createdRuoli = 0; + $ambiguous = 0; + $manualReview = 0; foreach ($rows as $rubrica) { $analysis = $this->analyzeRubrica($rubrica); @@ -88,9 +88,9 @@ public function handle(): int continue; } - $applied = $this->applySafeFixes($rubrica, $analysis); + $applied = $this->applySafeFixes($rubrica, $analysis); $updatedReferences += $applied['references_updated']; - $createdRuoli += $applied['ruoli_created']; + $createdRuoli += $applied['ruoli_created']; } $this->line(''); @@ -111,8 +111,8 @@ public function handle(): int */ private function analyzeRubrica(RubricaUniversale $rubrica): array { - $legacy = $this->parseLegacyMeta((string) ($rubrica->note ?? '')); - $stabile = $this->resolveStabile($legacy['cod_stabile']); + $legacy = $this->parseLegacyMeta((string) ($rubrica->note ?? '')); + $stabile = $this->resolveStabile($legacy['cod_stabile']); $existingRuoli = Schema::hasTable('rubrica_ruoli') ? DB::table('rubrica_ruoli')->where('rubrica_id', (int) $rubrica->id)->get()->all() : []; @@ -133,18 +133,18 @@ private function analyzeRubrica(RubricaUniversale $rubrica): array } $requiresManualReview = $uniqueUnit !== null - && ! empty($stabile['id']) - && ! $uniqueUnit['safe_role_creation'] - && count($existingRuoli) === 0; + && ! empty($stabile['id']) + && ! $uniqueUnit['safe_role_creation'] + && count($existingRuoli) === 0; return [ - 'legacy' => $legacy, - 'stabile' => $stabile, - 'stabile_label' => $this->buildStabileReference($stabile), - 'existing_ruoli' => $existingRuoli, - 'candidate_units' => $candidateUnits, - 'unique_unit' => $uniqueUnit, - 'persona_summary' => $this->summarizePersonas($personaMatches), + 'legacy' => $legacy, + 'stabile' => $stabile, + 'stabile_label' => $this->buildStabileReference($stabile), + 'existing_ruoli' => $existingRuoli, + 'candidate_units' => $candidateUnits, + 'unique_unit' => $uniqueUnit, + 'persona_summary' => $this->summarizePersonas($personaMatches), 'requires_manual_review' => $requiresManualReview, ]; } @@ -155,19 +155,19 @@ private function analyzeRubrica(RubricaUniversale $rubrica): array private function applySafeFixes(RubricaUniversale $rubrica, array $analysis): array { $referencesUpdated = 0; - $ruoliCreated = 0; - $stabile = $analysis['stabile']; - $uniqueUnit = $analysis['unique_unit']; + $ruoliCreated = 0; + $stabile = $analysis['stabile']; + $uniqueUnit = $analysis['unique_unit']; $dirty = false; if (($rubrica->riferimento_stabile === null || trim((string) $rubrica->riferimento_stabile) === '') && ! empty($stabile['id'])) { $rubrica->riferimento_stabile = $this->buildStabileReference($stabile); - $dirty = true; + $dirty = true; } if (($rubrica->riferimento_unita === null || trim((string) $rubrica->riferimento_unita) === '') && $uniqueUnit !== null) { $rubrica->riferimento_unita = $uniqueUnit['unit_reference']; - $dirty = true; + $dirty = true; } if ($dirty) { @@ -186,20 +186,20 @@ private function applySafeFixes(RubricaUniversale $rubrica, array $analysis): ar ) { DB::table('rubrica_ruoli')->updateOrInsert( [ - 'rubrica_id' => (int) $rubrica->id, - 'stabile_id' => (int) $stabile['id'], + 'rubrica_id' => (int) $rubrica->id, + 'stabile_id' => (int) $stabile['id'], 'unita_immobiliare_id' => (int) $uniqueUnit['unit_id'], - 'ruolo_standard' => $uniqueUnit['role_standard'], + 'ruolo_standard' => $uniqueUnit['role_standard'], ], [ - 'is_attivo' => true, + 'is_attivo' => true, 'is_preferito' => true, - 'meta' => json_encode([ - 'source' => $uniqueUnit['source'], + 'meta' => json_encode([ + 'source' => $uniqueUnit['source'], 'created_by' => 'gescon:qa-rubrica-legami', ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'updated_at' => now(), - 'created_at' => now(), + 'updated_at' => now(), + 'created_at' => now(), ] ); @@ -208,7 +208,7 @@ private function applySafeFixes(RubricaUniversale $rubrica, array $analysis): ar return [ 'references_updated' => $referencesUpdated, - 'ruoli_created' => $ruoliCreated, + 'ruoli_created' => $ruoliCreated, ]; } @@ -219,8 +219,8 @@ private function parseLegacyMeta(string $note): array { $payload = [ 'cod_stabile' => null, - 'cod_cond' => null, - 'ruolo' => null, + 'cod_cond' => null, + 'ruolo' => null, ]; foreach (['cod_stabile', 'cod_cond', 'ruolo'] as $key) { @@ -238,13 +238,13 @@ private function parseLegacyMeta(string $note): array private function resolveStabile(?string $legacyCode): array { $blank = ['id' => null, 'codice_stabile' => null, 'cod_stabile' => null, 'denominazione' => null]; - $code = trim((string) $legacyCode); + $code = trim((string) $legacyCode); if ($code === '') { return $blank; } $trimmed = ltrim($code, '0'); - $row = DB::table('stabili') + $row = DB::table('stabili') ->select(['id', 'codice_stabile', 'cod_stabile', 'denominazione']) ->where(function ($query) use ($code, $trimmed): void { $query->where('codice_stabile', $code) @@ -263,10 +263,10 @@ private function resolveStabile(?string $legacyCode): array } return [ - 'id' => (int) $row->id, + 'id' => (int) $row->id, 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, - 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, - 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, + 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, + 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, ]; } @@ -298,11 +298,11 @@ private function collectLegacyUnitCandidates(?int $stabileId, array $legacy): ar foreach ($rows as $row) { $unitId = (int) $row->unit_id; $grouped[$unitId] ??= [ - 'unit_id' => $unitId, - 'unit_label' => $this->buildUnitLabel($row), - 'unit_reference' => $this->buildUnitReference($row), - 'source' => 'legacy_local', - 'role_tokens' => [], + 'unit_id' => $unitId, + 'unit_label' => $this->buildUnitLabel($row), + 'unit_reference' => $this->buildUnitReference($row), + 'source' => 'legacy_local', + 'role_tokens' => [], 'safe_role_creation' => true, ]; @@ -315,7 +315,7 @@ private function collectLegacyUnitCandidates(?int $stabileId, array $legacy): ar return array_values(array_map(function (array $candidate) use ($legacy): array { $roleStandard = $this->mapRoleStandard($legacy['ruolo']); if ($roleStandard === null) { - $tokens = array_keys($candidate['role_tokens']); + $tokens = array_keys($candidate['role_tokens']); $roleStandard = count($tokens) === 1 ? $tokens[0] : null; } @@ -337,7 +337,7 @@ private function collectPersonaRelationCandidates(array $personaMatches, ?int $s } $personaIds = array_values(array_unique(array_map(fn(array $row): int => (int) $row['id'], $personaMatches))); - $rows = DB::table('persone_unita_relazioni as pur') + $rows = DB::table('persone_unita_relazioni as pur') ->join('unita_immobiliari as ui', 'ui.id', '=', 'pur.unita_id') ->whereIn('pur.persona_id', $personaIds) ->when($stabileId, fn($query) => $query->where('ui.stabile_id', $stabileId)) @@ -357,11 +357,11 @@ private function collectPersonaRelationCandidates(array $personaMatches, ?int $s foreach ($rows as $row) { $unitId = (int) $row->unit_id; $grouped[$unitId] ??= [ - 'unit_id' => $unitId, - 'unit_label' => $this->buildUnitLabel($row), - 'unit_reference' => $this->buildUnitReference($row), - 'source' => 'persona_relazione', - 'role_tokens' => [], + 'unit_id' => $unitId, + 'unit_label' => $this->buildUnitLabel($row), + 'unit_reference' => $this->buildUnitReference($row), + 'source' => 'persona_relazione', + 'role_tokens' => [], 'safe_role_creation' => true, ]; @@ -400,8 +400,8 @@ private function collectImportUnitCandidates(RubricaUniversale $rubrica, ?int $s foreach ($rows as $row) { $unit = $this->resolveUnitFromImportRow($stabileId, [ 'nom_cond' => (string) ($row->nom_cond ?? ''), - 'scala' => (string) ($row->scala ?? ''), - 'interno' => (string) ($row->interno ?? ''), + 'scala' => (string) ($row->scala ?? ''), + 'interno' => (string) ($row->interno ?? ''), ]); if ($unit === null) { @@ -410,11 +410,11 @@ private function collectImportUnitCandidates(RubricaUniversale $rubrica, ?int $s $roleFromNote = $this->mapRoleStandard($legacy['ruolo']); $candidates[] = [ - 'unit_id' => (int) $unit->unit_id, - 'unit_label' => $this->buildUnitLabel($unit), - 'unit_reference' => $this->buildUnitReference($unit), - 'source' => 'gescon_import', - 'role_standard' => $roleFromNote, + 'unit_id' => (int) $unit->unit_id, + 'unit_label' => $this->buildUnitLabel($unit), + 'unit_reference' => $this->buildUnitReference($unit), + 'source' => 'gescon_import', + 'role_standard' => $roleFromNote, 'safe_role_creation' => false, ]; } @@ -490,8 +490,8 @@ private function mergeCandidates(array ...$candidateSets): array continue; } - $merged[$unitId]['source'] .= '+' . $candidate['source']; - $merged[$unitId]['safe_role_creation'] = (bool) $merged[$unitId]['safe_role_creation'] || (bool) $candidate['safe_role_creation']; + $merged[$unitId]['source'] .= '+' . $candidate['source']; + $merged[$unitId]['safe_role_creation'] = (bool) $merged[$unitId]['safe_role_creation'] || (bool) $candidate['safe_role_creation']; if (($merged[$unitId]['role_standard'] ?? null) === null && ($candidate['role_standard'] ?? null) !== null) { $merged[$unitId]['role_standard'] = $candidate['role_standard']; } @@ -509,7 +509,7 @@ private function resolveUnitFromImportRow(int $stabileId, array $row): ?object ->where('stabile_id', $stabileId) ->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']); - $scala = trim((string) ($row['scala'] ?? '')); + $scala = trim((string) ($row['scala'] ?? '')); $interno = trim((string) ($row['interno'] ?? '')); if ($scala !== '' || $interno !== '') { @@ -560,19 +560,19 @@ private function buildStabileReference(array $stabile): ?string return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile['id']); } - private function buildUnitReference(object|array $row): string + private function buildUnitReference(object | array $row): string { - $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); + $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); $label = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', ''))); return trim($code . ($label !== '' ? ' - ' . $label : '')); } - private function buildUnitLabel(object|array $row): string + private function buildUnitLabel(object | array $row): string { - $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); - $scala = trim((string) data_get((array) $row, 'scala', data_get($row, 'scala', ''))); - $interno = trim((string) data_get((array) $row, 'interno', data_get($row, 'interno', ''))); + $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); + $scala = trim((string) data_get((array) $row, 'scala', data_get($row, 'scala', ''))); + $interno = trim((string) data_get((array) $row, 'interno', data_get($row, 'interno', ''))); $denominazione = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', ''))); $bits = array_values(array_filter([$code, $scala, $interno, $denominazione])); @@ -624,9 +624,9 @@ private function normalizeDigits(string $value): string private function normalizeText(string $value): string { - $upper = mb_strtoupper(trim($value)); + $upper = mb_strtoupper(trim($value)); $normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper); return is_string($normalized) ? $normalized : ''; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/SmdrListenCommand.php b/app/Console/Commands/SmdrListenCommand.php index 6ab0194..f99cb6b 100644 --- a/app/Console/Commands/SmdrListenCommand.php +++ b/app/Console/Commands/SmdrListenCommand.php @@ -163,12 +163,15 @@ public function handle(): int $lineCount++; $this->line("[{$lineCount}] {$line}"); - $phone = $parsed['phone_number']; - $direction = $parsed['direction']; - $receivedAt = $parsed['occurred_at'] ?? now(); - $durationSeconds = $parsed['duration_seconds']; - $targetExtension = (string) ($parsed['extension'] ?? ''); - [$assignedUserId, $stabileId] = $this->resolveRouting($targetExtension); + $phone = $parsed['phone_number']; + $direction = $parsed['direction']; + $receivedAt = $parsed['occurred_at'] ?? now(); + $durationSeconds = $parsed['duration_seconds']; + $targetExtension = (string) ($parsed['extension'] ?? ''); + $routing = $this->resolveRouting($parsed); + $assignedUserId = $routing['user_id']; + $stabileId = $routing['stabile_id']; + $amministratoreId = $routing['amministratore_id']; Log::info('SMDR line received', [ 'line' => $line, @@ -195,6 +198,9 @@ public function handle(): int 'source_port' => $port, 'assigned_user_id' => $assignedUserId, 'stabile_id' => $stabileId, + 'amministratore_id'=> $amministratoreId, + 'studio_role' => $routing['studio_role'], + 'studio_mode' => $routing['studio_mode'], 'smdr' => [ 'date' => $parsed['date'], 'time' => $parsed['time'], @@ -202,6 +208,9 @@ public function handle(): int 'call_phase' => $parsed['call_phase'], 'co' => $parsed['co'], 'co_line_number' => $parsed['co_line_number'], + 'line_label' => $routing['line_label'], + 'route_group' => $routing['route_group'], + 'target_extension' => $routing['target_extension'], 'dial_number_raw' => $parsed['dial_number_raw'], 'dial_number' => $parsed['dial_number'], 'ring_duration_raw' => $parsed['ring_duration_raw'], @@ -232,16 +241,19 @@ public function handle(): int CommunicationMessage::query()->create($communicationPayload); } - if ($writePostIt && Schema::hasTable('chiamate_post_it')) { + if ($writePostIt && Schema::hasTable('chiamate_post_it') && $this->shouldCreateOperationalPostIt($parsed)) { try { + $lineSuffix = ! empty($parsed['co']) ? ' - linea ' . (string) $parsed['co'] : ''; + ChiamataPostIt::query()->create([ 'telefono' => $phone, 'stabile_id' => $stabileId, 'creato_da_user_id' => $assignedUserId, - 'nome_chiamante' => 'SMDR ' . strtoupper($direction), - 'oggetto' => 'Chiamata ' . strtoupper($direction) . ' da SMDR', + 'nome_chiamante' => 'SMDR INBOUND', + 'oggetto' => 'Chiamata in entrata da SMDR' . $lineSuffix, 'nota' => $line, - 'direzione' => $this->mapPostItDirection($direction, $durationSeconds), + 'direzione' => 'in_arrivo', + 'esito' => ($durationSeconds ?? 0) > 0 ? 'answered' : 'missed', 'origine' => 'smdr', 'origine_id' => $fingerprint, 'durata_secondi' => $durationSeconds, @@ -482,16 +494,28 @@ private function mapPostItDirection(string $direction, ?int $durationSeconds): s return 'in_uscita'; } - private function resolveRouting(string $extension): array + /** @param array $parsed */ + private function resolveRouting(array $parsed): array { - $routing = app(PbxRoutingService::class)->resolveByExtension($extension); - if (! $routing['user']) { - return [null, null]; + return app(PbxRoutingService::class)->resolveBySmdr( + (string) ($parsed['extension'] ?? ''), + (string) ($parsed['co'] ?? ''), + (string) ($parsed['direction'] ?? '') + ); + } + + /** @param array $parsed */ + private function shouldCreateOperationalPostIt(array $parsed): bool + { + if (($parsed['direction'] ?? null) !== 'inbound') { + return false; } - return [ - $routing['user_id'], - $routing['stabile_id'], - ]; + $phone = preg_replace('/\D+/', '', (string) ($parsed['phone_number'] ?? '')) ?: ''; + if ($phone === '' || strlen($phone) <= 4) { + return false; + } + + return ! empty($parsed['co']); } } diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index c0e02d8..50578e3 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -49,6 +49,17 @@ class RubricaUniversaleScheda extends Page /** @var array> */ public array $stabili = []; + /** @var array */ + public array $collegamentoSuggerito = []; + + public ?int $collegamentoStabileId = null; + + public ?int $collegamentoUnitaId = null; + + public string $collegamentoRuolo = 'altro'; + + public string $collegamentoRuoloCustom = ''; + /** @var array> */ public array $contattiPrincipali = []; @@ -303,10 +314,468 @@ public function mount(int | string $record): void $this->loadEmailMultiple(); $this->fillInlineForm(); + $this->hydrateCollegamentoWorkspace(); $this->hydrateFornitoriWorkspace(); $this->hydrateStudioCollaboratoreWorkspace(); } + private function hydrateCollegamentoWorkspace(): void + { + $suggestion = $this->resolveCollegamentoSuggerito(); + + $this->collegamentoSuggerito = $suggestion; + $this->collegamentoStabileId = ! empty($suggestion['stabile_id']) ? (int) $suggestion['stabile_id'] : null; + $this->collegamentoUnitaId = ! empty($suggestion['unita_id']) ? (int) $suggestion['unita_id'] : null; + $this->collegamentoRuolo = ! empty($suggestion['role_standard']) ? (string) $suggestion['role_standard'] : 'altro'; + $this->collegamentoRuoloCustom = ! empty($suggestion['role_custom']) ? (string) $suggestion['role_custom'] : ''; + } + + public function collegaCollegamentoSuggerito(): void + { + if ($this->collegamentoSuggerito === []) { + Notification::make() + ->title('Nessun collegamento suggerito disponibile') + ->warning() + ->send(); + + return; + } + + $this->persistCollegamentoRubrica( + (int) ($this->collegamentoSuggerito['stabile_id'] ?? 0), + ! empty($this->collegamentoSuggerito['unita_id']) ? (int) $this->collegamentoSuggerito['unita_id'] : null, + (string) ($this->collegamentoSuggerito['role_standard'] ?? 'altro'), + (string) ($this->collegamentoSuggerito['role_custom'] ?? ''), + (string) ($this->collegamentoSuggerito['source'] ?? 'suggerimento_scheda') + ); + } + + public function salvaCollegamentoManuale(): void + { + $data = validator([ + 'stabile_id' => $this->collegamentoStabileId, + 'unita_immobiliare_id' => $this->collegamentoUnitaId, + 'ruolo_standard' => $this->collegamentoRuolo, + 'ruolo_custom' => $this->collegamentoRuoloCustom, + ], [ + 'stabile_id' => ['required', 'integer'], + 'unita_immobiliare_id' => ['nullable', 'integer'], + 'ruolo_standard' => ['required', 'string', 'max:120'], + 'ruolo_custom' => ['nullable', 'string', 'max:255'], + ])->validate(); + + $this->persistCollegamentoRubrica( + (int) $data['stabile_id'], + ! empty($data['unita_immobiliare_id']) ? (int) $data['unita_immobiliare_id'] : null, + (string) $data['ruolo_standard'], + (string) ($data['ruolo_custom'] ?? ''), + 'manuale_scheda' + ); + } + + private function persistCollegamentoRubrica( + int $stabileId, + ?int $unitaId, + string $ruoloStandard, + string $ruoloCustom = '', + string $source = 'manuale_scheda' + ): void { + $stabile = Stabile::query()->find($stabileId); + if (! $stabile) { + Notification::make() + ->title('Stabile non trovato') + ->warning() + ->send(); + + return; + } + + $user = Auth::user(); + $isSuperAdmin = $user instanceof User && method_exists($user, 'hasRole') + ? (bool) $user->hasRole('super-admin') + : false; + + if (! $isSuperAdmin && $this->adminId > 0 && (int) $stabile->amministratore_id !== $this->adminId) { + Notification::make() + ->title('Stabile fuori dal tenant attivo') + ->body('Il collegamento può essere creato solo sugli stabili dell\'amministratore corrente.') + ->danger() + ->send(); + + return; + } + + $unita = null; + if ($unitaId !== null && $unitaId > 0) { + $unita = UnitaImmobiliare::query() + ->where('id', $unitaId) + ->where('stabile_id', (int) $stabile->id) + ->first(); + + if (! $unita) { + Notification::make() + ->title('Unità non coerente con lo stabile') + ->warning() + ->send(); + + return; + } + } + + $ruoloStandard = trim($ruoloStandard) !== '' ? trim($ruoloStandard) : 'altro'; + $ruoloCustom = trim($ruoloCustom); + + $attributes = [ + 'rubrica_id' => (int) $this->rubrica->id, + 'ruolo_standard' => $ruoloStandard, + 'stabile_id' => (int) $stabile->id, + 'unita_immobiliare_id' => $unita?->id, + ]; + + if ($ruoloCustom !== '') { + $attributes['ruolo_custom'] = $ruoloCustom; + } + + $existing = RubricaRuolo::query() + ->where('rubrica_id', (int) $this->rubrica->id) + ->where('ruolo_standard', $ruoloStandard) + ->where('stabile_id', (int) $stabile->id) + ->where('unita_immobiliare_id', $unita?->id) + ->when($ruoloCustom !== '', fn(Builder $query) => $query->where('ruolo_custom', $ruoloCustom)) + ->first(); + + $payload = [ + 'ruolo_custom' => $ruoloCustom !== '' ? $ruoloCustom : null, + 'is_attivo' => true, + 'is_preferito' => $existing ? (bool) ($existing->is_preferito ?? false) : count($this->ruoliAttivi) === 0, + 'data_inizio' => $existing?->data_inizio ?: now()->toDateString(), + 'meta' => array_filter([ + 'source' => $source, + 'linked_from' => 'rubrica_scheda', + ], fn($value) => $value !== null && $value !== ''), + ]; + + if ($existing) { + $existing->fill($payload); + $existing->save(); + } else { + RubricaRuolo::query()->create(array_merge($attributes, $payload)); + } + + $this->rubrica->riferimento_stabile = $this->buildLocalStabileReference($stabile); + $this->rubrica->riferimento_unita = $unita ? $this->buildLocalUnitReference($unita) : $this->rubrica->riferimento_unita; + if (in_array($ruoloStandard, ['condomino', 'inquilino', 'fornitore', 'amministratore', 'altro'], true)) { + $this->rubrica->tipo_utenza_call = $ruoloStandard; + } + $this->rubrica->data_ultima_modifica = now()->toDateString(); + $this->rubrica->modificato_da = Auth::id(); + $this->rubrica->save(); + + $this->mount((int) $this->rubrica->id); + + Notification::make() + ->title('Collegamento aggiornato') + ->body('La scheda rubrica è ora agganciata a stabile e unità selezionati.') + ->success() + ->send(); + } + + /** @return array */ + private function resolveCollegamentoSuggerito(): array + { + $legacy = $this->parseCollegamentoLegacyMeta((string) ($this->rubrica->note ?? '')); + $stabile = $this->resolveCollegamentoStabile($legacy['cod_stabile'] ?? null, (string) ($this->rubrica->riferimento_stabile ?? '')); + if (empty($stabile['id'])) { + return []; + } + + $unit = $this->resolveCollegamentoUnita( + (int) $stabile['id'], + $legacy, + (string) ($this->rubrica->riferimento_unita ?? '') + ); + + return array_filter([ + 'stabile_id' => (int) $stabile['id'], + 'stabile_label' => $this->buildCollegamentoStabileReference($stabile), + 'unita_id' => (int) ($unit['unit_id'] ?? 0) ?: null, + 'unita_label' => (string) ($unit['unit_label'] ?? ''), + 'unita_reference' => (string) ($unit['unit_reference'] ?? ''), + 'role_standard' => $this->mapCollegamentoRole($legacy['ruolo'] ?? $this->rubrica->tipo_utenza_call), + 'role_custom' => null, + 'source' => (string) ($unit['source'] ?? 'riferimento_stabile'), + 'requires_review' => (bool) ($unit['source'] ?? false), + ], fn($value) => $value !== null && $value !== ''); + } + + /** @return array{cod_stabile:?string,cod_cond:?string,ruolo:?string} */ + private function parseCollegamentoLegacyMeta(string $note): array + { + $payload = [ + 'cod_stabile' => null, + 'cod_cond' => null, + 'ruolo' => null, + ]; + + foreach (array_keys($payload) as $key) { + if (preg_match('/' . preg_quote($key, '/') . '=([^|\n\r]+)/i', $note, $matches) === 1) { + $payload[$key] = trim((string) ($matches[1] ?? '')) ?: null; + } + } + + return $payload; + } + + /** @return array{id:int|null,codice_stabile:?string,cod_stabile:?string,denominazione:?string} */ + private function resolveCollegamentoStabile(?string $legacyCode, string $reference): array + { + $blank = ['id' => null, 'codice_stabile' => null, 'cod_stabile' => null, 'denominazione' => null]; + + foreach (array_filter([$legacyCode, ...$this->extractReferenceCandidates($reference)]) as $candidate) { + $code = trim((string) $candidate); + if ($code === '') { + continue; + } + + $trimmed = ltrim($code, '0'); + $row = Stabile::query() + ->when($this->adminId > 0, fn(Builder $query) => $query->where('amministratore_id', $this->adminId)) + ->where(function (Builder $query) use ($code, $trimmed): void { + $query->where('codice_stabile', $code) + ->orWhere('cod_stabile', $code); + + if ($trimmed !== '' && $trimmed !== $code) { + $query->orWhere('codice_stabile', $trimmed) + ->orWhere('cod_stabile', $trimmed); + } + }) + ->orderBy('id') + ->first(['id', 'codice_stabile', 'cod_stabile', 'denominazione']); + + if ($row) { + return [ + 'id' => (int) $row->id, + 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, + 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, + 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, + ]; + } + } + + $name = $this->extractReferenceName($reference); + if ($name !== '') { + $rows = Stabile::query() + ->when($this->adminId > 0, fn(Builder $query) => $query->where('amministratore_id', $this->adminId)) + ->whereRaw("LOWER(COALESCE(denominazione, '')) = ?", [mb_strtolower($name)]) + ->limit(2) + ->get(['id', 'codice_stabile', 'cod_stabile', 'denominazione']); + + if ($rows->count() === 1) { + $row = $rows->first(); + + return [ + 'id' => (int) $row->id, + 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, + 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, + 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, + ]; + } + } + + return $blank; + } + + /** @return array */ + private function resolveCollegamentoUnita(int $stabileId, array $legacy, string $reference): array + { + $reference = trim($reference); + if ($reference !== '') { + $code = trim((string) Str::before($reference, ' - ')); + if ($code !== '') { + $rows = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->where('codice_unita', $code) + ->limit(2) + ->get(['id', 'codice_unita', 'denominazione', 'scala', 'interno']); + + if ($rows->count() === 1) { + $row = $rows->first(); + + return [ + 'unit_id' => (int) $row->id, + 'unit_label' => $this->buildLocalUnitLabel($row), + 'unit_reference' => $this->buildLocalUnitReference($row), + 'source' => 'riferimento_unita', + ]; + } + } + } + + if (! empty($legacy['cod_stabile']) && ! empty($legacy['cod_cond']) && $this->hasGesconImportCondomin()) { + $rows = DB::connection('gescon_import') + ->table('condomin') + ->where('cod_stabile', trim((string) $legacy['cod_stabile'])) + ->where('cod_cond', trim((string) $legacy['cod_cond'])) + ->select(['nom_cond', 'scala', 'interno']) + ->get(); + + $candidates = []; + foreach ($rows as $row) { + $unit = $this->resolveUnitFromImportRow($stabileId, [ + 'nom_cond' => (string) ($row->nom_cond ?? ''), + 'scala' => (string) ($row->scala ?? ''), + 'interno' => (string) ($row->interno ?? ''), + ]); + + if (! $unit) { + continue; + } + + $candidates[(int) $unit->unit_id] = [ + 'unit_id' => (int) $unit->unit_id, + 'unit_label' => $this->buildLocalUnitLabel($unit), + 'unit_reference' => $this->buildLocalUnitReference($unit), + 'source' => 'gescon_import', + ]; + } + + if (count($candidates) === 1) { + return array_values($candidates)[0]; + } + } + + return []; + } + + /** @return array */ + private function extractReferenceCandidates(string $reference): array + { + $parts = preg_split('/\s*-\s*/', trim($reference)) ?: []; + + return array_values(array_filter(array_map(static function (string $part): string { + $value = trim($part); + return preg_match('/^[0-9]+$/', $value) === 1 ? $value : ''; + }, $parts))); + } + + private function extractReferenceName(string $reference): string + { + $parts = preg_split('/\s*-\s*/', trim($reference)) ?: []; + $parts = array_values(array_filter(array_map(static fn(string $part): string => trim($part), $parts), static fn(string $part): bool => $part !== '' && preg_match('/^[0-9]+$/', $part) !== 1)); + + return $parts !== [] ? (string) end($parts) : ''; + } + + private function buildLocalStabileReference(Stabile $stabile): string + { + $parts = array_values(array_filter([ + trim((string) ($stabile->cod_stabile ?? '')), + trim((string) ($stabile->codice_stabile ?? '')), + trim((string) ($stabile->denominazione ?? '')), + ])); + + return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile->id); + } + + private function buildCollegamentoStabileReference(array $stabile): string + { + $parts = array_values(array_filter([ + trim((string) ($stabile['cod_stabile'] ?? '')), + trim((string) ($stabile['codice_stabile'] ?? '')), + trim((string) ($stabile['denominazione'] ?? '')), + ])); + + return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) ($stabile['id'] ?? 0)); + } + + private function buildLocalUnitReference(object $unit): string + { + $code = trim((string) data_get($unit, 'codice_unita', '')); + $label = trim((string) data_get($unit, 'denominazione', '')); + + return trim($code . ($label !== '' ? ' - ' . $label : '')); + } + + private function buildLocalUnitLabel(object $unit): string + { + $bits = array_values(array_filter([ + trim((string) data_get($unit, 'codice_unita', '')), + trim((string) data_get($unit, 'scala', '')), + trim((string) data_get($unit, 'interno', '')), + trim((string) data_get($unit, 'denominazione', '')), + ])); + + return implode(' / ', $bits); + } + + private function mapCollegamentoRole(?string $value): ?string + { + $normalized = strtolower(trim((string) $value)); + + return match ($normalized) { + 'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', + 'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino', + 'fornitore' => 'fornitore', + 'amministratore' => 'amministratore', + 'collaboratore' => 'collaboratore', + 'altro' => 'altro', + default => null, + }; + } + + private function resolveUnitFromImportRow(int $stabileId, array $row): ?object + { + $query = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']); + + $scala = trim((string) ($row['scala'] ?? '')); + $interno = trim((string) ($row['interno'] ?? '')); + + if ($scala !== '' || $interno !== '') { + $strict = (clone $query) + ->when($scala !== '', fn($inner) => $inner->where('scala', $scala)) + ->when($interno !== '', fn($inner) => $inner->where('interno', $interno)) + ->get(); + if ($strict->count() === 1) { + return $strict->first(); + } + } + + $normalizedName = $this->normalizeCollegamentoText((string) ($row['nom_cond'] ?? '')); + if ($normalizedName === '') { + return null; + } + + $matches = (clone $query) + ->get() + ->filter(function ($unit) use ($normalizedName): bool { + $haystack = $this->normalizeCollegamentoText((string) ($unit->denominazione ?? '')); + + return $haystack !== '' && ($haystack === $normalizedName || str_contains($haystack, $normalizedName) || str_contains($normalizedName, $haystack)); + }) + ->values(); + + return $matches->count() === 1 ? $matches->first() : null; + } + + private function hasGesconImportCondomin(): bool + { + try { + return Schema::connection('gescon_import')->hasTable('condomin'); + } catch (\Throwable) { + return false; + } + } + + private function normalizeCollegamentoText(string $value): string + { + $upper = mb_strtoupper(trim($value)); + $normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper); + + return is_string($normalized) ? $normalized : ''; + } + private function applyLegacyIdentityDefaults(): void { $legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null); @@ -1570,7 +2039,7 @@ public function getUrlUnita(?int $unitaId): ?string } /** @return array */ - protected static function getRuoliLabels(): array + public static function getRuoliLabels(): array { return [ 'super_admin' => 'Super admin', @@ -1589,7 +2058,7 @@ protected static function getRuoliLabels(): array } /** @return array */ - protected function getStabiliOptions(): array + public function getStabiliOptions(): array { $query = Stabile::query(); @@ -1630,7 +2099,7 @@ protected function getTitoliOptions(): array } /** @return array */ - protected function getUnitaOptions($stabileId): array + public function getUnitaOptions($stabileId): array { $stabileId = (int) ($stabileId ?? 0); if ($stabileId <= 0) { diff --git a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php index 95ad311..9c4f9d6 100644 --- a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php +++ b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php @@ -1315,20 +1315,20 @@ public function getCollaboratoriCentralinoRowsProperty(): array return $query ->get() ->map(function (User $user): array { - $mapping = (array) ($mappings[(string) $user->id] ?? []); + $mapping = (array) ($mappings[(string) $user->id] ?? []); $extensions = array_values(array_filter((array) ($mapping['extensions'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); - $groups = array_values(array_filter((array) ($mapping['groups'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); - $lines = array_values(array_filter((array) ($mapping['lines'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); + $groups = array_values(array_filter((array) ($mapping['groups'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); + $lines = array_values(array_filter((array) ($mapping['lines'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); if ($extensions === [] && filled($user->pbx_extension)) { $extensions = [(string) $user->pbx_extension]; } - $this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? ''); - $this->collaboratorePbxExtensions[(int) $user->id] = implode(', ', $extensions); - $this->collaboratorePbxGroups[(int) $user->id] = implode(', ', $groups); - $this->collaboratorePbxLines[(int) $user->id] = implode(', ', $lines); - $this->collaboratorePbxPopupEnabled[(int) $user->id] = (bool) ($user->pbx_popup_enabled ?? false); + $this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? ''); + $this->collaboratorePbxExtensions[(int) $user->id] = implode(', ', $extensions); + $this->collaboratorePbxGroups[(int) $user->id] = implode(', ', $groups); + $this->collaboratorePbxLines[(int) $user->id] = implode(', ', $lines); + $this->collaboratorePbxPopupEnabled[(int) $user->id] = (bool) ($user->pbx_popup_enabled ?? false); $this->collaboratorePbxClickToCallEnabled[(int) $user->id] = (bool) ($user->pbx_click_to_call_enabled ?? false); return [ @@ -1355,8 +1355,8 @@ public function getPbxObservedTokensProperty(): array if (! Schema::hasTable('communication_messages')) { return [ 'extensions' => [], - 'groups' => [], - 'lines' => [], + 'groups' => [], + 'lines' => [], ]; } @@ -1366,8 +1366,8 @@ public function getPbxObservedTokensProperty(): array ->get(['target_extension', 'message_text', 'metadata']); $extensions = []; - $groups = []; - $lines = []; + $groups = []; + $lines = []; foreach ($rows as $message) { $metadata = (array) ($message->metadata ?? []); @@ -1392,8 +1392,8 @@ public function getPbxObservedTokensProperty(): array return [ 'extensions' => array_slice(array_values(array_unique($extensions)), 0, 24), - 'groups' => array_slice(array_values(array_unique($groups)), 0, 24), - 'lines' => array_slice(array_values(array_unique($lines)), 0, 24), + 'groups' => array_slice(array_values(array_unique($groups)), 0, 24), + 'lines' => array_slice(array_values(array_unique($lines)), 0, 24), ]; } @@ -1617,7 +1617,7 @@ private function resolvePbxChannels(array $settings): array return match ($channel) { 'panasonic', 'panasonic_csta', 'csta' => ['panasonic_csta'], - 'smdr' => ['smdr'], + 'smdr' => ['smdr'], default => ['panasonic_csta', 'smdr'], }; } @@ -1662,26 +1662,26 @@ public function salvaInternoCollaboratore(int $userId): void } $extensions = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxExtensions[$userId] ?? $this->collaboratorePbxExtension[$userId] ?? '')); - $groups = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxGroups[$userId] ?? '')); - $lines = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxLines[$userId] ?? '')); + $groups = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxGroups[$userId] ?? '')); + $lines = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxLines[$userId] ?? '')); - $user->pbx_extension = $extensions[0] ?? null; - $user->pbx_popup_enabled = (bool) ($this->collaboratorePbxPopupEnabled[$userId] ?? false); + $user->pbx_extension = $extensions[0] ?? null; + $user->pbx_popup_enabled = (bool) ($this->collaboratorePbxPopupEnabled[$userId] ?? false); $user->pbx_click_to_call_enabled = (bool) ($this->collaboratorePbxClickToCallEnabled[$userId] ?? false); $user->save(); $impostazioni = $this->amministratore->impostazioni ?? []; - $pbx = (array) Arr::get($impostazioni, 'pbx', []); - $mappings = (array) ($pbx['user_mappings'] ?? []); + $pbx = (array) Arr::get($impostazioni, 'pbx', []); + $mappings = (array) ($pbx['user_mappings'] ?? []); $mappings[(string) $userId] = [ 'extensions' => $extensions, - 'groups' => $groups, - 'lines' => $lines, + 'groups' => $groups, + 'lines' => $lines, ]; - $pbx['user_mappings'] = $mappings; - $impostazioni['pbx'] = $pbx; + $pbx['user_mappings'] = $mappings; + $impostazioni['pbx'] = $pbx; $this->amministratore->impostazioni = $impostazioni; $this->amministratore->save(); diff --git a/app/Filament/Pages/Strumenti/PostIt.php b/app/Filament/Pages/Strumenti/PostIt.php index a521a24..4fcfc74 100644 --- a/app/Filament/Pages/Strumenti/PostIt.php +++ b/app/Filament/Pages/Strumenti/PostIt.php @@ -34,20 +34,20 @@ class PostIt extends Page protected string $view = 'filament.pages.strumenti.post-it'; - public ?string $telefono = null; - public ?string $nomeChiamante = null; - public ?string $ricercaChiamante = null; - public string $direzione = 'in_arrivo'; - public ?string $esito = null; - public ?string $oggetto = null; - public ?string $nota = null; - public string $priorita = 'Media'; - public ?int $rubricaId = null; - public ?int $stabileId = null; - public ?int $focusPostItId = null; - public ?int $selectedPostItId = null; - public string $assegnazioneTipo = 'operatore'; - public ?int $assegnazioneUserId = null; + public ?string $telefono = null; + public ?string $nomeChiamante = null; + public ?string $ricercaChiamante = null; + public string $direzione = 'in_arrivo'; + public ?string $esito = null; + public ?string $oggetto = null; + public ?string $nota = null; + public string $priorita = 'Media'; + public ?int $rubricaId = null; + public ?int $stabileId = null; + public ?int $focusPostItId = null; + public ?int $selectedPostItId = null; + public string $assegnazioneTipo = 'operatore'; + public ?int $assegnazioneUserId = null; public ?int $assegnazioneFornitoreId = null; public ?int $selectedSuggerimentoId = null; @@ -61,8 +61,8 @@ public function mount(): void $this->direzione = 'in_arrivo'; $this->suggerimentiRubrica = collect(); - $focus = (int) request()->query('focus_post_it', 0); - $this->focusPostItId = $focus > 0 ? $focus : null; + $focus = (int) request()->query('focus_post_it', 0); + $this->focusPostItId = $focus > 0 ? $focus : null; $this->selectedPostItId = $this->focusPostItId; } @@ -95,14 +95,14 @@ public function assegnaPostIt(): void } $this->validate([ - 'assegnazioneTipo' => ['required', 'in:operatore,collaboratore,fornitore'], - 'assegnazioneUserId' => ['nullable', 'integer', 'exists:users,id'], + 'assegnazioneTipo' => ['required', 'in:operatore,collaboratore,fornitore'], + 'assegnazioneUserId' => ['nullable', 'integer', 'exists:users,id'], 'assegnazioneFornitoreId' => ['nullable', 'integer', 'exists:fornitori,id'], ]); $payload = [ - 'assegnazione_tipo' => $this->assegnazioneTipo, - 'assegnato_a_user_id' => null, + 'assegnazione_tipo' => $this->assegnazioneTipo, + 'assegnato_a_user_id' => null, 'assegnato_a_fornitore_id' => null, ]; @@ -131,7 +131,7 @@ public function assegnaPostIt(): void if ($this->assegnazioneTipo === 'fornitore') { $ticketPayload['assegnato_a_fornitore_id'] = $this->assegnazioneFornitoreId; - $ticketPayload['assegnato_a_user_id'] = null; + $ticketPayload['assegnato_a_user_id'] = null; } else { $ticketPayload['assegnato_a_user_id'] = $this->assegnazioneUserId; } @@ -142,7 +142,7 @@ public function assegnaPostIt(): void if (method_exists($postIt->ticket, 'messages') && Schema::hasTable('ticket_messages')) { $postIt->ticket->messages()->create([ - 'user_id' => Auth::id(), + 'user_id' => Auth::id(), 'messaggio' => 'Assegnazione da Post-it: ' . $this->getSelectedAssignmentLabel(), ]); } @@ -160,9 +160,9 @@ public function isPostItTableReady(): bool public function isPostItAssignmentReady(): bool { return $this->isPostItTableReady() - && Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo') - && Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id') - && Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id'); + && Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo') + && Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id') + && Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id'); } public function updatedRicercaChiamante(?string $value): void @@ -340,12 +340,12 @@ public function convertiInTicket(int $postItId): void } $ticket = Ticket::query()->create([ - 'stabile_id' => $postIt->stabile_id, - 'aperto_da_user_id' => Auth::id(), - 'assegnato_a_user_id' => $postIt->assegnazione_tipo !== 'fornitore' ? $postIt->assegnato_a_user_id : null, + 'stabile_id' => $postIt->stabile_id, + 'aperto_da_user_id' => Auth::id(), + 'assegnato_a_user_id' => $postIt->assegnazione_tipo !== 'fornitore' ? $postIt->assegnato_a_user_id : null, 'assegnato_a_fornitore_id' => $postIt->assegnazione_tipo === 'fornitore' ? $postIt->assegnato_a_fornitore_id : null, - 'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')), - 'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"), + 'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')), + 'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"), 'data_apertura' => now(), 'stato' => 'Aperto', 'priorita' => $postIt->priorita, @@ -538,17 +538,17 @@ private function loadSelectedPostItAssignment(): void return; } - $this->assegnazioneTipo = (string) ($postIt->assegnazione_tipo ?: 'operatore'); - $this->assegnazioneUserId = (int) ($postIt->assegnato_a_user_id ?? 0) ?: null; + $this->assegnazioneTipo = (string) ($postIt->assegnazione_tipo ?: 'operatore'); + $this->assegnazioneUserId = (int) ($postIt->assegnato_a_user_id ?? 0) ?: null; $this->assegnazioneFornitoreId = (int) ($postIt->assegnato_a_fornitore_id ?? 0) ?: null; } private function getSelectedAssignmentLabel(): string { return match ($this->assegnazioneTipo) { - 'fornitore' => 'fornitore #' . (int) ($this->assegnazioneFornitoreId ?? 0), + 'fornitore' => 'fornitore #' . (int) ($this->assegnazioneFornitoreId ?? 0), 'collaboratore' => 'collaboratore #' . (int) ($this->assegnazioneUserId ?? 0), - default => 'operatore #' . (int) ($this->assegnazioneUserId ?? 0), + default => 'operatore #' . (int) ($this->assegnazioneUserId ?? 0), }; } diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index ad8bb17..96b2cf8 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -14,6 +14,7 @@ use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use UnitEnum; class PostItGestione extends Page @@ -122,9 +123,9 @@ public function convertiInTicket(int $postItId): void 'aperto_da_user_id' => Auth::id(), 'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')), 'descrizione' => $this->buildTicketDescriptionFromPostIt($postItId, $postIt), - 'data_apertura' => now(), - 'stato' => 'Aperto', - 'priorita' => $postIt->priorita, + 'data_apertura' => now(), + 'stato' => 'Aperto', + 'priorita' => $postIt->priorita, ]); $postIt->ticket_id = $ticket->id; @@ -245,10 +246,19 @@ public function getRecentiProperty() public function getRecentiVisibiliProperty() { return $this->recenti - ->filter(fn(ChiamataPostIt $postIt): bool => ! $this->isInternalPostIt($postIt)) + ->filter(fn(ChiamataPostIt $postIt): bool => $this->shouldShowPostIt($postIt)) ->values(); } + private function shouldShowPostIt(ChiamataPostIt $postIt): bool + { + if ($this->isInternalPostIt($postIt)) { + return false; + } + + return ! $this->isLegacyFilteredSmdrPostIt($postIt); + } + public function getRecentiApertiVisibiliProperty() { return $this->recentiVisibili @@ -275,27 +285,91 @@ public function getRecentiChiusiRaggruppatiProperty() public function openGroupModal(string $modalKey, string $mode = 'open'): void { - $this->selectedGroupModalKey = $modalKey; + $this->selectedGroupModalKey = $modalKey; $this->selectedGroupModalMode = in_array($mode, ['open', 'closed'], true) ? $mode : 'open'; } public function closeGroupModal(): void { - $this->selectedGroupModalKey = null; + $this->selectedGroupModalKey = null; $this->selectedGroupModalMode = 'open'; } + public function createRubricaDraftFromGroup(string $modalKey, string $mode = 'open'): void + { + $group = $this->findGroupedCollectionByModalKey($modalKey, $mode); + if (! is_array($group)) { + Notification::make()->title('Gruppo chiamate non trovato')->warning()->send(); + return; + } + + $phoneDigits = preg_replace('/\D+/', '', (string) ($group['phone'] ?? '')) ?: ''; + if (! $this->canCreateRubricaFromPhone($phoneDigits)) { + Notification::make() + ->title('Numero non adatto alla rubrica') + ->body('Per creare una nuova anagrafica serve un numero esterno valido, non un interno breve.') + ->warning() + ->send(); + return; + } + + $existingId = $this->resolveRubricaIdByPhone($phoneDigits); + if ($existingId) { + $this->redirect(RubricaUniversaleScheda::getUrl(['record' => $existingId], panel: 'admin-filament'), navigate: true); + return; + } + + $identity = $this->buildRubricaDraftIdentity((string) ($group['caller_label'] ?? ''), $phoneDigits); + $user = Auth::user(); + $adminId = (int) ($user?->amministratore?->id ?? 0); + + $rubrica = RubricaUniversale::query()->create([ + 'amministratore_id' => $adminId > 0 ? $adminId : null, + 'nome' => $identity['nome'], + 'cognome' => $identity['cognome'], + 'ragione_sociale' => $identity['ragione_sociale'], + 'tipo_contatto' => $identity['tipo_contatto'], + 'telefono_cellulare' => $this->isLikelyMobilePhone($phoneDigits) ? $phoneDigits : null, + 'telefono_ufficio' => $this->isLikelyMobilePhone($phoneDigits) ? null : $phoneDigits, + 'categoria' => 'altro', + 'stato' => 'attivo', + 'data_inserimento' => now()->toDateString(), + 'data_ultima_modifica'=> now()->toDateString(), + 'creato_da' => Auth::id(), + 'modificato_da' => Auth::id(), + 'note_segreteria' => 'Bozza creata da Lavagna chiamate per gestire richiamo e identificazione del contatto.', + ]); + + $itemIds = collect($group['items']) + ->map(fn(ChiamataPostIt $item): int => (int) $item->id) + ->filter(fn(int $id): bool => $id > 0) + ->values() + ->all(); + + if ($itemIds !== []) { + ChiamataPostIt::query() + ->whereIn('id', $itemIds) + ->update([ + 'rubrica_id' => (int) $rubrica->id, + ]); + } + + $this->rubricaPhoneCache = []; + + Notification::make() + ->title('Bozza anagrafica creata') + ->body('Nuovo contatto collegato alla lavagna chiamate. Completa ora la scheda rubrica.') + ->success() + ->send(); + + $this->redirect(RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel: 'admin-filament'), navigate: true); + } + public function getSelectedGroupModalProperty(): ?array { - if ($this->selectedGroupModalKey === null) { - return null; - } - - $groups = $this->selectedGroupModalMode === 'closed' - ? $this->recentiChiusiRaggruppati - : $this->recentiRaggruppati; - - return $groups->firstWhere('modal_key', $this->selectedGroupModalKey); + return $this->selectedGroupModalKey !== null + ? $this->findGroupedCollectionByModalKey($this->selectedGroupModalKey, $this->selectedGroupModalMode) + : null; } public function getSelectedGroupModalDaysProperty() @@ -315,7 +389,7 @@ public function getSelectedGroupModalDaysProperty() ->values(); return [ - 'day' => $day, + 'day' => $day, 'items' => $sorted, ]; }) @@ -431,10 +505,10 @@ public function creaPostItDaMessaggio(int $messageId): void $nominativo = $this->getTecnicoNominativo($message); $lineLabel = $this->getTecnicoLineaLabel($message); $direction = match (strtolower(trim((string) $message->direction))) { - 'inbound' => 'Chiamata ricevuta', + 'inbound' => 'Chiamata ricevuta', 'outbound' => 'Chiamata effettuata', 'internal' => 'Chiamata interna', - default => 'Evento telefonico', + default => 'Evento telefonico', }; $oggetto = $direction; @@ -633,6 +707,10 @@ private function isInternalPostIt(ChiamataPostIt $postIt): bool } $phone = preg_replace('/\D+/', '', (string) ($postIt->telefono ?? '')); + if (is_string($phone) && $this->isShortInternalNumber($phone)) { + return true; + } + if (is_string($phone) && $phone !== '' && $this->isKnownPbxExtension($phone)) { return true; } @@ -640,6 +718,20 @@ private function isInternalPostIt(ChiamataPostIt $postIt): bool return false; } + private function isLegacyFilteredSmdrPostIt(ChiamataPostIt $postIt): bool + { + if ((string) ($postIt->origine ?? '') !== 'smdr') { + return false; + } + + $raw = strtoupper(trim((string) ($postIt->nota ?? ''))); + if ($raw === '') { + return false; + } + + return ! str_contains($raw, ''); + } + private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?CommunicationMessage { $originId = (int) ($postIt->origine_id ?? 0); @@ -651,7 +743,7 @@ private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?Communicatio return $this->postItMessageCache[$originId]; } - $message = CommunicationMessage::query()->find($originId); + $message = CommunicationMessage::query()->find($originId); $this->postItMessageCache[$originId] = $message; return $message; @@ -659,7 +751,9 @@ private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?Communicatio private function normalizeGroupedPhone(string $phone): string { - return preg_replace('/\D+/', '', $phone) ?: ''; + $digits = preg_replace('/\D+/', '', $phone) ?: ''; + + return $this->isShortInternalNumber($digits) ? '' : $digits; } private function resolveGroupedRubricaId(ChiamataPostIt $postIt): ?int @@ -739,15 +833,15 @@ private function buildGroupedCollection($items) } $groups[$groupKey] = [ - 'key' => $groupKey, - 'modal_key' => md5($groupKey), - 'items' => [$postIt], - 'count' => 1, - 'latest' => $postIt, + 'key' => $groupKey, + 'modal_key' => md5($groupKey), + 'items' => [$postIt], + 'count' => 1, + 'latest' => $postIt, 'caller_label' => $postIt->nome_chiamante ?: 'Chiamante non identificato', - 'phone' => (string) ($postIt->telefono ?? ''), - 'first_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-', - 'last_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-', + 'phone' => (string) ($postIt->telefono ?? ''), + 'first_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-', + 'last_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-', ]; } @@ -758,15 +852,21 @@ private function buildGroupedCollection($items) }); $group['latest'] = $this->resolveLatestGroupedPostIt($group['items']); - $oldest = collect($group['items']) + $group['primary_post_it'] = $this->resolvePrimaryGroupedPostIt($group['items']); + $oldest = collect($group['items']) ->sortBy(fn(ChiamataPostIt $item) => $item->chiamata_il?->getTimestamp() ?? 0) ->first(); - $group['first_at'] = optional($oldest?->chiamata_il)->format('d/m/Y H:i') ?: '-'; - $group['last_at'] = optional($group['latest']->chiamata_il)->format('d/m/Y H:i') ?: '-'; - $group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']); - $group['phone'] = $this->resolveGroupedPhoneLabel($group['items']); + $group['first_at'] = optional($oldest?->chiamata_il)->format('d/m/Y H:i') ?: '-'; + $group['last_at'] = optional($group['latest']->chiamata_il)->format('d/m/Y H:i') ?: '-'; + $group['caller_label'] = $this->resolveGroupedCallerLabel($group['items']); + $group['phone'] = $this->resolveGroupedPhoneLabel($group['items']); + $group['rubrica_url'] = $this->getRubricaUrlByPhone((string) $group['phone']); + $group['answered_count']= collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isAnsweredPostIt($item))->count(); + $group['missed_count'] = collect($group['items'])->filter(fn(ChiamataPostIt $item): bool => $this->isMissedPostIt($item))->count(); + $group['can_create_rubrica'] = ! $group['rubrica_url'] && $this->canCreateRubricaFromPhone((string) $group['phone']); + $group['primary_action_label'] = $group['answered_count'] > 0 ? 'Apri Post-it' : 'Apri richiamo'; $group['visible_items'] = collect($group['items'])->take(4)->values()->all(); - $group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items'])); + $group['hidden_count'] = max(0, count($group['items']) - count($group['visible_items'])); return $group; }) @@ -911,6 +1011,10 @@ private function resolveRubricaIdByPhone(?string $phone): ?int return null; } + if ($this->isShortInternalNumber($digits)) { + return null; + } + if ($this->isKnownPbxExtension($digits)) { return null; } @@ -967,6 +1071,134 @@ private function isKnownPbxExtension(string $digits): bool return User::query()->where('pbx_extension', $digits)->exists(); } + private function isShortInternalNumber(string $digits): bool + { + return $digits !== '' && strlen($digits) <= 4; + } + + private function canCreateRubricaFromPhone(?string $phone): bool + { + $digits = preg_replace('/\D+/', '', (string) $phone) ?: ''; + + return $digits !== '' && ! $this->isShortInternalNumber($digits); + } + + private function isLikelyMobilePhone(string $digits): bool + { + return str_starts_with($digits, '3') && strlen($digits) >= 9; + } + + private function normalizePostItOutcome(?string $outcome): string + { + return Str::lower(trim((string) $outcome)); + } + + public function isAnsweredPostIt(ChiamataPostIt $postIt): bool + { + $outcome = $this->normalizePostItOutcome($postIt->esito); + + if (($postIt->durata_secondi ?? 0) > 0) { + return true; + } + + return in_array($outcome, ['answered', 'risposta', 'gestita', 'connected', 'ok'], true); + } + + private function isMissedPostIt(ChiamataPostIt $postIt): bool + { + $outcome = $this->normalizePostItOutcome($postIt->esito); + + if (in_array($outcome, ['persa', 'missed', 'no_answer', 'unanswered', 'non_risposta'], true)) { + return true; + } + + return (string) $postIt->direzione === 'in_arrivo' && ! $this->isAnsweredPostIt($postIt) && (int) ($postIt->durata_secondi ?? 0) === 0; + } + + public function getPostItCallStateMeta(ChiamataPostIt $postIt): array + { + if ($this->isMissedPostIt($postIt)) { + return [ + 'label' => 'Mancata risposta', + 'class' => 'bg-rose-100 text-rose-800', + ]; + } + + if ($this->isAnsweredPostIt($postIt)) { + return [ + 'label' => (string) $postIt->direzione === 'in_uscita' ? 'Richiamo effettuato' : 'Risposta registrata', + 'class' => 'bg-emerald-100 text-emerald-800', + ]; + } + + return [ + 'label' => (string) $postIt->direzione === 'in_uscita' ? 'Richiamo da fare' : 'Da verificare', + 'class' => 'bg-amber-100 text-amber-900', + ]; + } + + /** @param array $items */ + private function resolvePrimaryGroupedPostIt(array $items): ChiamataPostIt + { + $answered = collect($items) + ->filter(fn(ChiamataPostIt $item): bool => $this->isAnsweredPostIt($item)) + ->sortByDesc(fn(ChiamataPostIt $item) => $item->chiamata_il?->getTimestamp() ?? 0) + ->values(); + + if ($answered->isNotEmpty()) { + return $answered->first(); + } + + return $this->resolveLatestGroupedPostIt($items); + } + + private function findGroupedCollectionByModalKey(string $modalKey, string $mode = 'open'): ?array + { + $groups = $mode === 'closed' + ? $this->recentiChiusiRaggruppati + : $this->recentiRaggruppati; + + $group = $groups->firstWhere('modal_key', $modalKey); + + return is_array($group) ? $group : null; + } + + private function buildRubricaDraftIdentity(string $label, string $phoneDigits): array + { + $cleanLabel = trim(preg_replace('/\s+/', ' ', $label) ?? ''); + $cleanLabel = $cleanLabel !== '' && $cleanLabel !== 'Chiamante non identificato' + ? $cleanLabel + : ('Nuovo contatto ' . $phoneDigits); + + if (str_contains($cleanLabel, '/') || preg_match('/\b(srl|sas|spa|snc|studio|condominio|associazione|impresa)\b/i', $cleanLabel) === 1) { + return [ + 'tipo_contatto' => 'persona_giuridica', + 'nome' => null, + 'cognome' => null, + 'ragione_sociale' => $cleanLabel, + ]; + } + + $parts = preg_split('/\s+/', $cleanLabel) ?: []; + if (count($parts) >= 2) { + $nome = array_shift($parts); + + return [ + 'tipo_contatto' => 'persona_fisica', + 'nome' => $nome, + 'cognome' => implode(' ', $parts), + 'ragione_sociale' => null, + ]; + } + + return [ + 'tipo_contatto' => 'persona_fisica', + 'nome' => $cleanLabel, + 'cognome' => null, + 'ragione_sociale' => null, + ]; + } + private function isManagedStudioNumberMessage(CommunicationMessage $message): bool { $phoneDigits = preg_replace('/\D+/', '', (string) ($message->phone_number ?: data_get($message->metadata, 'smdr.dial_number', ''))); diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index c45dd91..35b64fc 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -3,6 +3,8 @@ use App\Models\Amministratore; use App\Models\Stabile; +use App\Models\SupportBugRegistry; +use App\Models\SupportUpdateEntry; use App\Support\StabileContext; use BackedEnum; use Filament\Notifications\Notification; @@ -12,6 +14,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Throwable; use UnitEnum; @@ -148,12 +151,62 @@ class Modifiche extends Page /** @var array */ public array $latestCommits = []; + /** @var array */ + public array $pendingGitCommits = []; + + public int $pendingGitCommitCount = 0; + + /** @var array */ + public array $pendingGitChangedAreas = []; + /** @var array */ public array $recentFilamentPages = []; /** @var array */ public array $functionalHighlights = []; + /** @var array */ + public array $supportUpdateEntries = []; + + public int $supportUpdateVisibleCount = 0; + + public int $supportImplementationCount = 0; + + public int $supportBugfixCount = 0; + + public int $supportDebugCount = 0; + + public ?int $supportUpdateFormId = null; + + public string $supportUpdateFormCategory = 'implementazione'; + + public string $supportUpdateFormAudience = 'utenti'; + + public string $supportUpdateFormTitle = ''; + + public string $supportUpdateFormSummary = ''; + + public string $supportUpdateFormImpact = ''; + + public string $supportUpdateFormPageLabel = ''; + + public string $supportUpdateFormPageClass = ''; + + public string $supportUpdateFormRelatedPath = ''; + + public string $supportUpdateFormRelatedCommit = ''; + + public string $supportUpdateFormRecordedAt = ''; + + public bool $supportUpdateFormVisible = true; + + public bool $supportUpdateFormHighlighted = false; + + public ?string $pendingGitPreviewIssue = null; + + /** @var array|null */ + public ?array $lastNotificationSanitization = null; + /** @var array */ public array $manualSections = []; @@ -173,26 +226,25 @@ public static function canAccess(): bool return true; } - public function canRunUpdate(): bool - { - $user = Auth::user(); - - return $user && method_exists($user, 'hasAnyRole') - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); - } - public function reloadDashboardData(): void { $this->loadVersionInfo(); $this->loadLatestCommits(); $this->loadCommitNotes(); - $this->loadRuntimeErrors(); $this->loadRecentFilamentPages(); $this->loadFunctionalHighlights(); $this->loadManualSections(); $this->loadDevelopmentSnapshot(); $this->loadGitWorkspaceStatus(); + $this->loadPendingGitPreview(); + $this->loadSupportUpdateEntries(); + $this->loadRuntimeErrors(); $this->loadLatestBackupSummary(); + $this->loadLastNotificationSanitization(); + + if ($this->supportUpdateFormRecordedAt === '') { + $this->resetSupportUpdateForm(); + } if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) { $this->selectedCommitHash = (string) $this->latestCommits[0]['hash']; @@ -571,6 +623,98 @@ private function loadLatestCommits(): void } } + private function loadPendingGitPreview(): void + { + $this->pendingGitCommits = []; + $this->pendingGitCommitCount = 0; + $this->pendingGitChangedAreas = []; + $this->pendingGitPreviewIssue = null; + + $remote = trim($this->gitRemote); + $branch = trim($this->gitBranch); + + if ($remote === '' || $branch === '') { + return; + } + + $fetch = Process::path(base_path()) + ->timeout(120) + ->run(['git', 'fetch', $remote, $branch, '--quiet']); + + if (! $fetch->successful()) { + $this->pendingGitPreviewIssue = 'Impossibile aggiornare l anteprima remota da Gitea. Il conteggio mostrato potrebbe essere non aggiornato.'; + } + + $range = 'HEAD..refs/remotes/' . $remote . '/' . $branch; + $count = $this->runGitProcess(['rev-list', '--count', $range]); + + if (! is_string($count) || ! ctype_digit(trim($count))) { + return; + } + + $this->pendingGitCommitCount = (int) trim($count); + if ($this->pendingGitCommitCount <= 0) { + return; + } + + $format = '%H|%h|%an|%ad|%s'; + $command = 'git log -n 12 --date=format:"%d/%m/%Y %H:%M" --pretty=format:"' . $format . '" ' . escapeshellarg($range); + $result = Process::path(base_path())->run(['bash', '-lc', $command]); + + if ($result->successful()) { + $lines = preg_split('/\r\n|\r|\n/', trim($result->output())) ?: []; + foreach ($lines as $line) { + $parts = explode('|', $line, 5); + if (count($parts) !== 5) { + continue; + } + + $this->pendingGitCommits[] = [ + 'hash' => (string) $parts[0], + 'short_hash' => (string) $parts[1], + 'author' => (string) $parts[2], + 'date' => (string) $parts[3], + 'message' => (string) $parts[4], + ]; + } + } + + $changed = $this->runGitProcess([ + 'diff', + '--name-only', + $range, + '--', + 'app/Filament/Pages', + 'resources/views/filament/pages', + 'app/Console/Commands', + 'docs/support', + ]); + + if (! is_string($changed) || trim($changed) === '') { + return; + } + + $seen = []; + $lines = preg_split('/\r\n|\r|\n/', trim($changed)) ?: []; + foreach ($lines as $path) { + $path = trim((string) $path); + if ($path === '' || isset($seen[$path])) { + continue; + } + + $seen[$path] = true; + $this->pendingGitChangedAreas[] = [ + 'label' => $this->humanizePagePath($path), + 'path' => $path, + 'area' => $this->resolveChangedAreaLabel($path), + ]; + + if (count($this->pendingGitChangedAreas) >= 16) { + break; + } + } + } + private function startUpdateJob(bool $fallback): void { $this->registerPlannedUpdateSummary(); @@ -924,6 +1068,77 @@ private function loadGitWorkspaceStatus(): void } } + private function loadSupportUpdateEntries(): void + { + $this->supportUpdateEntries = []; + $this->supportUpdateVisibleCount = 0; + $this->supportImplementationCount = 0; + $this->supportBugfixCount = 0; + $this->supportDebugCount = 0; + + if (! $this->isSupportUpdateRegistryReady()) { + return; + } + + $this->syncStructuredUpdateRegistry(); + + $visibleEntries = SupportUpdateEntry::query() + ->where('is_visible', true) + ->get(); + + $this->supportUpdateVisibleCount = $visibleEntries->count(); + $this->supportImplementationCount = $visibleEntries->where('category', 'implementazione')->count(); + $this->supportBugfixCount = $visibleEntries->where('category', 'bugfix')->count(); + $this->supportDebugCount = $visibleEntries->where('category', 'debug')->count(); + + $entries = SupportUpdateEntry::query() + ->where('is_visible', true) + ->orderByDesc('is_highlighted') + ->orderByDesc('recorded_at') + ->orderByDesc('id') + ->limit(24) + ->get(); + + foreach ($entries as $entry) { + $category = (string) $entry->category; + + $this->supportUpdateEntries[] = [ + 'id' => (int) $entry->id, + 'release_key' => (string) $entry->release_key, + 'category' => $category, + 'audience' => (string) $entry->audience, + 'title' => (string) $entry->title, + 'summary' => (string) $entry->summary, + 'impact' => (string) ($entry->impact ?? ''), + 'page_label' => (string) ($entry->page_label ?? ''), + 'page_class' => (string) ($entry->page_class ?? ''), + 'related_path' => (string) ($entry->related_path ?? ''), + 'related_commit' => (string) ($entry->related_commit ?? ''), + 'recorded_at' => (string) ($entry->recorded_at ?? ''), + 'is_visible' => (bool) $entry->is_visible, + 'is_highlighted' => (bool) $entry->is_highlighted, + 'page_url' => $this->resolveSupportUpdatePageUrl((string) ($entry->page_class ?? '')), + ]; + } + } + + private function loadLastNotificationSanitization(): void + { + $this->lastNotificationSanitization = null; + + $path = storage_path('app/support/filament-notification-sanitize.json'); + if (! is_file($path)) { + return; + } + + $decoded = json_decode((string) @file_get_contents($path), true); + if (! is_array($decoded)) { + return; + } + + $this->lastNotificationSanitization = $decoded; + } + private function loadDevelopmentSnapshot(): void { $generatedPath = storage_path('app/support/generated/CONTINUITA-SVILUPPO-SNAPSHOT.md'); @@ -987,6 +1202,172 @@ private function humanizePagePath(string $path): string return trim($name); } + private function resolveChangedAreaLabel(string $path): string + { + if (str_starts_with($path, 'app/Filament/Pages/')) { + return 'pagina Filament'; + } + + if (str_starts_with($path, 'resources/views/filament/pages/')) { + return 'view pagina'; + } + + if (str_starts_with($path, 'app/Console/Commands/')) { + return 'comando applicativo'; + } + + if (str_starts_with($path, 'docs/support/')) { + return 'documentazione supporto'; + } + + return 'area applicativa'; + } + + private function isSupportUpdateRegistryReady(): bool + { + try { + return Schema::hasTable('support_update_entries'); + } catch (Throwable) { + return false; + } + } + + private function isSupportBugRegistryReady(): bool + { + try { + return Schema::hasTable('support_bug_registries'); + } catch (Throwable) { + return false; + } + } + + private function syncStructuredUpdateRegistry(): void + { + foreach ($this->getStructuredUpdateDefinitions() as $row) { + $exists = SupportUpdateEntry::query() + ->where('release_key', (string) $row['release_key']) + ->exists(); + + if ($exists) { + continue; + } + + $row['updated_at'] = now(); + $row['created_at'] = now(); + SupportUpdateEntry::query()->create($row); + } + } + + /** @return array> */ + private function getStructuredUpdateDefinitions(): array + { + return [ + [ + 'release_key' => 'post-it-assegnazione-operativa', + 'category' => 'implementazione', + 'audience' => 'utenti', + 'title' => 'Assegnazione Post-it a operatore, collaboratore o fornitore', + 'summary' => 'La scheda Post-it registra ora l assegnazione operativa direttamente in UI e propaga il riferimento anche al ticket collegato.', + 'impact' => 'Riduce passaggi manuali e mantiene il responsabile collegato al promemoria anche dopo la conversione in ticket.', + 'page_label' => 'Strumenti > Post-it', + 'page_class' => \App\Filament\Pages\Strumenti\PostIt::class, + 'related_path' => 'app/Filament/Pages/Strumenti/PostIt.php', + 'related_commit' => '236aef5', + 'is_visible' => true, + 'is_highlighted' => true, + 'recorded_at' => '2026-04-04 09:30', + ], + [ + 'release_key' => 'lavagna-chiamate-filtra-interni', + 'category' => 'bugfix', + 'audience' => 'utenti', + 'title' => 'Lavagna chiamate e storico Post-it ripuliti dai numeri interni', + 'summary' => 'Le chiamate interne restano nel flusso tecnico SMDR ma non inquinano piu la vista operativa della lavagna e dello storico.', + 'impact' => 'Gli operatori vedono solo chiamate lavorabili e la vista tecnica conserva tutto l import PBX completo.', + 'page_label' => 'Strumenti > Post-it Gestione', + 'page_class' => \App\Filament\Pages\Strumenti\PostItGestione::class, + 'related_path' => 'app/Filament/Pages/Strumenti/PostItGestione.php', + 'related_commit' => 'ab7b804', + 'is_visible' => true, + 'is_highlighted' => true, + 'recorded_at' => '2026-04-04 10:00', + ], + [ + 'release_key' => 'lavagna-archivio-modal-raggruppata', + 'category' => 'implementazione', + 'audience' => 'utenti', + 'title' => 'Lavagna gialla con archivio chiusi e dettaglio storico per giorno', + 'summary' => 'Le chiamate raggruppate sono state spostate in una tab dedicata con box uniformi, archivio dei chiusi e modal storico per giornate.', + 'impact' => 'La lettura diventa piu vicina alla bacheca reale e lo storico resta consultabile senza perdere il collegamento con rubrica e nominativo.', + 'page_label' => 'Strumenti > Post-it Gestione', + 'page_class' => \App\Filament\Pages\Strumenti\PostItGestione::class, + 'related_path' => 'resources/views/filament/pages/strumenti/post-it-gestione.blade.php', + 'related_commit' => 'b9b9e99', + 'is_visible' => true, + 'is_highlighted' => true, + 'recorded_at' => '2026-04-04 10:20', + ], + [ + 'release_key' => 'pbx-mappature-osservate-collaboratori', + 'category' => 'implementazione', + 'audience' => 'supporto', + 'title' => 'Scheda amministratore con mapping PBX osservato per collaboratori', + 'summary' => 'La configurazione PBX/TAPI accumula token osservati e permette mappature multiple per interni, gruppi, linee e flag click to call/popup.', + 'impact' => 'Riduce configurazioni cieche e rende il PBX piu neutro rispetto al fornitore, mantenendo traccia operativa dei valori visti.', + 'page_label' => 'Impostazioni > Scheda Amministratore', + 'page_class' => \App\Filament\Pages\Impostazioni\SchedaAmministratore::class, + 'related_path' => 'app/Filament/Pages/Impostazioni/SchedaAmministratore.php', + 'related_commit' => '236aef5', + 'is_visible' => true, + 'is_highlighted' => false, + 'recorded_at' => '2026-04-04 10:35', + ], + [ + 'release_key' => 'sync-git-rubrica-qa-sicura', + 'category' => 'debug', + 'audience' => 'supporto', + 'title' => 'Sync Git da Gitea con ricucitura sicura dei legami rubrica', + 'summary' => 'La sync UI prova anche gescon:qa-rubrica-legami --apply-safe, cosi il riallineamento staging resta dentro il flusso applicativo.', + 'impact' => 'Riduce dipendenza dal terminale e abbassa il rischio di dimenticare la ricostruzione minima della rubrica dopo il pull.', + 'page_label' => 'Supporto > Modifiche', + 'page_class' => self::class, + 'related_path' => 'app/Console/Commands/NetgesconGitSyncCommand.php', + 'related_commit' => '236aef5', + 'is_visible' => true, + 'is_highlighted' => false, + 'recorded_at' => '2026-04-04 10:50', + ], + ]; + } + + private function resolveSupportUpdatePageUrl(string $pageClass): ?string + { + if ($pageClass === '' || ! class_exists($pageClass) || ! method_exists($pageClass, 'getUrl')) { + return null; + } + + try { + return $pageClass::getUrl(); + } catch (Throwable) { + return null; + } + } + + private function buildSupportUpdateReleaseKey(string $title): string + { + $base = Str::slug(Str::limit($title, 80, ''), '-'); + $base = $base !== '' ? $base : 'support-update'; + $key = $base; + $i = 1; + + while (SupportUpdateEntry::query()->where('release_key', $key)->exists()) { + $i++; + $key = $base . '-' . $i; + } + + return $key; + } + public function setActiveTab(string $tab): void { if (! in_array($tab, ['manuale', 'errori', 'commit', 'migliorie'], true)) { @@ -996,6 +1377,130 @@ public function setActiveTab(string $tab): void $this->activeTab = $tab; } + public function resetSupportUpdateForm(): void + { + $this->supportUpdateFormId = null; + $this->supportUpdateFormCategory = 'implementazione'; + $this->supportUpdateFormAudience = 'utenti'; + $this->supportUpdateFormTitle = ''; + $this->supportUpdateFormSummary = ''; + $this->supportUpdateFormImpact = ''; + $this->supportUpdateFormPageLabel = ''; + $this->supportUpdateFormPageClass = ''; + $this->supportUpdateFormRelatedPath = ''; + $this->supportUpdateFormRelatedCommit = ''; + $this->supportUpdateFormRecordedAt = now()->format('Y-m-d H:i'); + $this->supportUpdateFormVisible = true; + $this->supportUpdateFormHighlighted = false; + } + + public function editSupportUpdateEntry(int $entryId): void + { + if (! $this->isSupportUpdateRegistryReady()) { + return; + } + + $entry = SupportUpdateEntry::query()->find($entryId); + if (! $entry instanceof SupportUpdateEntry) { + Notification::make()->title('Voce registro non trovata')->warning()->send(); + + return; + } + + $this->supportUpdateFormId = (int) $entry->id; + $this->supportUpdateFormCategory = (string) $entry->category; + $this->supportUpdateFormAudience = (string) $entry->audience; + $this->supportUpdateFormTitle = (string) $entry->title; + $this->supportUpdateFormSummary = (string) $entry->summary; + $this->supportUpdateFormImpact = (string) ($entry->impact ?? ''); + $this->supportUpdateFormPageLabel = (string) ($entry->page_label ?? ''); + $this->supportUpdateFormPageClass = (string) ($entry->page_class ?? ''); + $this->supportUpdateFormRelatedPath = (string) ($entry->related_path ?? ''); + $this->supportUpdateFormRelatedCommit = (string) ($entry->related_commit ?? ''); + $this->supportUpdateFormRecordedAt = (string) ($entry->recorded_at ?: now()->format('Y-m-d H:i')); + $this->supportUpdateFormVisible = (bool) $entry->is_visible; + $this->supportUpdateFormHighlighted = (bool) $entry->is_highlighted; + + Notification::make()->title('Voce caricata nel form')->success()->send(); + } + + public function saveSupportUpdateEntry(): void + { + if (! $this->isSupportUpdateRegistryReady()) { + Notification::make()->title('Registro DB non pronto')->warning()->send(); + + return; + } + + $title = trim($this->supportUpdateFormTitle); + $summary = trim($this->supportUpdateFormSummary); + $recordedAt = mb_substr(trim($this->supportUpdateFormRecordedAt), 0, 40); + $relatedCommit = mb_substr(trim($this->supportUpdateFormRelatedCommit), 0, 40); + + if ($title === '' || $summary === '') { + Notification::make()->title('Titolo e riepilogo sono obbligatori')->warning()->send(); + + return; + } + + $category = in_array($this->supportUpdateFormCategory, ['implementazione', 'bugfix', 'debug'], true) + ? $this->supportUpdateFormCategory + : 'implementazione'; + $audience = in_array($this->supportUpdateFormAudience, ['utenti', 'supporto'], true) + ? $this->supportUpdateFormAudience + : 'utenti'; + + $entry = $this->supportUpdateFormId !== null + ? SupportUpdateEntry::query()->find($this->supportUpdateFormId) + : new SupportUpdateEntry(); + + if (! $entry instanceof SupportUpdateEntry) { + $entry = new SupportUpdateEntry(); + } + + $releaseKey = $entry->exists + ? (string) $entry->release_key + : $this->buildSupportUpdateReleaseKey($title); + + $entry->fill([ + 'release_key' => $releaseKey, + 'category' => $category, + 'audience' => $audience, + 'title' => $title, + 'summary' => $summary, + 'impact' => trim($this->supportUpdateFormImpact), + 'page_label' => trim($this->supportUpdateFormPageLabel), + 'page_class' => trim($this->supportUpdateFormPageClass), + 'related_path' => trim($this->supportUpdateFormRelatedPath), + 'related_commit' => $relatedCommit, + 'recorded_at' => $recordedAt !== '' ? $recordedAt : now()->format('Y-m-d H:i'), + 'is_visible' => $this->supportUpdateFormVisible, + 'is_highlighted' => $this->supportUpdateFormHighlighted, + ]); + $entry->save(); + + $this->loadSupportUpdateEntries(); + $this->resetSupportUpdateForm(); + + Notification::make()->title('Voce registro salvata')->success()->send(); + } + + public function toggleSupportUpdateVisibility(int $entryId): void + { + if (! $this->isSupportUpdateRegistryReady()) { + return; + } + + $entry = SupportUpdateEntry::query()->find($entryId); + if (! $entry instanceof SupportUpdateEntry) { + return; + } + + $entry->is_visible = ! (bool) $entry->is_visible; + $entry->save(); + $this->loadSupportUpdateEntries(); + } + private function startGitSyncJob(): void { $jobId = now()->format('YmdHis') . '-' . Str::lower(Str::random(6)); @@ -1525,6 +2030,40 @@ private function loadBugRegistry(): void $this->bugRegistry = []; $this->nextBugId = 1; + if ($this->isSupportBugRegistryReady()) { + $rows = SupportBugRegistry::query() + ->orderBy('bug_id') + ->get(); + + foreach ($rows as $row) { + $fingerprint = (string) $row->fingerprint; + if ($fingerprint === '') { + continue; + } + + $id = (int) $row->bug_id; + if ($id <= 0) { + continue; + } + + $this->bugRegistry[$fingerprint] = [ + 'id' => $id, + 'status' => (string) ($row->status ?: 'open'), + 'first_seen' => (string) ($row->first_seen ?: '-'), + 'last_seen' => (string) ($row->last_seen ?: '-'), + 'resolved_at' => $row->resolved_at !== null ? (string) $row->resolved_at : null, + 'resolution_note' => (string) ($row->resolution_note ?? ''), + 'title' => (string) ($row->title ?? ''), + ]; + + $this->nextBugId = max($this->nextBugId, $id + 1); + } + + if ($this->bugRegistry !== []) { + return; + } + } + $path = storage_path('app/support/bug-tracker.json'); if (! is_file($path)) { return; @@ -1569,6 +2108,36 @@ private function loadBugRegistry(): void private function persistBugRegistry(): void { + if ($this->isSupportBugRegistryReady()) { + $rows = []; + foreach ($this->bugRegistry as $fingerprint => $meta) { + if (! is_string($fingerprint) || ! is_array($meta)) { + continue; + } + + $rows[] = [ + 'fingerprint' => $fingerprint, + 'bug_id' => (int) ($meta['id'] ?? 0), + 'status' => (string) ($meta['status'] ?? 'open'), + 'title' => (string) ($meta['title'] ?? ''), + 'first_seen' => (string) ($meta['first_seen'] ?? '-'), + 'last_seen' => (string) ($meta['last_seen'] ?? '-'), + 'resolved_at' => isset($meta['resolved_at']) && is_string($meta['resolved_at']) ? $meta['resolved_at'] : null, + 'resolution_note' => (string) ($meta['resolution_note'] ?? ''), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if ($rows !== []) { + SupportBugRegistry::query()->upsert( + $rows, + ['fingerprint'], + ['bug_id', 'status', 'title', 'first_seen', 'last_seen', 'resolved_at', 'resolution_note', 'updated_at'] + ); + } + } + $path = storage_path('app/support/bug-tracker.json'); $dir = dirname($path); if (! is_dir($dir)) { @@ -1586,6 +2155,41 @@ private function persistBugRegistry(): void ); } + /** @return array|null */ + public function getHighlightedBugProperty(): ?array + { + if ($this->selectedBugFingerprint !== null) { + foreach ($this->runtimeErrors as $row) { + if (($row['fingerprint'] ?? null) === $this->selectedBugFingerprint) { + return $row; + } + } + } + + foreach ($this->runtimeErrors as $row) { + if (($row['bug_code'] ?? '') === 'BUG-0033') { + return $row; + } + } + + return $this->runtimeErrors[0] ?? null; + } + + public function getHighlightedBugHintProperty(): ?string + { + $row = $this->highlightedBug; + if (! is_array($row)) { + return null; + } + + $message = Str::lower((string) ($row['message'] ?? '')); + if (str_contains($message, 'filament\\notifications\\collection') || str_contains($message, 'argument #1 ($notification) must be of type array, int given')) { + return 'Causa probabile: payload notifiche Filament corrotto in sessione. Il bootstrap applicativo ora filtra i valori non-array prima che Livewire li rilegga.'; + } + + return null; + } + private function loadCommitNotes(): void { $this->commitNotes = []; diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index 359f32d..f754d32 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -48,7 +48,7 @@ class TicketGestione extends Page protected string $view = 'filament.pages.supporto.ticket-gestione'; - public string $status = 'open'; + public string $status = 'all'; public string $activeTab = 'elenco'; diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 1fb0781..cf14ffe 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -45,7 +45,7 @@ class TicketMobile extends Page protected string $view = 'filament.pages.supporto.ticket-mobile'; - public string $status = 'open'; + public string $status = 'all'; public string $callerSearch = ''; @@ -603,7 +603,7 @@ public function creaTicketRapido(): void 'priorita' => $this->newTicketPriorita, ]); - $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); + $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); Notification::make() ->title('Ticket creato') @@ -615,14 +615,14 @@ public function creaTicketRapido(): void ->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->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->refreshData(); } @@ -846,6 +846,16 @@ private function appendPendingUploads(string $source): void $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') @@ -964,10 +974,10 @@ public function getTicketCallContextProperty(): array '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, + '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, ]; diff --git a/app/Models/ChiamataPostIt.php b/app/Models/ChiamataPostIt.php index 2763b08..b229768 100644 --- a/app/Models/ChiamataPostIt.php +++ b/app/Models/ChiamataPostIt.php @@ -34,11 +34,11 @@ class ChiamataPostIt extends Model ]; protected $casts = [ - 'chiamata_il' => 'datetime', - 'chiusa_il' => 'datetime', + 'chiamata_il' => 'datetime', + 'chiusa_il' => 'datetime', 'durata_secondi' => 'integer', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; public function rubrica() diff --git a/app/Models/SupportBugRegistry.php b/app/Models/SupportBugRegistry.php new file mode 100644 index 0000000..9dc2915 --- /dev/null +++ b/app/Models/SupportBugRegistry.php @@ -0,0 +1,18 @@ + 'boolean', + 'is_highlighted' => 'boolean', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2c2a3bb..c5a049f 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,6 +1,7 @@ sanitizeFilamentNotificationSessionPayload(); + // View Composer per la sidebar View::composer([ 'components.menu.sidebar', @@ -95,4 +98,48 @@ function userSetting($key, $default = null) // Silenzia in bootstrap (es. durante installazione senza DB) } } + + private function sanitizeFilamentNotificationSessionPayload(): void + { + if ($this->app->runningInConsole() || ! $this->app->bound('session.store')) { + return; + } + + try { + /** @var Session $session */ + $session = $this->app->make('session.store'); + $raw = $session->get('filament.notifications'); + + if ($raw === null) { + return; + } + + if (! is_array($raw)) { + $session->forget('filament.notifications'); + + return; + } + + $normalized = array_values(array_filter($raw, static fn(mixed $notification): bool => is_array($notification))); + + if ($normalized !== $raw) { + $session->put('filament.notifications', $normalized); + $path = storage_path('app/support/filament-notification-sanitize.json'); + $dir = dirname($path); + + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + @file_put_contents($path, json_encode([ + 'timestamp' => now()->toIso8601String(), + 'removed_items' => max(0, count($raw) - count($normalized)), + 'remaining_items' => count($normalized), + 'reason' => 'Filament notifications payload contained non-array items.', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + } catch (\Throwable $e) { + // Evita che una sessione corrotta blocchi il bootstrap dell'applicazione. + } + } } diff --git a/app/Services/Cti/LiveIncomingCallService.php b/app/Services/Cti/LiveIncomingCallService.php index e85fc44..38a5675 100644 --- a/app/Services/Cti/LiveIncomingCallService.php +++ b/app/Services/Cti/LiveIncomingCallService.php @@ -18,7 +18,7 @@ public function getForUser(User $user): ?array $watchedExtensions = app(PbxRoutingService::class)->getWatchedExtensionsForUser($user); $popupRestricted = (bool) ($user->pbx_popup_enabled ?? false) && count($watchedExtensions) > 0; - $latest = CommunicationMessage::query() + $candidates = CommunicationMessage::query() ->whereIn('channel', ['smdr', 'panasonic_csta']) ->where('direction', 'inbound') ->whereNotNull('received_at') @@ -37,8 +37,34 @@ public function getForUser(User $user): ?array ->when($popupRestricted, function ($query) use ($watchedExtensions): void { $query->whereIn('target_extension', $watchedExtensions); }) - ->latest('id') - ->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']); + ->latest('received_at') + ->limit(30) + ->get(['id', 'channel', 'status', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']); + + $latest = $candidates + ->sortByDesc(function (CommunicationMessage $message): int { + $metadata = is_array($message->metadata) ? $message->metadata : []; + $eventType = strtolower(trim((string) ($metadata['event_type'] ?? ''))); + $endedAt = trim((string) ($metadata['ended_at'] ?? '')); + $isActiveCsta = (string) $message->channel === 'panasonic_csta' + && (string) ($message->status ?? '') !== 'completed' + && $endedAt === '' + && ! in_array($eventType, ['connectioncleared', 'cleared', 'released'], true); + + if ($isActiveCsta) { + return 5000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); + } + + $isRecentCsta = (string) $message->channel === 'panasonic_csta' + && (int) ($message->received_at?->getTimestamp() ?? 0) >= now()->subSeconds(45)->getTimestamp(); + + if ($isRecentCsta) { + return 4000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); + } + + return 1000000000 + (int) ($message->received_at?->getTimestamp() ?? 0); + }) + ->first(); if (! $latest) { return null; diff --git a/app/Services/Cti/PbxRoutingService.php b/app/Services/Cti/PbxRoutingService.php index b0c04bd..4ced311 100644 --- a/app/Services/Cti/PbxRoutingService.php +++ b/app/Services/Cti/PbxRoutingService.php @@ -16,6 +16,13 @@ public function normalizeExtension(?string $extension): string return is_string($digits) ? $digits : ''; } + public function normalizeLineNumber(?string $line): string + { + $digits = preg_replace('/\D+/', '', (string) $line); + + return is_string($digits) ? $digits : ''; + } + /** * @return array{extension:string,user_id:int|null,stabile_id:int|null,user:?User,amministratore_id:int|null,studio_role:?string,studio_mode:?string} */ @@ -65,6 +72,117 @@ public function resolveByExtension(?string $extension): array ]; } + /** + * @return array{line:string,user_id:int|null,stabile_id:int|null,user:?User,amministratore_id:int|null,studio_role:?string,studio_mode:?string,target_extension:string,route_group:string,line_label:?string} + */ + public function resolveByIncomingLine(?string $line): array + { + $normalized = $this->normalizeLineNumber($line); + + $empty = [ + 'line' => $normalized, + 'user_id' => null, + 'stabile_id' => null, + 'user' => null, + 'amministratore_id' => null, + 'studio_role' => null, + 'studio_mode' => null, + 'target_extension' => '', + 'route_group' => '', + 'line_label' => null, + ]; + + if ($normalized === '') { + return $empty; + } + + foreach (Amministratore::query()->get(['id', 'impostazioni']) as $amministratore) { + $incomingLines = array_values(array_filter((array) (($amministratore->impostazioni ?? [])['pbx']['incoming_lines'] ?? []), fn($row): bool => is_array($row))); + + foreach ($incomingLines as $lineRow) { + if (! $this->matchesLineNumber((string) ($lineRow['number'] ?? ''), $normalized)) { + continue; + } + + $targetExtension = $this->normalizeExtension((string) ($lineRow['target_extension'] ?? '')); + $routeGroup = $this->normalizeExtension((string) ($lineRow['route_group'] ?? '')); + $targetRouting = $targetExtension !== '' + ? $this->resolveByExtension($targetExtension) + : ($routeGroup !== '' ? $this->resolveByExtension($routeGroup) : [ + 'user_id' => null, + 'stabile_id' => null, + 'user' => null, + ]); + + if (($targetRouting['user'] ?? null) === null) { + $mappedUser = $this->findUserByLineMapping($amministratore, $normalized); + if ($mappedUser instanceof User) { + $targetRouting = [ + 'user_id' => (int) $mappedUser->id, + 'stabile_id' => method_exists($mappedUser, 'stabiliAssegnati') ? ((int) ($mappedUser->stabiliAssegnati()->value('stabili.id') ?? 0) ?: null) : null, + 'user' => $mappedUser, + ]; + } + } + + return [ + 'line' => $normalized, + 'user_id' => (int) ($targetRouting['user_id'] ?? 0) ?: null, + 'stabile_id' => (int) ($targetRouting['stabile_id'] ?? 0) ?: null, + 'user' => $targetRouting['user'] ?? null, + 'amministratore_id' => (int) $amministratore->id, + 'studio_role' => 'linea', + 'studio_mode' => filled($lineRow['label'] ?? null) ? (string) $lineRow['label'] : null, + 'target_extension' => $targetExtension, + 'route_group' => $routeGroup, + 'line_label' => filled($lineRow['label'] ?? null) ? (string) $lineRow['label'] : null, + ]; + } + } + + return $empty; + } + + /** + * @return array{extension:string,line:string,user_id:int|null,stabile_id:int|null,user:?User,amministratore_id:int|null,studio_role:?string,studio_mode:?string,target_extension:string,route_group:string,line_label:?string} + */ + public function resolveBySmdr(?string $extension, ?string $line, ?string $direction): array + { + $normalizedDirection = strtolower(trim((string) $direction)); + $extensionRouting = $this->resolveByExtension($extension); + $lineRouting = $this->resolveByIncomingLine($line); + + if ($normalizedDirection === 'inbound' && ($lineRouting['amministratore_id'] ?? null) !== null) { + return [ + 'extension' => $extensionRouting['extension'], + 'line' => $lineRouting['line'], + 'user_id' => $lineRouting['user_id'] ?? $extensionRouting['user_id'], + 'stabile_id' => $lineRouting['stabile_id'] ?? $extensionRouting['stabile_id'], + 'user' => $lineRouting['user'] ?? $extensionRouting['user'], + 'amministratore_id' => $lineRouting['amministratore_id'], + 'studio_role' => $lineRouting['studio_role'], + 'studio_mode' => $lineRouting['studio_mode'], + 'target_extension' => $lineRouting['target_extension'], + 'route_group' => $lineRouting['route_group'], + 'line_label' => $lineRouting['line_label'], + ]; + } + + return [ + 'extension' => $extensionRouting['extension'], + 'line' => $lineRouting['line'], + 'user_id' => $extensionRouting['user_id'], + 'stabile_id' => $extensionRouting['stabile_id'], + 'user' => $extensionRouting['user'], + 'amministratore_id' => $extensionRouting['amministratore_id'] ?? $lineRouting['amministratore_id'], + 'studio_role' => $extensionRouting['studio_role'], + 'studio_mode' => $extensionRouting['studio_mode'] ?? $lineRouting['studio_mode'], + 'target_extension' => '', + 'route_group' => '', + 'line_label' => $lineRouting['line_label'], + ]; + } + /** * @return array */ @@ -230,4 +348,39 @@ private function resolveAmministratoreIdForUser(User $user): ?int return $amministratore ? (int) $amministratore->id : null; } + + private function matchesLineNumber(string $configuredLine, string $incomingLine): bool + { + $configured = $this->normalizeLineNumber($configuredLine); + if ($configured === '' || $incomingLine === '') { + return false; + } + + return $configured === $incomingLine || ltrim($configured, '0') === ltrim($incomingLine, '0'); + } + + private function findUserByLineMapping(Amministratore $amministratore, string $line): ?User + { + $mappings = (array) (($amministratore->impostazioni ?? [])['pbx']['user_mappings'] ?? []); + + foreach ($mappings as $userId => $mapping) { + if (! is_array($mapping)) { + continue; + } + + $lines = array_values(array_filter((array) ($mapping['lines'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); + foreach ($lines as $configuredLine) { + if (! $this->matchesLineNumber($configuredLine, $line)) { + continue; + } + + $user = User::query()->find((int) $userId); + if ($user instanceof User) { + return $user; + } + } + } + + return null; + } } diff --git a/app/Services/GesconImport/StabileEnrichmentService.php b/app/Services/GesconImport/StabileEnrichmentService.php index bc74a71..55cdcc1 100644 --- a/app/Services/GesconImport/StabileEnrichmentService.php +++ b/app/Services/GesconImport/StabileEnrichmentService.php @@ -50,7 +50,7 @@ public function enrich(string $legacyCode, string $basePath = '/mnt/gescon-archi // Catasto fields mapping heuristic (legacy → logical field) $mapCatasto = [ 'foglio' => ['foglio', 'ac_foglio', 'foglio_catasto'], - 'particella' => ['particella', 'ac_particella', 'mappale', 'catasto_particella'], + 'particella' => ['particella', 'ac_particella', 'ac_partic1', 'ac_partic_1', 'ac_particella1', 'ac_particella_1', 'mappale', 'catasto_particella'], 'subalterno' => ['sub', 'subalterno', 'ac_sub', 'ac_subalterno'], 'sezione' => ['sezione', 'ac_sezione', 'sez'], 'categoria' => ['categoria_catastale', 'categoria', 'cat_categoria'], diff --git a/bootstrap/app.php b/bootstrap/app.php index 35c9431..5aa93e2 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,9 +10,9 @@ use App\Console\Commands\GesconImportFattureAdeCsv; use App\Console\Commands\GesconImportFornitoriLegacyMdb; use App\Console\Commands\GesconMaterializePersoneRelazioniCommand; -use App\Console\Commands\GesconQaRubricaLegamiCommand; use App\Console\Commands\GesconQaContatori; use App\Console\Commands\GesconQaEmissione; +use App\Console\Commands\GesconQaRubricaLegamiCommand; use App\Console\Commands\GesconResyncStabili; use App\Console\Commands\GoogleImportKeepNotesCommand; use App\Console\Commands\GooglePushRubricaContactsCommand; diff --git a/database/migrations/2026_03_15_091500_add_assignment_fields_to_chiamate_post_it_table.php b/database/migrations/2026_03_15_091500_add_assignment_fields_to_chiamate_post_it_table.php index 773d64e..0b8423b 100644 --- a/database/migrations/2026_03_15_091500_add_assignment_fields_to_chiamate_post_it_table.php +++ b/database/migrations/2026_03_15_091500_add_assignment_fields_to_chiamate_post_it_table.php @@ -59,4 +59,4 @@ public function down(): void } }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_04_120000_create_support_update_entries_and_bug_registries.php b/database/migrations/2026_04_04_120000_create_support_update_entries_and_bug_registries.php new file mode 100644 index 0000000..304927c --- /dev/null +++ b/database/migrations/2026_04_04_120000_create_support_update_entries_and_bug_registries.php @@ -0,0 +1,48 @@ +id(); + $table->string('release_key')->unique(); + $table->string('category', 32)->index(); + $table->string('audience', 32)->default('supporto')->index(); + $table->string('title'); + $table->text('summary'); + $table->text('impact')->nullable(); + $table->string('page_label')->nullable(); + $table->string('page_class')->nullable(); + $table->string('related_path')->nullable(); + $table->string('related_commit', 40)->nullable()->index(); + $table->boolean('is_visible')->default(true)->index(); + $table->boolean('is_highlighted')->default(false); + $table->string('recorded_at', 40)->nullable()->index(); + $table->timestamps(); + }); + + Schema::create('support_bug_registries', function (Blueprint $table): void { + $table->id(); + $table->string('fingerprint', 64)->unique(); + $table->unsignedInteger('bug_id')->unique(); + $table->string('status', 20)->default('open')->index(); + $table->string('title')->nullable(); + $table->string('first_seen', 40)->nullable(); + $table->string('last_seen', 40)->nullable()->index(); + $table->string('resolved_at', 40)->nullable(); + $table->text('resolution_note')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('support_bug_registries'); + Schema::dropIfExists('support_update_entries'); + } +}; diff --git a/docs/CTI-WINDOWS-TAPI-PROBE.md b/docs/CTI-WINDOWS-TAPI-PROBE.md index ee77128..2a01070 100644 --- a/docs/CTI-WINDOWS-TAPI-PROBE.md +++ b/docs/CTI-WINDOWS-TAPI-PROBE.md @@ -175,7 +175,7 @@ ### Sequenza pratica consigliata - porta `33333` - nessun trasporto bloccato su `USB:PC` se il collegamento e IP -4. verifica che il centralino abbia licenza CTI attiva lato TSP/CSTA +1. verifica che il centralino abbia licenza CTI attiva lato TSP/CSTA 2. salva la configurazione e riavvia il tool Panasonic se richiesto 3. rilancia `get-netgescon-panasonic-tapi-info.ps1` 4. conferma che compaiano interni reali come `201`, `205`, `206` diff --git a/docs/support/CRM-ANAGRAFICA-LEGAMI.md b/docs/support/CRM-ANAGRAFICA-LEGAMI.md index 66abc8b..f159da4 100644 --- a/docs/support/CRM-ANAGRAFICA-LEGAMI.md +++ b/docs/support/CRM-ANAGRAFICA-LEGAMI.md @@ -67,4 +67,4 @@ ### Rubrica 3470 1. rubrica presente 2. unita candidata individuabile 3. manca la catena locale completa o il ruolo e ancora ambiguo -4. consentito solo aggiornare i riferimenti testuali se il caso resta univoco, ma non forzare sempre `rubrica_ruoli` \ No newline at end of file +4. consentito solo aggiornare i riferimenti testuali se il caso resta univoco, ma non forzare sempre `rubrica_ruoli` diff --git a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php index a5bc4d0..26371a3 100644 --- a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php +++ b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php @@ -469,6 +469,87 @@ Collegamenti
+ @if(empty($stabili) && !empty($collegamentoSuggerito)) +
+
Collegamento riconosciuto ma non ancora agganciato
+
+ La scheda contiene riferimenti a uno stabile coerente, ma manca ancora il legame strutturato che popola questo box. +
+
+
+
Stabile candidato
+
{{ $collegamentoSuggerito['stabile_label'] ?? '—' }}
+
+
+
Unità candidata
+
{{ $collegamentoSuggerito['unita_reference'] ?? 'Non rilevata' }}
+
+
+
Ruolo proposto
+
{{ $this::getRuoliLabels()[$collegamentoSuggerito['role_standard'] ?? 'altro'] ?? ($collegamentoSuggerito['role_standard'] ?? 'Altro') }}
+
+
+
+ + Aggancia collegamento suggerito + + + Apri editor completo ruoli / legami + +
+
+ @endif + +
+
Collegamento rapido a stabile / unità
+
+ Usa questo blocco per agganciare manualmente una nuova anagrafica a uno stabile del tenant attivo oppure per completare un record importato che oggi ha solo riferimenti testuali. +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + Salva collegamento + +
+
+
+
Fornitori (ambiente attivo)
@if(count($fornitori) > 0) diff --git a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php index 649d2f0..390e93c 100644 --- a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php +++ b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php @@ -131,9 +131,11 @@ - {{ $gruppo['phone'] }} @endif
- @php($rubricaUrlGrouped = $this->getRubricaUrlByPhone((string) $gruppo['phone'])) + @php($rubricaUrlGrouped = $gruppo['rubrica_url']) @if($rubricaUrlGrouped) Apri scheda rubrica + @elseif($gruppo['can_create_rubrica']) + @endif
{{ $gruppo['count'] }} chiamate @@ -143,15 +145,21 @@ Aperto dal {{ $gruppo['first_at'] }} al {{ $gruppo['last_at'] }} +
+ {{ (int) $gruppo['answered_count'] }} risposte + {{ (int) $gruppo['missed_count'] }} mancate risposte +
+
@foreach($gruppo['visible_items'] as $item) + @php($callState = $this->getPostItCallStateMeta($item))
{{ $item->oggetto ?: 'Senza oggetto' }}
{{ optional($item->chiamata_il)->format('d/m/Y H:i') }}
- {{ $item->stato }} + {{ $callState['label'] }}
@if($item->stabile) @@ -169,7 +177,10 @@
Vedi dettaglio - Apri Post-it + {{ $gruppo['primary_action_label'] }} + @if($gruppo['can_create_rubrica']) + Nuova anagrafica + @endif @if($box->ticket_id) Apri ticket @endif @@ -392,8 +403,19 @@
{{ $this->selectedGroupModal['caller_label'] }}@if($this->selectedGroupModal['phone'] !== '') - {{ $this->selectedGroupModal['phone'] }}@endif
{{ $this->selectedGroupModal['count'] }} chiamate totali · {{ $selectedGroupModalMode === 'closed' ? 'archivio chiusi' : 'lavagna aperti' }}
+
+ {{ (int) $this->selectedGroupModal['answered_count'] }} risposte + {{ (int) $this->selectedGroupModal['missed_count'] }} mancate risposte +
+
+
+ @if(($this->selectedGroupModal['rubrica_url'] ?? null)) + Apri scheda rubrica + @elseif($this->selectedGroupModal['can_create_rubrica']) + Crea anagrafica + @endif +
-
@@ -403,13 +425,14 @@
{{ $day['day'] }}
@foreach($day['items'] as $item) + @php($callState = $this->getPostItCallStateMeta($item))
{{ $item->oggetto ?: 'Senza oggetto' }}
{{ optional($item->chiamata_il)->format('d/m/Y H:i') }}
- {{ $item->stato }} + {{ $callState['label'] }}
@if($item->stabile) @@ -417,6 +440,10 @@ @endif
{{ $item->nota }}
+ +
@endforeach
diff --git a/resources/views/filament/pages/supporto/modifiche.blade.php b/resources/views/filament/pages/supporto/modifiche.blade.php index f1a8b58..1d5524c 100644 --- a/resources/views/filament/pages/supporto/modifiche.blade.php +++ b/resources/views/filament/pages/supporto/modifiche.blade.php @@ -169,28 +169,63 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
-
Aggiornamenti inclusi nel riallineamento Git
+
+
+
Anteprima prima della sync Git
+
Conteggio commit remoti, aree toccate e registro persistente delle modifiche gia strutturate.
+
+ + Ricalcola anteprima + +
+ +
+
+
Commit in arrivo
+
{{ (int) $this->pendingGitCommitCount }}
+
+
+
Registro DB visibile
+
{{ (int) $this->supportUpdateVisibleCount }}
+
+
+
Implementazioni
+
{{ (int) $this->supportImplementationCount }}
+
+
+
Fix + debug
+
{{ (int) ($this->supportBugfixCount + $this->supportDebugCount) }}
+
+
+
-
Ultimi commit
+
Commit remoti non ancora allineati
+ @if(filled($this->pendingGitPreviewIssue)) +
{{ $this->pendingGitPreviewIssue }}
+ @endif
    - @forelse(array_slice($this->latestCommits, 0, 5) as $commit) + @forelse($this->pendingGitCommits as $commit)
  • {{ $commit['short_hash'] }} {{ $commit['message'] }} +
    {{ $commit['date'] }} · {{ $commit['author'] }}
  • @empty -
  • Nessun commit disponibile.
  • +
  • Il nodo risulta gia allineato al branch remoto.
  • @endforelse
-
Migliorie/fix rilevati
-
    - @forelse(array_slice($this->functionalHighlights, 0, 6) as $item) -
  • {{ $item }}
  • +
    Pagine e aree toccate dai commit in arrivo
    +
      + @forelse($this->pendingGitChangedAreas as $item) +
    • +
      {{ $item['label'] }}
      +
      {{ $item['area'] }} · {{ $item['path'] }}
      +
    • @empty -
    • Nessuna miglioria estratta automaticamente.
    • +
    • Nessuna area Filament/supporto modificata nei commit remoti in attesa.
    • @endforelse
@@ -198,6 +233,171 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
+
+
+
+
Registro aggiornamenti persistente in DB
+
Implementazioni, fix e debug registrati in tabella applicativa con pagina impattata e commit di riferimento.
+
+
+ {{ (int) $this->supportUpdateVisibleCount }} voci visibili +
+
+ +
+
+
+
Editor voci aggiornamento
+ + Nuova voce + +
+ +
+ + +
+ + + + + + + +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + Salva voce registro + + + Reset form + +
+
+ +
+
Come usarlo prima di Git e staging
+
    +
  1. registra qui implementazioni, fix o debug dell ultimo blocco di lavoro
  2. +
  3. collega, se la conosci, la pagina UI e il commit previsto o gia creato
  4. +
  5. usa il conteggio commit remoti per controllare se lo staging e davvero indietro
  6. +
  7. poi fai il push Git e usa questa stessa tab per la sync da Gitea
  8. +
+ + @if($this->supportUpdateFormId) +
+ Stai modificando la voce ID {{ $this->supportUpdateFormId }}. Se vuoi inserirne una nuova, premi “Nuova voce”. +
+ @endif +
+
+ +
+ @forelse(array_slice($this->supportUpdateEntries, 0, 8) as $entry) +
+
+
{{ $entry['title'] }}
+ {{ $entry['category'] }} +
+
{{ $entry['summary'] }}
+ @if($entry['impact'] !== '') +
{{ $entry['impact'] }}
+ @endif +
+ @if($entry['page_label'] !== '') + Pagina: {{ $entry['page_label'] }} + @endif + @if($entry['related_commit'] !== '') + Commit: {{ $entry['related_commit'] }} + @endif + @if($entry['recorded_at'] !== '') + Registrato: {{ $entry['recorded_at'] }} + @endif +
+ @if($entry['related_path'] !== '' || $entry['page_url']) +
+ @if($entry['page_url']) + Apri pagina + @endif + @if($entry['related_path'] !== '') + {{ $entry['related_path'] }} + @endif +
+ @endif +
+ + Modifica + + + {{ $entry['is_visible'] ? 'Nascondi' : 'Mostra' }} + +
+
+ @empty +
Registro DB non ancora disponibile: esegui le migration del nuovo support registry su questo nodo.
+ @endforelse +
+
+
Aggiornamento staging da Gitea
@@ -528,19 +728,64 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
-
-
Nota risoluzione BUG
-
- BUG selezionato: {{ $this->selectedBugFingerprint ? 'selezionato' : 'nessuno' }} + @if($this->highlightedBug) +
+
+
+
BUG in evidenza
+
{{ $this->highlightedBug['bug_code'] }}
+
+
+ Occorrenze {{ (int) ($this->highlightedBug['occurrences'] ?? 1) }} + {{ ($this->highlightedBug['bug_status'] ?? 'open') === 'resolved' ? 'risolto' : 'aperto' }} +
+
+ +
+
Messaggio
+
{{ $this->highlightedBug['message'] }}
+
+ +
+
+
Contesto
+
{{ $this->highlightedBug['context'] !== '' ? $this->highlightedBug['context'] : '-' }}
+
+
+
Nota risoluzione
+ +
+
+ + @if(filled($this->highlightedBugHint)) +
{{ $this->highlightedBugHint }}
+ @endif + + @if($this->lastNotificationSanitization) +
+ Ultima sanificazione notifiche Filament: {{ $this->lastNotificationSanitization['timestamp'] ?? '-' }} + · rimossi {{ (int) ($this->lastNotificationSanitization['removed_items'] ?? 0) }} + · rimasti {{ (int) ($this->lastNotificationSanitization['remaining_items'] ?? 0) }} +
+ @endif + +
+ Seleziona BUG + Salva nota risoluzione + @if(($this->highlightedBug['bug_status'] ?? 'open') === 'resolved') + Riapri + @else + Segna risolto + @endif +
- -
- Salva nota risoluzione + @else +
+ Nessun BUG da mettere in evidenza.
-
+ @endif
@@ -561,7 +806,7 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t @forelse($this->runtimeErrors as $row) - + {{ $row['bug_code'] }} @if(($row['bug_status'] ?? 'open') === 'resolved') @@ -573,7 +818,9 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t {{ $row['timestamp'] }} {{ $row['source'] ?? '-' }} {{ $row['type'] }} - {{ (int) ($row['occurrences'] ?? 1) }} + + {{ (int) ($row['occurrences'] ?? 1) }} + {{ $row['message'] }}
{{ $row['context'] !== '' ? \Illuminate\Support\Str::limit($row['context'], 220) : '-' }}
@@ -585,7 +832,7 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t @endif - Nota + Apri nota @if(($row['bug_status'] ?? 'open') === 'resolved') Riapri @else diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index b788728..fb77bee 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -246,6 +246,11 @@