diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 44eadb5..0b3a0b1 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -42,7 +42,7 @@
-
+
Carregando...
@@ -57,20 +57,27 @@

Tudo limpo por aqui!

+
+ + + Mostrando {{ notificationsPreviewLimit }} de {{ notifications.length }} notificações + +
+
+ [class.danger]="getNotificationTipo(n) === 'Vencido'" + [class.warn]="getNotificationTipo(n) === 'AVencer'"> + [class.bi-x-lg]="getNotificationTipo(n) === 'Vencido'" + [class.bi-clock-history]="getNotificationTipo(n) === 'AVencer'" + [class.bi-info-circle]="getNotificationTipo(n) !== 'Vencido' && getNotificationTipo(n) !== 'AVencer'">
@@ -84,7 +91,7 @@

{{ getVigenciaLabel(n) }}: - + {{ getVigenciaDate(n) }}

@@ -399,7 +406,7 @@
Atenção à Vigência - +
A linha {{ toastItem.linha }} vence em breve. diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 704ab16..b30b58f 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -108,6 +108,72 @@ $border-color: #e5e7eb; .notifications-head { padding: 16px; border-bottom: 1px solid $border-color; display: flex; justify-content: space-between; align-items: center; .head-title { font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 6px; } .see-all { font-size: 12px; color: $primary; text-decoration: none; font-weight: 600; } } .notifications-body { max-height: 360px; overflow-y: auto; } .notifications-empty { padding: 32px; text-align: center; color: $text-muted; .empty-icon { font-size: 24px; margin-bottom: 8px; } } +.notifications-state { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 16px 8px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(28, 56, 201, 0.14); + background: rgba(28, 56, 201, 0.06); + color: #1e3a8a; + font-size: 12px; + font-weight: 600; + line-height: 1.35; + + i { + font-size: 14px; + color: $primary; + } + + .spinner-border { + width: 14px; + height: 14px; + border-width: 2px; + } + + &.loading { + background: #f8fafc; + border-color: #e2e8f0; + color: #475569; + } + + &.info { + background: #eff6ff; + border-color: #bfdbfe; + color: #1e3a8a; + + .notifications-truncate-copy { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px; + font-weight: 600; + color: #1e3a8a; + + strong { + padding: 1px 6px; + border-radius: 999px; + border: 1px solid #93c5fd; + background: #dbeafe; + color: #1d4ed8; + font-weight: 800; + line-height: 1.2; + } + } + } + + &.warn { + background: #fff7ed; + border-color: #fed7aa; + color: #9a3412; + + i { + color: #c2410c; + } + } +} .notification-item { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid $border-color; cursor: pointer; diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 1518cbe..dea9b10 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, Inject, ElementRef, ViewChild } from '@angular/core'; +import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; import { RouterLink, Router, NavigationEnd } from '@angular/router'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; @@ -17,7 +17,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select'; templateUrl: './header.html', styleUrls: ['./header.scss'], }) -export class Header { +export class Header implements AfterViewInit { isScrolled = false; menuOpen = false; @@ -32,6 +32,10 @@ export class Header { notificationsLoading = false; notificationsError = false; private notificationsLoaded = false; + private pendingToastCheck = false; + private lastNotificationsLoadAt = 0; + private readonly notificationsRefreshMs = 60_000; + readonly notificationsPreviewLimit = 40; @ViewChild('notifToast') notifToast?: ElementRef; createUserForm: FormGroup; @@ -195,7 +199,15 @@ export class Header { this.notificationsOpen = !this.notificationsOpen; if (this.notificationsOpen) { this.optionsOpen = false; - this.loadNotifications(); + if (!this.notificationsLoaded) { + this.loadNotifications(false); + return; + } + + if (Date.now() - this.lastNotificationsLoadAt > this.notificationsRefreshMs) { + // Atualiza em background para não bloquear a abertura visual do dropdown. + setTimeout(() => this.loadNotifications(false), 0); + } } } @@ -214,7 +226,7 @@ export class Header { } getVigenciaLabel(notification: NotificationDto): string { - return notification.tipo === 'Vencido' ? 'Venceu em' : 'Vence em'; + return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em'; } getVigenciaDate(notification: NotificationDto): string { @@ -223,7 +235,30 @@ export class Header { notification.referenciaData ?? notification.data; if (!raw) return '-'; - return new Date(raw).toLocaleDateString('pt-BR'); + const parsed = this.parseDateOnly(raw); + if (!parsed) return '-'; + return parsed.toLocaleDateString('pt-BR'); + } + + getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) return notification.tipo; + + const today = this.startOfDay(new Date()); + return parsed < today ? 'Vencido' : 'AVencer'; + } + + getNotificationDaysToExpire(notification: NotificationDto): number | null { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) { + return notification.diasParaVencer ?? null; + } + + const today = this.startOfDay(new Date()); + const msPerDay = 24 * 60 * 60 * 1000; + return Math.round((parsed.getTime() - today.getTime()) / msPerDay); } abbreviateName(value?: string | null): string { @@ -251,6 +286,18 @@ export class Header { return this.notifications.filter(n => !n.lida).length; } + get notificationsPreview() { + return this.notifications.slice(0, this.notificationsPreviewLimit); + } + + get hasNotificationsTruncated() { + return this.notifications.length > this.notificationsPreviewLimit; + } + + trackByNotificationId(_: number, notification: NotificationDto) { + return notification.id; + } + logout() { this.authService.logout(); this.optionsOpen = false; @@ -288,13 +335,27 @@ export class Header { localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged))); } - private ensureNotificationsLoaded() { - if (this.notificationsLoaded || this.notificationsLoading) return; - this.loadNotifications(); + acknowledgeCurrentToast() { + const current = this.toastNotification; + if (!current) return; + this.acknowledgeNotification(current); } - private loadNotifications() { + ngAfterViewInit() { + if (this.pendingToastCheck) { + this.pendingToastCheck = false; + void this.maybeShowVigenciaToast(); + } + } + + private ensureNotificationsLoaded() { + if (this.notificationsLoaded || this.notificationsLoading) return; + this.loadNotifications(true); + } + + private loadNotifications(showToast = true) { if (!isPlatformBrowser(this.platformId)) return; + if (this.notificationsLoading) return; this.notificationsLoading = true; this.notificationsError = false; this.notificationsService.list().subscribe({ @@ -302,7 +363,10 @@ export class Header { this.notifications = data || []; this.notificationsLoaded = true; this.notificationsLoading = false; - this.maybeShowVigenciaToast(); + this.lastNotificationsLoadAt = Date.now(); + if (showToast) { + void this.maybeShowVigenciaToast(); + } }, error: () => { this.notificationsError = true; @@ -312,12 +376,17 @@ export class Header { } private async maybeShowVigenciaToast() { - if (!this.notifToast || !isPlatformBrowser(this.platformId)) return; + if (!isPlatformBrowser(this.platformId)) return; + if (!this.notifToast) { + this.pendingToastCheck = true; + return; + } + const pending = this.getPendingVigenciaToast(); if (!pending) return; const bs = await import('bootstrap'); - const toast = new bs.Toast(this.notifToast.nativeElement, { autohide: false }); + const toast = bs.Toast.getOrCreateInstance(this.notifToast.nativeElement, { autohide: false }); toast.show(); } @@ -328,10 +397,35 @@ export class Header { private getPendingVigenciaToast() { const acknowledged = this.getAcknowledgedIds(); return this.notifications.find( - n => n.tipo === 'AVencer' && n.diasParaVencer === 5 && !acknowledged.has(n.id) + n => + this.getNotificationTipo(n) === 'AVencer' && + this.getNotificationDaysToExpire(n) === 5 && + !acknowledged.has(n.id) ); } + private parseDateOnly(raw?: string | null): Date | null { + if (!raw) return null; + const datePart = raw.split('T')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) { + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) { + return new Date(year, month - 1, day); + } + } + + const fallback = new Date(raw); + if (Number.isNaN(fallback.getTime())) return null; + return this.startOfDay(fallback); + } + + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + private getAcknowledgedIds() { if (!isPlatformBrowser(this.platformId)) return new Set(); try { diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index 9f16739..fb8fc8f 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -129,6 +129,13 @@ type InsightsKpisAdicionais = { totalLinesWithNoPaidAdditional?: number | null; }; +type InsightsLineTotal = { + tipo?: string | null; + qtdLinhas?: number | null; + valorTotalLine?: number | null; + lucroTotalLine?: number | null; +}; + type DashboardGeralInsightsDto = { kpis?: { totalLinhas?: number | null; @@ -136,6 +143,7 @@ type DashboardGeralInsightsDto = { vivo?: InsightsKpisVivo | null; travelMundo?: InsightsKpisTravel | null; adicionais?: InsightsKpisAdicionais | null; + totaisLine?: InsightsLineTotal[] | null; } | null; charts?: { linhasPorFranquia?: InsightsChartSeries | null; @@ -507,6 +515,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { 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 { @@ -532,6 +541,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { 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')), @@ -559,6 +569,19 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }; } + 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; @@ -585,7 +608,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return; } - const lineTotals = Array.isArray(this.resumo.lineTotais) ? this.resumo.lineTotais : []; + const lineTotals = this.getEffectiveLineTotals(); const pf = this.findLineTotal(lineTotals, ['PF', 'PESSOA FISICA']); const pj = this.findLineTotal(lineTotals, ['PJ', 'PESSOA JURIDICA']); const diferenca = this.findLineTotal(lineTotals, ['DIFERENCA PJ X PF', 'DIFERENÇA PJ X PF', 'DIFERENCA']); @@ -686,6 +709,20 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { 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 = []; diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 8c78c82..f1442db 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -83,8 +83,8 @@ class="list-item" *ngFor="let n of filteredNotifications" [class.is-read]="n.lida" - [class.is-danger]="n.tipo === 'Vencido'" - [class.is-warning]="n.tipo === 'AVencer'" + [class.is-danger]="getNotificationTipo(n) === 'Vencido'" + [class.is-warning]="getNotificationTipo(n) === 'AVencer'" >
@@ -94,7 +94,7 @@
- +
@@ -124,8 +124,8 @@ {{ n.planoContrato || '-' }}
- - {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} + + {{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index 930bf7d..7b149da 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -42,10 +42,10 @@ export class Notificacoes implements OnInit { get filteredNotifications() { if (this.filter === 'vencidas') { - return this.notifications.filter(n => n.tipo === 'Vencido'); + return this.notifications.filter(n => this.getNotificationTipo(n) === 'Vencido'); } if (this.filter === 'aVencer') { - return this.notifications.filter(n => n.tipo === 'AVencer'); + return this.notifications.filter(n => this.getNotificationTipo(n) === 'AVencer'); } if (this.filter === 'lidas') { return this.notifications.filter(n => n.lida); @@ -55,7 +55,18 @@ export class Notificacoes implements OnInit { formatDateLabel(date?: string | null): string { if (!date) return '-'; - return new Date(date).toLocaleDateString('pt-BR'); + const parsed = this.parseDateOnly(date); + if (!parsed) return '-'; + return parsed.toLocaleDateString('pt-BR'); + } + + getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) return notification.tipo; + + const today = this.startOfDay(new Date()); + return parsed < today ? 'Vencido' : 'AVencer'; } private loadNotifications() { @@ -74,20 +85,23 @@ export class Notificacoes implements OnInit { } countByType(tipo: 'Vencido' | 'AVencer'): number { - return this.notifications.filter(n => n.tipo === tipo && !n.lida).length; + return this.notifications.filter(n => this.getNotificationTipo(n) === tipo && !n.lida).length; } markAllAsRead() { if (this.filter === 'lidas' || this.bulkLoading) return; this.bulkLoading = true; - const filterParam = this.getFilterParam(); - const ids = Array.from(this.selectedIds); - this.notificationsService.markAllAsRead(filterParam, ids.length ? ids : undefined).subscribe({ + const selectedIds = Array.from(this.selectedIds); + const scopedIds = selectedIds.length + ? selectedIds + : (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []); + const filterParam = scopedIds.length ? undefined : this.getFilterParam(); + this.notificationsService.markAllAsRead(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({ next: () => { const now = new Date().toISOString(); this.notifications = this.notifications.map((n) => { - if (ids.length ? ids.includes(n.id) : this.shouldMarkRead(n)) { + if (scopedIds.length ? scopedIds.includes(n.id) : this.shouldMarkRead(n)) { return { ...n, lida: true, lidaEm: now }; } return n; @@ -105,9 +119,12 @@ export class Notificacoes implements OnInit { if (this.filter === 'lidas' || this.exportLoading) return; this.exportLoading = true; - const filterParam = this.getFilterParam(); - const ids = Array.from(this.selectedIds); - this.notificationsService.export(filterParam, ids.length ? ids : undefined).subscribe({ + const selectedIds = Array.from(this.selectedIds); + const scopedIds = selectedIds.length + ? selectedIds + : (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []); + const filterParam = scopedIds.length ? undefined : this.getFilterParam(); + this.notificationsService.export(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({ next: (res) => { const blob = res.body; if (!blob) { @@ -172,11 +189,33 @@ export class Notificacoes implements OnInit { private shouldMarkRead(n: NotificationDto): boolean { if (this.filter === 'todas') return true; - if (this.filter === 'aVencer') return n.tipo === 'AVencer'; - if (this.filter === 'vencidas') return n.tipo === 'Vencido'; + if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer'; + if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido'; return false; } + private parseDateOnly(raw?: string | null): Date | null { + if (!raw) return null; + const datePart = raw.split('T')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) { + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) { + return new Date(year, month - 1, day); + } + } + + const fallback = new Date(raw); + if (Number.isNaN(fallback.getTime())) return null; + return this.startOfDay(fallback); + } + + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + private extractFilename(contentDisposition: string | null): string | null { if (!contentDisposition) return null; const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index cb43f63..68539bb 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -308,7 +308,7 @@
Lucro Consolidado - {{ formatMoney(clientesTotals?.lucro) }} + {{ formatMoney(totaisLineLucroConsolidado) }}
diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts index 81dbf0b..2e3bd79 100644 --- a/src/app/pages/resumo/resumo.ts +++ b/src/app/pages/resumo/resumo.ts @@ -11,6 +11,7 @@ import { HostBinding } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; import { ActivatedRoute, Router } from '@angular/router'; import Chart from 'chart.js/auto'; import type { ChartConfiguration, ChartData, ScriptableContext, TooltipItem } from 'chart.js'; @@ -30,6 +31,7 @@ import { ReservaPorDdd, ReservaTotal } from '../../services/resumo.service'; +import { environment } from '../../../environments/environment'; type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva'; @@ -66,6 +68,7 @@ type MacrophonyGroup = { key: string; plano: string; gbLabel: string; totalLinha type GroupMetric = { label: string; value: string; tone?: string }; type GroupItem = { key: string; title: string; subtitle?: string; rows: T[]; metrics: GroupMetric[]; }; type GroupedTableState = { key: string; label: string; table: TableState; groupBy: (row: T) => string; groupTitle: (rows: T[]) => string; groupSubtitle?: (rows: T[]) => string | undefined; groupMetrics: (rows: T[]) => GroupMetric[]; groupSort?: (a: GroupItem, b: GroupItem) => number; search: string; page: number; pageSize: number; pageSizeOptions: number[]; compact: boolean; open: Set; groups: GroupItem[]; filtered: GroupItem[]; view: GroupItem[]; detailOpen: boolean; detailGroup: GroupItem | null; }; +type GeralLineTotalPayload = { tipo?: unknown; qtdLinhas?: unknown; valorTotalLine?: unknown; lucroTotalLine?: unknown; }; @Component({ standalone: true, @@ -97,6 +100,8 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { private charts: { [key: string]: Chart | undefined } = {}; private viewReady = false; private dataReady = false; + private readonly baseApi: string; + private totaisLineFromGeral: LineTotal[] = []; // Estados de Tabela e Grupo macrophonyGroups: MacrophonyGroup[] = []; @@ -126,11 +131,14 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { constructor( @Inject(PLATFORM_ID) private platformId: object, + private http: HttpClient, private resumoService: ResumoService, private route: ActivatedRoute, private router: Router, private cdr: ChangeDetectorRef ) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; this.initTables(); this.initGroupTables(); // Default chart configuration for Enterprise look @@ -269,6 +277,16 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { .sort((a, b) => b.lucro - a.lucro) .slice(0, 10); + // Garante altura suficiente para exibir todos os nomes no eixo Y sem auto-skip. + const parent = canvas.parentElement as HTMLElement | null; + if (parent) { + const minHeight = 300; + const rowHeight = 34; + parent.style.height = `${Math.max(minHeight, data.length * rowHeight)}px`; + } + + const common = this.getCommonChartOptions('currency') as any; + this.charts['clientes'] = new Chart(canvas, { type: 'bar', data: { @@ -282,8 +300,25 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { }] }, options: { - ...this.getCommonChartOptions('currency'), - indexAxis: 'y' + ...common, + indexAxis: 'y', + scales: { + x: { + ...(common.scales?.x ?? {}), + ticks: { + ...((common.scales?.x as any)?.ticks ?? {}), + maxTicksLimit: 8 + } + }, + y: { + ...(common.scales?.y ?? {}), + ticks: { + ...((common.scales?.y as any)?.ticks ?? {}), + autoSkip: false, + maxTicksLimit: data.length || 10 + } + } + } } }); } @@ -407,12 +442,15 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { this.loading = true; this.errorMessage = ''; this.dataReady = false; + this.totaisLineFromGeral = []; + this.resumoService.getResumo().subscribe({ next: (data) => { this.resumo = data ? this.normalizeResumo(data) : null; this.loading = false; this.dataReady = true; this.bindTables(); + this.loadTotaisLineFromGeral(); this.cdr.detectChanges(); this.tryBuildCharts(); }, @@ -424,6 +462,46 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { }); } + private loadTotaisLineFromGeral(): void { + if (!isPlatformBrowser(this.platformId)) return; + + this.http.get(`${this.baseApi}/dashboard/geral/insights`).subscribe({ + next: (dto) => { + const rows = this.extractTotaisLineFromInsights(dto); + if (!rows.length) return; + + this.totaisLineFromGeral = rows; + this.bindTables(); + this.cdr.detectChanges(); + this.tryBuildCharts(); + }, + error: () => { + // Mantem fallback para a tabela de Resumo quando insights falhar. + }, + }); + } + + private extractTotaisLineFromInsights(dto: any): LineTotal[] { + const kpis = dto?.kpis ?? dto?.Kpis; + const rawRows = kpis?.totaisLine ?? kpis?.TotaisLine; + if (!Array.isArray(rawRows)) return []; + + return rawRows + .map((row: GeralLineTotalPayload) => ({ + tipo: this.toText(row?.tipo), + qtdLinhas: this.toNumber(row?.qtdLinhas), + valorTotalLine: this.toNumber(row?.valorTotalLine), + lucroTotalLine: this.toNumber(row?.lucroTotalLine), + })) + .filter((row) => !!(row.tipo ?? '').toString().trim()); + } + + private toText(value: unknown): string | null { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text ? text : null; + } + // Animação de entrada private animateIn(): void { if (!isPlatformBrowser(this.platformId)) return; @@ -840,7 +918,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { this.tablePlanoContrato.data = this.buildPlanoContratoResumoConsolidado(resumo.planoContratoResumos ?? []); this.tableClientes.data = resumo.vivoLineResumos ?? []; this.tableClientesEspeciais.data = resumo.clienteEspeciais ?? []; - this.tableTotaisLine.data = resumo.lineTotais ?? []; + this.tableTotaisLine.data = this.getEffectiveLineTotais(); this.tableReserva.data = resumo.reservaLines ?? []; this.updateMacrophonyView(); @@ -1148,13 +1226,27 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); } findLineTotal(k: string[]): LineTotal | null { const keys = k.map((item) => item.toUpperCase()); - const list = Array.isArray(this.resumo?.lineTotais) ? this.resumo?.lineTotais : []; + const list = this.getEffectiveLineTotais(); for (const item of list) { const tipo = (item?.tipo ?? '').toString().toUpperCase(); if (keys.some((key) => tipo.includes(key))) return item; } return null; } + + get totaisLineLucroConsolidado(): number { + const pf = this.toNumber(this.findLineTotal(['PF', 'PESSOA FISICA'])?.lucroTotalLine) ?? 0; + const pj = this.toNumber(this.findLineTotal(['PJ', 'PESSOA JURIDICA'])?.lucroTotalLine) ?? 0; + return pf + pj; + } + + private getEffectiveLineTotais(): LineTotal[] { + if (this.totaisLineFromGeral.length > 0) { + return this.totaisLineFromGeral; + } + + return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : []; + } get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; } get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); }