503 lines
14 KiB
TypeScript
503 lines
14 KiB
TypeScript
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', '0–30 dias', '31–60 dias', '61–90 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;
|
||
}
|
||
}
|