1470 lines
49 KiB
TypeScript
1470 lines
49 KiB
TypeScript
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<number | null> | 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<HTMLCanvasElement>;
|
|
@ViewChild('chartTroca12') chartTroca12?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartStatusPie') chartStatusPie?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartAdicionaisComparativo') chartAdicionaisComparativo?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartVigenciaMesAno') chartVigenciaMesAno?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartVigenciaSupervisao') chartVigenciaSupervisao?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartLinhasPorFranquia') chartLinhasPorFranquia?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartAdicionaisPagos') chartAdicionaisPagos?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartTipoChip') chartTipoChip?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartTravelMundo') chartTravelMundo?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartResumoPfPjLinhas') chartResumoPfPjLinhas?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartResumoTopPlanos') chartResumoTopPlanos?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartResumoTopClientes') chartResumoTopClientes?: ElementRef<HTMLCanvasElement>;
|
|
@ViewChild('chartResumoReservaDdd') chartResumoReservaDdd?: ElementRef<HTMLCanvasElement>;
|
|
|
|
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<number | null> = [];
|
|
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<any>(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<DashboardDto> {
|
|
if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado');
|
|
const url = `${this.baseApi}/relatorios/dashboard`;
|
|
return await firstValueFrom(this.http.get<DashboardDto>(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<string, number>();
|
|
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<string, number>();
|
|
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<string, { label: string; value: number; total: number | null }>();
|
|
|
|
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<number | null> = [];
|
|
|
|
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<void> {
|
|
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<any>(`${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<string>();
|
|
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'
|
|
}
|
|
};
|
|
}
|
|
}
|