import { Component, AfterViewInit, OnInit, OnDestroy, ViewChild, ElementRef, Inject, } from '@angular/core'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; import { PLATFORM_ID } from '@angular/core'; import { RouterModule } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; import Chart from 'chart.js/auto'; import { ResumoService, ResumoResponse, LineTotal, } from '../../services/resumo.service'; // --- Interfaces (Mantidas intactas para não quebrar contrato) --- type KpiCard = { key: string; title: string; value: string; icon: string; hint?: string; }; type SerieMesDto = { mes: string; total: number; }; type TopClienteDto = { cliente: string; linhas: number; }; type MuregRecenteDto = { id: string; item: number; linhaAntiga?: string | null; linhaNova?: string | null; iccid?: string | null; dataDaMureg?: string | null; cliente?: string | null; mobileLineId: string; }; type TrocaRecenteDto = { id: string; item: number; linhaAntiga?: string | null; linhaNova?: string | null; iccid?: string | null; dataTroca?: string | null; motivo?: string | null; }; type VigenciaBucketsDto = { vencidos: number; aVencer0a30: number; aVencer31a60: number; aVencer61a90: number; acima90: number; }; type DashboardKpisDto = { totalLinhas: number; clientesUnicos: number; ativos: number; bloqueados: number; reservas: number; bloqueadosPerdaRoubo: number; bloqueados120Dias: number; bloqueadosOutros: number; totalMuregs: number; muregsUltimos30Dias: number; totalTrocas: number; trocasUltimos30Dias: number; totalVigenciaLinhas: number; vigenciaVencidos: number; vigenciaAVencer30: number; userDataRegistros: number; userDataComCpf: number; userDataComEmail: number; }; type DashboardDto = { kpis: DashboardKpisDto; topClientes: TopClienteDto[]; serieMuregUltimos12Meses: SerieMesDto[]; serieTrocaUltimos12Meses: SerieMesDto[]; muregsRecentes: MuregRecenteDto[]; trocasRecentes: TrocaRecenteDto[]; serieVigenciaEncerramentosProx12Meses: SerieMesDto[]; vigenciaBuckets: VigenciaBucketsDto; }; type InsightsChartSeries = { labels?: string[] | null; values?: number[] | null; totals?: Array | null; }; type InsightsKpisVivo = { qtdLinhas?: number | null; totalFranquiaGb?: number | null; totalBaseMensal?: number | null; totalAdicionaisMensal?: number | null; totalGeralMensal?: number | null; mediaPorLinha?: number | null; minPorLinha?: number | null; maxPorLinha?: number | null; }; type InsightsKpisTravel = { comTravel?: number | null; semTravel?: number | null; totalValue?: number | null; }; type InsightsKpisAdicionais = { totalLinesWithAnyPaidAdditional?: number | null; totalLinesWithNoPaidAdditional?: number | null; }; type InsightsLineTotal = { tipo?: string | null; qtdLinhas?: number | null; valorTotalLine?: number | null; lucroTotalLine?: number | null; }; type DashboardGeralInsightsDto = { kpis?: { totalLinhas?: number | null; totalAtivas?: number | null; vivo?: InsightsKpisVivo | null; travelMundo?: InsightsKpisTravel | null; adicionais?: InsightsKpisAdicionais | null; totaisLine?: InsightsLineTotal[] | null; } | null; charts?: { linhasPorFranquia?: InsightsChartSeries | null; adicionaisPagosPorServico?: InsightsChartSeries | null; travelMundo?: InsightsChartSeries | null; tipoChip?: InsightsChartSeries | null; } | null; }; type DashboardLineListItemDto = { gestaoVozDados?: number | null; skeelo?: number | null; vivoNewsPlus?: number | null; vivoTravelMundo?: number | null; vivoSync?: number | null; vivoGestaoDispositivo?: number | null; tipoDeChip?: string | null; }; type DashboardLinesPageDto = { page: number; pageSize: number; total: number; items: DashboardLineListItemDto[]; }; type ResumoTopCliente = { cliente: string; linhas: number; }; type ResumoTopPlano = { plano: string; linhas: number; }; type ResumoTopReserva = { ddd: string; total: number; linhas: number; }; type ResumoDiferencaPjPf = { pfLinhas: number | null; pjLinhas: number | null; totalLinhas: number | null; }; @Component({ selector: 'app-dashboard', standalone: true, imports: [CommonModule, RouterModule], templateUrl: './dashboard.html', styleUrls: ['./dashboard.scss'], }) export class Dashboard implements OnInit, AfterViewInit, OnDestroy { // Chart Elements Refs @ViewChild('chartMureg12') chartMureg12?: ElementRef; @ViewChild('chartTroca12') chartTroca12?: ElementRef; @ViewChild('chartStatusPie') chartStatusPie?: ElementRef; @ViewChild('chartAdicionaisComparativo') chartAdicionaisComparativo?: ElementRef; @ViewChild('chartVigenciaMesAno') chartVigenciaMesAno?: ElementRef; @ViewChild('chartVigenciaSupervisao') chartVigenciaSupervisao?: ElementRef; @ViewChild('chartLinhasPorFranquia') chartLinhasPorFranquia?: ElementRef; @ViewChild('chartAdicionaisPagos') chartAdicionaisPagos?: ElementRef; @ViewChild('chartTipoChip') chartTipoChip?: ElementRef; @ViewChild('chartTravelMundo') chartTravelMundo?: ElementRef; @ViewChild('chartResumoPfPjLinhas') chartResumoPfPjLinhas?: ElementRef; @ViewChild('chartResumoTopPlanos') chartResumoTopPlanos?: ElementRef; @ViewChild('chartResumoTopClientes') chartResumoTopClientes?: ElementRef; @ViewChild('chartResumoReservaDdd') chartResumoReservaDdd?: ElementRef; loading = true; errorMsg: string | null = null; kpis: KpiCard[] = []; // Chart Data Holders muregLabels: string[] = []; muregValues: number[] = []; trocaLabels: string[] = []; trocaValues: number[] = []; vigenciaLabels: string[] = []; vigenciaValues: number[] = []; vigBuckets: VigenciaBucketsDto = { vencidos: 0, aVencer0a30: 0, aVencer31a60: 0, aVencer61a90: 0, acima90: 0, }; statusResumo = { total: 0, ativos: 0, perdaRoubo: 0, bloq120: 0, reservas: 0, outras: 0, }; adicionaisComparativo = { com: 0, sem: 0, total: 0, pctCom: '0,0%', pctSem: '0,0%', }; insightsLoading = false; insightsError: string | null = null; insights: DashboardGeralInsightsDto | null = null; private dashboardRaw: DashboardKpisDto | null = null; franquiaLabels: string[] = []; franquiaValues: number[] = []; adicionaisLabels: string[] = []; adicionaisValues: number[] = []; adicionaisTotals: Array = []; travelLabels: string[] = []; travelValues: number[] = []; tipoChipLabels: string[] = []; tipoChipValues: number[] = []; private fallbackInsightsLoading = false; private readonly adicionaisLabelsPadrao = [ 'GESTÃO VOZ E DADOS', 'SKEELO', 'VIVO NEWS PLUS', 'VIVO TRAVEL MUNDO', 'VIVO SYNC', 'VIVO GESTÃO DISPOSITIVO', ]; resumoLoading = false; resumoError: string | null = null; resumo: ResumoResponse | null = null; resumoTopN = 5; resumoTopOptions = [5, 10, 15]; // Resumo Derived Data resumoTopClientes: ResumoTopCliente[] = []; resumoTopPlanos: ResumoTopPlano[] = []; resumoTopReserva: ResumoTopReserva[] = []; resumoPfPjLabels: string[] = []; resumoPfPjValues: number[] = []; resumoPlanosLabels: string[] = []; resumoPlanosValues: number[] = []; resumoClientesLabels: string[] = []; resumoClientesValues: number[] = []; resumoReservaLabels: string[] = []; resumoReservaValues: number[] = []; resumoDiferencaPjPf: ResumoDiferencaPjPf = { pfLinhas: null, pjLinhas: null, totalLinhas: null, }; private viewReady = false; private dataReady = false; private resumoReady = false; private chartRetryCount = 0; private resumoChartRetryCount = 0; private readonly chartRetryLimit = 8; private readonly resumoChartRetryLimit = 8; // Chart Instances private chartMureg?: Chart; private chartTroca?: Chart; private chartPie?: Chart; private chartAdicionaisComparativoDoughnut?: Chart; private chartVigMesAno?: Chart; private chartVigSuper?: Chart; private chartFranquia?: Chart; private chartAdicionais?: Chart; private chartTravel?: Chart; private chartTipoChipDistribuicao?: Chart; private chartResumoPfPj?: Chart; private chartResumoPlanos?: Chart; private chartResumoClientes?: Chart; private chartResumoReserva?: Chart; private readonly baseApi: string; constructor( private http: HttpClient, private resumoService: ResumoService, @Inject(PLATFORM_ID) private platformId: object ) { const raw = (environment.apiUrl || '').replace(/\/+$/, ''); this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; } ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; this.loadDashboard(); this.loadInsights(); this.loadResumoExecutive(); } ngAfterViewInit(): void { this.viewReady = true; this.tryBuildCharts(); this.tryBuildResumoCharts(); } ngOnDestroy(): void { this.destroyCharts(); this.destroyResumoCharts(); } private async loadDashboard() { this.loading = true; this.errorMsg = null; this.dataReady = false; try { const dto = await this.fetchDashboardReal(); this.applyDto(dto); this.dataReady = true; this.loading = false; this.tryBuildCharts(); } catch (error) { this.loading = false; this.dashboardRaw = null; this.kpis = []; this.errorMsg = this.isNetworkError(error) ? 'Falha ao carregar o Dashboard. Verifique a conexão.' : null; } } private isNetworkError(error: unknown): boolean { if (error instanceof HttpErrorResponse) { return error.status === 0; } if (error instanceof TypeError) { const message = (error.message ?? '').toLowerCase(); return message.includes('failed to fetch') || message.includes('network'); } return false; } private loadInsights() { if (!isPlatformBrowser(this.platformId)) return; this.insightsLoading = true; this.insightsError = null; const url = `${this.baseApi}/dashboard/geral/insights`; this.http.get(url).subscribe({ next: (dto) => { this.applyInsights(dto || null); this.insightsLoading = false; this.tryBuildCharts(); }, error: () => { this.insightsLoading = false; this.insightsError = 'Falha nos insights.'; this.clearInsightsData(); void this.loadFallbackFromLinesIfNeeded(true); }, }); } private loadResumoExecutive() { if (!isPlatformBrowser(this.platformId)) return; this.resumoLoading = true; this.resumoError = null; this.resumoReady = false; this.resumoService.getResumo().subscribe({ next: (dto) => { this.resumo = dto ? this.normalizeResumo(dto) : null; this.resumoLoading = false; this.resumoReady = true; this.buildResumoDerived(); this.tryBuildResumoCharts(); }, error: () => { this.resumoLoading = false; this.resumoError = 'Falha ao carregar dados do resumo.'; this.resumo = null; this.resumoReady = false; this.clearResumoDerived(); }, }); } onResumoTopNChange() { this.buildResumoDerived(); this.tryBuildResumoCharts(); } private async fetchDashboardReal(): 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)); } private applyDto(dto: DashboardDto) { const k = dto.kpis; this.dashboardRaw = k; this.muregLabels = (dto.serieMuregUltimos12Meses || []).map(x => x.mes); this.muregValues = (dto.serieMuregUltimos12Meses || []).map(x => x.total); this.trocaLabels = (dto.serieTrocaUltimos12Meses || []).map(x => x.mes); this.trocaValues = (dto.serieTrocaUltimos12Meses || []).map(x => x.total); this.vigenciaLabels = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.mes); this.vigenciaValues = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.total); this.vigBuckets = dto.vigenciaBuckets || this.vigBuckets; this.statusResumo = { total: k.totalLinhas ?? 0, ativos: k.ativos ?? 0, perdaRoubo: k.bloqueadosPerdaRoubo ?? 0, bloq120: k.bloqueados120Dias ?? 0, reservas: k.reservas ?? 0, outras: k.bloqueadosOutros ?? 0, }; this.rebuildPrimaryKpis(); } private applyInsights(rawDto: DashboardGeralInsightsDto | null) { const dto = this.normalizeInsightsDto(rawDto); this.insights = dto; const charts = dto?.charts ?? {}; const franquia = charts?.linhasPorFranquia ?? {}; const adicionais = charts?.adicionaisPagosPorServico ?? {}; const travel = charts?.travelMundo ?? {}; const tipoChip = charts?.tipoChip ?? {}; this.franquiaLabels = (franquia.labels ?? []).map((x) => String(x)); this.franquiaValues = (franquia.values ?? []).map((x) => Number(x ?? 0)); this.adicionaisLabels = (adicionais.labels ?? []).map((x) => String(x)); this.adicionaisValues = (adicionais.values ?? []).map((x) => Number(x ?? 0)); this.adicionaisTotals = (adicionais.totals ?? []).map((x) => x === null ? null : Number(x)); this.travelLabels = (travel.labels && travel.labels.length) ? travel.labels.map((x) => String(x)) : ['Com Travel', 'Sem Travel']; this.travelValues = (travel.values ?? []).map((x) => Number(x ?? 0)); this.tipoChipLabels = (tipoChip.labels && tipoChip.labels.length) ? tipoChip.labels.map((x) => String(x)) : ['e-SIM', 'SIMCARD']; this.tipoChipValues = (tipoChip.values && tipoChip.values.length) ? tipoChip.values.map((x) => Number(x ?? 0)) : [0, 0]; this.ensureAdicionaisSeriesCompleta(); this.rebuildAdicionaisComparativo(dto?.kpis?.adicionais ?? null); this.rebuildPrimaryKpis(); void this.loadFallbackFromLinesIfNeeded(); if (this.resumoReady && this.resumo) { this.buildResumoDerived(); this.tryBuildResumoCharts(); } } private normalizeInsightsDto(rawDto: DashboardGeralInsightsDto | null): DashboardGeralInsightsDto { const raw = (rawDto ?? {}) as any; const kpisRaw = this.readNode(raw, 'kpis', 'Kpis') ?? {}; const vivoRaw = this.readNode(kpisRaw, 'vivo', 'Vivo') ?? {}; const travelRaw = this.readNode(kpisRaw, 'travelMundo', 'TravelMundo') ?? {}; const adicionaisRaw = this.readNode(kpisRaw, 'adicionais', 'Adicionais') ?? {}; const totaisLineRaw = this.readNode(kpisRaw, 'totaisLine', 'TotaisLine'); const chartsRaw = this.readNode(raw, 'charts', 'Charts') ?? {}; return { kpis: { totalLinhas: this.toNumberOrNull(this.readNode(kpisRaw, 'totalLinhas', 'TotalLinhas')), totalAtivas: this.toNumberOrNull(this.readNode(kpisRaw, 'totalAtivas', 'TotalAtivas')), vivo: { qtdLinhas: this.toNumberOrNull(this.readNode(vivoRaw, 'qtdLinhas', 'QtdLinhas')), totalFranquiaGb: this.toNumberOrNull(this.readNode(vivoRaw, 'totalFranquiaGb', 'TotalFranquiaGb')), totalBaseMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalBaseMensal', 'TotalBaseMensal')), totalAdicionaisMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalAdicionaisMensal', 'TotalAdicionaisMensal')), totalGeralMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalGeralMensal', 'TotalGeralMensal')), mediaPorLinha: this.toNumberOrNull(this.readNode(vivoRaw, 'mediaPorLinha', 'MediaPorLinha')), minPorLinha: this.toNumberOrNull(this.readNode(vivoRaw, 'minPorLinha', 'MinPorLinha')), maxPorLinha: this.toNumberOrNull(this.readNode(vivoRaw, 'maxPorLinha', 'MaxPorLinha')), }, travelMundo: { comTravel: this.toNumberOrNull(this.readNode(travelRaw, 'comTravel', 'ComTravel')), semTravel: this.toNumberOrNull(this.readNode(travelRaw, 'semTravel', 'SemTravel')), totalValue: this.toNumberOrNull(this.readNode(travelRaw, 'totalValue', 'TotalValue')), }, adicionais: { totalLinesWithAnyPaidAdditional: this.toNumberOrNull(this.readNode(adicionaisRaw, 'totalLinesWithAnyPaidAdditional', 'TotalLinesWithAnyPaidAdditional')), totalLinesWithNoPaidAdditional: this.toNumberOrNull(this.readNode(adicionaisRaw, 'totalLinesWithNoPaidAdditional', 'TotalLinesWithNoPaidAdditional')), }, totaisLine: this.normalizeLineTotals(totaisLineRaw), }, charts: { linhasPorFranquia: this.normalizeChartSeries(this.readNode(chartsRaw, 'linhasPorFranquia', 'LinhasPorFranquia')), adicionaisPagosPorServico: this.normalizeChartSeries(this.readNode(chartsRaw, 'adicionaisPagosPorServico', 'AdicionaisPagosPorServico')), travelMundo: this.normalizeChartSeries(this.readNode(chartsRaw, 'travelMundo', 'TravelMundo')), tipoChip: this.normalizeChartSeries(this.readNode(chartsRaw, 'tipoChip', 'TipoChip')), }, }; } private normalizeChartSeries(rawSeries: any): InsightsChartSeries { const labelsRaw = this.readNode(rawSeries, 'labels', 'Labels'); const valuesRaw = this.readNode(rawSeries, 'values', 'Values'); const totalsRaw = this.readNode(rawSeries, 'totals', 'Totals'); return { labels: Array.isArray(labelsRaw) ? labelsRaw.map((x: any) => String(x ?? '')) : [], values: Array.isArray(valuesRaw) ? valuesRaw.map((x: any) => Number(this.toNumberOrNull(x) ?? 0)) : [], totals: Array.isArray(totalsRaw) ? totalsRaw.map((x: any) => { const n = this.toNumberOrNull(x); return n === null ? null : Number(n); }) : [], }; } private normalizeLineTotals(rawTotals: any): InsightsLineTotal[] { if (!Array.isArray(rawTotals)) return []; return rawTotals .map((row: any) => ({ tipo: String(this.readNode(row, 'tipo', 'Tipo') ?? '').trim() || null, qtdLinhas: this.toNumberOrNull(this.readNode(row, 'qtdLinhas', 'QtdLinhas')), valorTotalLine: this.toNumberOrNull(this.readNode(row, 'valorTotalLine', 'ValorTotalLine')), lucroTotalLine: this.toNumberOrNull(this.readNode(row, 'lucroTotalLine', 'LucroTotalLine')), })) .filter((row) => !!row.tipo); } private readNode(source: any, ...keys: string[]): any { if (!source || typeof source !== 'object') return undefined; for (const key of keys) { if (Object.prototype.hasOwnProperty.call(source, key)) { return source[key]; } } const entries = Object.keys(source); for (const key of keys) { const found = entries.find((entry) => entry.toLowerCase() === key.toLowerCase()); if (found) { return source[found]; } } return undefined; } private buildResumoDerived() { if (!this.resumo) { this.clearResumoDerived(); return; } const lineTotals = this.getEffectiveLineTotals(); const pf = this.findLineTotal(lineTotals, ['PF', 'PESSOA FISICA']); const pj = this.findLineTotal(lineTotals, ['PJ', 'PESSOA JURIDICA']); const pfLinhas = this.toNumberOrNull(pf?.qtdLinhas) ?? 0; const pjLinhas = this.toNumberOrNull(pj?.qtdLinhas) ?? 0; this.resumoDiferencaPjPf = { pfLinhas, pjLinhas, totalLinhas: pfLinhas + pjLinhas, }; const clientesMap = new Map(); for (const c of this.resumo.vivoLineResumos ?? []) { const cliente = (c.cliente ?? 'N/A').toString(); const linhas = this.toNumberOrNull(c.qtdLinhas) ?? 0; clientesMap.set(cliente, (clientesMap.get(cliente) ?? 0) + linhas); } const clientesRaw: ResumoTopCliente[] = Array.from(clientesMap.entries()).map(([cliente, linhas]) => ({ cliente, linhas, })); this.resumoTopClientes = [...clientesRaw] .sort((a, b) => b.linhas - a.linhas) .slice(0, this.resumoTopN); const planosSource = (this.resumo.planoContratoResumos && this.resumo.planoContratoResumos.length) ? this.resumo.planoContratoResumos : (this.resumo.macrophonyPlans ?? []); const planosRaw = planosSource.map((p) => ({ plano: (p.planoContrato ?? 'Plano').toString(), linhas: this.toNumberOrNull(p.totalLinhas) ?? 0, })); const sortedPlanosLinhas = [...planosRaw].sort((a, b) => b.linhas - a.linhas); this.resumoTopPlanos = sortedPlanosLinhas.slice(0, this.resumoTopN); let reservaRaw = (this.resumo.reservaPorDdd ?? []).map((r: any) => ({ ddd: (r.ddd ?? '-').toString(), total: this.toNumberOrNull(r.totalLinhas) ?? 0, linhas: this.toNumberOrNull(r.totalLinhas) ?? 0, })); if (!reservaRaw.length) { const fallbackLines = this.resumo.reservaLines ?? []; const dddMap = new Map(); fallbackLines.forEach((r: any) => { const key = (r.ddd ?? '-').toString(); const qtd = this.toNumberOrNull(r.qtdLinhas) ?? 0; dddMap.set(key, (dddMap.get(key) ?? 0) + qtd); }); reservaRaw = Array.from(dddMap.entries()).map(([ddd, total]) => ({ ddd, total, linhas: total })); } this.resumoTopReserva = reservaRaw.sort((a, b) => b.total - a.total).slice(0, this.resumoTopN); // Arrays for charts this.resumoPfPjLabels = ['Pessoa Física', 'Pessoa Jurídica']; this.resumoPfPjValues = [pfLinhas, pjLinhas]; this.resumoPlanosLabels = this.resumoTopPlanos.map(p => p.plano); this.resumoPlanosValues = this.resumoTopPlanos.map(p => p.linhas); this.resumoClientesLabels = this.resumoTopClientes.map(c => c.cliente); this.resumoClientesValues = this.resumoTopClientes.map(c => c.linhas); this.resumoReservaLabels = this.resumoTopReserva.map(r => r.ddd); this.resumoReservaValues = this.resumoTopReserva.map(r => r.total); this.rebuildPrimaryKpis(); } private clearResumoDerived() { this.resumoTopClientes = []; this.resumoTopPlanos = []; this.resumoTopReserva = []; this.resumoPfPjLabels = []; this.resumoPfPjValues = []; this.resumoPlanosLabels = []; this.resumoPlanosValues = []; this.resumoClientesLabels = []; this.resumoClientesValues = []; this.resumoReservaLabels = []; this.resumoReservaValues = []; this.resumoDiferencaPjPf = { pfLinhas: null, pjLinhas: null, totalLinhas: null, }; this.destroyResumoCharts(); this.rebuildPrimaryKpis(); } private findLineTotal(list: LineTotal[], keywords: string[]): LineTotal | null { const keys = keywords.map((k) => k.toUpperCase()); for (const item of list) { const label = (item.tipo ?? '').toString().toUpperCase(); if (keys.some((k) => label.includes(k))) return item; } return null; } private getEffectiveLineTotals(): LineTotal[] { const fromInsights = (this.insights?.kpis?.totaisLine ?? []) .map((row) => ({ tipo: row.tipo ?? null, qtdLinhas: row.qtdLinhas ?? null, valorTotalLine: row.valorTotalLine ?? null, lucroTotalLine: row.lucroTotalLine ?? null, })) .filter((row) => !!(row.tipo ?? '').toString().trim()); if (fromInsights.length) return fromInsights; return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : []; } private clearInsightsData() { this.insights = null; this.franquiaLabels = []; this.franquiaValues = []; this.adicionaisLabels = []; this.adicionaisValues = []; this.adicionaisTotals = []; this.travelLabels = []; this.travelValues = []; this.tipoChipLabels = []; this.tipoChipValues = []; this.rebuildAdicionaisComparativo(null); this.destroyInsightsCharts(); this.rebuildPrimaryKpis(); if (this.resumoReady && this.resumo) { this.buildResumoDerived(); this.tryBuildResumoCharts(); } } private ensureAdicionaisSeriesCompleta(): void { const existing = new Map(); this.adicionaisLabels.forEach((label, idx) => { const key = this.normalizeSeriesKey(label); existing.set(key, { label, value: this.adicionaisValues[idx] ?? 0, total: this.adicionaisTotals[idx] ?? null, }); }); const labels: string[] = []; const values: number[] = []; const totals: Array = []; this.adicionaisLabelsPadrao.forEach((label) => { const key = this.normalizeSeriesKey(label); const row = existing.get(key); labels.push(row?.label ?? label); values.push(row?.value ?? 0); totals.push(row?.total ?? null); }); this.adicionaisLabels = labels; this.adicionaisValues = values; this.adicionaisTotals = totals; } private async loadFallbackFromLinesIfNeeded(force = false): Promise { if (!isPlatformBrowser(this.platformId) || this.fallbackInsightsLoading) return; const syncIndex = this.adicionaisLabels.findIndex( (label) => this.normalizeSeriesKey(label) === this.normalizeSeriesKey('VIVO SYNC') ); const syncValue = syncIndex >= 0 ? this.adicionaisValues[syncIndex] ?? 0 : 0; const hasTipoChipData = (this.tipoChipValues?.reduce((acc, value) => acc + (Number(value) || 0), 0) ?? 0) > 0; const needFallback = force || syncIndex < 0 || syncValue <= 0 || !hasTipoChipData; if (!needFallback) return; this.fallbackInsightsLoading = true; try { const pageSize = 500; let page = 1; let processed = 0; let total = 0; const additionalCounts = { gvd: 0, skeelo: 0, news: 0, travel: 0, sync: 0, dispositivo: 0, }; let eSim = 0; let simCard = 0; do { const params = new HttpParams() .set('page', String(page)) .set('pageSize', String(pageSize)); const response = await firstValueFrom( this.http.get(`${this.baseApi}/lines`, { params }) ); const itemsRaw = this.readNode(response, 'items', 'Items'); const totalRaw = this.readNode(response, 'total', 'Total'); const items = Array.isArray(itemsRaw) ? (itemsRaw as DashboardLineListItemDto[]) : []; const parsedTotal = this.toNumberOrNull(totalRaw); total = parsedTotal !== null ? Number(parsedTotal) : (processed + items.length + (items.length === pageSize ? 1 : 0)); items.forEach((line) => { 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; if (this.readLineNumber(line, 'vivoSync', 'VivoSync') > 0) additionalCounts.sync += 1; if (this.readLineNumber(line, 'vivoGestaoDispositivo', 'VivoGestaoDispositivo') > 0) additionalCounts.dispositivo += 1; const chipType = this.normalizeChipType(this.readLineString(line, 'tipoDeChip', 'TipoDeChip')); if (chipType === 'ESIM') { eSim += 1; } else if (chipType === 'SIMCARD') { simCard += 1; } }); processed += items.length; page += 1; } while (processed < total); this.adicionaisLabels = [...this.adicionaisLabelsPadrao]; this.adicionaisValues = [ additionalCounts.gvd, additionalCounts.skeelo, additionalCounts.news, additionalCounts.travel, additionalCounts.sync, additionalCounts.dispositivo, ]; this.adicionaisTotals = this.adicionaisLabelsPadrao.map(() => null); this.tipoChipLabels = ['e-SIM', 'SIMCARD']; this.tipoChipValues = [eSim, simCard]; this.tryBuildCharts(); } catch { // Keep existing data if fallback fails. } finally { this.fallbackInsightsLoading = false; } } private normalizeSeriesKey(value: string): string { return (value ?? '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toUpperCase() .replace(/[^A-Z0-9]/g, ''); } 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; } private readLineString(line: DashboardLineListItemDto, camelCaseKey: keyof DashboardLineListItemDto, pascalCaseKey: string): string { const raw = this.readNode(line as any, String(camelCaseKey), pascalCaseKey) ?? ''; return String(raw); } private normalizeChipType(value: string | null | undefined): 'ESIM' | 'SIMCARD' | '' { const normalized = this.normalizeSeriesKey(value ?? ''); if (!normalized) return ''; if (normalized.includes('ESIM')) return 'ESIM'; if ( normalized.includes('SIM') || normalized.includes('SIMCARD') || normalized.includes('CHIP') || normalized.includes('FISIC') || normalized.includes('CARD') ) { return 'SIMCARD'; } return ''; } private destroyInsightsCharts() { try { this.chartFranquia?.destroy(); } catch {} try { this.chartAdicionais?.destroy(); } catch {} try { this.chartTravel?.destroy(); } catch {} try { this.chartTipoChipDistribuicao?.destroy(); } catch {} } private rebuildAdicionaisComparativo(adicionais: InsightsKpisAdicionais | null): void { const com = this.toNumberOrNull(adicionais?.totalLinesWithAnyPaidAdditional) ?? 0; const sem = this.toNumberOrNull(adicionais?.totalLinesWithNoPaidAdditional) ?? 0; const total = com + sem; this.adicionaisComparativo = { com, sem, total, pctCom: total > 0 ? this.formatPercent((com / total) * 100) : '0,0%', pctSem: total > 0 ? this.formatPercent((sem / total) * 100) : '0,0%', }; } private rebuildPrimaryKpis() { const cards: KpiCard[] = []; const used = new Set(); const add = (key: string, title: string, value: string, icon: string, hint?: string) => { if (used.has(key)) return; used.add(key); cards.push({ key, title, value, icon, hint }); }; const insights = this.insights?.kpis; const dashboard = this.dashboardRaw; if (dashboard) { add('linhas_total', 'Total de Linhas', this.formatInt(dashboard.totalLinhas), 'bi bi-sim-fill', 'Base geral'); add('linhas_ativas', 'Linhas Ativas', this.formatInt(dashboard.ativos), 'bi bi-check2-circle', 'Status ativo'); add('linhas_bloqueadas', 'Linhas Bloqueadas', this.formatInt(dashboard.bloqueados), 'bi bi-slash-circle', 'Todos os bloqueios'); add('linhas_reserva', 'Linhas em Reserva', this.formatInt(dashboard.reservas), 'bi bi-inboxes-fill', 'Base de reserva'); if (insights) { add( 'franquia_vivo_total', 'Total Franquia Vivo', this.formatGb(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0), 'bi bi-diagram-3-fill', 'Soma das franquias (Geral)' ); } add('vig_vencidos', 'Vigencia Vencida', this.formatInt(dashboard.vigenciaVencidos), 'bi bi-exclamation-triangle-fill', 'Prioridade alta'); add('vig_30', 'Vence em 30 dias', this.formatInt(dashboard.vigenciaAVencer30), 'bi bi-calendar2-week-fill', 'Prioridade'); add('mureg_30', 'MUREG 30 dias', this.formatInt(dashboard.muregsUltimos30Dias), 'bi bi-arrow-repeat', 'Movimentacao'); add('troca_30', 'Trocas 30 dias', this.formatInt(dashboard.trocasUltimos30Dias), 'bi bi-shuffle', 'Movimentacao'); add('cadastros_total', 'Cadastros', this.formatInt(dashboard.userDataRegistros), 'bi bi-person-vcard-fill', 'Base de usuarios'); } if (insights) { add( 'travel_com', 'Travel Ativo', this.formatInt(this.toNumberOrNull(insights.travelMundo?.comTravel) ?? 0), 'bi bi-globe-americas', 'Pagina Geral' ); add( 'adicional_pago', 'Com Adicional Pago', this.formatInt(this.toNumberOrNull(insights.adicionais?.totalLinesWithAnyPaidAdditional) ?? 0), 'bi bi-plus-circle-fill', 'Pagina Geral' ); } this.kpis = cards; } // --- CHART BUILDERS (Generic) --- private tryBuildCharts() { if (!isPlatformBrowser(this.platformId)) return; if (!this.viewReady || !this.dataReady) return; requestAnimationFrame(() => { const canvases = [ this.chartStatusPie?.nativeElement, this.chartAdicionaisComparativo?.nativeElement, this.chartVigenciaMesAno?.nativeElement, this.chartVigenciaSupervisao?.nativeElement, this.chartMureg12?.nativeElement, this.chartTroca12?.nativeElement, this.chartLinhasPorFranquia?.nativeElement, this.chartAdicionaisPagos?.nativeElement, this.chartTipoChip?.nativeElement, this.chartTravelMundo?.nativeElement, ].filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length) return; if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { this.scheduleChartRetry(); return; } this.chartRetryCount = 0; this.buildCharts(); }); } private tryBuildResumoCharts() { if (!isPlatformBrowser(this.platformId)) return; if (!this.viewReady || !this.resumoReady || this.resumoLoading || !!this.resumoError) return; requestAnimationFrame(() => { const canvases = [ this.chartResumoTopClientes?.nativeElement, this.chartResumoTopPlanos?.nativeElement, this.chartResumoPfPjLinhas?.nativeElement, this.chartResumoReservaDdd?.nativeElement, ].filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length || canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { this.scheduleResumoChartRetry(); return; } this.resumoChartRetryCount = 0; this.buildResumoCharts(); }); } private scheduleChartRetry(): void { if (this.chartRetryCount >= this.chartRetryLimit) return; this.chartRetryCount += 1; setTimeout(() => this.tryBuildCharts(), 120); } private scheduleResumoChartRetry(): void { if (this.resumoChartRetryCount >= this.resumoChartRetryLimit) return; this.resumoChartRetryCount += 1; setTimeout(() => this.tryBuildResumoCharts(), 120); } private buildCharts() { this.destroyCharts(); const palette = this.getPalette(); // 1. Status Pie if (this.chartStatusPie?.nativeElement) { this.chartPie = new Chart(this.chartStatusPie.nativeElement, { type: 'doughnut', data: { labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'], datasets: [{ data: [ this.statusResumo.ativos, this.statusResumo.perdaRoubo, this.statusResumo.bloq120, this.statusResumo.reservas, this.statusResumo.outras ], borderWidth: 0, backgroundColor: [ palette.status.ativos, palette.status.blocked, palette.status.purple, palette.status.reserve, '#cbd5e1' ], hoverOffset: 4 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '70%', plugins: { legend: { display: false } } } }); } if (this.chartAdicionaisComparativo?.nativeElement) { this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, { type: 'doughnut', data: { labels: ['Com adicionais', 'Sem adicionais'], datasets: [{ data: [this.adicionaisComparativo.com, this.adicionaisComparativo.sem], borderWidth: 0, backgroundColor: [palette.purple, palette.brand], hoverOffset: 4, }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '64%', plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(ctx.raw as number)}`, }, }, }, }, }); } // 2. MUREG & Troca (Bar charts similar to previous but cleaner) if (this.chartMureg12?.nativeElement) { this.chartMureg = this.createBarChart(this.chartMureg12.nativeElement, this.muregLabels, this.muregValues, palette.brand); } if (this.chartTroca12?.nativeElement) { this.chartTroca = this.createBarChart(this.chartTroca12.nativeElement, this.trocaLabels, this.trocaValues, palette.blue); } // 3. Vigencia & Insights if (this.chartVigenciaMesAno?.nativeElement) { this.chartVigMesAno = this.createBarChart(this.chartVigenciaMesAno.nativeElement, this.vigenciaLabels, this.vigenciaValues, palette.purple); } if (this.chartVigenciaSupervisao?.nativeElement) { this.chartVigSuper = new Chart(this.chartVigenciaSupervisao.nativeElement, { type: 'doughnut', data: { labels: ['Vencidos', '0-30d', '31-60d', '61-90d', '>90d'], datasets: [{ data: [this.vigBuckets.vencidos, this.vigBuckets.aVencer0a30, this.vigBuckets.aVencer31a60, this.vigBuckets.aVencer61a90, this.vigBuckets.acima90], backgroundColor: [palette.brand, '#fbbf24', '#3b82f6', '#8b5cf6', '#cbd5e1'], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '65%', plugins: { legend: { position: 'right', labels: { boxWidth: 10, font: { size: 10 } } } } } }); } if (this.chartTravelMundo?.nativeElement) { this.chartTravel = new Chart(this.chartTravelMundo.nativeElement, { type: 'pie', data: { labels: this.travelLabels, datasets: [{ data: this.travelValues, backgroundColor: [palette.blue, '#e2e8f0'], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } } }); } if (this.chartLinhasPorFranquia?.nativeElement) { this.chartFranquia = this.createBarChart( this.chartLinhasPorFranquia.nativeElement, this.franquiaLabels, this.franquiaValues, palette.brand, true ); } if (this.chartAdicionaisPagos?.nativeElement) { this.chartAdicionais = this.createHorizontalBarChart( this.chartAdicionaisPagos.nativeElement, this.adicionaisLabels, this.adicionaisValues, palette.purple, true ); } if (this.chartTipoChip?.nativeElement) { this.chartTipoChipDistribuicao = new Chart(this.chartTipoChip.nativeElement, { type: 'doughnut', data: { labels: this.tipoChipLabels, datasets: [{ data: this.tipoChipValues, backgroundColor: [palette.blue, palette.brand], borderWidth: 0, hoverOffset: 4 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '64%', plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(ctx.raw as number)}` } } } } }); } } private buildResumoCharts() { this.destroyResumoCharts(); const palette = this.getPalette(); if (this.chartResumoTopClientes?.nativeElement && this.resumoClientesValues.length) { this.chartResumoClientes = new Chart(this.chartResumoTopClientes.nativeElement, { type: 'bar', data: { labels: this.resumoClientesLabels.map((label) => label.length > 16 ? `${label.slice(0, 16)}...` : label), datasets: [{ label: 'Linhas', data: this.resumoClientesValues, backgroundColor: '#6a55ff', borderRadius: 8, borderWidth: 0, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', intersect: true }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => ` ${this.formatInt(ctx.raw as number)} linhas`, }, }, }, scales: { x: { beginAtZero: true, border: { display: false } }, y: { border: { display: false } }, }, }, }); } if (this.chartResumoTopPlanos?.nativeElement && this.resumoPlanosValues.length) { this.chartResumoPlanos = this.createBarChart( this.chartResumoTopPlanos.nativeElement, this.resumoPlanosLabels, this.resumoPlanosValues, palette.brand, true ); } if (this.chartResumoReservaDdd?.nativeElement && this.resumoReservaValues.length) { this.chartResumoReserva = this.createBarChart( this.chartResumoReservaDdd.nativeElement, this.resumoReservaLabels, this.resumoReservaValues, palette.blue, true ); } if (this.chartResumoPfPjLinhas?.nativeElement && this.resumoPfPjValues.length) { this.chartResumoPfPj = new Chart(this.chartResumoPfPjLinhas.nativeElement, { type: 'doughnut', data: { labels: this.resumoPfPjLabels, datasets: [{ data: this.resumoPfPjValues, backgroundColor: [palette.blue, palette.purple], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(ctx.raw as number)}` } } } } }); } } // Helper for consistent bar charts private createBarChart( canvas: HTMLCanvasElement, labels: string[], data: number[], color: string | string[], appendLinhasTooltip = false ) { return new Chart(canvas, { type: 'bar', data: { labels: labels.map(l => l.length > 15 ? l.substring(0,15)+'...' : l), datasets: [{ data: data, backgroundColor: color, borderRadius: 6, borderWidth: 0, barThickness: 24, minBarLength: 6, }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', intersect: true }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => appendLinhasTooltip ? ` ${this.formatInt(ctx.raw as number)} Linhas` : ` ${this.formatInt(ctx.raw as number)}` } } }, scales: { x: { grid: { display: false }, ticks: { font: { size: 10 } } }, y: { grid: { color: '#f1f5f9' }, beginAtZero: true, border: { display: false }, ticks: { display: false } } } } }); } private createHorizontalBarChart( canvas: HTMLCanvasElement, labels: string[], data: number[], color: string, appendLinhasTooltip = false ) { return new Chart(canvas, { type: 'bar', data: { labels: labels.map(l => l.length > 15 ? l.substring(0,15)+'...' : l), datasets: [{ data: data, backgroundColor: color, borderRadius: 4, borderWidth: 0, barThickness: 16, minBarLength: 6, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', intersect: true }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => appendLinhasTooltip ? ` ${this.formatInt(ctx.raw as number)} Linhas` : ` ${this.formatInt(ctx.raw as number)}` } } }, scales: { x: { display: false }, y: { grid: { display: false }, border: { display: false }, ticks: { autoSkip: false, font: { size: 10, weight: 'bold' } } } } } }); } private destroyCharts() { this.chartPie?.destroy(); this.chartAdicionaisComparativoDoughnut?.destroy(); this.chartMureg?.destroy(); this.chartTroca?.destroy(); this.chartVigMesAno?.destroy(); this.chartVigSuper?.destroy(); this.chartTravel?.destroy(); this.chartFranquia?.destroy(); this.chartAdicionais?.destroy(); this.chartTipoChipDistribuicao?.destroy(); this.chartPie = undefined; this.chartAdicionaisComparativoDoughnut = undefined; this.chartMureg = undefined; this.chartTroca = undefined; this.chartVigMesAno = undefined; this.chartVigSuper = undefined; this.chartTravel = undefined; this.chartFranquia = undefined; this.chartAdicionais = undefined; this.chartTipoChipDistribuicao = undefined; } private destroyResumoCharts() { this.chartResumoClientes?.destroy(); this.chartResumoPlanos?.destroy(); this.chartResumoReserva?.destroy(); this.chartResumoPfPj?.destroy(); this.chartResumoClientes = undefined; this.chartResumoPlanos = undefined; this.chartResumoReserva = undefined; this.chartResumoPfPj = undefined; } private normalizeResumo(data: ResumoResponse): ResumoResponse { // Helper to ensure arrays are arrays return { ...data, macrophonyPlans: Array.isArray(data.macrophonyPlans) ? data.macrophonyPlans : [], vivoLineResumos: Array.isArray(data.vivoLineResumos) ? data.vivoLineResumos : [], clienteEspeciais: Array.isArray(data.clienteEspeciais) ? data.clienteEspeciais : [], planoContratoResumos: Array.isArray(data.planoContratoResumos) ? data.planoContratoResumos : [], lineTotais: Array.isArray(data.lineTotais) ? data.lineTotais : [], gbDistribuicao: Array.isArray(data.gbDistribuicao) ? data.gbDistribuicao : [], reservaLines: Array.isArray(data.reservaLines) ? data.reservaLines : [], }; } // --- Utils --- formatInt(v: any) { const n = this.toNumberOrNull(v); return n === null ? '0' : n.toLocaleString('pt-BR'); } formatPercent(v: any) { const n = Number(v ?? 0); if (!Number.isFinite(n)) return '0,0%'; return `${n.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; } formatMoneySafe(v: any) { const n = this.toNumberOrNull(v); return n === null ? 'R$ 0,00' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); } formatGb(v: any) { const n = this.toNumberOrNull(v); if (n === null) return '0 GB'; const value = n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return `${value} GB`; } private toNumberOrNull(v: any) { if (v === null || v === undefined || v === '') return null; if (typeof v === 'number') return Number.isFinite(v) ? v : null; const raw = String(v).trim(); if (!raw) return null; let cleaned = raw.replace(/[^\d,.-]/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } const n = Number(cleaned); return Number.isNaN(n) ? null : n; } trackByKpiKey = (_: number, item: KpiCard) => item.key; private getPalette() { return { brand: '#E33DCF', blue: '#030FAA', purple: '#6A55FF', dark: '#B832A8', status: { ativos: '#030FAA', blocked: '#B832A8', purple: '#6A55FF', reserve: '#F3B0E8' } }; } }