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

503 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Component,
AfterViewInit,
OnInit,
OnDestroy,
ViewChild,
ElementRef,
Inject,
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { PLATFORM_ID } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import Chart from 'chart.js/auto';
type KpiCard = {
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[];
// ✅ vigência
serieVigenciaEncerramentosProx12Meses: SerieMesDto[];
vigenciaBuckets: VigenciaBucketsDto;
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './dashboard.html',
styleUrls: ['./dashboard.scss'],
})
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('chartMureg12') chartMureg12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartTroca12') chartTroca12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartStatusPie') chartStatusPie?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaMesAno') chartVigenciaMesAno?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaSupervisao') chartVigenciaSupervisao?: ElementRef<HTMLCanvasElement>;
loading = true;
errorMsg: string | null = null;
kpis: KpiCard[] = [];
muregLabels: string[] = [];
muregValues: number[] = [];
trocaLabels: string[] = [];
trocaValues: number[] = [];
vigenciaLabels: string[] = [];
vigenciaValues: number[] = [];
vigBuckets: VigenciaBucketsDto = {
vencidos: 0,
aVencer0a30: 0,
aVencer31a60: 0,
aVencer61a90: 0,
acima90: 0,
};
topClientes: TopClienteDto[] = [];
muregsRecentes: MuregRecenteDto[] = [];
trocasRecentes: TrocaRecenteDto[] = [];
statusResumo = {
total: 0,
ativos: 0,
perdaRoubo: 0,
bloq120: 0,
reservas: 0,
outras: 0,
};
private viewReady = false;
private dataReady = false;
private chartMureg?: Chart;
private chartTroca?: Chart;
private chartPie?: Chart;
private chartVigMesAno?: Chart;
private chartVigSuper?: Chart;
private readonly baseApi: string;
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object,
private hostRef: ElementRef<HTMLElement>
) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
ngOnInit(): void {
this.loadDashboard();
}
ngAfterViewInit(): void {
this.viewReady = true;
this.tryBuildCharts();
}
ngOnDestroy(): void {
this.destroyCharts();
}
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 {
this.loading = false;
this.errorMsg =
'Falha ao carregar Dashboard. Verifique se a API está rodando e o endpoint /api/dashboard está acessível.';
}
}
private async fetchDashboardReal(): Promise<DashboardDto> {
if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts');
const url = `${this.baseApi}/dashboard`;
return await firstValueFrom(this.http.get<DashboardDto>(url));
}
private applyDto(dto: DashboardDto) {
const k = dto.kpis;
this.kpis = [
{ title: 'Linhas', value: this.formatInt(k.totalLinhas), icon: 'bi bi-sim', hint: 'Total cadastradas' },
{ title: 'Clientes', value: this.formatInt(k.clientesUnicos), icon: 'bi bi-building', hint: 'Clientes únicos' },
{ title: 'Ativos', value: this.formatInt(k.ativos), icon: 'bi bi-check2-circle', hint: 'Linhas ativas' },
{ title: 'Bloqueados', value: this.formatInt(k.bloqueados), icon: 'bi bi-slash-circle', hint: 'Somatório de bloqueios' },
{ title: 'Reservas', value: this.formatInt(k.reservas), icon: 'bi bi-inboxes', hint: 'Linhas em reserva' },
{ title: 'MUREGs (30d)', value: this.formatInt(k.muregsUltimos30Dias), icon: 'bi bi-arrow-repeat', hint: 'Últimos 30 dias' },
{ title: 'Trocas (30d)', value: this.formatInt(k.trocasUltimos30Dias), icon: 'bi bi-shuffle', hint: 'Últimos 30 dias' },
{ title: 'Vencidos', value: this.formatInt(k.vigenciaVencidos), icon: 'bi bi-exclamation-triangle', hint: 'Vigência vencida' },
{ title: 'A vencer (30d)', value: this.formatInt(k.vigenciaAVencer30), icon: 'bi bi-calendar2-week', hint: 'Vigência a vencer' },
{ title: 'Registros', value: this.formatInt(k.userDataRegistros), icon: 'bi bi-person-vcard', hint: 'Cadastros de usuário' },
];
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.topClientes = dto.topClientes || [];
this.muregsRecentes = dto.muregsRecentes || [];
this.trocasRecentes = dto.trocasRecentes || [];
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,
};
}
private tryBuildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
if (!this.viewReady || !this.dataReady) return;
requestAnimationFrame(() => {
const canvases = [
this.chartStatusPie?.nativeElement,
this.chartVigenciaMesAno?.nativeElement,
this.chartVigenciaSupervisao?.nativeElement,
this.chartMureg12?.nativeElement,
this.chartTroca12?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[];
if (canvases.length === 0) return;
// evita render quando canvas ainda não mediu (bug comum que "some")
if (canvases.some(c => c.clientWidth === 0 || c.clientHeight === 0)) {
requestAnimationFrame(() => this.tryBuildCharts());
return;
}
this.buildCharts();
});
}
private buildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
this.destroyCharts();
const palette = this.getPalette();
// ✅ Status das linhas (paleta do sistema)
const cP = this.chartStatusPie?.nativeElement;
if (cP) {
this.chartPie = new Chart(cP, {
type: 'doughnut',
data: {
labels: [
'Ativos',
'Bloqueadas (perda/roubo)',
'Bloqueadas (120 dias)',
'Reservas',
'Bloqueadas (outros)'
],
datasets: [{
data: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras,
],
borderWidth: 1,
backgroundColor: [
palette.status.ativos,
palette.status.perdaRoubo,
palette.status.bloq120,
palette.status.reservas,
palette.status.outros,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ Contratos a encerrar (próximos 12 meses) - barra azul padrão
const cV1 = this.chartVigenciaMesAno?.nativeElement;
if (cV1) {
this.chartVigMesAno = new Chart(cV1, {
type: 'bar',
data: {
labels: this.vigenciaLabels,
datasets: [{
label: 'Encerramentos',
data: this.vigenciaValues,
borderWidth: 0,
backgroundColor: palette.series.vigencia,
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Vigência (supervisão) - paleta por urgência
const cV2 = this.chartVigenciaSupervisao?.nativeElement;
if (cV2) {
this.chartVigSuper = new Chart(cV2, {
type: 'doughnut',
data: {
labels: ['Vencidos', '030 dias', '3160 dias', '6190 dias', '> 90 dias'],
datasets: [{
data: [
this.vigBuckets.vencidos,
this.vigBuckets.aVencer0a30,
this.vigBuckets.aVencer31a60,
this.vigBuckets.aVencer61a90,
this.vigBuckets.acima90,
],
borderWidth: 1,
backgroundColor: [
palette.vigencia.vencidos,
palette.vigencia.d0a30,
palette.vigencia.d31a60,
palette.vigencia.d61a90,
palette.vigencia.acima90,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ MUREG 12 meses
const cM = this.chartMureg12?.nativeElement;
if (cM) {
this.chartMureg = new Chart(cM, {
type: 'bar',
data: {
labels: this.muregLabels,
datasets: [{
label: 'MUREG',
data: this.muregValues,
borderWidth: 0,
backgroundColor: palette.series.mureg,
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Troca 12 meses
const cT = this.chartTroca12?.nativeElement;
if (cT) {
this.chartTroca = new Chart(cT, {
type: 'bar',
data: {
labels: this.trocaLabels,
datasets: [{
label: 'Troca',
data: this.trocaValues,
borderWidth: 0,
backgroundColor: palette.series.troca,
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
}
private destroyCharts() {
try { this.chartMureg?.destroy(); } catch {}
try { this.chartTroca?.destroy(); } catch {}
try { this.chartPie?.destroy(); } catch {}
try { this.chartVigMesAno?.destroy(); } catch {}
try { this.chartVigSuper?.destroy(); } catch {}
this.chartMureg = undefined;
this.chartTroca = undefined;
this.chartPie = undefined;
this.chartVigMesAno = undefined;
this.chartVigSuper = undefined;
}
private formatInt(v: number) {
return (v || 0).toLocaleString('pt-BR');
}
private getPalette() {
return {
status: {
ativos: this.readCssVar('--chart-blue', '#030FAA'),
perdaRoubo: this.readCssVar('--chart-pink-dark', '#B832A8'),
bloq120: this.readCssVar('--chart-violet', '#6A55FF'),
reservas: this.readCssVar('--chart-pink-soft', '#F3B0E8'),
outros: this.readCssVar('--chart-blue-soft', 'rgba(3, 15, 170, 0.2)'),
},
vigencia: {
vencidos: this.readCssVar('--chart-pink', '#E33DCF'),
d0a30: this.readCssVar('--chart-violet', '#6A55FF'),
d31a60: this.readCssVar('--chart-blue', '#030FAA'),
d61a90: this.readCssVar('--chart-pink-dark', '#B832A8'),
acima90: this.readCssVar('--chart-pink-soft', '#F3B0E8'),
},
series: {
vigencia: this.readCssVar('--chart-blue', '#030FAA'),
mureg: this.readCssVar('--chart-pink', '#E33DCF'),
troca: this.readCssVar('--chart-violet', '#6A55FF'),
},
};
}
private readCssVar(name: string, fallback: string) {
if (!isPlatformBrowser(this.platformId)) return fallback;
const styles = getComputedStyle(this.hostRef.nativeElement);
const value = styles.getPropertyValue(name).trim();
return value || fallback;
}
}