diff --git a/package-lock.json b/package-lock.json index c495cf0..c562289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -640,6 +640,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -6944,6 +6945,7 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -9576,6 +9578,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index 8a75c81..89eb1e0 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -29,6 +29,24 @@ +
+
+ + Filtro de Operadora +
+
+ +
+
+
{{ k.title }} {{ k.value }} - {{ k.hint }}
@@ -66,7 +83,12 @@
-
+
@@ -107,7 +129,12 @@
-
+
@@ -143,7 +170,12 @@

Status de vencimento atual

-
+
@@ -156,7 +188,12 @@

Linhas com e sem serviço ativo

-
+
@@ -170,7 +207,12 @@

Distribuição da base por faixa de franquia

-
+
@@ -182,7 +224,12 @@

Quantidade de linhas por serviço adicional ativo

-
+
@@ -194,13 +241,65 @@

Quantidade de linhas e-SIM e SIMCARD

-
+
+
+
+

Comparativo VIVO

+

Comparação entre contas da operadora VIVO: MACROPHONY x LINE MÓVEL.

+
+ +
+
+
+
+

Linhas por Empresa

+

Volume total de linhas VIVO por empresa.

+
+
+
+ +
+
+ +
+
+
+

Adicionais por Empresa

+

Comparação de linhas com e sem adicionais pagos.

+
+
+
+ +
+
+
+ +
+ Não há linhas VIVO vinculadas às empresas MACROPHONY ou LINE MÓVEL para o filtro atual. +
+
+

Página Resumo

Indicadores do Resumo focados em quantidade e distribuição de linhas.

@@ -247,22 +346,50 @@
Top Clientes (Qtd. Linhas)
-
+
+ +
Top Planos (Qtd. Linhas)
-
+
+ +
PF vs PJ (Qtd. Linhas)
-
+
+ +
Reserva por DDD
-
+
+ +
@@ -301,7 +428,12 @@

Histórico mensal de mudanças de plano/aparelho

-
+
@@ -313,7 +445,12 @@

Histórico mensal de trocas realizadas

-
+
@@ -326,7 +463,12 @@

Contratos a encerrar por mês

-
+
@@ -352,7 +494,12 @@
-
+
@@ -373,7 +520,12 @@

Quantidade de linhas por faixa de franquia contratada

-
+
@@ -388,7 +540,12 @@

Planos com maior volume na sua operação

-
+
@@ -401,7 +558,12 @@

Apenas usuários de fato (sem bloqueados/aguardando)

-
+
@@ -414,7 +576,12 @@

Distribuição entre e-SIM, SIMCARD e outros

-
+
@@ -441,5 +608,54 @@ + + diff --git a/src/app/pages/dashboard/dashboard.scss b/src/app/pages/dashboard/dashboard.scss index 9b1c902..e1076ec 100644 --- a/src/app/pages/dashboard/dashboard.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -98,6 +98,72 @@ @media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; } } +.operadora-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -8px 0 22px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow-sm); + + @media (max-width: 840px) { + flex-direction: column; + align-items: stretch; + } +} + +.operadora-filter-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + font-weight: 700; + letter-spacing: 0.02em; + + i { + color: var(--brand); + } +} + +.filter-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-tab { + border: 1px solid rgba(15, 23, 42, 0.15); + background: #fff; + color: var(--text-muted); + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgba(227, 61, 207, 0.45); + color: var(--brand); + } + + &.active { + background: var(--brand-soft); + border-color: rgba(227, 61, 207, 0.45); + color: var(--brand); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + .badge-pill { display: inline-flex; align-items: center; @@ -392,6 +458,22 @@ &.compact-half { height: 200px; } } +.chart-click-target { + cursor: zoom-in; + border-radius: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 10px 18px -16px rgba(17, 18, 20, 0.65); + } + + &:focus-visible { + outline: 2px solid rgba(3, 15, 170, 0.26); + outline-offset: 2px; + } +} + .card-adicionais .card-body-adicionais { padding: 14px 16px 12px; display: grid; @@ -630,7 +712,221 @@ @media(max-width: 1080px) { grid-template-columns: 1fr; } } +.vivo-comparison-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + + @media (max-width: 980px) { + grid-template-columns: 1fr; + } +} + +.vivo-comparison-empty { + margin-top: 10px; + padding: 12px 14px; + border-radius: 10px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.28); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} + +.chart-modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(10, 14, 35, 0.58); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + padding: 28px 20px; +} + +.chart-modal-card { + width: min(1120px, 96vw); + max-height: min(86vh, 860px); + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 18px; + box-shadow: 0 30px 70px -26px rgba(2, 8, 23, 0.65); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalChartIn 0.22s ease; +} + +.chart-modal-header { + padding: 14px 18px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.chart-modal-title-wrap { + h3 { + margin: 0; + font-size: 16px; + font-weight: 800; + color: var(--text-main); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-muted); + font-weight: 600; + } +} + +.chart-modal-close { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #fff; + color: rgba(17, 18, 20, 0.7); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: rgba(227, 61, 207, 0.35); + color: var(--brand); + background: var(--brand-soft); + } +} + +.chart-modal-body { + position: relative; + height: min(72vh, 680px); + min-height: 360px; + padding: 14px 16px 16px; +} + +.chart-modal-content { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr); + gap: 14px; + height: 100%; +} + +.chart-modal-visual { + position: relative; + min-height: 0; +} + +.chart-modal-info { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 12px; + background: #f8fafc; + overflow: auto; + display: flex; + flex-direction: column; +} + +.chart-modal-info-head, +.chart-modal-info-row { + display: grid; + grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(78px, 1fr)); + gap: 10px; + padding: 10px 12px; + align-items: center; +} + +.chart-modal-info-head { + position: sticky; + top: 0; + z-index: 1; + background: #eef2ff; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + font-size: 11px; + font-weight: 800; + color: #334155; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.chart-modal-info-row { + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + font-size: 12px; + color: #0f172a; + font-weight: 600; + background: #fff; + + &:nth-child(odd) { + background: #fdfdff; + } +} + +.chart-modal-info .col-label { + text-align: left; + word-break: break-word; +} + +.chart-modal-info .col-value { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.chart-modal-info-total { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + border-top: 1px solid rgba(15, 23, 42, 0.08); + padding: 10px 12px 12px; + background: #f1f5f9; + font-size: 11px; + font-weight: 700; + color: #334155; +} + +@keyframes modalChartIn { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + /* Utils */ .text-brand { color: var(--brand); } .text-brand-dark { color: #b832a8; } .full-width { width: 100%; } + +@media (max-width: 760px) { + .chart-modal-overlay { + padding: 16px 10px; + } + + .chart-modal-card { + width: 100%; + border-radius: 14px; + } + + .chart-modal-body { + min-height: 300px; + height: min(72vh, 620px); + padding: 10px 10px 12px; + } + + .chart-modal-content { + grid-template-columns: 1fr; + grid-template-rows: minmax(200px, 1fr) minmax(140px, auto); + } + + .chart-modal-info-head, + .chart-modal-info-row { + grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(72px, 1fr)); + } +} diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index 31ce174..b9a878e 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -6,6 +6,7 @@ import { ViewChild, ElementRef, Inject, + HostListener, } from '@angular/core'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; @@ -22,6 +23,13 @@ import { } from '../../services/resumo.service'; import { AuthService } from '../../services/auth.service'; import { buildApiBaseUrl } from '../../utils/api-base.util'; +import { + type AccountCompanyOption, + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + type OperadoraFiltro, + resolveOperadoraContext, +} from '../../utils/account-operator.util'; // --- Interfaces (Mantidas intactas para não quebrar contrato) --- type KpiCard = { @@ -162,12 +170,18 @@ type DashboardGeralInsightsDto = { }; type DashboardLineListItemDto = { + id?: string | null; + conta?: string | null; + contaEmpresa?: string | null; + empresaConta?: string | null; linha?: string | null; cliente?: string | null; usuario?: string | null; skil?: string | null; planoContrato?: string | null; status?: string | null; + vencConta?: string | null; + franquiaVivo?: number | null; franquiaLine?: number | null; gestaoVozDados?: number | null; skeelo?: number | null; @@ -212,6 +226,61 @@ type ClientDashboardOverview = { outrosStatus: number; }; +type DashboardHistoryEvent = { + mobileLineId: string; + linhaAntiga: string; + linhaNova: string; + date: Date | null; +}; + +type DashboardVivoComparison = { + macrophonyLinhas: number; + lineMovelLinhas: number; + macrophonyComAdicionais: number; + macrophonySemAdicionais: number; + lineMovelComAdicionais: number; + lineMovelSemAdicionais: number; +}; + +type DashboardChartModalKey = + | 'status' + | 'adicionaisComparativo' + | 'vigenciaBuckets' + | 'travel' + | 'linhasFranquia' + | 'adicionaisPagos' + | 'tipoChip' + | 'vivoEmpresasLinhas' + | 'vivoEmpresasAdicionais' + | 'resumoTopClientes' + | 'resumoTopPlanos' + | 'resumoPfPj' + | 'resumoReservaDdd' + | 'mureg12' + | 'troca12' + | 'vigenciaMesAno'; + +type DashboardChartModalMeta = { + title: string; + subtitle?: string; +}; + +type DashboardChartModalInfoCell = { + dataset: string; + valueText: string; + numericValue: number | null; +}; + +type DashboardChartModalInfoRow = { + label: string; + cells: DashboardChartModalInfoCell[]; +}; + +type DashboardChartModalDatasetTotal = { + dataset: string; + totalText: string; +}; + @Component({ selector: 'app-dashboard', standalone: true, @@ -235,10 +304,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { @ViewChild('chartResumoTopPlanos') chartResumoTopPlanos?: ElementRef; @ViewChild('chartResumoTopClientes') chartResumoTopClientes?: ElementRef; @ViewChild('chartResumoReservaDdd') chartResumoReservaDdd?: ElementRef; + @ViewChild('chartVivoEmpresasLinhas') chartVivoEmpresasLinhas?: ElementRef; + @ViewChild('chartVivoEmpresasAdicionais') chartVivoEmpresasAdicionais?: ElementRef; + @ViewChild('chartExpandedCanvas') chartExpandedCanvas?: ElementRef; loading = true; errorMsg: string | null = null; isCliente = false; + operadoraFilter: OperadoraFiltro = 'TODOS'; + operadoraFilterLoading = false; + readonly operadoraFilters: Array<{ label: string; value: OperadoraFiltro }> = [ + { label: 'TODOS', value: 'TODOS' }, + { label: 'VIVO', value: 'VIVO' }, + { label: 'CLARO', value: 'CLARO' }, + { label: 'TIM', value: 'TIM' }, + ]; kpis: KpiCard[] = []; @@ -334,6 +414,20 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { pjLinhas: null, totalLinhas: null, }; + vivoComparison: DashboardVivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; + chartModalOpen = false; + chartModalTitle = ''; + chartModalSubtitle = ''; + chartModalInfoRows: DashboardChartModalInfoRow[] = []; + chartModalDatasetHeaders: string[] = []; + chartModalDatasetTotals: DashboardChartModalDatasetTotal[] = []; private viewReady = false; private dataReady = false; @@ -358,6 +452,44 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private chartResumoPlanos?: Chart; private chartResumoClientes?: Chart; private chartResumoReserva?: Chart; + private chartVivoComparacaoLinhas?: Chart; + private chartVivoComparacaoAdicionais?: Chart; + private chartExpanded?: Chart; + private chartModalKey: DashboardChartModalKey | null = null; + private bodyOverflowBeforeChartModal: string | null = null; + private readonly chartModalMeta: Record = { + status: { title: 'Status da Base', subtitle: 'Distribuição atual das linhas' }, + adicionaisComparativo: { title: 'Serviços Adicionais', subtitle: 'Comparativo entre linhas com e sem adicionais' }, + vigenciaBuckets: { title: 'Vigência (Buckets)', subtitle: 'Status de vencimento atual' }, + travel: { title: 'Vivo Travel', subtitle: 'Linhas com e sem serviço ativo' }, + linhasFranquia: { title: 'Linhas por Franquia', subtitle: 'Distribuição por faixa de franquia' }, + adicionaisPagos: { title: 'Adicionais Pagos (Serviços)', subtitle: 'Quantidade por serviço adicional ativo' }, + tipoChip: { title: 'Tipo de Chip', subtitle: 'Distribuição entre e-SIM, SIMCARD e outros' }, + vivoEmpresasLinhas: { title: 'Comparativo VIVO: Linhas por Empresa' }, + vivoEmpresasAdicionais: { title: 'Comparativo VIVO: Adicionais por Empresa' }, + resumoTopClientes: { title: 'Resumo: Top Clientes' }, + resumoTopPlanos: { title: 'Resumo: Top Planos' }, + resumoPfPj: { title: 'Resumo: PF vs PJ' }, + resumoReservaDdd: { title: 'Resumo: Reserva por DDD' }, + mureg12: { title: 'MUREG (12 Meses)' }, + troca12: { title: 'Troca de Número (12 Meses)' }, + vigenciaMesAno: { title: 'Vigência (Próx. 12 Meses)' }, + }; + + private dashboardApiCache: DashboardDto | null = null; + private insightsApiCache: DashboardGeralInsightsDto | null = null; + private resumoApiCache: ResumoResponse | null = null; + private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({ + empresa: group.empresa, + contas: [...group.contas], + })); + private accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; + private operatorLinesCache: DashboardLineListItemDto[] = []; + private operatorMuregCache: DashboardHistoryEvent[] = []; + private operatorTrocaCache: DashboardHistoryEvent[] = []; + private filteredLinesCache: DashboardLineListItemDto[] = []; + private operatorDatasetReady = false; + private lineFranquiaCacheById = new Map(); private readonly baseApi: string; private readonly kpiNavigationMap: Record = { @@ -401,9 +533,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return; } + void this.loadAccountCompaniesForFilters(); this.loadDashboard(); this.loadInsights(); this.loadResumoExecutive(); + void this.preloadOperatorDatasets().catch(() => { + this.operatorDatasetReady = false; + }); } ngAfterViewInit(): void { @@ -413,10 +549,69 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.closeChartModal(); this.destroyCharts(); this.destroyResumoCharts(); } + get isOperadoraFiltroAtivo(): boolean { + return !this.isCliente && this.operadoraFilter !== 'TODOS'; + } + + get showVivoComparison(): boolean { + return this.isOperadoraFiltroAtivo && this.operadoraFilter === 'VIVO'; + } + + onOperadoraFilterChange(filter: OperadoraFiltro): void { + if (this.operadoraFilter === filter || this.isCliente) return; + this.operadoraFilter = filter; + void this.applyOperadoraFilter(); + } + + @HostListener('document:keydown.escape', ['$event']) + onEscapeKey(event: Event): void { + if (!this.chartModalOpen) return; + if (event instanceof KeyboardEvent) { + event.preventDefault(); + } + this.closeChartModal(); + } + + onChartTargetKeydown(event: Event, key: DashboardChartModalKey): void { + if (!(event instanceof KeyboardEvent)) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + this.openChartModal(key); + } + + openChartModal(key: DashboardChartModalKey): void { + if (!isPlatformBrowser(this.platformId)) return; + + const sourceChart = this.getChartInstanceByModalKey(key); + if (!sourceChart) return; + + this.chartModalKey = key; + this.chartModalTitle = this.chartModalMeta[key]?.title ?? 'Gráfico'; + this.chartModalSubtitle = this.chartModalMeta[key]?.subtitle ?? ''; + this.updateChartModalInfo(sourceChart); + this.chartModalOpen = true; + this.lockBodyScrollForChartModal(); + + requestAnimationFrame(() => this.renderExpandedChart(sourceChart)); + } + + closeChartModal(): void { + this.chartModalOpen = false; + this.chartModalKey = null; + this.chartModalTitle = ''; + this.chartModalSubtitle = ''; + this.chartModalInfoRows = []; + this.chartModalDatasetHeaders = []; + this.chartModalDatasetTotals = []; + this.destroyExpandedChart(); + this.restoreBodyScrollForChartModal(); + } + private async loadDashboard() { this.loading = true; this.errorMsg = null; @@ -424,13 +619,17 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { try { const dto = await this.fetchDashboardReal(); - this.applyDto(dto); + this.dashboardApiCache = dto; + if (this.operadoraFilter === 'TODOS') { + this.applyDto(dto); + } this.dataReady = true; this.loading = false; this.tryBuildCharts(); } catch (error) { + this.dashboardApiCache = null; this.loading = false; this.dashboardRaw = null; this.kpis = []; @@ -674,6 +873,14 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoClientesValues = []; this.resumoReservaLabels = []; this.resumoReservaValues = []; + this.vivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; this.rebuildPrimaryKpis(); this.destroyCharts(); this.destroyResumoCharts(); @@ -700,15 +907,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const url = `${this.baseApi}/dashboard/geral/insights`; this.http.get(url).subscribe({ next: (dto) => { - this.applyInsights(dto || null); + this.insightsApiCache = dto || null; + if (this.operadoraFilter === 'TODOS') { + this.applyInsights(dto || null); + } this.insightsLoading = false; this.tryBuildCharts(); }, error: () => { + this.insightsApiCache = null; this.insightsLoading = false; this.insightsError = 'Falha nos insights.'; - this.clearInsightsData(); - void this.loadFallbackFromLinesIfNeeded(true); + if (this.operadoraFilter === 'TODOS') { + this.clearInsightsData(); + void this.loadFallbackFromLinesIfNeeded(true); + } }, }); } @@ -721,18 +934,28 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoService.getResumo().subscribe({ next: (dto) => { - this.resumo = dto ? this.normalizeResumo(dto) : null; + this.resumoApiCache = dto ? this.normalizeResumo(dto) : null; + if (this.operadoraFilter === 'TODOS') { + this.resumo = this.resumoApiCache; + } this.resumoLoading = false; this.resumoReady = true; - this.buildResumoDerived(); + if (this.operadoraFilter === 'TODOS') { + this.buildResumoDerived(); + } this.tryBuildResumoCharts(); }, error: () => { + this.resumoApiCache = null; this.resumoLoading = false; this.resumoError = 'Falha ao carregar dados do resumo.'; - this.resumo = null; + if (this.operadoraFilter === 'TODOS') { + this.resumo = null; + } this.resumoReady = false; - this.clearResumoDerived(); + if (this.operadoraFilter === 'TODOS') { + this.clearResumoDerived(); + } }, }); } @@ -742,14 +965,832 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { void this.loadClientDashboardData(); return; } + if (this.isOperadoraFiltroAtivo) { + this.applyOperadoraDerivedState(this.filteredLinesCache); + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + return; + } this.buildResumoDerived(); this.tryBuildResumoCharts(); } - private async fetchDashboardReal(): Promise { + private async fetchDashboardReal(operadora: OperadoraFiltro = 'TODOS'): Promise { if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado'); const url = `${this.baseApi}/relatorios/dashboard`; - return await firstValueFrom(this.http.get(url)); + let params = new HttpParams(); + if (operadora !== 'TODOS') { + params = params.set('operadora', operadora); + } + + return await firstValueFrom(this.http.get(url, { params })); + } + + private async fetchInsightsReal(operadora: OperadoraFiltro = 'TODOS'): Promise { + if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado'); + const url = `${this.baseApi}/dashboard/geral/insights`; + let params = new HttpParams(); + if (operadora !== 'TODOS') { + params = params.set('operadora', operadora); + } + + return await firstValueFrom(this.http.get(url, { params })); + } + + private async applyOperadoraFilter(): Promise { + if (this.isCliente) return; + + if (this.operadoraFilter === 'TODOS') { + this.operadoraFilterLoading = false; + this.restoreDashboardFromApiCaches(); + return; + } + + this.operadoraFilterLoading = true; + this.loading = true; + this.resumoLoading = true; + this.errorMsg = null; + this.resumoError = null; + let filtered: DashboardLineListItemDto[] = []; + + try { + await this.preloadOperatorDatasets(); + filtered = this.resolveLinesByOperadora(this.operadoraFilter); + this.applyOperadoraDerivedState(filtered); + const [filteredDashboard, filteredInsights] = await Promise.all([ + this.fetchDashboardReal(this.operadoraFilter), + this.fetchInsightsReal(this.operadoraFilter), + ]); + this.applyDto(filteredDashboard); + this.applyInsights(filteredInsights); + this.insightsError = null; + this.loading = false; + this.resumoLoading = false; + this.dataReady = true; + this.resumoReady = true; + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + } catch { + if (filtered.length > 0) { + this.applyOperadoraDerivedState(filtered, { updateCoreMetrics: true }); + this.dataReady = true; + this.resumoReady = true; + } else { + this.dataReady = false; + this.resumoReady = false; + } + this.loading = false; + this.resumoLoading = false; + this.errorMsg = 'Falha ao aplicar filtro de operadora.'; + } finally { + this.operadoraFilterLoading = false; + } + } + + private restoreDashboardFromApiCaches(): void { + this.loading = false; + this.resumoLoading = false; + this.errorMsg = null; + this.resumoError = null; + + if (this.dashboardApiCache) { + this.applyDto(this.dashboardApiCache); + } else { + void this.loadDashboard(); + } + + if (this.insightsApiCache) { + this.applyInsights(this.insightsApiCache); + } else { + this.loadInsights(); + } + + if (this.resumoApiCache) { + this.resumo = this.normalizeResumo(this.resumoApiCache); + this.resumoReady = true; + this.buildResumoDerived(); + this.tryBuildResumoCharts(); + } else { + this.loadResumoExecutive(); + } + + this.dataReady = true; + this.tryBuildCharts(); + } + + private async loadAccountCompaniesForFilters(): Promise { + try { + const data = await firstValueFrom( + this.http.get(`${this.baseApi}/lines/account-companies`) + ); + const normalized = this.normalizeAccountCompanies(data); + const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies; + this.accountCompanies = mergeAccountCompaniesWithDefaults(source); + } catch { + this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies); + } + + if (this.isOperadoraFiltroAtivo && this.operatorDatasetReady) { + const filtered = this.resolveLinesByOperadora(this.operadoraFilter); + this.applyOperadoraDerivedState(filtered); + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + } + } + + private normalizeAccountCompanies( + data: AccountCompanyOption[] | null | undefined + ): AccountCompanyOption[] { + if (!Array.isArray(data)) return []; + + const result: AccountCompanyOption[] = []; + data.forEach((item) => { + const empresa = String(item?.empresa ?? '').trim(); + if (!empresa) return; + + const contas = Array.isArray(item?.contas) + ? Array.from(new Set(item.contas.map((x) => String(x ?? '').trim()).filter(Boolean))) + : []; + + result.push({ empresa, contas }); + }); + + return result; + } + + private async preloadOperatorDatasets(): Promise { + if (this.operatorDatasetReady) return; + + const [lines, muregs, trocas] = await Promise.all([ + this.fetchAllOperatorLines(), + this.fetchAllHistoryEvents('mureg', ['dataDaMureg', 'data_da_mureg', 'DataDaMureg']), + this.fetchAllHistoryEvents('trocanumero', ['dataTroca', 'data_troca', 'DataTroca', 'dataDaTroca']), + ]); + + this.operatorLinesCache = lines; + this.operatorMuregCache = muregs; + this.operatorTrocaCache = trocas; + this.operatorDatasetReady = true; + } + + private async fetchAllOperatorLines(): Promise { + const [allLines, reservaLines] = await Promise.all([ + this.fetchAllDashboardLines(false), + this.fetchAllDashboardLines(true), + ]); + + const merged = [...allLines, ...reservaLines]; + const dedup = new Map(); + + merged.forEach((line, idx) => { + const key = this.getLineDedupKey(line, idx); + if (!key) return; + if (!dedup.has(key)) dedup.set(key, line); + }); + + return Array.from(dedup.values()); + } + + private getLineDedupKey(line: DashboardLineListItemDto, index: number): string { + const lineId = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (lineId) return `id:${lineId}`; + + const linha = this.normalizeLineDigits(this.readNode(line as any, 'linha', 'Linha')); + const conta = this.readNode(line as any, 'conta', 'Conta'); + const normalizedConta = String(conta ?? '').trim(); + if (linha || normalizedConta) return `line:${linha}|conta:${normalizedConta}`; + + return `idx:${index}`; + } + + private async ensureFranquiaCacheForLines(lines: DashboardLineListItemDto[]): Promise { + if (!Array.isArray(lines) || lines.length === 0) return; + + const idsToLoad: string[] = []; + + lines.forEach((line) => { + const id = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (!id || this.lineFranquiaCacheById.has(id)) return; + + const franquiaVivoFromList = this.toNumberOrNull( + this.readLineRawField(line, 'franquiaVivo', 'FranquiaVivo') + ); + const franquiaLineFromList = this.toNumberOrNull( + this.readLineRawField(line, 'franquiaLine', 'FranquiaLine') + ); + + if (franquiaVivoFromList !== null || franquiaLineFromList !== null) { + this.lineFranquiaCacheById.set(id, { + franquiaVivo: franquiaVivoFromList, + franquiaLine: franquiaLineFromList, + }); + return; + } + + idsToLoad.push(id); + }); + + if (!idsToLoad.length) return; + + const concurrency = Math.min(20, idsToLoad.length); + let cursor = 0; + + const workers = Array.from({ length: concurrency }, async () => { + while (true) { + const index = cursor++; + if (index >= idsToLoad.length) break; + const id = idsToLoad[index]; + await this.loadFranquiaFromLineDetail(id); + } + }); + + await Promise.all(workers); + } + + private async loadFranquiaFromLineDetail(id: string): Promise { + try { + const detail = await firstValueFrom(this.http.get(`${this.baseApi}/lines/${id}`)); + + const franquiaVivo = this.toNumberOrNull( + this.readUnknownNumericField(detail, 'franquiaVivo', 'FranquiaVivo') + ); + const franquiaLine = this.toNumberOrNull( + this.readUnknownNumericField(detail, 'franquiaLine', 'FranquiaLine') + ); + + this.lineFranquiaCacheById.set(id, { + franquiaVivo, + franquiaLine, + }); + } catch { + this.lineFranquiaCacheById.set(id, { + franquiaVivo: null, + franquiaLine: null, + }); + } + } + + private async fetchAllHistoryEvents( + endpoint: string, + dateKeys: string[] + ): Promise { + const pageSize = 2000; + let page = 1; + const events: DashboardHistoryEvent[] = []; + + while (page <= 500) { + const params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom(this.http.get(`${this.baseApi}/${endpoint}`, { params })); + const itemsRaw = this.readNode(response, 'items', 'Items'); + const items = Array.isArray(response) + ? response + : (Array.isArray(itemsRaw) ? itemsRaw : []); + + events.push(...items.map((row: any) => this.mapHistoryEventRow(row, dateKeys))); + + if (Array.isArray(response)) break; + + const total = this.toNumberOrNull(this.readNode(response, 'total', 'Total')); + if (!items.length) break; + if (total !== null && events.length >= total) break; + if (items.length < pageSize) break; + page += 1; + } + + return events; + } + + private mapHistoryEventRow(row: any, dateKeys: string[]): DashboardHistoryEvent { + return { + mobileLineId: String(this.readNode(row, 'mobileLineId', 'MobileLineId', 'mobile_line_id') ?? '').trim(), + linhaAntiga: String(this.readNode(row, 'linhaAntiga', 'LinhaAntiga', 'linha_antiga') ?? ''), + linhaNova: String(this.readNode(row, 'linhaNova', 'LinhaNova', 'linha_nova') ?? ''), + date: this.parseDateValue(this.readNode(row, ...dateKeys)), + }; + } + + private resolveLinesByOperadora(filter: OperadoraFiltro): DashboardLineListItemDto[] { + if (filter === 'TODOS') return [...this.operatorLinesCache]; + + return this.operatorLinesCache.filter((line) => { + const conta = this.readNode(line as any, 'conta', 'Conta'); + const empresaConta = this.readNode( + line as any, + 'contaEmpresa', + 'ContaEmpresa', + 'empresaConta', + 'EmpresaConta', + 'empresa_conta', + 'Empresa_Conta', + 'empresa (conta)', + 'EMPRESA (CONTA)', + 'empresa', + 'Empresa' + ); + const operadora = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }).operadora; + return operadora === filter; + }); + } + + private applyOperadoraDerivedState( + lines: DashboardLineListItemDto[], + options?: { updateCoreMetrics?: boolean } + ): void { + this.filteredLinesCache = [...lines]; + const shouldUpdateCoreMetrics = options?.updateCoreMetrics === true; + + const now = new Date(); + const clientesSet = new Set(); + const usuariosSet = new Set(); + const planoMap = new Map(); + const clienteMap = new Map(); + const franquiaBandMap = new Map(); + const reservaDddMap = new Map(); + + const additionalCounts = { + gvd: 0, + skeelo: 0, + news: 0, + travel: 0, + sync: 0, + dispositivo: 0, + }; + + let totalLinhas = 0; + let ativas = 0; + let bloqueadas = 0; + let reservas = 0; + let perdaRoubo = 0; + let bloq120 = 0; + let outras = 0; + let franquiaVivoTotalGb = 0; + let franquiaLineTotalGb = 0; + let pfLinhas = 0; + let pjLinhas = 0; + let eSim = 0; + let simCard = 0; + let outrosChip = 0; + let comAdicionais = 0; + let semAdicionais = 0; + let travelCom = 0; + let vigenciaTotal = 0; + + const vigenciaBuckets: VigenciaBucketsDto = { + vencidos: 0, + aVencer0a30: 0, + aVencer31a60: 0, + aVencer61a90: 0, + acima90: 0, + }; + + const vigenciaFuturaMap = new Map(); + const vigenciaAxis = this.buildMonthAxis(0, 12); + vigenciaAxis.keys.forEach((key) => vigenciaFuturaMap.set(key, 0)); + + const lineIds = new Set(); + const lineDigits = new Set(); + + for (const line of lines) { + totalLinhas += 1; + + const lineId = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (lineId) lineIds.add(lineId); + + const linhaDigits = this.normalizeLineDigits(this.readNode(line as any, 'linha', 'Linha')); + if (linhaDigits) lineDigits.add(linhaDigits); + + const cliente = this.readLineString(line, 'cliente', 'Cliente').trim(); + const usuario = this.readLineString(line, 'usuario', 'Usuario').trim(); + const statusRaw = this.readLineString(line, 'status', 'Status'); + const status = this.normalizeSeriesKey(statusRaw); + const skil = this.normalizeSeriesKey(this.readLineString(line, 'skil', 'Skil')); + const plano = this.readLineString(line, 'planoContrato', 'PlanoContrato').trim(); + const franquiaVivo = this.readLineNumber(line, 'franquiaVivo', 'FranquiaVivo'); + const franquiaLine = this.readLineNumber(line, 'franquiaLine', 'FranquiaLine'); + const tipoChip = this.normalizeChipType(this.readLineString(line, 'tipoDeChip', 'TipoDeChip')); + const isReserva = this.isReservaLine(line); + + if (cliente && !this.normalizeSeriesKey(cliente).includes('RESERVA')) { + clientesSet.add(cliente); + clienteMap.set(cliente, (clienteMap.get(cliente) ?? 0) + 1); + } + if (usuario && this.normalizeSeriesKey(usuario) !== 'RESERVA') { + usuariosSet.add(usuario); + } + + if (isReserva) { + reservas += 1; + const ddd = this.extractDddFromLine(this.readLineString(line, 'linha', 'Linha')) ?? '-'; + reservaDddMap.set(ddd, (reservaDddMap.get(ddd) ?? 0) + 1); + } else if (status.includes('ATIV')) { + ativas += 1; + } else if (status.includes('BLOQUE') || status.includes('PERDA') || status.includes('ROUBO') || status.includes('SUSPEN') || status.includes('CANCEL')) { + bloqueadas += 1; + if (status.includes('PERDA') || status.includes('ROUBO')) { + perdaRoubo += 1; + } else if (status.includes('120')) { + bloq120 += 1; + } else { + outras += 1; + } + } else { + outras += 1; + } + + if (!isReserva) { + const planoKey = plano || 'Sem plano'; + planoMap.set(planoKey, (planoMap.get(planoKey) ?? 0) + 1); + } + + if (skil.includes('FISICA') || skil === 'PF') pfLinhas += 1; + if (skil.includes('JURIDICA') || skil === 'PJ') pjLinhas += 1; + + if (franquiaVivo > 0) franquiaVivoTotalGb += franquiaVivo; + if (franquiaLine > 0) franquiaLineTotalGb += franquiaLine; + const faixa = this.resolveFranquiaLineBand(franquiaLine); + franquiaBandMap.set(faixa, (franquiaBandMap.get(faixa) ?? 0) + 1); + + if (tipoChip === 'ESIM') eSim += 1; + else if (tipoChip === 'SIMCARD') simCard += 1; + else outrosChip += 1; + + const hasPaidAdditional = this.hasAnyPaidAdditional(line); + if (hasPaidAdditional) comAdicionais += 1; + else semAdicionais += 1; + + if (this.readLineNumber(line, 'gestaoVozDados', 'GestaoVozDados') > 0) additionalCounts.gvd += 1; + if (this.readLineNumber(line, 'skeelo', 'Skeelo') > 0) additionalCounts.skeelo += 1; + if (this.readLineNumber(line, 'vivoNewsPlus', 'VivoNewsPlus') > 0) additionalCounts.news += 1; + if (this.readLineNumber(line, 'vivoTravelMundo', 'VivoTravelMundo') > 0) { + additionalCounts.travel += 1; + travelCom += 1; + } + if (this.readLineNumber(line, 'vivoSync', 'VivoSync') > 0) additionalCounts.sync += 1; + if (this.readLineNumber(line, 'vivoGestaoDispositivo', 'VivoGestaoDispositivo') > 0) additionalCounts.dispositivo += 1; + + const vencContaRaw = this.readNode(line as any, 'vencConta', 'VencConta'); + const vencConta = this.parseDateValue(vencContaRaw); + if (vencConta) { + vigenciaTotal += 1; + const diff = Math.floor((this.startOfDay(vencConta).getTime() - this.startOfDay(now).getTime()) / 86400000); + if (diff < 0) vigenciaBuckets.vencidos += 1; + else if (diff <= 30) vigenciaBuckets.aVencer0a30 += 1; + else if (diff <= 60) vigenciaBuckets.aVencer31a60 += 1; + else if (diff <= 90) vigenciaBuckets.aVencer61a90 += 1; + else vigenciaBuckets.acima90 += 1; + + const key = `${vencConta.getFullYear()}-${this.pad2(vencConta.getMonth() + 1)}`; + if (vigenciaFuturaMap.has(key)) { + vigenciaFuturaMap.set(key, (vigenciaFuturaMap.get(key) ?? 0) + 1); + } + } + } + + const muregFiltered = this.filterHistoryEventsByLines(this.operatorMuregCache, lineIds, lineDigits); + const trocaFiltered = this.filterHistoryEventsByLines(this.operatorTrocaCache, lineIds, lineDigits); + + const muregSeries = this.buildHistorySeries(muregFiltered, 11, 12); + const trocaSeries = this.buildHistorySeries(trocaFiltered, 11, 12); + const muregCutoff = this.startOfDay(new Date(now.getTime() - 30 * 86400000)); + const muregsUltimos30Dias = muregFiltered.filter((event) => event.date && this.startOfDay(event.date) >= muregCutoff).length; + const trocasUltimos30Dias = trocaFiltered.filter((event) => event.date && this.startOfDay(event.date) >= muregCutoff).length; + + const franquiaOrder = ['Sem franquia', 'Até 10 GB', '10 a 20 GB', '20 a 50 GB', 'Acima de 50 GB']; + this.franquiaLabels = franquiaOrder; + this.franquiaValues = franquiaOrder.map((label) => franquiaBandMap.get(label) ?? 0); + + this.adicionaisLabels = [...this.adicionaisLabelsPadrao]; + this.adicionaisValues = [ + additionalCounts.gvd, + additionalCounts.skeelo, + additionalCounts.news, + additionalCounts.travel, + additionalCounts.sync, + additionalCounts.dispositivo, + ]; + this.adicionaisTotals = this.adicionaisLabels.map(() => null); + this.travelLabels = ['Com Travel', 'Sem Travel']; + this.travelValues = [travelCom, Math.max(totalLinhas - travelCom, 0)]; + this.tipoChipLabels = ['e-SIM', 'SIMCARD', 'Outros']; + this.tipoChipValues = [eSim, simCard, outrosChip]; + + this.muregLabels = muregSeries.labels; + this.muregValues = muregSeries.values; + this.trocaLabels = trocaSeries.labels; + this.trocaValues = trocaSeries.values; + this.vigenciaLabels = vigenciaAxis.labels; + this.vigenciaValues = vigenciaAxis.keys.map((key) => vigenciaFuturaMap.get(key) ?? 0); + this.vigBuckets = vigenciaBuckets; + + this.statusResumo = { + total: totalLinhas, + ativos: ativas, + bloqueadas, + perdaRoubo, + bloq120, + reservas, + outras, + }; + + this.rebuildAdicionaisComparativo({ + totalLinesWithAnyPaidAdditional: comAdicionais, + totalLinesWithNoPaidAdditional: semAdicionais, + }); + + if (shouldUpdateCoreMetrics) { + this.dashboardRaw = { + totalLinhas, + clientesUnicos: clientesSet.size, + ativos: ativas, + bloqueados: bloqueadas, + reservas, + bloqueadosPerdaRoubo: perdaRoubo, + bloqueados120Dias: bloq120, + bloqueadosOutros: outras, + totalMuregs: muregFiltered.length, + muregsUltimos30Dias, + totalTrocas: trocaFiltered.length, + trocasUltimos30Dias, + totalVigenciaLinhas: vigenciaTotal, + vigenciaVencidos: vigenciaBuckets.vencidos, + vigenciaAVencer30: vigenciaBuckets.aVencer0a30, + userDataRegistros: totalLinhas, + userDataComCpf: 0, + userDataComEmail: 0, + }; + } + + if (shouldUpdateCoreMetrics) { + this.insights = this.buildSyntheticInsights( + totalLinhas, + ativas, + franquiaVivoTotalGb, + franquiaLineTotalGb, + comAdicionais, + semAdicionais, + pfLinhas, + pjLinhas + ); + } + + this.buildResumoDerivedFromLines(lines, clienteMap, planoMap, reservaDddMap, pfLinhas, pjLinhas); + this.buildVivoComparison(lines); + if (shouldUpdateCoreMetrics) { + this.rebuildPrimaryKpis(); + } + + this.insightsError = null; + this.resumoError = null; + this.resumoLoading = false; + this.resumoReady = true; + } + + private buildSyntheticInsights( + totalLinhas: number, + totalAtivas: number, + totalFranquiaVivo: number, + totalFranquiaLine: number, + comAdicionais: number, + semAdicionais: number, + pfLinhas: number, + pjLinhas: number + ): DashboardGeralInsightsDto { + return { + kpis: { + totalLinhas, + totalAtivas, + vivo: { + qtdLinhas: totalLinhas, + totalFranquiaGb: totalFranquiaVivo, + totalFranquiaLine: totalFranquiaLine, + }, + travelMundo: { + comTravel: this.travelValues[0] ?? 0, + semTravel: this.travelValues[1] ?? 0, + totalValue: 0, + }, + adicionais: { + totalLinesWithAnyPaidAdditional: comAdicionais, + totalLinesWithNoPaidAdditional: semAdicionais, + }, + totaisLine: [ + { tipo: 'PF', qtdLinhas: pfLinhas, valorTotalLine: null, lucroTotalLine: null }, + { tipo: 'PJ', qtdLinhas: pjLinhas, valorTotalLine: null, lucroTotalLine: null }, + ], + }, + charts: { + linhasPorFranquia: { + labels: this.franquiaLabels, + values: this.franquiaValues, + }, + adicionaisPagosPorServico: { + labels: this.adicionaisLabels, + values: this.adicionaisValues, + totals: this.adicionaisTotals, + }, + travelMundo: { + labels: this.travelLabels, + values: this.travelValues, + }, + tipoChip: { + labels: this.tipoChipLabels, + values: this.tipoChipValues, + }, + }, + }; + } + + private buildResumoDerivedFromLines( + lines: DashboardLineListItemDto[], + clienteMap: Map, + planoMap: Map, + reservaDddMap: Map, + pfLinhas: number, + pjLinhas: number + ): void { + const topClientes = Array.from(clienteMap.entries()) + .map(([cliente, qtd]) => ({ cliente, linhas: qtd })) + .sort((a, b) => b.linhas - a.linhas || a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' })) + .slice(0, this.resumoTopN); + + const topPlanos = Array.from(planoMap.entries()) + .map(([plano, qtd]) => ({ plano, linhas: qtd })) + .sort((a, b) => b.linhas - a.linhas || a.plano.localeCompare(b.plano, 'pt-BR', { sensitivity: 'base' })) + .slice(0, this.resumoTopN); + + const topReserva = Array.from(reservaDddMap.entries()) + .map(([ddd, total]) => ({ ddd, total, linhas: total })) + .sort((a, b) => b.total - a.total) + .slice(0, this.resumoTopN); + + this.resumoTopClientes = topClientes; + this.resumoClientesLabels = topClientes.map((item) => item.cliente); + this.resumoClientesValues = topClientes.map((item) => item.linhas); + + this.resumoTopPlanos = topPlanos; + this.resumoPlanosLabels = topPlanos.map((item) => item.plano); + this.resumoPlanosValues = topPlanos.map((item) => item.linhas); + + this.resumoTopReserva = topReserva; + this.resumoReservaLabels = topReserva.map((item) => item.ddd); + this.resumoReservaValues = topReserva.map((item) => item.total); + + this.resumoPfPjLabels = ['Pessoa Física', 'Pessoa Jurídica']; + this.resumoPfPjValues = [pfLinhas, pjLinhas]; + this.resumoDiferencaPjPf = { + pfLinhas, + pjLinhas, + totalLinhas: lines.length, + }; + } + + private buildVivoComparison(lines: DashboardLineListItemDto[]): void { + const comparison: DashboardVivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; + + lines.forEach((line) => { + const conta = this.readNode(line as any, 'conta', 'Conta'); + const empresaConta = this.readNode( + line as any, + 'contaEmpresa', + 'ContaEmpresa', + 'empresaConta', + 'EmpresaConta', + 'empresa_conta', + 'Empresa_Conta', + 'empresa (conta)', + 'EMPRESA (CONTA)', + 'empresa', + 'Empresa' + ); + + const context = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }); + if (context.operadora !== 'VIVO') return; + + const hasAdditional = this.hasAnyPaidAdditional(line); + if (context.vivoEmpresaGrupo === 'MACROPHONY') { + comparison.macrophonyLinhas += 1; + if (hasAdditional) comparison.macrophonyComAdicionais += 1; + else comparison.macrophonySemAdicionais += 1; + } else if (context.vivoEmpresaGrupo === 'LINE MOVEL') { + comparison.lineMovelLinhas += 1; + if (hasAdditional) comparison.lineMovelComAdicionais += 1; + else comparison.lineMovelSemAdicionais += 1; + } + }); + + this.vivoComparison = comparison; + } + + private hasAnyPaidAdditional(line: DashboardLineListItemDto): boolean { + return ( + this.readLineNumber(line, 'gestaoVozDados', 'GestaoVozDados') > 0 + || this.readLineNumber(line, 'skeelo', 'Skeelo') > 0 + || this.readLineNumber(line, 'vivoNewsPlus', 'VivoNewsPlus') > 0 + || this.readLineNumber(line, 'vivoTravelMundo', 'VivoTravelMundo') > 0 + || this.readLineNumber(line, 'vivoSync', 'VivoSync') > 0 + || this.readLineNumber(line, 'vivoGestaoDispositivo', 'VivoGestaoDispositivo') > 0 + ); + } + + private filterHistoryEventsByLines( + events: DashboardHistoryEvent[], + lineIds: Set, + lineDigits: Set + ): DashboardHistoryEvent[] { + return events.filter((event) => { + const eventLineId = String(event.mobileLineId ?? '').trim(); + if (eventLineId && lineIds.has(eventLineId)) return true; + + const linhaNova = this.normalizeLineDigits(event.linhaNova); + if (linhaNova && lineDigits.has(linhaNova)) return true; + + const linhaAntiga = this.normalizeLineDigits(event.linhaAntiga); + if (linhaAntiga && lineDigits.has(linhaAntiga)) return true; + + return false; + }); + } + + private buildHistorySeries(events: DashboardHistoryEvent[], monthsBack: number, count: number): { labels: string[]; values: number[] } { + const axis = this.buildMonthAxis(monthsBack, count); + const map = new Map(); + axis.keys.forEach((key) => map.set(key, 0)); + + events.forEach((event) => { + if (!event.date) return; + const key = `${event.date.getFullYear()}-${this.pad2(event.date.getMonth() + 1)}`; + if (!map.has(key)) return; + map.set(key, (map.get(key) ?? 0) + 1); + }); + + return { + labels: axis.labels, + values: axis.keys.map((key) => map.get(key) ?? 0), + }; + } + + private buildMonthAxis(monthsBack: number, count: number): { labels: string[]; keys: string[] } { + const now = new Date(); + const labels: string[] = []; + const keys: string[] = []; + + for (let i = monthsBack; i >= monthsBack - count + 1; i -= 1) { + const month = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${month.getFullYear()}-${this.pad2(month.getMonth() + 1)}`; + const label = month + .toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }) + .replace('.', '') + .replace(' de ', '/') + .trim(); + keys.push(key); + labels.push(label); + } + + return { labels, keys }; + } + + private parseDateValue(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + const raw = String(value ?? '').trim(); + if (!raw) return null; + + const br = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (br) { + const parsed = new Date(Number(br[3]), Number(br[2]) - 1, Number(br[1])); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + private normalizeLineDigits(value: unknown): string { + return String(value ?? '').replace(/\D/g, '').trim(); + } + + private startOfDay(value: Date): Date { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } private applyDto(dto: DashboardDto) { @@ -1171,13 +2212,157 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { .replace(/[^A-Z0-9]/g, ''); } + private buildLineFieldAliases(camelCaseKey: string, pascalCaseKey: string): string[] { + const snakeCaseKey = pascalCaseKey + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); + const flatCaseKey = snakeCaseKey.replace(/_/g, ''); + const spacedKey = pascalCaseKey.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + const kebabKey = snakeCaseKey.replace(/_/g, '-'); + + const aliases = new Set([ + camelCaseKey, + pascalCaseKey, + snakeCaseKey, + flatCaseKey, + kebabKey, + spacedKey, + spacedKey.toLowerCase(), + spacedKey.toUpperCase(), + flatCaseKey.toUpperCase(), + ]); + + return Array.from(aliases); + } + + private normalizeLooseFieldKey(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9]/g, '') + .toUpperCase() + .trim(); + } + + private findLooseFieldValue(source: any, aliases: string[]): any { + if (!source || typeof source !== 'object') return undefined; + const targets = aliases + .map((alias) => this.normalizeLooseFieldKey(alias)) + .filter(Boolean); + if (!targets.length) return undefined; + + const entries = Object.entries(source as Record); + + for (const [entryKey, value] of entries) { + const normalizedEntry = this.normalizeLooseFieldKey(entryKey); + if (!normalizedEntry) continue; + if (targets.includes(normalizedEntry)) return value; + } + + for (const [entryKey, value] of entries) { + const normalizedEntry = this.normalizeLooseFieldKey(entryKey); + if (!normalizedEntry) continue; + if (targets.some((target) => normalizedEntry.includes(target) || target.includes(normalizedEntry))) { + return value; + } + } + + return undefined; + } + + private readLineRawField( + line: DashboardLineListItemDto, + camelCaseKey: keyof DashboardLineListItemDto, + pascalCaseKey: string + ): any { + const aliases = this.buildLineFieldAliases(String(camelCaseKey), pascalCaseKey); + const raw = this.readNode(line as any, ...aliases); + if (raw !== undefined) return raw; + const looseRaw = this.findLooseFieldValue(line as any, aliases); + if (looseRaw !== undefined) return looseRaw; + + const nested = this.readNode( + line as any, + 'financeiro', + 'Financeiro', + 'dadosFinanceiros', + 'DadosFinanceiros' + ); + if (nested && typeof nested === 'object') { + const nestedRaw = this.readNode(nested, ...aliases); + if (nestedRaw !== undefined) return nestedRaw; + return this.findLooseFieldValue(nested, aliases); + } + + return undefined; + } + + private readUnknownNumericField(source: any, camelCaseKey: string, pascalCaseKey: string): number | null { + if (!source || typeof source !== 'object') return null; + const aliases = this.buildLineFieldAliases(camelCaseKey, pascalCaseKey); + const raw = + this.readNode(source, ...aliases) + ?? this.findLooseFieldValue(source, aliases); + if (raw !== undefined) { + return this.toNumberOrNull(raw); + } + + const nested = this.readNode( + source, + 'financeiro', + 'Financeiro', + 'dadosFinanceiros', + 'DadosFinanceiros' + ); + if (nested && typeof nested === 'object') { + const nestedRaw = + this.readNode(nested, ...aliases) + ?? this.findLooseFieldValue(nested, aliases); + return this.toNumberOrNull(nestedRaw); + } + + return null; + } + + private readCachedFranquiaByLine( + line: DashboardLineListItemDto, + camelCaseKey: keyof DashboardLineListItemDto + ): number | null { + const id = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (!id) return null; + + const cached = this.lineFranquiaCacheById.get(id); + if (!cached) return null; + + if (camelCaseKey === 'franquiaVivo') return cached.franquiaVivo; + if (camelCaseKey === 'franquiaLine') return cached.franquiaLine; + return null; + } + private readLineNumber(line: DashboardLineListItemDto, camelCaseKey: keyof DashboardLineListItemDto, pascalCaseKey: string): number { - const raw = this.readNode(line as any, String(camelCaseKey), pascalCaseKey); - return this.toNumberOrNull(raw) ?? 0; + const raw = this.readLineRawField(line, camelCaseKey, pascalCaseKey); + const value = this.toNumberOrNull(raw); + if (value !== null) return value; + + const cached = this.readCachedFranquiaByLine(line, camelCaseKey); + return cached ?? 0; + } + + private sumLineField( + lines: DashboardLineListItemDto[] | null | undefined, + camelCaseKey: keyof DashboardLineListItemDto, + pascalCaseKey: string + ): number { + if (!Array.isArray(lines) || lines.length === 0) return 0; + return lines.reduce((acc, line) => { + const value = this.readLineNumber(line, camelCaseKey, pascalCaseKey); + if (!Number.isFinite(value)) return acc; + return acc + value; + }, 0); } private readLineString(line: DashboardLineListItemDto, camelCaseKey: keyof DashboardLineListItemDto, pascalCaseKey: string): string { - const raw = this.readNode(line as any, String(camelCaseKey), pascalCaseKey) ?? ''; + const raw = this.readLineRawField(line, camelCaseKey, pascalCaseKey) ?? ''; return String(raw); } @@ -1335,13 +2520,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { ?? (this.resumo?.vivoLineResumos ?? []).reduce( (acc, row) => acc + (this.toNumberOrNull(row?.franquiaTotal) ?? 0), 0 - ); + ) + ?? (this.isOperadoraFiltroAtivo + ? (this.sumLineField(this.filteredLinesCache, 'franquiaVivo', 'FranquiaVivo') ?? 0) + : 0); add( 'franquia_vivo_total', - 'Total Franquia Vivo', + 'Total Franquia Contratada', this.formatDataAllowance(franquiaVivoTotal), - 'bi bi-diagram-3-fill', - 'Soma das franquias (Geral)' + 'bi bi-diagram-3-fill' ); const franquiaLineTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaLine) @@ -1349,7 +2536,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { ?? (this.resumo?.vivoLineResumos ?? []).reduce( (acc, row) => acc + (this.toNumberOrNull(row?.franquiaLine) ?? 0), 0 - ); + ) + ?? (this.isOperadoraFiltroAtivo + ? (this.sumLineField(this.filteredLinesCache, 'franquiaLine', 'FranquiaLine') ?? 0) + : 0); add( 'franquia_line_total', 'Total Franquia Line', @@ -1401,6 +2591,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartAdicionaisPagos?.nativeElement, this.chartTipoChip?.nativeElement, this.chartTravelMundo?.nativeElement, + this.chartVivoEmpresasLinhas?.nativeElement, + this.chartVivoEmpresasAdicionais?.nativeElement, ].filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length) return; @@ -1610,6 +2802,83 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } }); } + + if (this.showVivoComparison) { + if (this.chartVivoEmpresasLinhas?.nativeElement) { + this.chartVivoComparacaoLinhas = new Chart(this.chartVivoEmpresasLinhas.nativeElement, { + type: 'bar', + data: { + labels: ['MACROPHONY', 'LINE MÓVEL'], + datasets: [{ + label: 'Linhas', + data: [this.vivoComparison.macrophonyLinhas, this.vivoComparison.lineMovelLinhas], + backgroundColor: [palette.brand, palette.blue], + borderRadius: 8, + borderWidth: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => ` ${this.formatInt(ctx.raw as number)} linhas`, + }, + }, + }, + scales: { + x: { grid: { display: false }, border: { display: false } }, + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, border: { display: false } }, + }, + }, + }); + } + + if (this.chartVivoEmpresasAdicionais?.nativeElement) { + this.chartVivoComparacaoAdicionais = new Chart(this.chartVivoEmpresasAdicionais.nativeElement, { + type: 'bar', + data: { + labels: ['MACROPHONY', 'LINE MÓVEL'], + datasets: [ + { + label: 'Com adicionais', + data: [this.vivoComparison.macrophonyComAdicionais, this.vivoComparison.lineMovelComAdicionais], + backgroundColor: palette.purple, + borderRadius: 6, + borderWidth: 0, + }, + { + label: 'Sem adicionais', + data: [this.vivoComparison.macrophonySemAdicionais, this.vivoComparison.lineMovelSemAdicionais], + backgroundColor: '#cbd5e1', + borderRadius: 6, + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + callbacks: { + label: (ctx) => ` ${ctx.dataset.label}: ${this.formatInt(ctx.raw as number)}`, + }, + }, + }, + scales: { + x: { stacked: false, grid: { display: false }, border: { display: false } }, + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, border: { display: false } }, + }, + }, + }); + } + } + + this.refreshExpandedChartIfOpen(); } private buildResumoCharts() { @@ -1695,6 +2964,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }); } + this.refreshExpandedChartIfOpen(); } // Helper for consistent bar charts @@ -1796,6 +3066,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartFranquia?.destroy(); this.chartAdicionais?.destroy(); this.chartTipoChipDistribuicao?.destroy(); + this.chartVivoComparacaoLinhas?.destroy(); + this.chartVivoComparacaoAdicionais?.destroy(); this.chartPie = undefined; this.chartAdicionaisComparativoDoughnut = undefined; @@ -1807,6 +3079,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartFranquia = undefined; this.chartAdicionais = undefined; this.chartTipoChipDistribuicao = undefined; + this.chartVivoComparacaoLinhas = undefined; + this.chartVivoComparacaoAdicionais = undefined; } private destroyResumoCharts() { @@ -1821,6 +3095,177 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartResumoPfPj = undefined; } + private getChartInstanceByModalKey(key: DashboardChartModalKey): Chart | undefined { + switch (key) { + case 'status': return this.chartPie; + case 'adicionaisComparativo': return this.chartAdicionaisComparativoDoughnut; + case 'vigenciaBuckets': return this.chartVigSuper; + case 'travel': return this.chartTravel; + case 'linhasFranquia': return this.chartFranquia; + case 'adicionaisPagos': return this.chartAdicionais; + case 'tipoChip': return this.chartTipoChipDistribuicao; + case 'vivoEmpresasLinhas': return this.chartVivoComparacaoLinhas; + case 'vivoEmpresasAdicionais': return this.chartVivoComparacaoAdicionais; + case 'resumoTopClientes': return this.chartResumoClientes; + case 'resumoTopPlanos': return this.chartResumoPlanos; + case 'resumoPfPj': return this.chartResumoPfPj; + case 'resumoReservaDdd': return this.chartResumoReserva; + case 'mureg12': return this.chartMureg; + case 'troca12': return this.chartTroca; + case 'vigenciaMesAno': return this.chartVigMesAno; + default: return undefined; + } + } + + private refreshExpandedChartIfOpen(): void { + if (!this.chartModalOpen || !this.chartModalKey) return; + const sourceChart = this.getChartInstanceByModalKey(this.chartModalKey); + if (!sourceChart) return; + this.updateChartModalInfo(sourceChart); + this.renderExpandedChart(sourceChart); + } + + private renderExpandedChart(sourceChart: Chart): void { + if (!this.chartModalOpen) return; + + const canvas = this.chartExpandedCanvas?.nativeElement; + if (!canvas) return; + + this.destroyExpandedChart(); + + const sourceConfig = sourceChart.config as any; + const expandedConfig = { + type: sourceConfig.type, + data: this.cloneChartValue(sourceConfig.data), + options: this.cloneChartValue(sourceConfig.options ?? {}), + plugins: this.cloneChartValue(sourceConfig.plugins ?? []), + } as any; + + expandedConfig.options = expandedConfig.options ?? {}; + expandedConfig.options.responsive = true; + expandedConfig.options.maintainAspectRatio = false; + + this.chartExpanded = new Chart(canvas, expandedConfig); + } + + private destroyExpandedChart(): void { + try { + this.chartExpanded?.destroy(); + } catch {} + this.chartExpanded = undefined; + } + + private cloneChartValue(value: T): T { + if (value === null || value === undefined) return value; + if (typeof value === 'function') return value; + if (typeof value !== 'object') return value; + if (value instanceof Date) return new Date(value.getTime()) as T; + if (Array.isArray(value)) { + return value.map((entry) => this.cloneChartValue(entry)) as T; + } + + const output: Record = {}; + Object.entries(value as Record).forEach(([key, entry]) => { + output[key] = this.cloneChartValue(entry); + }); + return output as T; + } + + private updateChartModalInfo(sourceChart: Chart): void { + const labelsRaw = Array.isArray(sourceChart.data?.labels) ? sourceChart.data.labels : []; + const datasetsRaw = Array.isArray(sourceChart.data?.datasets) ? sourceChart.data.datasets : []; + + const datasetHeaders = datasetsRaw.map((dataset, index) => { + const raw = (dataset as any)?.label; + const text = String(raw ?? '').trim(); + return text || `Série ${index + 1}`; + }); + + const maxDataLength = datasetsRaw.reduce((max, dataset) => { + const data = Array.isArray((dataset as any)?.data) ? (dataset as any).data : []; + return Math.max(max, data.length); + }, 0); + + const rowCount = Math.max(labelsRaw.length, maxDataLength); + + const rows: DashboardChartModalInfoRow[] = []; + for (let index = 0; index < rowCount; index += 1) { + const label = this.resolveChartModalRowLabel(labelsRaw, index); + const cells = datasetsRaw.map((dataset, datasetIndex) => { + const dataArray = Array.isArray((dataset as any)?.data) ? (dataset as any).data : []; + const numericValue = this.toChartPointNumber(dataArray[index]); + return { + dataset: datasetHeaders[datasetIndex] ?? `Série ${datasetIndex + 1}`, + valueText: this.formatChartModalValue(numericValue), + numericValue, + }; + }); + rows.push({ label, cells }); + } + + const totals = datasetHeaders.map((dataset, datasetIndex) => { + const sum = rows.reduce((acc, row) => { + const value = row.cells[datasetIndex]?.numericValue; + if (value === null) return acc; + return acc + value; + }, 0); + return { + dataset, + totalText: this.formatChartModalValue(sum), + }; + }); + + this.chartModalDatasetHeaders = datasetHeaders; + this.chartModalInfoRows = rows; + this.chartModalDatasetTotals = totals; + } + + private resolveChartModalRowLabel(labelsRaw: unknown[], index: number): string { + const raw = labelsRaw[index]; + const text = String(raw ?? '').trim(); + if (text) return text; + return `Item ${index + 1}`; + } + + private toChartPointNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + + const direct = this.toNumberOrNull(value); + if (direct !== null) return direct; + + if (typeof value === 'object') { + const nested = value as Record; + const fromY = this.toNumberOrNull(nested.y); + if (fromY !== null) return fromY; + const fromR = this.toNumberOrNull(nested.r); + if (fromR !== null) return fromR; + } + + return null; + } + + private formatChartModalValue(value: number | null): string { + if (value === null) return '-'; + if (Number.isInteger(value)) return this.formatInt(value); + return value.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + } + + private lockBodyScrollForChartModal(): void { + if (!isPlatformBrowser(this.platformId)) return; + const body = window.document.body; + if (this.bodyOverflowBeforeChartModal === null) { + this.bodyOverflowBeforeChartModal = body.style.overflow; + } + body.style.overflow = 'hidden'; + } + + private restoreBodyScrollForChartModal(): void { + if (!isPlatformBrowser(this.platformId)) return; + if (this.bodyOverflowBeforeChartModal === null) return; + window.document.body.style.overflow = this.bodyOverflowBeforeChartModal; + this.bodyOverflowBeforeChartModal = null; + } + private normalizeResumo(data: ResumoResponse): ResumoResponse { // Helper to ensure arrays are arrays return { @@ -1894,7 +3339,12 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return Number.isNaN(n) ? null : n; } + private pad2(value: number): string { + return value.toString().padStart(2, '0'); + } + trackByKpiKey = (_: number, item: KpiCard) => item.key; + trackByOperadoraFilter = (_: number, item: { value: OperadoraFiltro }) => item.value; isKpiClickable(card: KpiCard): boolean { return !!this.kpiNavigationMap[card.key]; @@ -1908,7 +3358,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }); } - onKpiCardKeydown(event: KeyboardEvent, card: KpiCard): void { + onKpiCardKeydown(event: Event, card: KpiCard): void { + if (!(event instanceof KeyboardEvent)) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); this.onKpiClick(card); diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 2937efe..a46a1cb 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -75,180 +75,214 @@ -
-
- - - - - - - - - - - -
- - -
- + + - - -
- - {{ client }} - - -
- + + + + - - -
- - -
-
- - -
-
-
Modo
-
- - - -
+
+
+
+
-
-
Serviços
-
- +
+ +
+
+ + +
+ + +
+ + +
+
- diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 38116a6..f3fa7c0 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -123,11 +123,35 @@ .btn-glass { border-radius: 12px; font-weight: 900; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(3, 15, 170, 0.25); color: var(--blue); &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } } /* Filtros e Multi-Select */ -.filters-row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; } +.filters-stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.filters-row { + display: flex; + justify-content: center; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + margin-top: 0; + position: relative; + z-index: 30; + overflow: visible; +} + +.filters-row-top { + justify-content: center; +} + +.filters-row-bottom { + justify-content: center; +} .filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); } .filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } } -.client-filter-wrap { position: relative; } +.client-filter-wrap { position: relative; z-index: 40; } .btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } } .chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; } .client-chip { display: inline-flex; align-items: center; background: rgba(227, 61, 207, 0.1); color: var(--brand); border: 1px solid rgba(227, 61, 207, 0.2); border-radius: 6px; padding: 2px 6px; font-size: 0.75rem; font-weight: 800; cursor: default; user-select: none; } @@ -140,6 +164,34 @@ .additional-filter-wrap { position: relative; + z-index: 40; +} + +.operadora-empresa-filters { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + position: relative; + z-index: 50; +} + +.filter-select-box { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 190px; + position: relative; + z-index: 50; +} + +.filter-select-label { + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.58); + padding-left: 2px; } .btn-additional-filter { @@ -249,6 +301,18 @@ } } +@media (max-width: 768px) { + .operadora-empresa-filters { + width: 100%; + justify-content: center; + } + + .filter-select-box { + flex: 1 1 220px; + min-width: 0; + } +} + /* KPIs */ .geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } } .geral-kpis.geral-kpis-client { diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts index 05f32d1..07f461d 100644 --- a/src/app/pages/geral/geral.spec.ts +++ b/src/app/pages/geral/geral.spec.ts @@ -73,4 +73,36 @@ describe('Geral', () => { expect(component.createBatchLines[0].linha).toBe('11888888888'); expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B'); }); + + it('should apply TIM filter in client-side pipeline using conta TIM textual', () => { + component.filterOperadora = 'TIM'; + component.filterContaEmpresa = ''; + component.filterStatus = 'ALL'; + component.additionalMode = 'ALL'; + component.selectedAdditionalServices = []; + + const filtered = (component as any).applyAdditionalFiltersClientSide([ + { id: '1', item: 1, conta: 'TIM', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' }, + { id: '2', item: 2, conta: '455371844', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' }, + ]); + + expect(filtered.length).toBe(1); + expect(filtered[0].conta).toBe('TIM'); + }); + + it('should combine operadora and empresa filters for VIVO MACROPHONY', () => { + component.filterOperadora = 'VIVO'; + component.filterContaEmpresa = 'VIVO MACROPHONY'; + component.filterStatus = 'ALL'; + component.additionalMode = 'ALL'; + component.selectedAdditionalServices = []; + + const filtered = (component as any).applyAdditionalFiltersClientSide([ + { id: '1', item: 1, conta: '460161507', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' }, + { id: '2', item: 2, conta: '0435288088', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' }, + ]); + + expect(filtered.length).toBe(1); + expect(filtered[0].conta).toBe('460161507'); + }); }); diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 0bd8044..2410ed4 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -45,11 +45,20 @@ import { buildBatchMassPreview, mergeMassRows } from './batch-mass-input.util'; +import { + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + normalizeConta as normalizeContaValue, + resolveEmpresaByConta, + resolveOperadoraContext, + sameConta as sameContaValue, +} from '../../utils/account-operator.util'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; type CreateEntryMode = 'SINGLE' | 'BATCH'; type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; +type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM'; type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120'; @@ -79,6 +88,9 @@ interface ApiPagedResult { interface ApiLineList { id: string; item: number; + conta?: string | null; + contaEmpresa?: string | null; + empresaConta?: string | null; linha: string | null; chip?: string | null; cliente: string | null; @@ -361,6 +373,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { blockedStatusMode: BlockedStatusMode = 'ALL'; additionalMode: AdditionalMode = 'ALL'; selectedAdditionalServices: AdditionalServiceKey[] = []; + filterOperadora: OperadoraFilterMode = 'ALL'; + filterContaEmpresa = ''; readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [ { key: 'gvd', label: 'Gestão Voz e Dados' }, { key: 'skeelo', label: 'Skeelo' }, @@ -369,6 +383,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { { key: 'sync', label: 'Vivo Sync' }, { key: 'dispositivo', label: 'Vivo Gestão Dispositivo' } ]; + readonly operadoraFilterOptions: Array<{ label: string; value: OperadoraFilterMode }> = [ + { label: 'Todas operadoras', value: 'ALL' }, + { label: 'VIVO', value: 'VIVO' }, + { label: 'CLARO', value: 'CLARO' }, + { label: 'TIM', value: 'TIM' }, + ]; clientsList: string[] = []; loadingClientsList = false; @@ -472,12 +492,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { 'M2M 50MB' ]; - private readonly fallbackAccountCompanies: AccountCompanyOption[] = [ - { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840'] }, - { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844'] }, - { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] }, - { empresa: 'TIM LINE MÓVEL', contas: ['0072046192'] } - ]; + private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({ + empresa: group.empresa, + contas: [...group.contas], + })); accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; loadingAccountCompanies = false; @@ -489,6 +507,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return this.accountCompanies.map((x) => x.empresa); } + get contaEmpresaFilterOptions(): Array<{ label: string; value: string }> { + const empresas = this.getContaEmpresaOptionsByOperadora(this.filterOperadora); + const merged = this.mergeOption(this.filterContaEmpresa, empresas); + return [ + { label: 'Todas empresas', value: '' }, + ...merged.map((empresa) => ({ label: empresa, value: empresa })), + ]; + } + get contaOptionsForCreate(): string[] { return this.getContasByEmpresa(this.createModel?.contaEmpresa); } @@ -740,8 +767,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0; } + get hasOperadoraEmpresaFiltersApplied(): boolean { + return this.filterOperadora !== 'ALL' || !!this.filterContaEmpresa.trim(); + } + get hasClientSideFiltersApplied(): boolean { - return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED'; + return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied; } get additionalModeLabel(): string { @@ -1042,17 +1073,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.http.get(`${this.apiBase}/account-companies`).subscribe({ next: (data) => { const normalized = this.normalizeAccountCompanies(data); - this.accountCompanies = - normalized.length > 0 ? normalized : [...this.fallbackAccountCompanies]; + const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies; + this.accountCompanies = mergeAccountCompaniesWithDefaults(source); this.loadingAccountCompanies = false; + this.syncContaEmpresaFilterByOperadora(); this.syncContaEmpresaSelection(this.createModel); this.syncContaEmpresaSelection(this.editModel); this.cdr.detectChanges(); }, error: () => { - this.accountCompanies = [...this.fallbackAccountCompanies]; + this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies); this.loadingAccountCompanies = false; + this.syncContaEmpresaFilterByOperadora(); this.syncContaEmpresaSelection(this.createModel); this.syncContaEmpresaSelection(this.editModel); @@ -1494,6 +1527,32 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } + setOperadoraFilter(mode: OperadoraFilterMode) { + if (this.isClientRestricted) return; + this.filterOperadora = mode; + this.syncContaEmpresaFilterByOperadora(); + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + + setContaEmpresaFilter(empresa: string) { + if (this.isClientRestricted) return; + const next = (empresa ?? '').toString().trim(); + this.filterContaEmpresa = next; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + private applyBaseFilters(params: HttpParams): HttpParams { let next = params; @@ -1577,6 +1636,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return false; } + if (!this.matchesOperadoraContaEmpresaFilters(line)) { + return false; + } + const selected = this.selectedAdditionalServices; const hasSelected = selected.length > 0; @@ -1600,6 +1663,40 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return true; } + private matchesOperadoraContaEmpresaFilters(line: ApiLineList): boolean { + const hasOperadora = this.filterOperadora !== 'ALL'; + const selectedEmpresa = this.filterContaEmpresa.trim(); + const hasEmpresa = !!selectedEmpresa; + if (!hasOperadora && !hasEmpresa) return true; + + const conta = (line as any)?.conta ?? (line as any)?.Conta ?? ''; + const empresaConta = (line as any)?.contaEmpresa + ?? (line as any)?.empresaConta + ?? (line as any)?.ContaEmpresa + ?? (line as any)?.EmpresaConta + ?? (line as any)?.empresa_conta + ?? (line as any)?.Empresa_Conta + ?? (line as any)?.['empresa (conta)'] + ?? (line as any)?.['EMPRESA (CONTA)'] + ?? ''; + + const context = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }); + + if (hasOperadora && context.operadora !== this.filterOperadora) { + return false; + } + + if (!hasEmpresa) return true; + + const resolvedEmpresa = (context.empresaConta || this.findEmpresaByConta(conta) || '').toString().trim(); + if (!resolvedEmpresa) return false; + return this.normalizeFilterToken(resolvedEmpresa) === this.normalizeFilterToken(selectedEmpresa); + } + private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] { if (!Array.isArray(lines) || lines.length === 0) return []; return lines.filter((line) => this.matchesAdditionalFilters(line)); @@ -2411,6 +2508,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { parts.push(this.selectedAdditionalServices.join('-')); } + if (this.filterOperadora !== 'ALL') { + parts.push(`operadora-${this.filterOperadora.toLowerCase()}`); + } + if (this.filterContaEmpresa.trim()) { + parts.push(`empresa-${this.normalizeFilterToken(this.filterContaEmpresa).toLowerCase()}`); + } + return parts.join('_'); } @@ -4538,26 +4642,46 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return found ? [...found.contas] : []; } - private findEmpresaByConta(conta: any): string { - const target = this.normalizeConta(conta); - if (!target) return ''; + private getContaEmpresaOptionsByOperadora(mode: OperadoraFilterMode): string[] { + const empresas = this.mergeOptionList([], this.accountCompanies.map((group) => group?.empresa ?? '')) + .filter((empresa) => !!(empresa ?? '').toString().trim()); - const found = this.accountCompanies.find((group) => - (group.contas ?? []).some((c) => this.sameConta(c, target)) - ); - return found?.empresa ?? ''; + const filtered = mode === 'ALL' + ? empresas + : empresas.filter((empresa) => { + const operadora = resolveOperadoraContext({ + empresaConta: empresa, + accountCompanies: this.accountCompanies, + }).operadora; + return operadora === mode; + }); + + return filtered.sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })); + } + + private syncContaEmpresaFilterByOperadora(): void { + const selected = this.filterContaEmpresa.trim(); + if (!selected) return; + + const available = this.getContaEmpresaOptionsByOperadora(this.filterOperadora); + const normalizedSelected = this.normalizeFilterToken(selected); + const hasSelected = available.some((empresa) => this.normalizeFilterToken(empresa) === normalizedSelected); + + if (!hasSelected) { + this.filterContaEmpresa = ''; + } + } + + private findEmpresaByConta(conta: any): string { + return resolveEmpresaByConta(conta, this.accountCompanies); } private normalizeConta(value: any): string { - const raw = (value ?? '').toString().trim(); - if (!raw) return ''; - if (!/^\d+$/.test(raw)) return raw.toUpperCase(); - const noLeadingZero = raw.replace(/^0+/, ''); - return noLeadingZero || '0'; + return normalizeContaValue(value); } private sameConta(a: any, b: any): boolean { - return this.normalizeConta(a) === this.normalizeConta(b); + return sameContaValue(a, b); } private syncContaEmpresaSelection(model: any) { diff --git a/src/app/utils/account-operator.util.spec.ts b/src/app/utils/account-operator.util.spec.ts new file mode 100644 index 0000000..ed1c014 --- /dev/null +++ b/src/app/utils/account-operator.util.spec.ts @@ -0,0 +1,80 @@ +import { + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + normalizeConta, + resolveEmpresaByConta, + resolveOperadoraContext, + sameConta, +} from './account-operator.util'; + +describe('account-operator.util', () => { + it('normaliza contas removendo zeros a esquerda', () => { + expect(normalizeConta('0455371844')).toBe('455371844'); + expect(normalizeConta('000187890982')).toBe('187890982'); + }); + + it('compara contas normalizadas', () => { + expect(sameConta('0435288088', '435288088')).toBeTrue(); + expect(sameConta('172593311', '172593840')).toBeFalse(); + }); + + it('resolve empresa por conta com regras deterministicas obrigatorias', () => { + expect(resolveEmpresaByConta('455371844', [])).toBe('VIVO MACROPHONY'); + expect(resolveEmpresaByConta('460161507', [])).toBe('VIVO MACROPHONY'); + expect(resolveEmpresaByConta('187890982', [])).toBe('CLARO LINE MÓVEL'); + expect(resolveEmpresaByConta('TIM', [])).toBe('TIM LINE MÓVEL'); + }); + + it('mescla lista da API com defaults sem perder contas obrigatorias', () => { + const merged = mergeAccountCompaniesWithDefaults([ + { empresa: 'VIVO MACROPHONY', contas: ['0430237019'] }, + ]); + + const vivo = merged.find((group) => group.empresa === 'VIVO MACROPHONY'); + const contas = (vivo?.contas ?? []).map((value) => normalizeConta(value)); + + expect(contas).toContain(normalizeConta('455371844')); + expect(contas).toContain(normalizeConta('460161507')); + expect(contas).toContain(normalizeConta('0430237019')); + }); + + it('classifica operadora e grupo da vivo por contexto', () => { + const vivo = resolveOperadoraContext({ + conta: '455371844', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(vivo.operadora).toBe('VIVO'); + expect(vivo.vivoEmpresaGrupo).toBe('MACROPHONY'); + + const claro = resolveOperadoraContext({ + conta: '187890982', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(claro.operadora).toBe('CLARO'); + expect(claro.vivoEmpresaGrupo).toBeNull(); + + const tim = resolveOperadoraContext({ + empresaConta: 'TIM LINE MÓVEL', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(tim.operadora).toBe('TIM'); + + const timByConta = resolveOperadoraContext({ + conta: 'TIM', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(timByConta.operadora).toBe('TIM'); + }); + + it('prioriza mapeamento deterministico por conta mesmo com empresa da linha divergente', () => { + const vivoDeterministico = resolveOperadoraContext({ + conta: '455371844', + empresaConta: 'VIVO LINE MÓVEL', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + + expect(vivoDeterministico.operadora).toBe('VIVO'); + expect(vivoDeterministico.empresaConta).toBe('VIVO MACROPHONY'); + expect(vivoDeterministico.vivoEmpresaGrupo).toBe('MACROPHONY'); + }); +}); diff --git a/src/app/utils/account-operator.util.ts b/src/app/utils/account-operator.util.ts new file mode 100644 index 0000000..3dab6fc --- /dev/null +++ b/src/app/utils/account-operator.util.ts @@ -0,0 +1,176 @@ +import { normalizeAccentInsensitive } from './text-normalization.util'; + +export type OperadoraNome = 'VIVO' | 'CLARO' | 'TIM' | 'OUTRA'; +export type OperadoraFiltro = 'TODOS' | 'VIVO' | 'CLARO' | 'TIM'; +export type VivoEmpresaGrupo = 'MACROPHONY' | 'LINE MOVEL' | 'OUTRA'; + +export interface AccountCompanyOption { + empresa: string; + contas: string[]; +} + +export interface OperadoraResolution { + operadora: OperadoraNome; + empresaConta: string; + vivoEmpresaGrupo: VivoEmpresaGrupo | null; +} + +export const DEFAULT_ACCOUNT_COMPANIES: AccountCompanyOption[] = [ + { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840', '187890982'] }, + { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844', '455371844', '460161507'] }, + { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] }, + { empresa: 'TIM LINE MÓVEL', contas: ['TIM'] }, +]; + +const DEFAULT_EMPRESA_BY_CONTA = buildDefaultEmpresaByConta(); + +function buildDefaultEmpresaByConta(): Map { + const result = new Map(); + + DEFAULT_ACCOUNT_COMPANIES.forEach((group) => { + (group.contas ?? []).forEach((conta) => { + const normalized = normalizeConta(conta); + if (!normalized) return; + result.set(normalized, group.empresa); + }); + }); + + return result; +} + +function normalizeEmpresaKey(value: unknown): string { + return normalizeAccentInsensitive(value, 'upper').replace(/[^A-Z0-9]/g, ''); +} + +function normalizeContas(contas: unknown): string[] { + if (!Array.isArray(contas)) return []; + + const result: string[] = []; + const seen = new Set(); + + contas.forEach((value) => { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return; + const normalized = normalizeConta(trimmed); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + result.push(trimmed); + }); + + return result; +} + +export function normalizeConta(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + + if (!/^\d+$/.test(raw)) { + return normalizeAccentInsensitive(raw, 'upper'); + } + + const noLeadingZero = raw.replace(/^0+/, ''); + return noLeadingZero || '0'; +} + +export function sameConta(a: unknown, b: unknown): boolean { + return normalizeConta(a) === normalizeConta(b); +} + +export function mergeAccountCompaniesWithDefaults( + source: AccountCompanyOption[] | null | undefined +): AccountCompanyOption[] { + const merged = new Map(); + const contaSeenByEmpresa = new Map>(); + + const addGroup = (empresaRaw: unknown, contasRaw: unknown) => { + const empresa = String(empresaRaw ?? '').trim(); + if (!empresa) return; + + const key = normalizeEmpresaKey(empresa); + const contas = normalizeContas(contasRaw); + + if (!merged.has(key)) { + merged.set(key, { empresa, contas: [] }); + contaSeenByEmpresa.set(key, new Set()); + } + + const record = merged.get(key); + const seen = contaSeenByEmpresa.get(key); + if (!record || !seen) return; + + contas.forEach((conta) => { + const normalized = normalizeConta(conta); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + record.contas.push(conta); + }); + }; + + (source ?? []).forEach((group) => addGroup(group?.empresa, group?.contas)); + DEFAULT_ACCOUNT_COMPANIES.forEach((group) => addGroup(group.empresa, group.contas)); + + return Array.from(merged.values()); +} + +export function resolveEmpresaByConta( + conta: unknown, + accountCompanies: AccountCompanyOption[] | null | undefined +): string { + const target = normalizeConta(conta); + if (!target) return ''; + + const deterministic = DEFAULT_EMPRESA_BY_CONTA.get(target); + if (deterministic) return deterministic; + + const found = (accountCompanies ?? []).find((group) => + (group.contas ?? []).some((candidate) => sameConta(candidate, target)) + ); + return found?.empresa ?? ''; +} + +function resolveOperadoraByEmpresa(empresa: unknown): OperadoraNome { + const normalized = normalizeEmpresaKey(empresa); + if (!normalized) return 'OUTRA'; + if (normalized.includes('CLARO')) return 'CLARO'; + if (normalized.includes('TIM')) return 'TIM'; + if (normalized.includes('VIVO') || normalized.includes('MACROPHONY')) return 'VIVO'; + return 'OUTRA'; +} + +function resolveVivoEmpresaGrupo(empresa: unknown): VivoEmpresaGrupo { + const normalized = normalizeEmpresaKey(empresa); + if (!normalized) return 'OUTRA'; + if (normalized.includes('MACROPHONY')) return 'MACROPHONY'; + if (normalized.includes('LINEMOVEL') || normalized.includes('LINEMOV')) return 'LINE MOVEL'; + return 'OUTRA'; +} + +export function resolveOperadoraContext(input: { + conta?: unknown; + empresaConta?: unknown; + accountCompanies?: AccountCompanyOption[] | null; +}): OperadoraResolution { + const contaRaw = String(input.conta ?? '').trim(); + const contaEmpresaRaw = String(input.empresaConta ?? '').trim(); + const empresaFromConta = resolveEmpresaByConta(input.conta, input.accountCompanies); + // Regras por conta (determinísticas) têm prioridade sobre texto livre da linha. + const empresaConta = empresaFromConta || contaEmpresaRaw; + + let operadora = resolveOperadoraByEmpresa(empresaConta); + if (operadora === 'OUTRA' && empresaFromConta) { + operadora = resolveOperadoraByEmpresa(empresaFromConta); + } + if (operadora === 'OUTRA' && contaRaw) { + operadora = resolveOperadoraByEmpresa(contaRaw); + } + + const vivoEmpresaGrupo = operadora === 'VIVO' + ? resolveVivoEmpresaGrupo(empresaConta || empresaFromConta || contaRaw) + : null; + + return { + operadora, + empresaConta: empresaConta || '', + vivoEmpresaGrupo, + }; +}