486 lines
14 KiB
TypeScript
486 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 RelatoriosDashboardDto = {
|
||
kpis: DashboardKpisDto;
|
||
|
||
topClientes: TopClienteDto[];
|
||
|
||
serieMuregUltimos12Meses: SerieMesDto[];
|
||
serieTrocaUltimos12Meses: SerieMesDto[];
|
||
|
||
muregsRecentes: MuregRecenteDto[];
|
||
trocasRecentes: TrocaRecenteDto[];
|
||
|
||
// ✅ vigência
|
||
serieVigenciaEncerramentosProx12Meses: SerieMesDto[];
|
||
vigenciaBuckets: VigenciaBucketsDto;
|
||
};
|
||
|
||
@Component({
|
||
selector: 'app-relatorios',
|
||
standalone: true,
|
||
imports: [CommonModule],
|
||
templateUrl: './relatorios.html',
|
||
styleUrls: ['./relatorios.scss'],
|
||
})
|
||
export class Relatorios 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;
|
||
|
||
// ✅ Paletas "padrão de dashboard" (fácil de entender)
|
||
private readonly STATUS_COLORS = {
|
||
ativos: '#2E7D32', // verde
|
||
perdaRoubo: '#D32F2F', // vermelho
|
||
bloq120: '#F57C00', // laranja
|
||
reservas: '#1976D2', // azul
|
||
outros: '#607D8B', // cinza
|
||
};
|
||
|
||
private readonly VIG_COLORS = {
|
||
vencidos: '#D32F2F', // vermelho
|
||
d0a30: '#F57C00', // laranja
|
||
d31a60: '#FBC02D', // amarelo
|
||
d61a90: '#1976D2', // azul
|
||
acima90: '#2E7D32', // verde
|
||
};
|
||
|
||
constructor(
|
||
private http: HttpClient,
|
||
@Inject(PLATFORM_ID) private platformId: object
|
||
) {
|
||
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 Relatórios. Verifique se a API está rodando e o endpoint /api/relatorios/dashboard está acessível.';
|
||
}
|
||
}
|
||
|
||
private async fetchDashboardReal(): Promise<RelatoriosDashboardDto> {
|
||
if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts');
|
||
const url = `${this.baseApi}/relatorios/dashboard`;
|
||
return await firstValueFrom(this.http.get<RelatoriosDashboardDto>(url));
|
||
}
|
||
|
||
private applyDto(dto: RelatoriosDashboardDto) {
|
||
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();
|
||
|
||
// ✅ Status das linhas (paleta padrão)
|
||
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: [
|
||
this.STATUS_COLORS.ativos,
|
||
this.STATUS_COLORS.perdaRoubo,
|
||
this.STATUS_COLORS.bloq120,
|
||
this.STATUS_COLORS.reservas,
|
||
this.STATUS_COLORS.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: '#1976D2', // azul padrão
|
||
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: [
|
||
this.VIG_COLORS.vencidos,
|
||
this.VIG_COLORS.d0a30,
|
||
this.VIG_COLORS.d31a60,
|
||
this.VIG_COLORS.d61a90,
|
||
this.VIG_COLORS.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: '#6A1B9A', // roxo (bem comum em dashboards)
|
||
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: '#00897B', // teal (bem comum)
|
||
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');
|
||
}
|
||
}
|