line-gestao-frontend/src/app/pages/dashboard/dashboard.ts

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'
}
};
}
}