From 1f277b8c8a48a5c14807b448cd708d245bc4a961 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Thu, 12 Feb 2026 16:49:42 -0300 Subject: [PATCH] =?UTF-8?q?Subindo=20altera=C3=A7=C3=B5es=20para=20produ?= =?UTF-8?q?=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 12 - src/app/app.routes.ts | 6 +- src/app/components/header/header.html | 130 +++++++---- src/app/components/header/header.scss | 114 +++++++++- src/app/components/header/header.ts | 135 ++++++++++- .../chips-controle-recebidos.scss | 3 +- src/app/pages/dashboard/dashboard.html | 12 +- src/app/pages/dashboard/dashboard.ts | 25 +-- src/app/pages/notificacoes/notificacoes.html | 133 ++++++----- src/app/pages/notificacoes/notificacoes.scss | 41 ++++ src/app/pages/notificacoes/notificacoes.ts | 165 ++++++++++++-- src/app/pages/resumo/resumo.html | 56 ++--- src/app/pages/resumo/resumo.scss | 14 ++ src/app/pages/resumo/resumo.ts | 209 +++++++++++++----- src/app/services/notifications.service.ts | 50 ++++- 15 files changed, 868 insertions(+), 237 deletions(-) diff --git a/package-lock.json b/package-lock.json index be57a92..bb6e153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d541fc3..53c721b 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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' }, diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 0b3a0b1..9aead02 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -32,44 +32,85 @@ -
-
-
- Notificações - {{ unreadCount }} nova(s) -
- Ver tudo -
+
+
+
+ Notificações + {{ unreadCount }} nova(s) +
+
+ + + Ver tudo +
+
-
-
-
+
+ + +
+ +
+
+
Carregando...
- Falha ao carregar. -
+ Falha ao carregar. +
+ +
+
+

Não há notificações no momento.

+
+ +
+ + + Mostrando {{ notificationsPreviewLimit }} de {{ notificationsVisibleCount }} notificações + +
-
-
-

Tudo limpo por aqui!

-
- -
- - - Mostrando {{ notificationsPreviewLimit }} de {{ notifications.length }} notificações - -
- -
+
-
- -
-
-
-
+
+ +
+ +
+
+
@@ -442,10 +492,10 @@ Mureg - + Faturamento - + Parcelamentos @@ -457,7 +507,7 @@ Vigência - + Chips Virgens e Recebidos diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index b30b58f..5164f33 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -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 */ diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index dea9b10..1d481ca 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, Inject, ElementRef, ViewChild, 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]; diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss index 0194b05..0760531 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss @@ -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; } diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index f657111..5c2f891 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -259,16 +259,16 @@
DIFERENÇA PJ X PF
- Valor Total Line - {{ formatMoneySafe(resumoDiferencaPjPf.valorTotalLine) }} + PF (Linhas) + {{ formatInt(resumoDiferencaPjPf.pfLinhas) }}
- Lucro Total Line - {{ formatMoneySafe(resumoDiferencaPjPf.lucroTotalLine) }} + PJ (Linhas) + {{ formatInt(resumoDiferencaPjPf.pjLinhas) }}
- Qtd Linhas - {{ formatInt(resumoDiferencaPjPf.qtdLinhas) }} + Total Linhas + {{ formatInt(resumoDiferencaPjPf.totalLinhas) }}
diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index fb8fc8f..8171b8c 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -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(); 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(); diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index f1442db..1376e22 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -8,10 +8,10 @@

Gerencie seus alertas de vencimento e avisos do sistema.

-
- +
+ - -
+ +
-
-
- - - Mostrando {{ filteredNotifications.length }} notificações - • {{ selectedIds.size }} selecionada(s) - -
-
- +
+
+ +
+
+ + + Mostrando {{ filteredNotifications.length }} notificações + • {{ selectedIds.size }} selecionada(s) + +
+
+ -
-
+ Exportando... + + +
+ +
+
@@ -69,14 +101,14 @@

Não foi possível carregar as notificações.

-
-
- -
-

Tudo em dia!

-

Você não tem nenhuma notificação pendente.

-

Nenhuma notificação neste filtro.

-
+
+
+ +
+

Tudo em dia!

+

Não há notificações no momento.

+

Nenhuma notificação neste filtro.

+
- +
@@ -131,18 +163,17 @@
-
- -
+
+ +
diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 8aa8fcf..7d4a357 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -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; diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index 7b149da..bb509da 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -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(); + 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]; diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index 68539bb..6edb6da 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -19,7 +19,7 @@ -

Visão consolidada de performance, contratos e indicadores financeiros.

+

Visão consolidada de performance, contratos e indicadores operacionais.

@@ -47,18 +47,18 @@

Planos & Contratos

-

Performance financeira agrupada por modalidade de plano.

+

{{ showFinancial ? 'Performance financeira agrupada por modalidade de plano.' : 'Distribuição e volume de linhas por modalidade de plano.' }}

-
+
Total Linhas {{ formatNumber(planosTotals?.totalLinhasTotal) }}
-
+
Valor Total {{ formatMoney(planosTotals?.valorTotal) }}
-
+
Contratos {{ formatMoney(contratosTotals?.valorTotal) }}
@@ -67,7 +67,7 @@
-
+

Top Planos (Valor)

Os planos com maior representatividade financeira.

@@ -76,7 +76,7 @@
-
+

Top Planos (Volume)

Quantidade de linhas ativas por tipo de plano.

@@ -147,7 +147,7 @@
-
+
Valor Total {{ formatMoney(group.valorTotal) }} @@ -172,9 +172,9 @@ Plano / Variação Franquia - Valor Un. + Valor Un. Linhas - Total + Total @@ -186,9 +186,9 @@
{{ formatGb(row.gb) }} - {{ formatMoney(row.valorIndividualComSvas) }} + {{ formatMoney(row.valorIndividualComSvas) }} {{ formatNumber(row.totalLinhas) }} - {{ formatMoney(row.valorTotal) }} + {{ formatMoney(row.valorTotal) }} @@ -221,7 +221,7 @@ Total de Linhas {{ formatNumber(planosTotals.totalLinhasTotal) }}
-
+
Valor Total Global {{ formatMoney(planosTotals.valorTotal) }}
@@ -246,18 +246,18 @@

Clientes & Performance

-

Analise a rentabilidade e custos por cliente.

+

{{ showFinancial ? 'Analise a rentabilidade e custos por cliente.' : 'Distribuição e volume de linhas por cliente.' }}

-
+
Total Linhas {{ formatNumber(clientesTotals?.qtdLinhasTotal) }}
-
+
Receita Line {{ formatMoney(clientesTotals?.valorContratoLine) }}
-
+
Lucro Total {{ formatMoney(clientesTotals?.lucro) }}
@@ -268,8 +268,8 @@
-

Top Clientes (Lucratividade)

-

Clientes ordenados pelo maior retorno financeiro.

+

{{ showFinancial ? 'Top Clientes (Lucratividade)' : 'Top Clientes (Qtd. Linhas)' }}

+

{{ showFinancial ? 'Clientes ordenados pelo maior retorno financeiro.' : 'Clientes com maior volume de linhas.' }}

@@ -306,7 +306,7 @@ PJ Linhas {{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}
-
+
Lucro Consolidado {{ formatMoney(totaisLineLucroConsolidado) }}
@@ -386,7 +386,7 @@ Linhas em Estoque {{ formatNumber(reservaTotals?.qtdLinhasTotal) }}
-
+
Custo de Reserva {{ formatNumber(reservaTotals?.total) }}
@@ -505,7 +505,7 @@
-
+
Receita Line {{ formatMoney(clientesTotals.valorContratoLine) }} @@ -586,25 +586,25 @@ Variação GB - Valor Un. + Valor Un. Total Linhas - Valor Total + Valor Total {{ row.planoContrato || '-' }} {{ formatGb(row.gb) }} - {{ formatMoney(row.valorIndividualComSvas) }} + {{ formatMoney(row.valorIndividualComSvas) }} {{ formatNumber(row.totalLinhas) }} - {{ formatMoney(row.valorTotal) }} + {{ formatMoney(row.valorTotal) }} - Total deste grupo + Total deste grupo {{ formatNumber(macrophonyDetailGroup.totalLinhas) }} - {{ formatMoney(macrophonyDetailGroup.valorTotal) }} + {{ formatMoney(macrophonyDetailGroup.valorTotal) }} diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss index f740f85..d2bce06 100644 --- a/src/app/pages/resumo/resumo.scss +++ b/src/app/pages/resumo/resumo.scss @@ -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; diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts index 2e3bd79..3a7706d 100644 --- a/src/app/pages/resumo/resumo.ts +++ b/src/app/pages/resumo/resumo.ts @@ -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 = (cols: TableColumn[]) => + this.showFinancial ? cols : cols.filter((c) => c.type !== 'money'); + + const macrophonyColumns: TableColumn[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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); diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index 174fbc9..835ec5f 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -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(); + 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 { - return this.http.patch(`${this.baseApi}/notifications/${id}/read`, {}); + const readAtIso = new Date().toISOString(); + return this.http.patch(`${this.baseApi}/notifications/${id}/read`, {}).pipe( + tap(() => this.eventsSubject.next({ type: 'read', ids: [id], readAtIso })) + ); + } + + markAsUnread(id: string): Observable { + return this.http.patch(`${this.baseApi}/notifications/${id}/unread`, {}).pipe( + tap(() => this.eventsSubject.next({ type: 'unread', ids: [id] })) + ); } markAllAsRead(filter?: string, notificationIds?: string[]): Observable { let params = new HttpParams(); if (filter) params = params.set('filter', filter); const body = notificationIds && notificationIds.length ? { notificationIds } : {}; - return this.http.patch(`${this.baseApi}/notifications/read-all`, body, { params }); + const readAtIso = new Date().toISOString(); + return this.http.patch(`${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 { + let params = new HttpParams(); + if (filter) params = params.set('filter', filter); + const body = notificationIds && notificationIds.length ? { notificationIds } : {}; + return this.http.patch(`${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> {