Branch Produção
This commit is contained in:
parent
751d965e8f
commit
8729ffddbb
|
|
@ -42,7 +42,7 @@
|
|||
</div>
|
||||
|
||||
<div class="notifications-body custom-scroll">
|
||||
<div class="notifications-state" *ngIf="notificationsLoading">
|
||||
<div class="notifications-state loading" *ngIf="notificationsLoading">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span>Carregando...</span>
|
||||
</div>
|
||||
|
|
@ -57,20 +57,27 @@
|
|||
<p>Tudo limpo por aqui!</p>
|
||||
</div>
|
||||
|
||||
<div class="notifications-state info" *ngIf="!notificationsLoading && !notificationsError && hasNotificationsTruncated">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span class="notifications-truncate-copy">
|
||||
Mostrando <strong>{{ notificationsPreviewLimit }}</strong> de <strong>{{ notifications.length }}</strong> notificações
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="notification-item"
|
||||
*ngFor="let n of notifications"
|
||||
*ngFor="let n of notificationsPreview; trackBy: trackByNotificationId"
|
||||
[class.unread]="!n.lida"
|
||||
(click)="markNotificationRead(n)"
|
||||
>
|
||||
<div class="notif-icon-area">
|
||||
<div class="icon-circle"
|
||||
[class.danger]="n.tipo === 'Vencido'"
|
||||
[class.warn]="n.tipo === 'AVencer'">
|
||||
[class.danger]="getNotificationTipo(n) === 'Vencido'"
|
||||
[class.warn]="getNotificationTipo(n) === 'AVencer'">
|
||||
<i class="bi"
|
||||
[class.bi-x-lg]="n.tipo === 'Vencido'"
|
||||
[class.bi-clock-history]="n.tipo === 'AVencer'"
|
||||
[class.bi-info-circle]="n.tipo !== 'Vencido' && n.tipo !== 'AVencer'"></i>
|
||||
[class.bi-x-lg]="getNotificationTipo(n) === 'Vencido'"
|
||||
[class.bi-clock-history]="getNotificationTipo(n) === 'AVencer'"
|
||||
[class.bi-info-circle]="getNotificationTipo(n) !== 'Vencido' && getNotificationTipo(n) !== 'AVencer'"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,7 +91,7 @@
|
|||
</div>
|
||||
<p class="notif-desc">
|
||||
<span class="notif-verb">{{ getVigenciaLabel(n) }}:</span>
|
||||
<strong class="notif-date-strong" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
|
||||
<strong class="notif-date-strong" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
|
||||
{{ getVigenciaDate(n) }}
|
||||
</strong>
|
||||
</p>
|
||||
|
|
@ -399,7 +406,7 @@
|
|||
<div class="toast-header">
|
||||
<i class="bi bi-exclamation-circle-fill text-warning me-2"></i>
|
||||
<strong class="me-auto">Atenção à Vigência</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
<button type="button" class="btn-close" (click)="acknowledgeCurrentToast()" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body" *ngIf="toastNotification as toastItem">
|
||||
A linha <strong>{{ toastItem.linha }}</strong> vence em breve.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
>
|
||||
<div class="status-strip"></div>
|
||||
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
</label>
|
||||
|
||||
<div class="item-icon">
|
||||
<i class="bi" [class.bi-x-circle-fill]="n.tipo === 'Vencido'" [class.bi-clock-fill]="n.tipo === 'AVencer'"></i>
|
||||
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i>
|
||||
</div>
|
||||
|
||||
<div class="item-content">
|
||||
|
|
@ -124,8 +124,8 @@
|
|||
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
|
||||
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
||||
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
|
||||
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@
|
|||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-lbl">Lucro Consolidado</span>
|
||||
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
|
||||
<strong class="kpi-val text-success">{{ formatMoney(totaisLineLucroConsolidado) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<T> = { key: string; title: string; subtitle?: string; rows: T[]; metrics: GroupMetric[]; };
|
||||
type GroupedTableState<T> = { key: string; label: string; table: TableState<T>; groupBy: (row: T) => string; groupTitle: (rows: T[]) => string; groupSubtitle?: (rows: T[]) => string | undefined; groupMetrics: (rows: T[]) => GroupMetric[]; groupSort?: (a: GroupItem<T>, b: GroupItem<T>) => number; search: string; page: number; pageSize: number; pageSizeOptions: number[]; compact: boolean; open: Set<string>; groups: GroupItem<T>[]; filtered: GroupItem<T>[]; view: GroupItem<T>[]; detailOpen: boolean; detailGroup: GroupItem<T> | 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<any>(`${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,7 +1226,7 @@ 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;
|
||||
|
|
@ -1156,6 +1234,20 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
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); }
|
||||
get macrophonyFilteredGroups() { return this.macrophonyFiltered; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue