Subindo alterações para produção

This commit is contained in:
Eduardo 2026-02-12 16:49:42 -03:00
parent 70fc642286
commit 1f277b8c8a
15 changed files with 868 additions and 237 deletions

12
package-lock.json generated
View File

@ -594,7 +594,6 @@
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz",
"integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -27,14 +27,14 @@ export const routes: Routes = [
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' }, { path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' }, { 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: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' }, { 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: '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: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },

View File

@ -32,44 +32,85 @@
<span class="badge-pulse" *ngIf="unreadCount > 0"></span> <span class="badge-pulse" *ngIf="unreadCount > 0"></span>
</button> </button>
<div class="notifications-dropdown" *ngIf="notificationsOpen"> <div class="notifications-dropdown" *ngIf="notificationsOpen">
<div class="notifications-head"> <div class="notifications-head">
<div class="head-title"> <div class="head-title">
<span>Notificações</span> <span>Notificações</span>
<span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span> <span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span>
</div> </div>
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver tudo</a> <div class="head-actions">
</div> <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-tabs">
<div class="notifications-state loading" *ngIf="notificationsLoading"> <button
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> 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> <span>Carregando...</span>
</div> </div>
<div class="notifications-state warn" *ngIf="notificationsError"> <div class="notifications-state warn" *ngIf="notificationsError">
<i class="bi bi-exclamation-triangle"></i> <i class="bi bi-exclamation-triangle"></i>
<span>Falha ao carregar.</span> <span>Falha ao carregar.</span>
</div> </div>
<div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notifications.length === 0"> <div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notificationsVisibleCount === 0">
<div class="empty-icon"><i class="bi bi-bell-slash"></i></div> <div class="empty-icon"><i class="bi bi-bell-slash"></i></div>
<p>Tudo limpo por aqui!</p> <p>Não há notificações no momento.</p>
</div> </div>
<div class="notifications-state info" *ngIf="!notificationsLoading && !notificationsError && hasNotificationsTruncated"> <div class="notifications-state info" *ngIf="!notificationsLoading && !notificationsError && hasNotificationsTruncated">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
<span class="notifications-truncate-copy"> <span class="notifications-truncate-copy">
Mostrando <strong>{{ notificationsPreviewLimit }}</strong> de <strong>{{ notifications.length }}</strong> notificações Mostrando <strong>{{ notificationsPreviewLimit }}</strong> de <strong>{{ notificationsVisibleCount }}</strong> notificações
</span> </span>
</div> </div>
<div <div
class="notification-item" class="notification-item"
*ngFor="let n of notificationsPreview; trackBy: trackByNotificationId" *ngFor="let n of notificationsPreview; trackBy: trackByNotificationId"
[class.unread]="!n.lida" [class.unread]="!n.lida"
(click)="markNotificationRead(n)" (click)="onNotificationItemClick(n)"
> >
<div class="notif-icon-area"> <div class="notif-icon-area">
<div class="icon-circle" <div class="icon-circle"
[class.danger]="getNotificationTipo(n) === 'Vencido'" [class.danger]="getNotificationTipo(n) === 'Vencido'"
@ -107,12 +148,21 @@
</div> </div>
</div> </div>
<div class="notif-status" *ngIf="!n.lida" title="Marcar como lida"> <div class="notif-status" *ngIf="!n.lida" title="Marcar como lida">
<span class="status-dot"></span> <span class="status-dot"></span>
</div> </div>
</div> <button
</div> type="button"
</div> 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>
<div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()"> <div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()">
@ -442,10 +492,10 @@
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span> <i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
</a> </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> <i class="bi bi-receipt"></i> <span>Faturamento</span>
</a> </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> <i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
</a> </a>
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <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()"> <a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span> <i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
</a> </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> <i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
</a> </a>
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">

View File

@ -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); } } @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-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-empty { padding: 32px; text-align: center; color: $text-muted; .empty-icon { font-size: 24px; margin-bottom: 8px; } }
.notifications-state { .notifications-state {
@ -199,6 +293,24 @@ $border-color: #e5e7eb;
.notif-meta-line { display: flex; gap: 6px; font-size: 12px; color: $text-muted; } .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-label { font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; }
.meta-value { font-weight: 600; color: $text-main; } .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 */ /* MODAIS GERAIS */

View File

@ -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 { RouterLink, Router, NavigationEnd } from '@angular/router';
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core'; 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 { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../custom-select/custom-select'; import { CustomSelectComponent } from '../custom-select/custom-select';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@ -17,7 +18,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select';
templateUrl: './header.html', templateUrl: './header.html',
styleUrls: ['./header.scss'], styleUrls: ['./header.scss'],
}) })
export class Header implements AfterViewInit { export class Header implements AfterViewInit, OnDestroy {
isScrolled = false; isScrolled = false;
menuOpen = false; menuOpen = false;
@ -31,12 +32,16 @@ export class Header implements AfterViewInit {
notifications: NotificationDto[] = []; notifications: NotificationDto[] = [];
notificationsLoading = false; notificationsLoading = false;
notificationsError = false; notificationsError = false;
notificationsView: 'pendentes' | 'lidas' = 'pendentes';
notificationsBulkReadLoading = false;
notificationsBulkUnreadLoading = false;
private notificationsLoaded = false; private notificationsLoaded = false;
private pendingToastCheck = false; private pendingToastCheck = false;
private lastNotificationsLoadAt = 0; private lastNotificationsLoadAt = 0;
private readonly notificationsRefreshMs = 60_000; private readonly notificationsRefreshMs = 60_000;
readonly notificationsPreviewLimit = 40; readonly notificationsPreviewLimit = 40;
@ViewChild('notifToast') notifToast?: ElementRef; @ViewChild('notifToast') notifToast?: ElementRef;
private readonly subs = new Subscription();
createUserForm: FormGroup; createUserForm: FormGroup;
createUserSubmitting = false; createUserSubmitting = false;
@ -126,6 +131,53 @@ export class Header implements AfterViewInit {
if (this.isLoggedHeader) { if (this.isLoggedHeader) {
this.ensureNotificationsLoaded(); 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) { private syncHeaderState(rawUrl: string) {
@ -198,6 +250,7 @@ export class Header implements AfterViewInit {
toggleNotifications() { toggleNotifications() {
this.notificationsOpen = !this.notificationsOpen; this.notificationsOpen = !this.notificationsOpen;
if (this.notificationsOpen) { if (this.notificationsOpen) {
this.notificationsView = 'pendentes';
this.optionsOpen = false; this.optionsOpen = false;
if (!this.notificationsLoaded) { if (!this.notificationsLoaded) {
this.loadNotifications(false); this.loadNotifications(false);
@ -221,10 +274,69 @@ export class Header implements AfterViewInit {
next: () => { next: () => {
notification.lida = true; notification.lida = true;
notification.lidaEm = new Date().toISOString(); 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 { getVigenciaLabel(notification: NotificationDto): string {
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em'; 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; 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() { get notificationsPreview() {
return this.notifications.slice(0, this.notificationsPreviewLimit); return this.notificationsVisible.slice(0, this.notificationsPreviewLimit);
} }
get hasNotificationsTruncated() { get hasNotificationsTruncated() {
return this.notifications.length > this.notificationsPreviewLimit; return this.notificationsVisibleCount > this.notificationsPreviewLimit;
} }
trackByNotificationId(_: number, notification: NotificationDto) { trackByNotificationId(_: number, notification: NotificationDto) {
@ -398,12 +520,17 @@ export class Header implements AfterViewInit {
const acknowledged = this.getAcknowledgedIds(); const acknowledged = this.getAcknowledgedIds();
return this.notifications.find( return this.notifications.find(
n => n =>
!n.lida &&
this.getNotificationTipo(n) === 'AVencer' && this.getNotificationTipo(n) === 'AVencer' &&
this.getNotificationDaysToExpire(n) === 5 && this.getNotificationDaysToExpire(n) === 5 &&
!acknowledged.has(n.id) !acknowledged.has(n.id)
); );
} }
ngOnDestroy(): void {
this.subs.unsubscribe();
}
private parseDateOnly(raw?: string | null): Date | null { private parseDateOnly(raw?: string | null): Date | null {
if (!raw) return null; if (!raw) return null;
const datePart = raw.split('T')[0]; const datePart = raw.split('T')[0];

View File

@ -81,8 +81,7 @@
max-width: 1240px; max-width: 1240px;
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-top: 40px; margin: 40px auto 24px; /* ✅ garante centralização horizontal */
margin-bottom: 24px; /* ✅ remove aquele "200px" que ajudava o footer global a aparecer */
display: flex; display: flex;
min-height: 0; min-height: 0;
} }

View File

@ -259,16 +259,16 @@
<h6>DIFERENÇA PJ X PF</h6> <h6>DIFERENÇA PJ X PF</h6>
<div class="metric-stack"> <div class="metric-stack">
<div class="metric-line"> <div class="metric-line">
<span>Valor Total Line</span> <span>PF (Linhas)</span>
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.valorTotalLine) }}</strong> <strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
</div> </div>
<div class="metric-line"> <div class="metric-line">
<span>Lucro Total Line</span> <span>PJ (Linhas)</span>
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.lucroTotalLine) }}</strong> <strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
</div> </div>
<div class="metric-line"> <div class="metric-line">
<span>Qtd Linhas</span> <span>Total Linhas</span>
<strong>{{ formatInt(resumoDiferencaPjPf.qtdLinhas) }}</strong> <strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
</div> </div>
</div> </div>
</div> </div>

View File

@ -187,9 +187,9 @@ type ResumoTopReserva = {
}; };
type ResumoDiferencaPjPf = { type ResumoDiferencaPjPf = {
valorTotalLine: number | null; pfLinhas: number | null;
lucroTotalLine: number | null; pjLinhas: number | null;
qtdLinhas: number | null; totalLinhas: number | null;
}; };
@Component({ @Component({
@ -297,9 +297,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
resumoReservaLabels: string[] = []; resumoReservaLabels: string[] = [];
resumoReservaValues: number[] = []; resumoReservaValues: number[] = [];
resumoDiferencaPjPf: ResumoDiferencaPjPf = { resumoDiferencaPjPf: ResumoDiferencaPjPf = {
valorTotalLine: null, pfLinhas: null,
lucroTotalLine: null, pjLinhas: null,
qtdLinhas: null, totalLinhas: null,
}; };
private viewReady = false; private viewReady = false;
@ -611,14 +611,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const lineTotals = this.getEffectiveLineTotals(); const lineTotals = this.getEffectiveLineTotals();
const pf = this.findLineTotal(lineTotals, ['PF', 'PESSOA FISICA']); const pf = this.findLineTotal(lineTotals, ['PF', 'PESSOA FISICA']);
const pj = this.findLineTotal(lineTotals, ['PJ', 'PESSOA JURIDICA']); 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 pfLinhas = this.toNumberOrNull(pf?.qtdLinhas) ?? 0;
const pjLinhas = this.toNumberOrNull(pj?.qtdLinhas) ?? 0; const pjLinhas = this.toNumberOrNull(pj?.qtdLinhas) ?? 0;
this.resumoDiferencaPjPf = { this.resumoDiferencaPjPf = {
valorTotalLine: this.toNumberOrNull(diferenca?.valorTotalLine), pfLinhas,
lucroTotalLine: this.toNumberOrNull(diferenca?.lucroTotalLine), pjLinhas,
qtdLinhas: this.toNumberOrNull(diferenca?.qtdLinhas), totalLinhas: pfLinhas + pjLinhas,
}; };
const clientesMap = new Map<string, number>(); const clientesMap = new Map<string, number>();
for (const c of this.resumo.vivoLineResumos ?? []) { for (const c of this.resumo.vivoLineResumos ?? []) {
@ -692,9 +691,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.resumoReservaLabels = []; this.resumoReservaLabels = [];
this.resumoReservaValues = []; this.resumoReservaValues = [];
this.resumoDiferencaPjPf = { this.resumoDiferencaPjPf = {
valorTotalLine: null, pfLinhas: null,
lucroTotalLine: null, pjLinhas: null,
qtdLinhas: null, totalLinhas: null,
}; };
this.destroyResumoCharts(); this.destroyResumoCharts();
this.rebuildPrimaryKpis(); this.rebuildPrimaryKpis();

View File

@ -8,10 +8,10 @@
<p>Gerencie seus alertas de vencimento e avisos do sistema.</p> <p>Gerencie seus alertas de vencimento e avisos do sistema.</p>
</div> </div>
<div class="filters-bar"> <div class="filters-bar">
<button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')"> <button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')">
Todas Todas
</button> </button>
<button type="button" class="pill" [class.active]="filter === 'aVencer'" (click)="setFilter('aVencer')"> <button type="button" class="pill" [class.active]="filter === 'aVencer'" (click)="setFilter('aVencer')">
A vencer A vencer
<span class="count-badge" *ngIf="countByType('AVencer') > 0">{{ countByType('AVencer') }}</span> <span class="count-badge" *ngIf="countByType('AVencer') > 0">{{ countByType('AVencer') }}</span>
@ -20,26 +20,47 @@
Vencidas Vencidas
<span class="count-badge danger" *ngIf="countByType('Vencido') > 0">{{ countByType('Vencido') }}</span> <span class="count-badge danger" *ngIf="countByType('Vencido') > 0">{{ countByType('Vencido') }}</span>
</button> </button>
<button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')"> <button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')">
Arquivadas / Lidas Arquivadas / Lidas
</button> </button>
</div> </div>
<div class="bulk-actions-bar" *ngIf="!loading && !error"> <div class="search-row" *ngIf="!loading && !error">
<div class="bulk-left"> <div class="search-box">
<label class="select-all" *ngIf="filter !== 'lidas' && filteredNotifications.length > 0"> <i class="bi bi-search"></i>
<input type="checkbox" [checked]="isAllSelected" (change)="toggleSelectAll()" /> <input
<span>Selecionar todas</span> type="text"
</label> placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
<span class="bulk-count"> [(ngModel)]="search"
Mostrando {{ filteredNotifications.length }} notificações (ngModelChange)="clearSelection()"
<span class="bulk-selected" *ngIf="selectedIds.size > 0">• {{ selectedIds.size }} selecionada(s)</span> />
</span> <button
</div> type="button"
<div class="bulk-actions" *ngIf="filter !== 'lidas'"> class="clear-btn"
<button *ngIf="search"
type="button" (click)="clearSearch()"
class="bulk-btn" 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()" (click)="markAllAsRead()"
[disabled]="bulkLoading || filteredNotifications.length === 0" [disabled]="bulkLoading || filteredNotifications.length === 0"
> >
@ -53,10 +74,21 @@
[disabled]="exportLoading || filteredNotifications.length === 0" [disabled]="exportLoading || filteredNotifications.length === 0"
> >
<span *ngIf="!exportLoading"><i class="bi bi-download me-1"></i> Exportar</span> <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> <span *ngIf="exportLoading"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
</div> </div>
</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>
<div class="state-container" *ngIf="loading"> <div class="state-container" *ngIf="loading">
@ -69,14 +101,14 @@
<p>Não foi possível carregar as notificações.</p> <p>Não foi possível carregar as notificações.</p>
</div> </div>
<div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0"> <div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0">
<div class="illustration"> <div class="illustration">
<i class="bi bi-check-circle-fill"></i> <i class="bi bi-check-circle-fill"></i>
</div> </div>
<h3>Tudo em dia!</h3> <h3>Tudo em dia!</h3>
<p *ngIf="filter === 'todas'">Você não tem nenhuma notificação pendente.</p> <p *ngIf="filter === 'todas'">Não há notificações no momento.</p>
<p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p> <p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p>
</div> </div>
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0"> <div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
<div <div
@ -88,10 +120,10 @@
> >
<div class="status-strip"></div> <div class="status-strip"></div>
<label class="item-select" *ngIf="filter !== 'lidas'"> <label class="item-select">
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" /> <input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
<span></span> <span></span>
</label> </label>
<div class="item-icon"> <div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i> <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> </div>
<div class="item-actions"> <div class="item-actions">
<button <button
type="button" type="button"
class="btn-action" class="btn-action"
[title]="n.lida ? 'Já lida' : 'Marcar como lida'" [title]="n.lida ? 'Marcar como não lida' : 'Marcar como lida'"
(click)="markAsRead(n)" (click)="n.lida ? markAsUnread(n) : markAsRead(n)"
[disabled]="n.lida" >
> <i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i>
<i class="bi" [class.bi-check2-all]="n.lida" [class.bi-check2]="!n.lida"></i> <span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span>
<span class="d-none d-md-inline">{{ n.lida ? 'Lida' : 'Marcar lida' }}</span> </button>
</button> </div>
</div>
</div> </div>
</div> </div>

View File

@ -110,6 +110,47 @@ $border: #e5e7eb;
flex-wrap: wrap; justify-content: center; 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 { .pill {
border: none; border: none;
background: transparent; background: transparent;

View File

@ -1,28 +1,83 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { NotificationsService, NotificationDto } from '../../services/notifications.service'; import { NotificationsService, NotificationDto } from '../../services/notifications.service';
@Component({ @Component({
selector: 'app-notificacoes', selector: 'app-notificacoes',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, FormsModule],
templateUrl: './notificacoes.html', templateUrl: './notificacoes.html',
styleUrls: ['./notificacoes.scss'], styleUrls: ['./notificacoes.scss'],
}) })
export class Notificacoes implements OnInit { export class Notificacoes implements OnInit, OnDestroy {
notifications: NotificationDto[] = []; notifications: NotificationDto[] = [];
filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas'; filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas';
search = '';
loading = false; loading = false;
error = false; error = false;
bulkLoading = false; bulkLoading = false;
bulkUnreadLoading = false;
exportLoading = false; exportLoading = false;
selectedIds = new Set<string>(); selectedIds = new Set<string>();
private readonly subs = new Subscription();
constructor(private notificationsService: NotificationsService) {} constructor(private notificationsService: NotificationsService) {}
ngOnInit(): void { ngOnInit(): void {
this.loadNotifications(); 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) { 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') { setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') {
this.filter = value; this.filter = value;
this.clearSelection(); this.clearSelection();
} }
get filteredNotifications() { get filteredNotifications() {
if (this.filter === 'vencidas') { const base = this.getBaseFilteredNotifications();
return this.notifications.filter(n => this.getNotificationTipo(n) === 'Vencido'); const q = (this.search || '').trim().toLowerCase();
} if (!q) return base;
if (this.filter === 'aVencer') { return base.filter(n => this.buildSearchText(n).includes(q));
return this.notifications.filter(n => this.getNotificationTipo(n) === 'AVencer'); }
}
if (this.filter === 'lidas') { clearSearch() {
return this.notifications.filter(n => n.lida); this.search = '';
} this.clearSelection();
return this.notifications;
} }
formatDateLabel(date?: string | null): string { 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() { exportNotifications() {
if (this.filter === 'lidas' || this.exportLoading) return; if (this.filter === 'lidas' || this.exportLoading) return;
this.exportLoading = true; this.exportLoading = true;
@ -194,6 +284,55 @@ export class Notificacoes implements OnInit {
return false; 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 { private parseDateOnly(raw?: string | null): Date | null {
if (!raw) return null; if (!raw) return null;
const datePart = raw.split('T')[0]; const datePart = raw.split('T')[0];

View File

@ -19,7 +19,7 @@
</div> </div>
</div> </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>
<div class="header-actions"> <div class="header-actions">
@ -47,18 +47,18 @@
<div class="hero-content"> <div class="hero-content">
<div class="hero-text"> <div class="hero-text">
<h3>Planos & Contratos</h3> <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>
<div class="hero-kpis"> <div class="hero-kpis">
<div class="kpi-card"> <div class="kpi-card kpi-card--total-lines">
<span class="kpi-lbl">Total Linhas</span> <span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong> <strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong>
</div> </div>
<div class="kpi-card"> <div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Valor Total</span> <span class="kpi-lbl">Valor Total</span>
<strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong> <strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong>
</div> </div>
<div class="kpi-card"> <div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Contratos</span> <span class="kpi-lbl">Contratos</span>
<strong class="kpi-val">{{ formatMoney(contratosTotals?.valorTotal) }}</strong> <strong class="kpi-val">{{ formatMoney(contratosTotals?.valorTotal) }}</strong>
</div> </div>
@ -67,7 +67,7 @@
</div> </div>
<div class="section-grid planos-charts" data-animate> <div class="section-grid planos-charts" data-animate>
<div class="chart-card"> <div class="chart-card" *ngIf="showFinancial">
<div class="card-header-clean"> <div class="card-header-clean">
<h3>Top Planos (Valor)</h3> <h3>Top Planos (Valor)</h3>
<p>Os planos com maior representatividade financeira.</p> <p>Os planos com maior representatividade financeira.</p>
@ -76,7 +76,7 @@
<canvas #chartPlanos></canvas> <canvas #chartPlanos></canvas>
</div> </div>
</div> </div>
<div class="chart-card"> <div class="chart-card" [class.full-span]="!showFinancial">
<div class="card-header-clean"> <div class="card-header-clean">
<h3>Top Planos (Volume)</h3> <h3>Top Planos (Volume)</h3>
<p>Quantidade de linhas ativas por tipo de plano.</p> <p>Quantidade de linhas ativas por tipo de plano.</p>
@ -147,7 +147,7 @@
</div> </div>
</div> </div>
<div class="group-metrics"> <div class="group-metrics" *ngIf="showFinancial">
<div class="metric"> <div class="metric">
<span class="lbl">Valor Total</span> <span class="lbl">Valor Total</span>
<strong class="val-money">{{ formatMoney(group.valorTotal) }}</strong> <strong class="val-money">{{ formatMoney(group.valorTotal) }}</strong>
@ -172,9 +172,9 @@
<tr> <tr>
<th>Plano / Variação</th> <th>Plano / Variação</th>
<th>Franquia</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">Linhas</th>
<th class="text-right">Total</th> <th class="text-right" *ngIf="showFinancial">Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -186,9 +186,9 @@
</div> </div>
</td> </td>
<td>{{ formatGb(row.gb) }}</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">{{ 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> </tr>
</tbody> </tbody>
</table> </table>
@ -221,7 +221,7 @@
<span>Total de Linhas</span> <span>Total de Linhas</span>
<strong>{{ formatNumber(planosTotals.totalLinhasTotal) }}</strong> <strong>{{ formatNumber(planosTotals.totalLinhasTotal) }}</strong>
</div> </div>
<div class="summary-item highlight"> <div class="summary-item highlight" *ngIf="showFinancial">
<span>Valor Total Global</span> <span>Valor Total Global</span>
<strong>{{ formatMoney(planosTotals.valorTotal) }}</strong> <strong>{{ formatMoney(planosTotals.valorTotal) }}</strong>
</div> </div>
@ -246,18 +246,18 @@
<div class="hero-content"> <div class="hero-content">
<div class="hero-text"> <div class="hero-text">
<h3>Clientes & Performance</h3> <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>
<div class="hero-kpis"> <div class="hero-kpis">
<div class="kpi-card"> <div class="kpi-card kpi-card--total-lines">
<span class="kpi-lbl">Total Linhas</span> <span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong> <strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong>
</div> </div>
<div class="kpi-card"> <div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Receita Line</span> <span class="kpi-lbl">Receita Line</span>
<strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong> <strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong>
</div> </div>
<div class="kpi-card highlight"> <div class="kpi-card highlight" *ngIf="showFinancial">
<span class="kpi-lbl">Lucro Total</span> <span class="kpi-lbl">Lucro Total</span>
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong> <strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
</div> </div>
@ -268,8 +268,8 @@
<div class="section-grid full-chart" data-animate> <div class="section-grid full-chart" data-animate>
<div class="chart-card"> <div class="chart-card">
<div class="card-header-clean"> <div class="card-header-clean">
<h3>Top Clientes (Lucratividade)</h3> <h3>{{ showFinancial ? 'Top Clientes (Lucratividade)' : 'Top Clientes (Qtd. Linhas)' }}</h3>
<p>Clientes ordenados pelo maior retorno financeiro.</p> <p>{{ showFinancial ? 'Clientes ordenados pelo maior retorno financeiro.' : 'Clientes com maior volume de linhas.' }}</p>
</div> </div>
<div class="chart-area"> <div class="chart-area">
<canvas #chartClientes></canvas> <canvas #chartClientes></canvas>
@ -306,7 +306,7 @@
<span class="kpi-lbl">PJ Linhas</span> <span class="kpi-lbl">PJ Linhas</span>
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}</strong> <strong class="kpi-val">{{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}</strong>
</div> </div>
<div class="kpi-card"> <div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Lucro Consolidado</span> <span class="kpi-lbl">Lucro Consolidado</span>
<strong class="kpi-val text-success">{{ formatMoney(totaisLineLucroConsolidado) }}</strong> <strong class="kpi-val text-success">{{ formatMoney(totaisLineLucroConsolidado) }}</strong>
</div> </div>
@ -386,7 +386,7 @@
<span class="kpi-lbl">Linhas em Estoque</span> <span class="kpi-lbl">Linhas em Estoque</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.qtdLinhasTotal) }}</strong> <strong class="kpi-val">{{ formatNumber(reservaTotals?.qtdLinhasTotal) }}</strong>
</div> </div>
<div class="kpi-card"> <div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Custo de Reserva</span> <span class="kpi-lbl">Custo de Reserva</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.total) }}</strong> <strong class="kpi-val">{{ formatNumber(reservaTotals?.total) }}</strong>
</div> </div>
@ -505,7 +505,7 @@
</div> </div>
</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"> <div class="sum-col">
<span class="lbl">Receita Line</span> <span class="lbl">Receita Line</span>
<strong>{{ formatMoney(clientesTotals.valorContratoLine) }}</strong> <strong>{{ formatMoney(clientesTotals.valorContratoLine) }}</strong>
@ -586,25 +586,25 @@
<tr> <tr>
<th>Variação</th> <th>Variação</th>
<th>GB</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">Total Linhas</th>
<th class="text-right">Valor Total</th> <th class="text-right" *ngIf="showFinancial">Valor Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let row of macrophonyDetailGroup?.rows; trackBy: trackByIndex"> <tr *ngFor="let row of macrophonyDetailGroup?.rows; trackBy: trackByIndex">
<td>{{ row.planoContrato || '-' }}</td> <td>{{ row.planoContrato || '-' }}</td>
<td>{{ formatGb(row.gb) }}</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">{{ 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> </tr>
</tbody> </tbody>
<tfoot *ngIf="macrophonyDetailGroup"> <tfoot *ngIf="macrophonyDetailGroup">
<tr class="total-row"> <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">{{ 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> </tr>
</tfoot> </tfoot>
</table> </table>

View File

@ -331,6 +331,10 @@
font-feature-settings: "tnum"; font-feature-settings: "tnum";
} }
.kpi-card.kpi-card--total-lines .kpi-val {
font-size: 16px;
}
/* Grids */ /* Grids */
.section-grid { .section-grid {
display: grid; display: grid;
@ -344,6 +348,10 @@
@media (max-width: 960px) { grid-column: span 12; } @media (max-width: 960px) { grid-column: span 12; }
} }
.planos-charts .chart-card.full-span {
grid-column: span 12;
}
.full-chart .chart-card { .full-chart .chart-card {
grid-column: span 12; 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 { .group-toggle {
width: 32px; width: 32px;
height: 32px; height: 32px;

View File

@ -79,6 +79,10 @@ type GeralLineTotalPayload = { tipo?: unknown; qtdLinhas?: unknown; valorTotalLi
export class Resumo implements OnInit, AfterViewInit, OnDestroy { export class Resumo implements OnInit, AfterViewInit, OnDestroy {
@HostBinding('class.animate-ready') animateReady = false; @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; loading = false;
errorMessage = ''; errorMessage = '';
resumo: ResumoResponse | null = null; resumo: ResumoResponse | null = null;
@ -199,7 +203,9 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
this.destroyCharts(); this.destroyCharts();
if (this.activeTab === 'planos') { if (this.activeTab === 'planos') {
this.buildChartPlanos(); if (this.showFinancial) {
this.buildChartPlanos();
}
this.buildChartPlanosLinhas(); this.buildChartPlanosLinhas();
} else if (this.activeTab === 'clientes') { } else if (this.activeTab === 'clientes') {
this.buildChartClientes(); this.buildChartClientes();
@ -251,6 +257,8 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const bg = ctx ? this.createGradient(ctx, CHART_THEME.blue, '#2563eb') : CHART_THEME.blue; 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, { this.charts['planosLinhas'] = new Chart(canvas, {
type: 'bar', type: 'bar',
data: { data: {
@ -264,7 +272,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
minBarLength: 8, 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; const canvas = this.chartClientesRef?.nativeElement;
if (!canvas) return; if (!canvas) return;
const metricLabel = this.showFinancial ? 'Lucro Estimado' : 'Qtd. Linhas';
const metricType = this.showFinancial ? 'currency' : 'number';
const data = (this.resumo?.vivoLineResumos ?? []) const data = (this.resumo?.vivoLineResumos ?? [])
.map(c => ({ label: c.cliente, lucro: this.toNumber(c.lucro) ?? 0 })) .map(c => ({
.sort((a, b) => b.lucro - a.lucro) 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); .slice(0, 10);
// Garante altura suficiente para exibir todos os nomes no eixo Y sem auto-skip. // 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`; 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, { this.charts['clientes'] = new Chart(canvas, {
type: 'bar', type: 'bar',
data: { data: {
labels: data.map(d => (d.label?.length ?? 0) > 25 ? (d.label ?? '').slice(0, 25) + '...' : d.label), labels: data.map(d => (d.label?.length ?? 0) > 25 ? (d.label ?? '').slice(0, 25) + '...' : d.label),
datasets: [{ datasets: [{
label: 'Lucro Estimado', label: metricLabel,
data: data.map(d => d.lucro), data: data.map(d => d.value),
backgroundColor: CHART_THEME.success, backgroundColor: CHART_THEME.success,
borderRadius: 4, borderRadius: 4,
barPercentage: 0.7, barPercentage: 0.7,
@ -302,6 +341,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
options: { options: {
...common, ...common,
indexAxis: 'y', indexAxis: 'y',
plugins,
scales: { scales: {
x: { x: {
...(common.scales?.x ?? {}), ...(common.scales?.x ?? {}),
@ -638,99 +678,112 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
} }
private initTables() { 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 = { this.tableMacrophony = {
key: 'macrophony', key: 'macrophony',
label: 'Macrophony', label: 'Macrophony',
data: [], data: [],
columns: [ columns: hideMoneyColumns(macrophonyColumns),
{ 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 },
],
search: '', search: '',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50], pageSizeOptions: [10, 20, 50],
sortKey: 'valorTotal', sortKey: this.showFinancial ? 'valorTotal' : 'totalLinhas',
sortDir: 'desc', sortDir: 'desc',
compact: false, compact: false,
view: null, 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 = { this.tablePlanoContrato = {
key: 'planoContrato', key: 'planoContrato',
label: 'Plano Contrato', label: 'Plano Contrato',
data: [], data: [],
columns: [ columns: hideMoneyColumns(planoContratoColumns),
{ 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 },
],
search: '', search: '',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50], pageSizeOptions: [10, 20, 50],
sortKey: 'valorTotal', sortKey: this.showFinancial ? 'valorTotal' : 'totalLinhas',
sortDir: 'desc', sortDir: 'desc',
compact: false, compact: false,
view: null, 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 = { this.tableClientes = {
key: 'clientes', key: 'clientes',
label: 'Clientes', label: 'Clientes',
data: [], data: [],
columns: [ columns: hideMoneyColumns(clientesColumns),
{ 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 },
],
search: '', search: '',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50], pageSizeOptions: [10, 20, 50],
sortKey: 'lucro', sortKey: this.showFinancial ? 'lucro' : 'qtdLinhas',
sortDir: 'desc', sortDir: 'desc',
compact: false, compact: false,
view: null, 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 = { this.tableClientesEspeciais = {
key: 'clientesEspeciais', key: 'clientesEspeciais',
label: 'Clientes Especiais', label: 'Clientes Especiais',
data: [], data: [],
columns: [ columns: hideMoneyColumns(clientesEspeciaisColumns),
{ key: 'nome', label: 'Nome', type: 'text', value: (r) => r.nome ?? '-' },
{ key: 'valor', label: 'Valor', type: 'money', align: 'right', value: (r) => r.valor, tone: true },
],
search: '', search: '',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50], pageSizeOptions: [10, 20, 50],
sortKey: 'valor', sortKey: this.showFinancial ? 'valor' : 'nome',
sortDir: 'desc', sortDir: this.showFinancial ? 'desc' : 'asc',
compact: false, compact: false,
view: null, 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 = { this.tableTotaisLine = {
key: 'totaisLine', key: 'totaisLine',
label: 'Totais Line', label: 'Totais Line',
data: [], data: [],
columns: [ columns: hideMoneyColumns(totaisLineColumns),
{ 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 },
],
search: '', search: '',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@ -769,11 +822,21 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
(row) => (row.planoContrato ?? '-').toString(), (row) => (row.planoContrato ?? '-').toString(),
(rows) => (rows[0]?.planoContrato ?? '-').toString(), (rows) => (rows[0]?.planoContrato ?? '-').toString(),
(rows) => `Franquia ${this.formatGb(rows[0]?.gb)}`, (rows) => `Franquia ${this.formatGb(rows[0]?.gb)}`,
(rows) => [ (rows) => {
{ label: 'Linhas', value: this.formatNumber(this.sumGroup(rows, (r) => r.totalLinhas)) }, const metrics: GroupMetric[] = [
{ label: 'Valor', value: this.formatMoney(this.sumGroup(rows, (r) => r.valorTotal)) }, { label: 'Linhas', value: this.formatNumber(this.sumGroup(rows, (r) => r.totalLinhas)) },
], ];
(a, b) => this.sumGroup(b.rows, (r) => r.valorTotal) - this.sumGroup(a.rows, (r) => r.valorTotal) 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( this.groupClientes = this.createGroupedTableState(
@ -784,6 +847,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
(rows) => (rows[0]?.cliente ?? '-').toString(), (rows) => (rows[0]?.cliente ?? '-').toString(),
(rows) => `${this.formatNumber(this.sumGroup(rows, (r) => r.qtdLinhas))} linhas`, (rows) => `${this.formatNumber(this.sumGroup(rows, (r) => r.qtdLinhas))} linhas`,
(rows) => { (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 receita = this.sumGroup(rows, (r) => r.valorContratoLine);
const custo = this.sumGroup(rows, (r) => r.valorContratoVivo); const custo = this.sumGroup(rows, (r) => r.valorContratoVivo);
const lucro = this.sumGroup(rows, (r) => r.lucro); 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) }, { 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( this.groupClientesEspeciais = this.createGroupedTableState(
@ -804,10 +876,16 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
(rows) => (rows[0]?.nome ?? '-').toString(), (rows) => (rows[0]?.nome ?? '-').toString(),
undefined, undefined,
(rows) => { (rows) => {
if (!this.showFinancial) {
return [];
}
const total = this.sumGroup(rows, (r) => r.valor); const total = this.sumGroup(rows, (r) => r.valor);
return [{ label: 'Valor', value: this.formatMoney(total), tone: this.getToneClass(total) }]; 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( this.groupTotaisLine = this.createGroupedTableState(
@ -819,13 +897,14 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
undefined, undefined,
(rows) => { (rows) => {
const linhas = this.sumGroup(rows, (r) => r.qtdLinhas); const linhas = this.sumGroup(rows, (r) => r.qtdLinhas);
const valor = this.sumGroup(rows, (r) => r.valorTotalLine); const metrics: GroupMetric[] = [{ label: 'Linhas', value: this.formatNumber(linhas) }];
const lucro = this.sumGroup(rows, (r) => r.lucroTotalLine); if (this.showFinancial) {
return [ const valor = this.sumGroup(rows, (r) => r.valorTotalLine);
{ label: 'Linhas', value: this.formatNumber(linhas) }, const lucro = this.sumGroup(rows, (r) => r.lucroTotalLine);
{ label: 'Valor', value: this.formatMoney(valor) }, metrics.push({ label: 'Valor', value: this.formatMoney(valor) });
{ label: 'Lucro', value: this.formatMoney(lucro), tone: this.getToneClass(lucro) }, 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) (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, totalLinhas,
valorTotal, valorTotal,
valorUnitMedio: valorUnit, 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; this.macrophonyGroups = groups;
const search = this.normalizeText(this.macrophonySearch); const search = this.normalizeText(this.macrophonySearch);

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@ -26,9 +26,18 @@ export type NotificationDto = {
dtTerminoFidelizacao?: string | null; 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' }) @Injectable({ providedIn: 'root' })
export class NotificationsService { export class NotificationsService {
private readonly baseApi: string; private readonly baseApi: string;
private readonly eventsSubject = new Subject<NotificationsEvent>();
readonly events$ = this.eventsSubject.asObservable();
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, ''); const raw = (environment.apiUrl || '').replace(/\/+$/, '');
@ -40,14 +49,49 @@ export class NotificationsService {
} }
markAsRead(id: string): Observable<void> { 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> { markAllAsRead(filter?: string, notificationIds?: string[]): Observable<void> {
let params = new HttpParams(); let params = new HttpParams();
if (filter) params = params.set('filter', filter); if (filter) params = params.set('filter', filter);
const body = notificationIds && notificationIds.length ? { notificationIds } : {}; 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>> { export(filter?: string, notificationIds?: string[]): Observable<HttpResponse<Blob>> {