Subindo alterações para produção
This commit is contained in:
parent
70fc642286
commit
1f277b8c8a
|
|
@ -594,7 +594,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz",
|
||||
"integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
|
@ -9114,17 +9113,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xhr2": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
|
||||
"integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@ export const routes: Routes = [
|
|||
|
||||
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
|
||||
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard], title: 'Faturamento' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], title: 'Faturamento' },
|
||||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
|
||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
|
||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
|
||||
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, adminGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||
|
||||
|
|
|
|||
|
|
@ -32,44 +32,85 @@
|
|||
<span class="badge-pulse" *ngIf="unreadCount > 0"></span>
|
||||
</button>
|
||||
|
||||
<div class="notifications-dropdown" *ngIf="notificationsOpen">
|
||||
<div class="notifications-head">
|
||||
<div class="head-title">
|
||||
<span>Notificações</span>
|
||||
<span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span>
|
||||
</div>
|
||||
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver tudo</a>
|
||||
</div>
|
||||
<div class="notifications-dropdown" *ngIf="notificationsOpen">
|
||||
<div class="notifications-head">
|
||||
<div class="head-title">
|
||||
<span>Notificações</span>
|
||||
<span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="head-btn"
|
||||
(click)="markAllNotificationsRead(); $event.stopPropagation()"
|
||||
[disabled]="notificationsBulkReadLoading || unreadCount === 0"
|
||||
*ngIf="notificationsView === 'pendentes'"
|
||||
>
|
||||
<span *ngIf="!notificationsBulkReadLoading"><i class="bi bi-check2-all"></i> Ler tudo</span>
|
||||
<span *ngIf="notificationsBulkReadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Lendo...</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="head-btn"
|
||||
(click)="markAllNotificationsUnread(); $event.stopPropagation()"
|
||||
[disabled]="notificationsBulkUnreadLoading || notificationsVisibleCount === 0"
|
||||
*ngIf="notificationsView === 'lidas'"
|
||||
>
|
||||
<span *ngIf="!notificationsBulkUnreadLoading"><i class="bi bi-arrow-counterclockwise"></i> Restaurar tudo</span>
|
||||
<span *ngIf="notificationsBulkUnreadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Restaurando...</span>
|
||||
</button>
|
||||
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver tudo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notifications-body custom-scroll">
|
||||
<div class="notifications-state loading" *ngIf="notificationsLoading">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<div class="notifications-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="notif-tab"
|
||||
[class.active]="notificationsView === 'pendentes'"
|
||||
(click)="setNotificationsView('pendentes'); $event.stopPropagation()"
|
||||
>
|
||||
Pendentes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="notif-tab"
|
||||
[class.active]="notificationsView === 'lidas'"
|
||||
(click)="setNotificationsView('lidas'); $event.stopPropagation()"
|
||||
>
|
||||
Arquivadas / Lidas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="notifications-body custom-scroll">
|
||||
<div class="notifications-state loading" *ngIf="notificationsLoading">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span>Carregando...</span>
|
||||
</div>
|
||||
|
||||
<div class="notifications-state warn" *ngIf="notificationsError">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>Falha ao carregar.</span>
|
||||
</div>
|
||||
<span>Falha ao carregar.</span>
|
||||
</div>
|
||||
|
||||
<div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notificationsVisibleCount === 0">
|
||||
<div class="empty-icon"><i class="bi bi-bell-slash"></i></div>
|
||||
<p>Não há notificações no momento.</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>{{ notificationsVisibleCount }}</strong> notificações
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notifications.length === 0">
|
||||
<div class="empty-icon"><i class="bi bi-bell-slash"></i></div>
|
||||
<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 notificationsPreview; trackBy: trackByNotificationId"
|
||||
[class.unread]="!n.lida"
|
||||
(click)="markNotificationRead(n)"
|
||||
>
|
||||
<div
|
||||
class="notification-item"
|
||||
*ngFor="let n of notificationsPreview; trackBy: trackByNotificationId"
|
||||
[class.unread]="!n.lida"
|
||||
(click)="onNotificationItemClick(n)"
|
||||
>
|
||||
<div class="notif-icon-area">
|
||||
<div class="icon-circle"
|
||||
[class.danger]="getNotificationTipo(n) === 'Vencido'"
|
||||
|
|
@ -107,12 +148,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notif-status" *ngIf="!n.lida" title="Marcar como lida">
|
||||
<span class="status-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-status" *ngIf="!n.lida" title="Marcar como lida">
|
||||
<span class="status-dot"></span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="notif-restore-btn"
|
||||
*ngIf="n.lida"
|
||||
(click)="markNotificationUnread(n); $event.stopPropagation()"
|
||||
title="Marcar como não lida"
|
||||
>
|
||||
Restaurar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()">
|
||||
|
|
@ -442,10 +492,10 @@
|
|||
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
||||
</a>
|
||||
<a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
||||
</a>
|
||||
<a routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
||||
</a>
|
||||
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
|
|
@ -457,7 +507,7 @@
|
|||
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
||||
</a>
|
||||
<a routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
|
||||
</a>
|
||||
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
|
|
|
|||
|
|
@ -105,7 +105,101 @@ $border-color: #e5e7eb;
|
|||
}
|
||||
}
|
||||
@keyframes pulse { 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0.7); } 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba($danger, 0); } 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0); } }
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-btn {
|
||||
border: 1px solid rgba(28, 56, 201, 0.18);
|
||||
background: #fff;
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
i { font-size: 12px; }
|
||||
|
||||
.spinner-border {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(28, 56, 201, 0.04);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(28, 56, 201, 0.12);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.see-all {
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.notifications-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.notif-tab {
|
||||
border: 1px solid $border-color;
|
||||
background: #f8fafc;
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
|
||||
&:hover { background: $bg-light; color: $text-main; }
|
||||
|
||||
&.active {
|
||||
background: rgba(28, 56, 201, 0.08);
|
||||
border-color: rgba(28, 56, 201, 0.25);
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -199,6 +293,24 @@ $border-color: #e5e7eb;
|
|||
.notif-meta-line { display: flex; gap: 6px; font-size: 12px; color: $text-muted; }
|
||||
.meta-label { font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.meta-value { font-weight: 600; color: $text-main; }
|
||||
.notif-restore-btn {
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
border: 1px solid rgba(28, 56, 201, 0.2);
|
||||
background: #fff;
|
||||
color: $primary;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(28, 56, 201, 0.06);
|
||||
border-color: rgba(28, 56, 201, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* MODAIS GERAIS */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
||||
import { RouterLink, Router, NavigationEnd } from '@angular/router';
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
|
|
@ -9,6 +9,7 @@ import { UsersService, CreateUserPayload, ApiFieldError } from '../../services/u
|
|||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormsModule } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { CustomSelectComponent } from '../custom-select/custom-select';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
|
|
@ -17,7 +18,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select';
|
|||
templateUrl: './header.html',
|
||||
styleUrls: ['./header.scss'],
|
||||
})
|
||||
export class Header implements AfterViewInit {
|
||||
export class Header implements AfterViewInit, OnDestroy {
|
||||
isScrolled = false;
|
||||
|
||||
menuOpen = false;
|
||||
|
|
@ -31,12 +32,16 @@ export class Header implements AfterViewInit {
|
|||
notifications: NotificationDto[] = [];
|
||||
notificationsLoading = false;
|
||||
notificationsError = false;
|
||||
notificationsView: 'pendentes' | 'lidas' = 'pendentes';
|
||||
notificationsBulkReadLoading = false;
|
||||
notificationsBulkUnreadLoading = false;
|
||||
private notificationsLoaded = false;
|
||||
private pendingToastCheck = false;
|
||||
private lastNotificationsLoadAt = 0;
|
||||
private readonly notificationsRefreshMs = 60_000;
|
||||
readonly notificationsPreviewLimit = 40;
|
||||
@ViewChild('notifToast') notifToast?: ElementRef;
|
||||
private readonly subs = new Subscription();
|
||||
|
||||
createUserForm: FormGroup;
|
||||
createUserSubmitting = false;
|
||||
|
|
@ -126,6 +131,53 @@ export class Header implements AfterViewInit {
|
|||
if (this.isLoggedHeader) {
|
||||
this.ensureNotificationsLoaded();
|
||||
}
|
||||
|
||||
this.subs.add(
|
||||
this.notificationsService.events$.subscribe((ev) => {
|
||||
if (ev.type === 'read') {
|
||||
const byId = new Set(ev.ids);
|
||||
this.notifications.forEach((n) => {
|
||||
if (byId.has(n.id)) {
|
||||
n.lida = true;
|
||||
n.lidaEm = ev.readAtIso;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'unread') {
|
||||
const byId = new Set(ev.ids);
|
||||
this.notifications.forEach((n) => {
|
||||
if (byId.has(n.id)) {
|
||||
n.lida = false;
|
||||
n.lidaEm = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'readAll') {
|
||||
this.notifications.forEach((n) => {
|
||||
if (!n.lida) {
|
||||
n.lida = true;
|
||||
n.lidaEm = ev.readAtIso;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'unreadAll') {
|
||||
this.notifications.forEach((n) => {
|
||||
if (n.lida) {
|
||||
n.lida = false;
|
||||
n.lidaEm = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'reload') {
|
||||
// Para mudanças de escopo desconhecido, recarrega em background.
|
||||
if (this.isLoggedHeader) setTimeout(() => this.loadNotifications(false), 0);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private syncHeaderState(rawUrl: string) {
|
||||
|
|
@ -198,6 +250,7 @@ export class Header implements AfterViewInit {
|
|||
toggleNotifications() {
|
||||
this.notificationsOpen = !this.notificationsOpen;
|
||||
if (this.notificationsOpen) {
|
||||
this.notificationsView = 'pendentes';
|
||||
this.optionsOpen = false;
|
||||
if (!this.notificationsLoaded) {
|
||||
this.loadNotifications(false);
|
||||
|
|
@ -221,10 +274,69 @@ export class Header implements AfterViewInit {
|
|||
next: () => {
|
||||
notification.lida = true;
|
||||
notification.lidaEm = new Date().toISOString();
|
||||
// Evita que o toast volte a aparecer para a mesma notificação.
|
||||
this.acknowledgeNotification(notification);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
markNotificationUnread(notification: NotificationDto) {
|
||||
if (!notification.lida) return;
|
||||
this.notificationsService.markAsUnread(notification.id).subscribe({
|
||||
next: () => {
|
||||
notification.lida = false;
|
||||
notification.lidaEm = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setNotificationsView(view: 'pendentes' | 'lidas') {
|
||||
this.notificationsView = view;
|
||||
}
|
||||
|
||||
markAllNotificationsRead() {
|
||||
if (this.notificationsView === 'lidas') return;
|
||||
if (this.unreadCount === 0 || this.notificationsBulkReadLoading) return;
|
||||
this.notificationsBulkReadLoading = true;
|
||||
this.notificationsService.markAllAsRead().subscribe({
|
||||
next: () => {
|
||||
// Evento do service já sincroniza o estado; aqui só finalizamos loading e acknowledge.
|
||||
const unreadIds = this.notifications.filter(n => !n.lida).map(n => n.id);
|
||||
if (unreadIds.length && isPlatformBrowser(this.platformId)) {
|
||||
const acknowledged = this.getAcknowledgedIds();
|
||||
unreadIds.forEach(id => acknowledged.add(id));
|
||||
localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged)));
|
||||
}
|
||||
this.notificationsBulkReadLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.notificationsBulkReadLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markAllNotificationsUnread() {
|
||||
if (this.notificationsView !== 'lidas') return;
|
||||
if (this.notificationsVisibleCount === 0 || this.notificationsBulkUnreadLoading) return;
|
||||
this.notificationsBulkUnreadLoading = true;
|
||||
this.notificationsService.markAllAsUnread().subscribe({
|
||||
next: () => {
|
||||
this.notificationsBulkUnreadLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.notificationsBulkUnreadLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onNotificationItemClick(notification: NotificationDto) {
|
||||
if (this.notificationsView === 'lidas') {
|
||||
this.markNotificationUnread(notification);
|
||||
return;
|
||||
}
|
||||
this.markNotificationRead(notification);
|
||||
}
|
||||
|
||||
getVigenciaLabel(notification: NotificationDto): string {
|
||||
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em';
|
||||
}
|
||||
|
|
@ -286,12 +398,22 @@ export class Header implements AfterViewInit {
|
|||
return this.notifications.filter(n => !n.lida).length;
|
||||
}
|
||||
|
||||
get notificationsVisible() {
|
||||
return this.notificationsView === 'lidas'
|
||||
? this.notifications.filter(n => n.lida)
|
||||
: this.notifications.filter(n => !n.lida);
|
||||
}
|
||||
|
||||
get notificationsVisibleCount() {
|
||||
return this.notificationsVisible.length;
|
||||
}
|
||||
|
||||
get notificationsPreview() {
|
||||
return this.notifications.slice(0, this.notificationsPreviewLimit);
|
||||
return this.notificationsVisible.slice(0, this.notificationsPreviewLimit);
|
||||
}
|
||||
|
||||
get hasNotificationsTruncated() {
|
||||
return this.notifications.length > this.notificationsPreviewLimit;
|
||||
return this.notificationsVisibleCount > this.notificationsPreviewLimit;
|
||||
}
|
||||
|
||||
trackByNotificationId(_: number, notification: NotificationDto) {
|
||||
|
|
@ -398,12 +520,17 @@ export class Header implements AfterViewInit {
|
|||
const acknowledged = this.getAcknowledgedIds();
|
||||
return this.notifications.find(
|
||||
n =>
|
||||
!n.lida &&
|
||||
this.getNotificationTipo(n) === 'AVencer' &&
|
||||
this.getNotificationDaysToExpire(n) === 5 &&
|
||||
!acknowledged.has(n.id)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
private parseDateOnly(raw?: string | null): Date | null {
|
||||
if (!raw) return null;
|
||||
const datePart = raw.split('T')[0];
|
||||
|
|
|
|||
|
|
@ -81,8 +81,7 @@
|
|||
max-width: 1240px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 24px; /* ✅ remove aquele "200px" que ajudava o footer global a aparecer */
|
||||
margin: 40px auto 24px; /* ✅ garante centralização horizontal */
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,16 +259,16 @@
|
|||
<h6>DIFERENÇA PJ X PF</h6>
|
||||
<div class="metric-stack">
|
||||
<div class="metric-line">
|
||||
<span>Valor Total Line</span>
|
||||
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.valorTotalLine) }}</strong>
|
||||
<span>PF (Linhas)</span>
|
||||
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
|
||||
</div>
|
||||
<div class="metric-line">
|
||||
<span>Lucro Total Line</span>
|
||||
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.lucroTotalLine) }}</strong>
|
||||
<span>PJ (Linhas)</span>
|
||||
<strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
|
||||
</div>
|
||||
<div class="metric-line">
|
||||
<span>Qtd Linhas</span>
|
||||
<strong>{{ formatInt(resumoDiferencaPjPf.qtdLinhas) }}</strong>
|
||||
<span>Total Linhas</span>
|
||||
<strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -187,9 +187,9 @@ type ResumoTopReserva = {
|
|||
};
|
||||
|
||||
type ResumoDiferencaPjPf = {
|
||||
valorTotalLine: number | null;
|
||||
lucroTotalLine: number | null;
|
||||
qtdLinhas: number | null;
|
||||
pfLinhas: number | null;
|
||||
pjLinhas: number | null;
|
||||
totalLinhas: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
|
@ -297,9 +297,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
resumoReservaLabels: string[] = [];
|
||||
resumoReservaValues: number[] = [];
|
||||
resumoDiferencaPjPf: ResumoDiferencaPjPf = {
|
||||
valorTotalLine: null,
|
||||
lucroTotalLine: null,
|
||||
qtdLinhas: null,
|
||||
pfLinhas: null,
|
||||
pjLinhas: null,
|
||||
totalLinhas: null,
|
||||
};
|
||||
|
||||
private viewReady = false;
|
||||
|
|
@ -611,14 +611,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
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']);
|
||||
|
||||
const pfLinhas = this.toNumberOrNull(pf?.qtdLinhas) ?? 0;
|
||||
const pjLinhas = this.toNumberOrNull(pj?.qtdLinhas) ?? 0;
|
||||
this.resumoDiferencaPjPf = {
|
||||
valorTotalLine: this.toNumberOrNull(diferenca?.valorTotalLine),
|
||||
lucroTotalLine: this.toNumberOrNull(diferenca?.lucroTotalLine),
|
||||
qtdLinhas: this.toNumberOrNull(diferenca?.qtdLinhas),
|
||||
pfLinhas,
|
||||
pjLinhas,
|
||||
totalLinhas: pfLinhas + pjLinhas,
|
||||
};
|
||||
const clientesMap = new Map<string, number>();
|
||||
for (const c of this.resumo.vivoLineResumos ?? []) {
|
||||
|
|
@ -692,9 +691,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.resumoReservaLabels = [];
|
||||
this.resumoReservaValues = [];
|
||||
this.resumoDiferencaPjPf = {
|
||||
valorTotalLine: null,
|
||||
lucroTotalLine: null,
|
||||
qtdLinhas: null,
|
||||
pfLinhas: null,
|
||||
pjLinhas: null,
|
||||
totalLinhas: null,
|
||||
};
|
||||
this.destroyResumoCharts();
|
||||
this.rebuildPrimaryKpis();
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
<p>Gerencie seus alertas de vencimento e avisos do sistema.</p>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar">
|
||||
<button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')">
|
||||
Todas
|
||||
</button>
|
||||
<div class="filters-bar">
|
||||
<button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')">
|
||||
Todas
|
||||
</button>
|
||||
<button type="button" class="pill" [class.active]="filter === 'aVencer'" (click)="setFilter('aVencer')">
|
||||
A vencer
|
||||
<span class="count-badge" *ngIf="countByType('AVencer') > 0">{{ countByType('AVencer') }}</span>
|
||||
|
|
@ -20,26 +20,47 @@
|
|||
Vencidas
|
||||
<span class="count-badge danger" *ngIf="countByType('Vencido') > 0">{{ countByType('Vencido') }}</span>
|
||||
</button>
|
||||
<button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')">
|
||||
Arquivadas / Lidas
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')">
|
||||
Arquivadas / Lidas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bulk-actions-bar" *ngIf="!loading && !error">
|
||||
<div class="bulk-left">
|
||||
<label class="select-all" *ngIf="filter !== 'lidas' && filteredNotifications.length > 0">
|
||||
<input type="checkbox" [checked]="isAllSelected" (change)="toggleSelectAll()" />
|
||||
<span>Selecionar todas</span>
|
||||
</label>
|
||||
<span class="bulk-count">
|
||||
Mostrando {{ filteredNotifications.length }} notificações
|
||||
<span class="bulk-selected" *ngIf="selectedIds.size > 0">• {{ selectedIds.size }} selecionada(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bulk-actions" *ngIf="filter !== 'lidas'">
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-btn"
|
||||
<div class="search-row" *ngIf="!loading && !error">
|
||||
<div class="search-box">
|
||||
<i class="bi bi-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
|
||||
[(ngModel)]="search"
|
||||
(ngModelChange)="clearSelection()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
*ngIf="search"
|
||||
(click)="clearSearch()"
|
||||
aria-label="Limpar busca"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bulk-actions-bar" *ngIf="!loading && !error">
|
||||
<div class="bulk-left">
|
||||
<label class="select-all" *ngIf="filteredNotifications.length > 0">
|
||||
<input type="checkbox" [checked]="isAllSelected" (change)="toggleSelectAll()" />
|
||||
<span>Selecionar todas</span>
|
||||
</label>
|
||||
<span class="bulk-count">
|
||||
Mostrando {{ filteredNotifications.length }} notificações
|
||||
<span class="bulk-selected" *ngIf="selectedIds.size > 0">• {{ selectedIds.size }} selecionada(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bulk-actions" *ngIf="filter !== 'lidas'">
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-btn"
|
||||
(click)="markAllAsRead()"
|
||||
[disabled]="bulkLoading || filteredNotifications.length === 0"
|
||||
>
|
||||
|
|
@ -53,10 +74,21 @@
|
|||
[disabled]="exportLoading || filteredNotifications.length === 0"
|
||||
>
|
||||
<span *ngIf="!exportLoading"><i class="bi bi-download me-1"></i> Exportar</span>
|
||||
<span *ngIf="exportLoading"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span *ngIf="exportLoading"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bulk-actions" *ngIf="filter === 'lidas'">
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-btn"
|
||||
(click)="markAllAsUnread()"
|
||||
[disabled]="bulkUnreadLoading || filteredNotifications.length === 0"
|
||||
>
|
||||
<span *ngIf="!bulkUnreadLoading"><i class="bi bi-arrow-counterclockwise me-1"></i> Restaurar selecionadas/todas</span>
|
||||
<span *ngIf="bulkUnreadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Restaurando...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-container" *ngIf="loading">
|
||||
|
|
@ -69,14 +101,14 @@
|
|||
<p>Não foi possível carregar as notificações.</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0">
|
||||
<div class="illustration">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
<h3>Tudo em dia!</h3>
|
||||
<p *ngIf="filter === 'todas'">Você não tem nenhuma notificação pendente.</p>
|
||||
<p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p>
|
||||
</div>
|
||||
<div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0">
|
||||
<div class="illustration">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
<h3>Tudo em dia!</h3>
|
||||
<p *ngIf="filter === 'todas'">Não há notificações no momento.</p>
|
||||
<p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p>
|
||||
</div>
|
||||
|
||||
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
|
||||
<div
|
||||
|
|
@ -88,10 +120,10 @@
|
|||
>
|
||||
<div class="status-strip"></div>
|
||||
|
||||
<label class="item-select" *ngIf="filter !== 'lidas'">
|
||||
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
|
||||
<span></span>
|
||||
</label>
|
||||
<label class="item-select">
|
||||
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
|
||||
<span></span>
|
||||
</label>
|
||||
|
||||
<div class="item-icon">
|
||||
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i>
|
||||
|
|
@ -131,18 +163,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
[title]="n.lida ? 'Já lida' : 'Marcar como lida'"
|
||||
(click)="markAsRead(n)"
|
||||
[disabled]="n.lida"
|
||||
>
|
||||
<i class="bi" [class.bi-check2-all]="n.lida" [class.bi-check2]="!n.lida"></i>
|
||||
<span class="d-none d-md-inline">{{ n.lida ? 'Lida' : 'Marcar lida' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
[title]="n.lida ? 'Marcar como não lida' : 'Marcar como lida'"
|
||||
(click)="n.lida ? markAsUnread(n) : markAsRead(n)"
|
||||
>
|
||||
<i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i>
|
||||
<span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,47 @@ $border: #e5e7eb;
|
|||
flex-wrap: wrap; justify-content: center;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: min(720px, 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: $white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.03);
|
||||
|
||||
i { color: $text-secondary; }
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: $text-main;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { background: rgba(0,0,0,0.04); color: $text-main; }
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,83 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notificacoes',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './notificacoes.html',
|
||||
styleUrls: ['./notificacoes.scss'],
|
||||
})
|
||||
export class Notificacoes implements OnInit {
|
||||
export class Notificacoes implements OnInit, OnDestroy {
|
||||
notifications: NotificationDto[] = [];
|
||||
filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas';
|
||||
search = '';
|
||||
loading = false;
|
||||
error = false;
|
||||
bulkLoading = false;
|
||||
bulkUnreadLoading = false;
|
||||
exportLoading = false;
|
||||
selectedIds = new Set<string>();
|
||||
private readonly subs = new Subscription();
|
||||
|
||||
constructor(private notificationsService: NotificationsService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadNotifications();
|
||||
|
||||
this.subs.add(
|
||||
this.notificationsService.events$.subscribe((ev) => {
|
||||
if (ev.type === 'read') {
|
||||
const ids = new Set(ev.ids);
|
||||
this.notifications.forEach((n) => {
|
||||
if (ids.has(n.id)) {
|
||||
n.lida = true;
|
||||
n.lidaEm = ev.readAtIso;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'readAll') {
|
||||
this.notifications.forEach((n) => {
|
||||
if (!n.lida) {
|
||||
n.lida = true;
|
||||
n.lidaEm = ev.readAtIso;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'unread') {
|
||||
const ids = new Set(ev.ids);
|
||||
this.notifications.forEach((n) => {
|
||||
if (ids.has(n.id)) {
|
||||
n.lida = false;
|
||||
n.lidaEm = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'unreadAll') {
|
||||
this.notifications.forEach((n) => {
|
||||
if (n.lida) {
|
||||
n.lida = false;
|
||||
n.lidaEm = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.type === 'reload') {
|
||||
this.loadNotifications();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
markAsRead(notification: NotificationDto) {
|
||||
|
|
@ -35,22 +90,31 @@ export class Notificacoes implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
markAsUnread(notification: NotificationDto) {
|
||||
if (!notification.lida) return;
|
||||
this.notificationsService.markAsUnread(notification.id).subscribe({
|
||||
next: () => {
|
||||
notification.lida = false;
|
||||
notification.lidaEm = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') {
|
||||
this.filter = value;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
get filteredNotifications() {
|
||||
if (this.filter === 'vencidas') {
|
||||
return this.notifications.filter(n => this.getNotificationTipo(n) === 'Vencido');
|
||||
}
|
||||
if (this.filter === 'aVencer') {
|
||||
return this.notifications.filter(n => this.getNotificationTipo(n) === 'AVencer');
|
||||
}
|
||||
if (this.filter === 'lidas') {
|
||||
return this.notifications.filter(n => n.lida);
|
||||
}
|
||||
return this.notifications;
|
||||
const base = this.getBaseFilteredNotifications();
|
||||
const q = (this.search || '').trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter(n => this.buildSearchText(n).includes(q));
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.search = '';
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
formatDateLabel(date?: string | null): string {
|
||||
|
|
@ -115,6 +179,32 @@ export class Notificacoes implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
markAllAsUnread() {
|
||||
if (this.filter !== 'lidas' || this.bulkUnreadLoading) return;
|
||||
this.bulkUnreadLoading = true;
|
||||
|
||||
const selectedIds = Array.from(this.selectedIds);
|
||||
const scopedIds = selectedIds.length
|
||||
? selectedIds
|
||||
: this.filteredNotifications.map(n => n.id);
|
||||
|
||||
this.notificationsService.markAllAsUnread(undefined, scopedIds.length ? scopedIds : undefined).subscribe({
|
||||
next: () => {
|
||||
this.notifications = this.notifications.map((n) => {
|
||||
if (scopedIds.length ? scopedIds.includes(n.id) : n.lida) {
|
||||
return { ...n, lida: false, lidaEm: null };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
this.clearSelection();
|
||||
this.bulkUnreadLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.bulkUnreadLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exportNotifications() {
|
||||
if (this.filter === 'lidas' || this.exportLoading) return;
|
||||
this.exportLoading = true;
|
||||
|
|
@ -194,6 +284,55 @@ export class Notificacoes implements OnInit {
|
|||
return false;
|
||||
}
|
||||
|
||||
private getBaseFilteredNotifications(): NotificationDto[] {
|
||||
if (this.filter === 'lidas') {
|
||||
return this.notifications.filter(n => n.lida);
|
||||
}
|
||||
if (this.filter === 'vencidas') {
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
|
||||
}
|
||||
if (this.filter === 'aVencer') {
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
|
||||
}
|
||||
// "todas" aqui representa o inbox: pendentes (não lidas).
|
||||
return this.notifications.filter(n => !n.lida);
|
||||
}
|
||||
|
||||
private buildSearchText(n: NotificationDto): string {
|
||||
const parts: string[] = [];
|
||||
const push = (v?: string | null) => {
|
||||
const t = (v ?? '').toString().trim();
|
||||
if (t) parts.push(t);
|
||||
};
|
||||
|
||||
push(n.cliente);
|
||||
push(n.conta);
|
||||
push(n.linha);
|
||||
push(n.usuario);
|
||||
push(n.planoContrato);
|
||||
push(n.titulo);
|
||||
push(n.mensagem);
|
||||
push(n.data);
|
||||
push(n.referenciaData ?? null);
|
||||
push(n.dtEfetivacaoServico ?? null);
|
||||
push(n.dtTerminoFidelizacao ?? null);
|
||||
|
||||
const efetivacao = this.formatDateSearch(n.dtEfetivacaoServico);
|
||||
const termino = this.formatDateSearch(n.dtTerminoFidelizacao);
|
||||
push(efetivacao);
|
||||
push(termino);
|
||||
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
private formatDateSearch(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
const parsed = this.parseDateOnly(raw);
|
||||
if (!parsed) return '';
|
||||
// Ex.: 12/02/2026 (facilita busca por padrão BR).
|
||||
return parsed.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
private parseDateOnly(raw?: string | null): Date | null {
|
||||
if (!raw) return null;
|
||||
const datePart = raw.split('T')[0];
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="page-subtitle">Visão consolidada de performance, contratos e indicadores financeiros.</p>
|
||||
<p class="page-subtitle">Visão consolidada de performance, contratos e indicadores operacionais.</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
|
|
@ -47,18 +47,18 @@
|
|||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h3>Planos & Contratos</h3>
|
||||
<p>Performance financeira agrupada por modalidade de plano.</p>
|
||||
<p>{{ showFinancial ? 'Performance financeira agrupada por modalidade de plano.' : 'Distribuição e volume de linhas por modalidade de plano.' }}</p>
|
||||
</div>
|
||||
<div class="hero-kpis">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card kpi-card--total-lines">
|
||||
<span class="kpi-lbl">Total Linhas</span>
|
||||
<strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Valor Total</span>
|
||||
<strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Contratos</span>
|
||||
<strong class="kpi-val">{{ formatMoney(contratosTotals?.valorTotal) }}</strong>
|
||||
</div>
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
</div>
|
||||
|
||||
<div class="section-grid planos-charts" data-animate>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" *ngIf="showFinancial">
|
||||
<div class="card-header-clean">
|
||||
<h3>Top Planos (Valor)</h3>
|
||||
<p>Os planos com maior representatividade financeira.</p>
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
<canvas #chartPlanos></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" [class.full-span]="!showFinancial">
|
||||
<div class="card-header-clean">
|
||||
<h3>Top Planos (Volume)</h3>
|
||||
<p>Quantidade de linhas ativas por tipo de plano.</p>
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group-metrics">
|
||||
<div class="group-metrics" *ngIf="showFinancial">
|
||||
<div class="metric">
|
||||
<span class="lbl">Valor Total</span>
|
||||
<strong class="val-money">{{ formatMoney(group.valorTotal) }}</strong>
|
||||
|
|
@ -172,9 +172,9 @@
|
|||
<tr>
|
||||
<th>Plano / Variação</th>
|
||||
<th>Franquia</th>
|
||||
<th class="text-right">Valor Un.</th>
|
||||
<th class="text-right" *ngIf="showFinancial">Valor Un.</th>
|
||||
<th class="text-right">Linhas</th>
|
||||
<th class="text-right">Total</th>
|
||||
<th class="text-right" *ngIf="showFinancial">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -186,9 +186,9 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>{{ formatGb(row.gb) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
|
||||
<td class="text-right num-font font-bold">{{ formatMoney(row.valorTotal) }}</td>
|
||||
<td class="text-right num-font font-bold" *ngIf="showFinancial">{{ formatMoney(row.valorTotal) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
<span>Total de Linhas</span>
|
||||
<strong>{{ formatNumber(planosTotals.totalLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="summary-item highlight">
|
||||
<div class="summary-item highlight" *ngIf="showFinancial">
|
||||
<span>Valor Total Global</span>
|
||||
<strong>{{ formatMoney(planosTotals.valorTotal) }}</strong>
|
||||
</div>
|
||||
|
|
@ -246,18 +246,18 @@
|
|||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h3>Clientes & Performance</h3>
|
||||
<p>Analise a rentabilidade e custos por cliente.</p>
|
||||
<p>{{ showFinancial ? 'Analise a rentabilidade e custos por cliente.' : 'Distribuição e volume de linhas por cliente.' }}</p>
|
||||
</div>
|
||||
<div class="hero-kpis">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card kpi-card--total-lines">
|
||||
<span class="kpi-lbl">Total Linhas</span>
|
||||
<strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Receita Line</span>
|
||||
<strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card highlight">
|
||||
<div class="kpi-card highlight" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Lucro Total</span>
|
||||
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
|
||||
</div>
|
||||
|
|
@ -268,8 +268,8 @@
|
|||
<div class="section-grid full-chart" data-animate>
|
||||
<div class="chart-card">
|
||||
<div class="card-header-clean">
|
||||
<h3>Top Clientes (Lucratividade)</h3>
|
||||
<p>Clientes ordenados pelo maior retorno financeiro.</p>
|
||||
<h3>{{ showFinancial ? 'Top Clientes (Lucratividade)' : 'Top Clientes (Qtd. Linhas)' }}</h3>
|
||||
<p>{{ showFinancial ? 'Clientes ordenados pelo maior retorno financeiro.' : 'Clientes com maior volume de linhas.' }}</p>
|
||||
</div>
|
||||
<div class="chart-area">
|
||||
<canvas #chartClientes></canvas>
|
||||
|
|
@ -306,7 +306,7 @@
|
|||
<span class="kpi-lbl">PJ Linhas</span>
|
||||
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Lucro Consolidado</span>
|
||||
<strong class="kpi-val text-success">{{ formatMoney(totaisLineLucroConsolidado) }}</strong>
|
||||
</div>
|
||||
|
|
@ -386,7 +386,7 @@
|
|||
<span class="kpi-lbl">Linhas em Estoque</span>
|
||||
<strong class="kpi-val">{{ formatNumber(reservaTotals?.qtdLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card" *ngIf="showFinancial">
|
||||
<span class="kpi-lbl">Custo de Reserva</span>
|
||||
<strong class="kpi-val">{{ formatNumber(reservaTotals?.total) }}</strong>
|
||||
</div>
|
||||
|
|
@ -505,7 +505,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grouped-summary-bar" *ngIf="footer === 'clientes' && clientesTotals">
|
||||
<div class="grouped-summary-bar" *ngIf="showFinancial && footer === 'clientes' && clientesTotals">
|
||||
<div class="sum-col">
|
||||
<span class="lbl">Receita Line</span>
|
||||
<strong>{{ formatMoney(clientesTotals.valorContratoLine) }}</strong>
|
||||
|
|
@ -586,25 +586,25 @@
|
|||
<tr>
|
||||
<th>Variação</th>
|
||||
<th>GB</th>
|
||||
<th class="text-right">Valor Un.</th>
|
||||
<th class="text-right" *ngIf="showFinancial">Valor Un.</th>
|
||||
<th class="text-right">Total Linhas</th>
|
||||
<th class="text-right">Valor Total</th>
|
||||
<th class="text-right" *ngIf="showFinancial">Valor Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of macrophonyDetailGroup?.rows; trackBy: trackByIndex">
|
||||
<td>{{ row.planoContrato || '-' }}</td>
|
||||
<td>{{ formatGb(row.gb) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(row.valorTotal) }}</td>
|
||||
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorTotal) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot *ngIf="macrophonyDetailGroup">
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Total deste grupo</td>
|
||||
<td [attr.colspan]="showFinancial ? 3 : 2">Total deste grupo</td>
|
||||
<td class="text-right num-font">{{ formatNumber(macrophonyDetailGroup.totalLinhas) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(macrophonyDetailGroup.valorTotal) }}</td>
|
||||
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(macrophonyDetailGroup.valorTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -331,6 +331,10 @@
|
|||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.kpi-card.kpi-card--total-lines .kpi-val {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.section-grid {
|
||||
display: grid;
|
||||
|
|
@ -344,6 +348,10 @@
|
|||
@media (max-width: 960px) { grid-column: span 12; }
|
||||
}
|
||||
|
||||
.planos-charts .chart-card.full-span {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.full-chart .chart-card {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
|
@ -520,6 +528,12 @@ details[open] .summary-icon { transform: rotate(180deg); }
|
|||
}
|
||||
}
|
||||
|
||||
.macrophony-row .group-actions,
|
||||
.grouped-row .group-actions {
|
||||
grid-column: -1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.group-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ type GeralLineTotalPayload = { tipo?: unknown; qtdLinhas?: unknown; valorTotalLi
|
|||
export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
||||
@HostBinding('class.animate-ready') animateReady = false;
|
||||
|
||||
// Controle rápido para ocultar/exibir informações financeiras (valores/lucro).
|
||||
// Deixe false para não expor faturamento no Resumo/Dashboard; mude para true se o cliente solicitar no futuro.
|
||||
readonly showFinancial = false;
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
resumo: ResumoResponse | null = null;
|
||||
|
|
@ -199,7 +203,9 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.destroyCharts();
|
||||
|
||||
if (this.activeTab === 'planos') {
|
||||
this.buildChartPlanos();
|
||||
if (this.showFinancial) {
|
||||
this.buildChartPlanos();
|
||||
}
|
||||
this.buildChartPlanosLinhas();
|
||||
} else if (this.activeTab === 'clientes') {
|
||||
this.buildChartClientes();
|
||||
|
|
@ -251,6 +257,8 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
const ctx = canvas.getContext('2d');
|
||||
const bg = ctx ? this.createGradient(ctx, CHART_THEME.blue, '#2563eb') : CHART_THEME.blue;
|
||||
|
||||
const common = this.getCommonChartOptions('number') as any;
|
||||
|
||||
this.charts['planosLinhas'] = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
|
|
@ -264,7 +272,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
minBarLength: 8,
|
||||
}]
|
||||
},
|
||||
options: this.getCommonChartOptions('number')
|
||||
options: {
|
||||
...common,
|
||||
plugins: {
|
||||
...common.plugins,
|
||||
tooltip: {
|
||||
...(common.plugins?.tooltip ?? {}),
|
||||
callbacks: {
|
||||
...((common.plugins?.tooltip as any)?.callbacks ?? {}),
|
||||
label: (ctx: TooltipItem<'bar'>) => ` ${this.formatNumber(ctx.raw)} Linhas`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -272,9 +292,15 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
const canvas = this.chartClientesRef?.nativeElement;
|
||||
if (!canvas) return;
|
||||
|
||||
const metricLabel = this.showFinancial ? 'Lucro Estimado' : 'Qtd. Linhas';
|
||||
const metricType = this.showFinancial ? 'currency' : 'number';
|
||||
|
||||
const data = (this.resumo?.vivoLineResumos ?? [])
|
||||
.map(c => ({ label: c.cliente, lucro: this.toNumber(c.lucro) ?? 0 }))
|
||||
.sort((a, b) => b.lucro - a.lucro)
|
||||
.map(c => ({
|
||||
label: c.cliente,
|
||||
value: this.showFinancial ? (this.toNumber(c.lucro) ?? 0) : (this.toNumber(c.qtdLinhas) ?? 0),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
// Garante altura suficiente para exibir todos os nomes no eixo Y sem auto-skip.
|
||||
|
|
@ -285,15 +311,28 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
parent.style.height = `${Math.max(minHeight, data.length * rowHeight)}px`;
|
||||
}
|
||||
|
||||
const common = this.getCommonChartOptions('currency') as any;
|
||||
const common = this.getCommonChartOptions(metricType) as any;
|
||||
const plugins =
|
||||
metricType === 'number'
|
||||
? {
|
||||
...common.plugins,
|
||||
tooltip: {
|
||||
...(common.plugins?.tooltip ?? {}),
|
||||
callbacks: {
|
||||
...((common.plugins?.tooltip as any)?.callbacks ?? {}),
|
||||
label: (ctx: TooltipItem<'bar'>) => ` ${this.formatNumber(ctx.raw)} Linhas`,
|
||||
}
|
||||
}
|
||||
}
|
||||
: common.plugins;
|
||||
|
||||
this.charts['clientes'] = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => (d.label?.length ?? 0) > 25 ? (d.label ?? '').slice(0, 25) + '...' : d.label),
|
||||
datasets: [{
|
||||
label: 'Lucro Estimado',
|
||||
data: data.map(d => d.lucro),
|
||||
label: metricLabel,
|
||||
data: data.map(d => d.value),
|
||||
backgroundColor: CHART_THEME.success,
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.7,
|
||||
|
|
@ -302,6 +341,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
options: {
|
||||
...common,
|
||||
indexAxis: 'y',
|
||||
plugins,
|
||||
scales: {
|
||||
x: {
|
||||
...(common.scales?.x ?? {}),
|
||||
|
|
@ -638,99 +678,112 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
private initTables() {
|
||||
const hideMoneyColumns = <T>(cols: TableColumn<T>[]) =>
|
||||
this.showFinancial ? cols : cols.filter((c) => c.type !== 'money');
|
||||
|
||||
const macrophonyColumns: TableColumn<MacrophonyPlan>[] = [
|
||||
{ key: 'planoContrato', label: 'Plano', type: 'text', value: (r) => r.planoContrato ?? '-' },
|
||||
{ key: 'gb', label: 'Franquia', type: 'gb', value: (r) => r.gb },
|
||||
{ key: 'valorIndividualComSvas', label: 'Valor Un.', type: 'money', align: 'right', value: (r) => r.valorIndividualComSvas },
|
||||
{ key: 'totalLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.totalLinhas },
|
||||
{ key: 'valorTotal', label: 'Valor Total', type: 'money', align: 'right', value: (r) => r.valorTotal },
|
||||
];
|
||||
|
||||
this.tableMacrophony = {
|
||||
key: 'macrophony',
|
||||
label: 'Macrophony',
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'planoContrato', label: 'Plano', type: 'text', value: (r) => r.planoContrato ?? '-' },
|
||||
{ key: 'gb', label: 'Franquia', type: 'gb', value: (r) => r.gb },
|
||||
{ key: 'valorIndividualComSvas', label: 'Valor Un.', type: 'money', align: 'right', value: (r) => r.valorIndividualComSvas },
|
||||
{ key: 'totalLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.totalLinhas },
|
||||
{ key: 'valorTotal', label: 'Valor Total', type: 'money', align: 'right', value: (r) => r.valorTotal },
|
||||
],
|
||||
columns: hideMoneyColumns(macrophonyColumns),
|
||||
search: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sortKey: 'valorTotal',
|
||||
sortKey: this.showFinancial ? 'valorTotal' : 'totalLinhas',
|
||||
sortDir: 'desc',
|
||||
compact: false,
|
||||
view: null,
|
||||
};
|
||||
|
||||
const planoContratoColumns: TableColumn<PlanoContratoResumo>[] = [
|
||||
{ key: 'planoContrato', label: 'Plano', type: 'text', value: (r) => r.planoContrato ?? '-' },
|
||||
{ key: 'gb', label: 'Franquia', type: 'gb', value: (r) => r.gb },
|
||||
{ key: 'valorIndividualComSvas', label: 'Valor Un.', type: 'money', align: 'right', value: (r) => r.valorIndividualComSvas },
|
||||
{ key: 'totalLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.totalLinhas },
|
||||
{ key: 'valorTotal', label: 'Valor Total', type: 'money', align: 'right', value: (r) => r.valorTotal },
|
||||
];
|
||||
|
||||
this.tablePlanoContrato = {
|
||||
key: 'planoContrato',
|
||||
label: 'Plano Contrato',
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'planoContrato', label: 'Plano', type: 'text', value: (r) => r.planoContrato ?? '-' },
|
||||
{ key: 'gb', label: 'Franquia', type: 'gb', value: (r) => r.gb },
|
||||
{ key: 'valorIndividualComSvas', label: 'Valor Un.', type: 'money', align: 'right', value: (r) => r.valorIndividualComSvas },
|
||||
{ key: 'totalLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.totalLinhas },
|
||||
{ key: 'valorTotal', label: 'Valor Total', type: 'money', align: 'right', value: (r) => r.valorTotal },
|
||||
],
|
||||
columns: hideMoneyColumns(planoContratoColumns),
|
||||
search: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sortKey: 'valorTotal',
|
||||
sortKey: this.showFinancial ? 'valorTotal' : 'totalLinhas',
|
||||
sortDir: 'desc',
|
||||
compact: false,
|
||||
view: null,
|
||||
};
|
||||
|
||||
const clientesColumns: TableColumn<VivoLineResumo>[] = [
|
||||
{ key: 'cliente', label: 'Cliente', type: 'text', value: (r) => r.cliente ?? '-' },
|
||||
{ key: 'qtdLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.qtdLinhas },
|
||||
{ key: 'franquiaTotal', label: 'Franquia Vivo', type: 'gb', align: 'right', value: (r) => r.franquiaTotal },
|
||||
{ key: 'valorContratoVivo', label: 'Custo Vivo', type: 'money', align: 'right', value: (r) => r.valorContratoVivo },
|
||||
{ key: 'franquiaLine', label: 'Franquia Line', type: 'gb', align: 'right', value: (r) => r.franquiaLine },
|
||||
{ key: 'valorContratoLine', label: 'Receita Line', type: 'money', align: 'right', value: (r) => r.valorContratoLine },
|
||||
{ key: 'lucro', label: 'Lucro', type: 'money', align: 'right', value: (r) => r.lucro, tone: true },
|
||||
];
|
||||
|
||||
this.tableClientes = {
|
||||
key: 'clientes',
|
||||
label: 'Clientes',
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'cliente', label: 'Cliente', type: 'text', value: (r) => r.cliente ?? '-' },
|
||||
{ key: 'qtdLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.qtdLinhas },
|
||||
{ key: 'franquiaTotal', label: 'Franquia Vivo', type: 'gb', align: 'right', value: (r) => r.franquiaTotal },
|
||||
{ key: 'valorContratoVivo', label: 'Custo Vivo', type: 'money', align: 'right', value: (r) => r.valorContratoVivo },
|
||||
{ key: 'franquiaLine', label: 'Franquia Line', type: 'gb', align: 'right', value: (r) => r.franquiaLine },
|
||||
{ key: 'valorContratoLine', label: 'Receita Line', type: 'money', align: 'right', value: (r) => r.valorContratoLine },
|
||||
{ key: 'lucro', label: 'Lucro', type: 'money', align: 'right', value: (r) => r.lucro, tone: true },
|
||||
],
|
||||
columns: hideMoneyColumns(clientesColumns),
|
||||
search: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sortKey: 'lucro',
|
||||
sortKey: this.showFinancial ? 'lucro' : 'qtdLinhas',
|
||||
sortDir: 'desc',
|
||||
compact: false,
|
||||
view: null,
|
||||
};
|
||||
|
||||
const clientesEspeciaisColumns: TableColumn<ClienteEspecial>[] = [
|
||||
{ key: 'nome', label: 'Nome', type: 'text', value: (r) => r.nome ?? '-' },
|
||||
{ key: 'valor', label: 'Valor', type: 'money', align: 'right', value: (r) => r.valor, tone: true },
|
||||
];
|
||||
|
||||
this.tableClientesEspeciais = {
|
||||
key: 'clientesEspeciais',
|
||||
label: 'Clientes Especiais',
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'nome', label: 'Nome', type: 'text', value: (r) => r.nome ?? '-' },
|
||||
{ key: 'valor', label: 'Valor', type: 'money', align: 'right', value: (r) => r.valor, tone: true },
|
||||
],
|
||||
columns: hideMoneyColumns(clientesEspeciaisColumns),
|
||||
search: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
sortKey: 'valor',
|
||||
sortDir: 'desc',
|
||||
sortKey: this.showFinancial ? 'valor' : 'nome',
|
||||
sortDir: this.showFinancial ? 'desc' : 'asc',
|
||||
compact: false,
|
||||
view: null,
|
||||
};
|
||||
|
||||
const totaisLineColumns: TableColumn<LineTotal>[] = [
|
||||
{ key: 'tipo', label: 'Tipo', type: 'text', value: (r) => r.tipo ?? '-' },
|
||||
{ key: 'qtdLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.qtdLinhas },
|
||||
{ key: 'valorTotalLine', label: 'Valor', type: 'money', align: 'right', value: (r) => r.valorTotalLine },
|
||||
{ key: 'lucroTotalLine', label: 'Lucro', type: 'money', align: 'right', value: (r) => r.lucroTotalLine, tone: true },
|
||||
];
|
||||
|
||||
this.tableTotaisLine = {
|
||||
key: 'totaisLine',
|
||||
label: 'Totais Line',
|
||||
data: [],
|
||||
columns: [
|
||||
{ key: 'tipo', label: 'Tipo', type: 'text', value: (r) => r.tipo ?? '-' },
|
||||
{ key: 'qtdLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.qtdLinhas },
|
||||
{ key: 'valorTotalLine', label: 'Valor', type: 'money', align: 'right', value: (r) => r.valorTotalLine },
|
||||
{ key: 'lucroTotalLine', label: 'Lucro', type: 'money', align: 'right', value: (r) => r.lucroTotalLine, tone: true },
|
||||
],
|
||||
columns: hideMoneyColumns(totaisLineColumns),
|
||||
search: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
|
|
@ -769,11 +822,21 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
(row) => (row.planoContrato ?? '-').toString(),
|
||||
(rows) => (rows[0]?.planoContrato ?? '-').toString(),
|
||||
(rows) => `Franquia ${this.formatGb(rows[0]?.gb)}`,
|
||||
(rows) => [
|
||||
{ label: 'Linhas', value: this.formatNumber(this.sumGroup(rows, (r) => r.totalLinhas)) },
|
||||
{ label: 'Valor', value: this.formatMoney(this.sumGroup(rows, (r) => r.valorTotal)) },
|
||||
],
|
||||
(a, b) => this.sumGroup(b.rows, (r) => r.valorTotal) - this.sumGroup(a.rows, (r) => r.valorTotal)
|
||||
(rows) => {
|
||||
const metrics: GroupMetric[] = [
|
||||
{ label: 'Linhas', value: this.formatNumber(this.sumGroup(rows, (r) => r.totalLinhas)) },
|
||||
];
|
||||
if (this.showFinancial) {
|
||||
metrics.push({ label: 'Valor', value: this.formatMoney(this.sumGroup(rows, (r) => r.valorTotal)) });
|
||||
}
|
||||
return metrics;
|
||||
},
|
||||
(a, b) => {
|
||||
if (this.showFinancial) {
|
||||
return this.sumGroup(b.rows, (r) => r.valorTotal) - this.sumGroup(a.rows, (r) => r.valorTotal);
|
||||
}
|
||||
return this.sumGroup(b.rows, (r) => r.totalLinhas) - this.sumGroup(a.rows, (r) => r.totalLinhas);
|
||||
}
|
||||
);
|
||||
|
||||
this.groupClientes = this.createGroupedTableState(
|
||||
|
|
@ -784,6 +847,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
(rows) => (rows[0]?.cliente ?? '-').toString(),
|
||||
(rows) => `${this.formatNumber(this.sumGroup(rows, (r) => r.qtdLinhas))} linhas`,
|
||||
(rows) => {
|
||||
if (!this.showFinancial) {
|
||||
const linhas = this.sumGroup(rows, (r) => r.qtdLinhas);
|
||||
return [{ label: 'Linhas', value: this.formatNumber(linhas) }];
|
||||
}
|
||||
const receita = this.sumGroup(rows, (r) => r.valorContratoLine);
|
||||
const custo = this.sumGroup(rows, (r) => r.valorContratoVivo);
|
||||
const lucro = this.sumGroup(rows, (r) => r.lucro);
|
||||
|
|
@ -793,7 +860,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
{ label: 'Lucro', value: this.formatMoney(lucro), tone: this.getToneClass(lucro) },
|
||||
];
|
||||
},
|
||||
(a, b) => this.sumGroup(b.rows, (r) => r.lucro) - this.sumGroup(a.rows, (r) => r.lucro)
|
||||
(a, b) => {
|
||||
if (this.showFinancial) {
|
||||
return this.sumGroup(b.rows, (r) => r.lucro) - this.sumGroup(a.rows, (r) => r.lucro);
|
||||
}
|
||||
return this.sumGroup(b.rows, (r) => r.qtdLinhas) - this.sumGroup(a.rows, (r) => r.qtdLinhas);
|
||||
}
|
||||
);
|
||||
|
||||
this.groupClientesEspeciais = this.createGroupedTableState(
|
||||
|
|
@ -804,10 +876,16 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
(rows) => (rows[0]?.nome ?? '-').toString(),
|
||||
undefined,
|
||||
(rows) => {
|
||||
if (!this.showFinancial) {
|
||||
return [];
|
||||
}
|
||||
const total = this.sumGroup(rows, (r) => r.valor);
|
||||
return [{ label: 'Valor', value: this.formatMoney(total), tone: this.getToneClass(total) }];
|
||||
},
|
||||
(a, b) => this.sumGroup(b.rows, (r) => r.valor) - this.sumGroup(a.rows, (r) => r.valor)
|
||||
(a, b) => {
|
||||
if (!this.showFinancial) return 0;
|
||||
return this.sumGroup(b.rows, (r) => r.valor) - this.sumGroup(a.rows, (r) => r.valor);
|
||||
}
|
||||
);
|
||||
|
||||
this.groupTotaisLine = this.createGroupedTableState(
|
||||
|
|
@ -819,13 +897,14 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
undefined,
|
||||
(rows) => {
|
||||
const linhas = this.sumGroup(rows, (r) => r.qtdLinhas);
|
||||
const valor = this.sumGroup(rows, (r) => r.valorTotalLine);
|
||||
const lucro = this.sumGroup(rows, (r) => r.lucroTotalLine);
|
||||
return [
|
||||
{ label: 'Linhas', value: this.formatNumber(linhas) },
|
||||
{ label: 'Valor', value: this.formatMoney(valor) },
|
||||
{ label: 'Lucro', value: this.formatMoney(lucro), tone: this.getToneClass(lucro) },
|
||||
];
|
||||
const metrics: GroupMetric[] = [{ label: 'Linhas', value: this.formatNumber(linhas) }];
|
||||
if (this.showFinancial) {
|
||||
const valor = this.sumGroup(rows, (r) => r.valorTotalLine);
|
||||
const lucro = this.sumGroup(rows, (r) => r.lucroTotalLine);
|
||||
metrics.push({ label: 'Valor', value: this.formatMoney(valor) });
|
||||
metrics.push({ label: 'Lucro', value: this.formatMoney(lucro), tone: this.getToneClass(lucro) });
|
||||
}
|
||||
return metrics;
|
||||
},
|
||||
(a, b) => this.sumGroup(b.rows, (r) => r.qtdLinhas) - this.sumGroup(a.rows, (r) => r.qtdLinhas)
|
||||
);
|
||||
|
|
@ -951,11 +1030,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
|
|||
totalLinhas,
|
||||
valorTotal,
|
||||
valorUnitMedio: valorUnit,
|
||||
rows: [...items].sort((a, b) => (this.toNumber(b.valorTotal) ?? 0) - (this.toNumber(a.valorTotal) ?? 0)),
|
||||
rows: [...items].sort((a, b) => {
|
||||
if (this.showFinancial) {
|
||||
return (this.toNumber(b.valorTotal) ?? 0) - (this.toNumber(a.valorTotal) ?? 0);
|
||||
}
|
||||
return (this.toNumber(b.totalLinhas) ?? 0) - (this.toNumber(a.totalLinhas) ?? 0);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
groups.sort((a, b) => b.valorTotal - a.valorTotal);
|
||||
groups.sort((a, b) => {
|
||||
if (this.showFinancial) return b.valorTotal - a.valorTotal;
|
||||
return b.totalLinhas - a.totalLinhas;
|
||||
});
|
||||
this.macrophonyGroups = groups;
|
||||
|
||||
const search = this.normalizeText(this.macrophonySearch);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, Subject, tap } from 'rxjs';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
|
|
@ -26,9 +26,18 @@ export type NotificationDto = {
|
|||
dtTerminoFidelizacao?: string | null;
|
||||
};
|
||||
|
||||
export type NotificationsEvent =
|
||||
| { type: 'read'; ids: string[]; readAtIso: string }
|
||||
| { type: 'unread'; ids: string[] }
|
||||
| { type: 'readAll'; readAtIso: string }
|
||||
| { type: 'unreadAll' }
|
||||
| { type: 'reload' };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationsService {
|
||||
private readonly baseApi: string;
|
||||
private readonly eventsSubject = new Subject<NotificationsEvent>();
|
||||
readonly events$ = this.eventsSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
|
|
@ -40,14 +49,49 @@ export class NotificationsService {
|
|||
}
|
||||
|
||||
markAsRead(id: string): Observable<void> {
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {});
|
||||
const readAtIso = new Date().toISOString();
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {}).pipe(
|
||||
tap(() => this.eventsSubject.next({ type: 'read', ids: [id], readAtIso }))
|
||||
);
|
||||
}
|
||||
|
||||
markAsUnread(id: string): Observable<void> {
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/unread`, {}).pipe(
|
||||
tap(() => this.eventsSubject.next({ type: 'unread', ids: [id] }))
|
||||
);
|
||||
}
|
||||
|
||||
markAllAsRead(filter?: string, notificationIds?: string[]): Observable<void> {
|
||||
let params = new HttpParams();
|
||||
if (filter) params = params.set('filter', filter);
|
||||
const body = notificationIds && notificationIds.length ? { notificationIds } : {};
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/read-all`, body, { params });
|
||||
const readAtIso = new Date().toISOString();
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/read-all`, body, { params }).pipe(
|
||||
tap(() => {
|
||||
if (notificationIds && notificationIds.length) {
|
||||
this.eventsSubject.next({ type: 'read', ids: notificationIds, readAtIso });
|
||||
return;
|
||||
}
|
||||
// Se não sabemos o escopo (sem IDs), preferimos sinalizar que "todas" foram lidas.
|
||||
// Para casos futuros de filtros sem IDs, o consumer pode optar por recarregar.
|
||||
this.eventsSubject.next(filter ? { type: 'reload' } : { type: 'readAll', readAtIso });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
markAllAsUnread(filter?: string, notificationIds?: string[]): Observable<void> {
|
||||
let params = new HttpParams();
|
||||
if (filter) params = params.set('filter', filter);
|
||||
const body = notificationIds && notificationIds.length ? { notificationIds } : {};
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/unread-all`, body, { params }).pipe(
|
||||
tap(() => {
|
||||
if (notificationIds && notificationIds.length) {
|
||||
this.eventsSubject.next({ type: 'unread', ids: notificationIds });
|
||||
return;
|
||||
}
|
||||
this.eventsSubject.next(filter ? { type: 'reload' } : { type: 'unreadAll' });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export(filter?: string, notificationIds?: string[]): Observable<HttpResponse<Blob>> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue