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 @@