From 698f690315c25b3c2a68910c6a133816c2c6d326 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:57:58 -0300 Subject: [PATCH 01/46] =?UTF-8?q?Define=20Relat=C3=B3rios=20como=20p=C3=A1?= =?UTF-8?q?gina=20inicial=20ap=C3=B3s=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.html | 13 ++++++------- src/app/pages/login/login.ts | 10 +++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 92c2497..8407e71 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -8,7 +8,7 @@ - +
@@ -62,7 +62,7 @@ (click)="$event.stopPropagation()" >
- @@ -73,6 +73,10 @@
+ + Relatórios + + Geral @@ -96,10 +100,5 @@ Dados dos Usuários - - - - Relatórios -
diff --git a/src/app/pages/login/login.ts b/src/app/pages/login/login.ts index b9e6f67..52b8c48 100644 --- a/src/app/pages/login/login.ts +++ b/src/app/pages/login/login.ts @@ -117,18 +117,18 @@ export class LoginComponent { const nome = this.getNameFromToken(token); console.log('👤 Nome extraído:', nome); - console.log('🔄 Tentando ir para /geral...'); - this.router.navigate(['/geral'], { + console.log('🔄 Tentando ir para /relatorios...'); + this.router.navigate(['/relatorios'], { state: { toastMessage: `Bem-vindo, ${nome}!` } }).then(sucesso => { if (sucesso) console.log('✅ Navegação funcionou!'); - else console.error('❌ Navegação falhou! A rota "/geral" existe?'); + else console.error('❌ Navegação falhou! A rota "/relatorios" existe?'); }); } catch (e) { console.error('❌ Erro ao processar token ou navegar:', e); // Força a ida mesmo se o nome falhar - this.router.navigate(['/geral']); + this.router.navigate(['/relatorios']); } }, error: (err) => { @@ -145,4 +145,4 @@ export class LoginComponent { if (error) return control.touched && control.hasError(error); return !!(control.touched && control.invalid); } -} \ No newline at end of file +} From a73276211f5928f252d9d3fbac69df53e17db308 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:11:13 -0300 Subject: [PATCH 02/46] =?UTF-8?q?Refina=20estilo=20das=20tabelas=20de=20re?= =?UTF-8?q?lat=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/pages/relatorios/relatorios.scss | 52 +++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/app/pages/relatorios/relatorios.scss b/src/app/pages/relatorios/relatorios.scss index 5c3a1f0..447fae8 100644 --- a/src/app/pages/relatorios/relatorios.scss +++ b/src/app/pages/relatorios/relatorios.scss @@ -299,21 +299,25 @@ /* Table */ .table-wrap { - padding: 10px 12px 14px; + padding: 12px 12px 16px; overflow-x: auto; + background: rgba(255, 255, 255, 0.7); + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } .tablex { width: 100%; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0 8px; min-width: 720px; } .tablex th, .tablex td { - padding: 10px 10px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - font-weight: 800; + padding: 12px 12px; + font-weight: 700; color: rgba(17, 18, 20, 0.8); text-align: left; white-space: nowrap; @@ -322,11 +326,47 @@ .tablex th { color: rgba(17, 18, 20, 0.65); font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding-bottom: 6px; +} + +.tablex tbody tr { + background: #fff; + box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.tablex tbody tr:hover { + transform: translateY(-1px); + box-shadow: 0 12px 22px rgba(17, 18, 20, 0.12); +} + +.tablex tbody td { + border-top: 1px solid rgba(17, 18, 20, 0.06); + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + background: rgba(255, 255, 255, 0.96); +} + +.tablex tbody td:first-child { + border-left: 1px solid rgba(17, 18, 20, 0.06); + border-top-left-radius: 12px; + border-bottom-left-radius: 12px; +} + +.tablex tbody td:last-child { + border-right: 1px solid rgba(17, 18, 20, 0.06); + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; +} + +.tablex tbody tr:nth-child(even) td { + background: rgba(248, 249, 255, 0.9); } .muted { color: rgba(17, 18, 20, 0.55); - font-weight: 800; + font-weight: 700; } .cell-strong { From 0f950d9d4d458a4b998f7cc8771d29923a578738 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:27:40 -0300 Subject: [PATCH 03/46] =?UTF-8?q?Alinha=20paleta=20dos=20gr=C3=A1ficos=20a?= =?UTF-8?q?os=20tons=20do=20sistema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/pages/relatorios/relatorios.scss | 26 ++++++-- src/app/pages/relatorios/relatorios.ts | 81 ++++++++++++++---------- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/app/pages/relatorios/relatorios.scss b/src/app/pages/relatorios/relatorios.scss index 447fae8..a2546d1 100644 --- a/src/app/pages/relatorios/relatorios.scss +++ b/src/app/pages/relatorios/relatorios.scss @@ -2,6 +2,18 @@ display: block; width: 100%; overflow-x: hidden; + --brand-primary: #E33DCF; + --brand-blue: #030FAA; + --brand-deep: #B832A8; + --brand-violet: #6A55FF; + --brand-soft: rgba(227, 61, 207, 0.2); + --brand-blue-soft: rgba(3, 15, 170, 0.2); + --chart-pink: var(--brand-primary); + --chart-pink-dark: var(--brand-deep); + --chart-pink-soft: #F3B0E8; + --chart-blue: var(--brand-blue); + --chart-blue-soft: var(--brand-blue-soft); + --chart-violet: var(--brand-violet); } /* ✅ remove footer nessa página */ @@ -239,7 +251,7 @@ /* se quiser tirar o rosa do total, troque aqui */ .metric.total .meta .v { - color: #ff2d95; + color: var(--chart-pink); } .dot { @@ -250,12 +262,12 @@ } /* ✅ DOTS COM CORES "PADRÃO DE DASHBOARD" */ -.dot.d1 { background: #ff2d95; } /* total (mantém rosa no card de total, se você quiser) */ -.dot.d2 { background: #2E7D32; } /* Ativos - verde */ -.dot.d3 { background: #D32F2F; } /* Perda/Roubo - vermelho */ -.dot.d4 { background: #F57C00; } /* 120 dias - laranja */ -.dot.d5 { background: #1976D2; } /* Reservas - azul */ -.dot.d6 { background: #607D8B; } /* Outros - cinza */ +.dot.d1 { background: var(--chart-pink); } +.dot.d2 { background: var(--chart-blue); } +.dot.d3 { background: var(--chart-pink-dark); } +.dot.d4 { background: var(--chart-violet); } +.dot.d5 { background: var(--chart-pink-soft); } +.dot.d6 { background: var(--chart-blue-soft); } .meta .k { font-weight: 900; diff --git a/src/app/pages/relatorios/relatorios.ts b/src/app/pages/relatorios/relatorios.ts index 39e6358..ee40fce 100644 --- a/src/app/pages/relatorios/relatorios.ts +++ b/src/app/pages/relatorios/relatorios.ts @@ -164,26 +164,10 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { private readonly baseApi: string; - // ✅ Paletas "padrão de dashboard" (fácil de entender) - private readonly STATUS_COLORS = { - ativos: '#2E7D32', // verde - perdaRoubo: '#D32F2F', // vermelho - bloq120: '#F57C00', // laranja - reservas: '#1976D2', // azul - outros: '#607D8B', // cinza - }; - - private readonly VIG_COLORS = { - vencidos: '#D32F2F', // vermelho - d0a30: '#F57C00', // laranja - d31a60: '#FBC02D', // amarelo - d61a90: '#1976D2', // azul - acima90: '#2E7D32', // verde - }; - constructor( private http: HttpClient, - @Inject(PLATFORM_ID) private platformId: object + @Inject(PLATFORM_ID) private platformId: object, + private hostRef: ElementRef ) { const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; @@ -299,7 +283,9 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { this.destroyCharts(); - // ✅ Status das linhas (paleta padrão) + const palette = this.getPalette(); + + // ✅ Status das linhas (paleta do sistema) const cP = this.chartStatusPie?.nativeElement; if (cP) { this.chartPie = new Chart(cP, { @@ -322,11 +308,11 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { ], borderWidth: 1, backgroundColor: [ - this.STATUS_COLORS.ativos, - this.STATUS_COLORS.perdaRoubo, - this.STATUS_COLORS.bloq120, - this.STATUS_COLORS.reservas, - this.STATUS_COLORS.outros, + palette.status.ativos, + palette.status.perdaRoubo, + palette.status.bloq120, + palette.status.reservas, + palette.status.outros, ], }], }, @@ -355,7 +341,7 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { label: 'Encerramentos', data: this.vigenciaValues, borderWidth: 0, - backgroundColor: '#1976D2', // azul padrão + backgroundColor: palette.series.vigencia, borderRadius: 10, }], }, @@ -388,11 +374,11 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { ], borderWidth: 1, backgroundColor: [ - this.VIG_COLORS.vencidos, - this.VIG_COLORS.d0a30, - this.VIG_COLORS.d31a60, - this.VIG_COLORS.d61a90, - this.VIG_COLORS.acima90, + palette.vigencia.vencidos, + palette.vigencia.d0a30, + palette.vigencia.d31a60, + palette.vigencia.d61a90, + palette.vigencia.acima90, ], }], }, @@ -421,7 +407,7 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { label: 'MUREG', data: this.muregValues, borderWidth: 0, - backgroundColor: '#6A1B9A', // roxo (bem comum em dashboards) + backgroundColor: palette.series.mureg, borderRadius: 10, }], }, @@ -448,7 +434,7 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { label: 'Troca', data: this.trocaValues, borderWidth: 0, - backgroundColor: '#00897B', // teal (bem comum) + backgroundColor: palette.series.troca, borderRadius: 10, }], }, @@ -482,4 +468,35 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { private formatInt(v: number) { return (v || 0).toLocaleString('pt-BR'); } + + private getPalette() { + return { + status: { + ativos: this.readCssVar('--chart-blue', '#030FAA'), + perdaRoubo: this.readCssVar('--chart-pink-dark', '#B832A8'), + bloq120: this.readCssVar('--chart-violet', '#6A55FF'), + reservas: this.readCssVar('--chart-pink-soft', '#F3B0E8'), + outros: this.readCssVar('--chart-blue-soft', 'rgba(3, 15, 170, 0.2)'), + }, + vigencia: { + vencidos: this.readCssVar('--chart-pink', '#E33DCF'), + d0a30: this.readCssVar('--chart-violet', '#6A55FF'), + d31a60: this.readCssVar('--chart-blue', '#030FAA'), + d61a90: this.readCssVar('--chart-pink-dark', '#B832A8'), + acima90: this.readCssVar('--chart-pink-soft', '#F3B0E8'), + }, + series: { + vigencia: this.readCssVar('--chart-blue', '#030FAA'), + mureg: this.readCssVar('--chart-pink', '#E33DCF'), + troca: this.readCssVar('--chart-violet', '#6A55FF'), + }, + }; + } + + private readCssVar(name: string, fallback: string) { + if (!isPlatformBrowser(this.platformId)) return fallback; + const styles = getComputedStyle(this.hostRef.nativeElement); + const value = styles.getPropertyValue(name).trim(); + return value || fallback; + } } From e1744bb20256869e95e4c5007557a8c19ab7e329 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:33:16 -0300 Subject: [PATCH 04/46] Remove faixa promocional do header na home --- src/app/components/header/header.html | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 8407e71..97763e0 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -45,10 +45,6 @@ - -
- Somos a escolha certa para estar sempre conectado! -
From c9c2f2fffb563ef0749a7d5f1059d66ee956a385 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:03:58 -0300 Subject: [PATCH 05/46] =?UTF-8?q?Renomeia=20Relat=C3=B3rios=20para=20Dashb?= =?UTF-8?q?oard=20e=20adiciona=20menu=20de=20op=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.routes.ts | 8 +- src/app/app.ts | 2 +- src/app/components/header/header.html | 63 +++++++--- src/app/components/header/header.scss | 117 ++++++++++++++++-- src/app/components/header/header.ts | 26 +++- .../dashboard.html} | 4 +- .../dashboard.scss} | 2 +- .../relatorios.ts => dashboard/dashboard.ts} | 20 +-- src/app/pages/login/login.ts | 8 +- 9 files changed, 200 insertions(+), 50 deletions(-) rename src/app/pages/{relatorios/relatorios.html => dashboard/dashboard.html} (98%) rename src/app/pages/{relatorios/relatorios.scss => dashboard/dashboard.scss} (99%) rename src/app/pages/{relatorios/relatorios.ts => dashboard/dashboard.ts} (96%) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 06022b9..5d2acd6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -11,7 +11,7 @@ import { authGuard } from './guards/auth.guard'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; -import { Relatorios } from './pages/relatorios/relatorios'; +import { Dashboard } from './pages/dashboard/dashboard'; export const routes: Routes = [ { path: '', component: Home }, @@ -26,10 +26,10 @@ export const routes: Routes = [ { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, // ✅ rota correta - { path: 'relatorios', component: Relatorios, canActivate: [authGuard] }, + { path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, - // ✅ compatibilidade: se alguém acessar /portal/relatorios, manda pra /relatorios - { path: 'portal/relatorios', redirectTo: 'relatorios', pathMatch: 'full' }, + // ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard + { path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '**', redirectTo: '' }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index f4b13c7..93920fa 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -33,7 +33,7 @@ export class AppComponent { '/dadosusuarios', '/vigencia', '/trocanumero', - '/relatorios', // ✅ ADICIONADO: esconde footer na página de relatórios + '/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard ]; constructor( diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 97763e0..81d7242 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -3,19 +3,54 @@ -
- +
+
+ - - + +
+ + + LIVE + + + + +
+ + +
+ + Perfil + + +
-
- LineGestão -
- +
@@ -58,7 +93,7 @@ (click)="$event.stopPropagation()" >
- @@ -69,8 +104,8 @@
- - Relatórios + + Dashboard diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 0275bf3..d9449af 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -30,6 +30,20 @@ gap: 12px; } +.logged-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +} + +.logged-actions { + display: flex; + align-items: center; + gap: 10px; +} + /* Logo */ .logo-area { display: flex; @@ -123,22 +137,99 @@ } } -/* Faixa home */ -.header-bar { - margin-top: 10px; - width: 100%; - height: 34px; - display: flex; +/* ✅ Status e opções (logado) */ +.status-pill { + display: inline-flex; align-items: center; - justify-content: center; - background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%); + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + color: #0f766e; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.06em; } -.header-bar-text { - color: #ffffff; - font-size: 15px; - font-weight: 800; - font-family: 'Poppins', sans-serif; +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18); +} + +.btn-bell { + width: 42px; + height: 42px; + border-radius: 12px; + + i { + font-size: 18px; + } +} + +.options-menu { + position: relative; + display: flex; + align-items: center; +} + +.options-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 12px; + border: 1px solid rgba(0,0,0,0.1); + background: #fff; + font-weight: 700; + color: var(--text-main); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + i { + font-size: 12px; + } + + &:hover { + border-color: rgba(227, 61, 207, 0.35); + box-shadow: 0 12px 22px rgba(0,0,0,0.08); + } +} + +.options-dropdown { + position: absolute; + right: 0; + top: calc(100% + 8px); + min-width: 200px; + padding: 8px 0; + border-radius: 14px; + border: 1px solid rgba(0,0,0,0.08); + background: #fff; + box-shadow: 0 18px 40px rgba(0,0,0,0.12); + z-index: 1200; +} + +.options-item { + display: flex; + align-items: center; + width: 100%; + padding: 10px 16px; + font-weight: 700; + color: rgba(17, 18, 20, 0.85); + text-decoration: none; + background: transparent; + border: none; + cursor: pointer; + + &:hover { + background: rgba(227, 61, 207, 0.08); + } + + &.danger { + color: #c2410c; + } } /* ========================= */ diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index ebbc5ba..37c52b5 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -3,6 +3,7 @@ import { RouterLink, Router, NavigationEnd } from '@angular/router'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; import { filter } from 'rxjs/operators'; +import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-header', @@ -15,6 +16,7 @@ export class Header { isScrolled = false; menuOpen = false; + optionsOpen = false; isLoggedHeader = false; isHome = false; @@ -25,11 +27,12 @@ export class Header { '/dadosusuarios', '/vigencia', '/trocanumero', - '/relatorios', // ✅ ADICIONADO + '/dashboard', // ✅ ADICIONADO ]; constructor( private router: Router, + private authService: AuthService, @Inject(PLATFORM_ID) private platformId: object ) { // ✅ resolve no carregamento inicial @@ -42,6 +45,7 @@ export class Header { const rawUrl = event.urlAfterRedirects || event.url; this.syncHeaderState(rawUrl); this.menuOpen = false; + this.optionsOpen = false; }); } @@ -63,15 +67,35 @@ export class Header { this.menuOpen = false; } + toggleOptions() { + this.optionsOpen = !this.optionsOpen; + } + + closeOptions() { + this.optionsOpen = false; + } + + logout() { + this.authService.logout(); + this.optionsOpen = false; + this.router.navigate(['/']); + } + @HostListener('window:scroll', []) onWindowScroll() { if (!isPlatformBrowser(this.platformId)) return; this.isScrolled = window.scrollY > 10; } + @HostListener('document:click', []) + onDocumentClick() { + this.optionsOpen = false; + } + @HostListener('document:keydown.escape', []) onEsc() { if (!isPlatformBrowser(this.platformId)) return; this.closeMenu(); + this.closeOptions(); } } diff --git a/src/app/pages/relatorios/relatorios.html b/src/app/pages/dashboard/dashboard.html similarity index 98% rename from src/app/pages/relatorios/relatorios.html rename to src/app/pages/dashboard/dashboard.html index 5088116..35a2e2e 100644 --- a/src/app/pages/relatorios/relatorios.html +++ b/src/app/pages/dashboard/dashboard.html @@ -1,10 +1,10 @@ -
+
- Relatórios + Dashboard

Resumo e indicadores do ambiente.

diff --git a/src/app/pages/relatorios/relatorios.scss b/src/app/pages/dashboard/dashboard.scss similarity index 99% rename from src/app/pages/relatorios/relatorios.scss rename to src/app/pages/dashboard/dashboard.scss index a2546d1..18a401f 100644 --- a/src/app/pages/relatorios/relatorios.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -24,7 +24,7 @@ display: none !important; } -.relatorios-page { +.dashboard-page { width: 100%; overflow-x: hidden; } diff --git a/src/app/pages/relatorios/relatorios.ts b/src/app/pages/dashboard/dashboard.ts similarity index 96% rename from src/app/pages/relatorios/relatorios.ts rename to src/app/pages/dashboard/dashboard.ts index ee40fce..ae3ad0f 100644 --- a/src/app/pages/relatorios/relatorios.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -88,7 +88,7 @@ type DashboardKpisDto = { userDataComEmail: number; }; -type RelatoriosDashboardDto = { +type DashboardDto = { kpis: DashboardKpisDto; topClientes: TopClienteDto[]; @@ -105,13 +105,13 @@ type RelatoriosDashboardDto = { }; @Component({ - selector: 'app-relatorios', + selector: 'app-dashboard', standalone: true, imports: [CommonModule], - templateUrl: './relatorios.html', - styleUrls: ['./relatorios.scss'], + templateUrl: './dashboard.html', + styleUrls: ['./dashboard.scss'], }) -export class Relatorios implements OnInit, AfterViewInit, OnDestroy { +export class Dashboard implements OnInit, AfterViewInit, OnDestroy { @ViewChild('chartMureg12') chartMureg12?: ElementRef; @ViewChild('chartTroca12') chartTroca12?: ElementRef; @ViewChild('chartStatusPie') chartStatusPie?: ElementRef; @@ -202,17 +202,17 @@ export class Relatorios implements OnInit, AfterViewInit, OnDestroy { } catch { this.loading = false; this.errorMsg = - 'Falha ao carregar Relatórios. Verifique se a API está rodando e o endpoint /api/relatorios/dashboard está acessível.'; + 'Falha ao carregar Dashboard. Verifique se a API está rodando e o endpoint /api/dashboard está acessível.'; } } - private async fetchDashboardReal(): Promise { + private async fetchDashboardReal(): Promise { if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts'); - const url = `${this.baseApi}/relatorios/dashboard`; - return await firstValueFrom(this.http.get(url)); + const url = `${this.baseApi}/dashboard`; + return await firstValueFrom(this.http.get(url)); } - private applyDto(dto: RelatoriosDashboardDto) { + private applyDto(dto: DashboardDto) { const k = dto.kpis; this.kpis = [ diff --git a/src/app/pages/login/login.ts b/src/app/pages/login/login.ts index 52b8c48..34ef817 100644 --- a/src/app/pages/login/login.ts +++ b/src/app/pages/login/login.ts @@ -117,18 +117,18 @@ export class LoginComponent { const nome = this.getNameFromToken(token); console.log('👤 Nome extraído:', nome); - console.log('🔄 Tentando ir para /relatorios...'); - this.router.navigate(['/relatorios'], { + console.log('🔄 Tentando ir para /dashboard...'); + this.router.navigate(['/dashboard'], { state: { toastMessage: `Bem-vindo, ${nome}!` } }).then(sucesso => { if (sucesso) console.log('✅ Navegação funcionou!'); - else console.error('❌ Navegação falhou! A rota "/relatorios" existe?'); + else console.error('❌ Navegação falhou! A rota "/dashboard" existe?'); }); } catch (e) { console.error('❌ Erro ao processar token ou navegar:', e); // Força a ida mesmo se o nome falhar - this.router.navigate(['/relatorios']); + this.router.navigate(['/dashboard']); } }, error: (err) => { From 6c243ef2c5fadd28eba8af5ce420ac9cfe82d08e Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:11:59 -0300 Subject: [PATCH 06/46] =?UTF-8?q?Restaura=20endpoint=20de=20relat=C3=B3rio?= =?UTF-8?q?s=20no=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/pages/dashboard/dashboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index ae3ad0f..d059397 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -202,13 +202,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } catch { this.loading = false; this.errorMsg = - 'Falha ao carregar Dashboard. Verifique se a API está rodando e o endpoint /api/dashboard está acessível.'; + 'Falha ao carregar Dashboard. Verifique se a API está rodando e o endpoint /api/relatorios/dashboard está acessível.'; } } private async fetchDashboardReal(): Promise { if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts'); - const url = `${this.baseApi}/dashboard`; + const url = `${this.baseApi}/relatorios/dashboard`; return await firstValueFrom(this.http.get(url)); } From 1eac19177c76d5cb2af2c674453043df302948e1 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:53:02 -0300 Subject: [PATCH 07/46] =?UTF-8?q?Remove=20status=20LIVE=20e=20ajusta=20a?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20Perfil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.html | 9 ++------- src/app/components/header/header.scss | 22 +--------------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 81d7242..ce0151e 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -20,11 +20,6 @@
- - - LIVE - - @@ -42,9 +37,9 @@
- + diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index d9449af..512248e 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -137,27 +137,7 @@ } } -/* ✅ Status e opções (logado) */ -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 999px; - background: rgba(16, 185, 129, 0.12); - color: #0f766e; - font-weight: 800; - font-size: 12px; - letter-spacing: 0.06em; -} - -.status-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: #22c55e; - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18); -} +/* ✅ Opções (logado) */ .btn-bell { width: 42px; From 29348e54ae1bac0632eb766d7496ec2a46a6a51e Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:34:00 -0300 Subject: [PATCH 08/46] =?UTF-8?q?Adiciona=20notifica=C3=A7=C3=B5es=20no=20?= =?UTF-8?q?header=20e=20p=C3=A1gina=20dedicada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.routes.ts | 2 + src/app/app.ts | 1 + src/app/components/header/header.html | 46 ++++++- src/app/components/header/header.scss | 120 +++++++++++++++++ src/app/components/header/header.ts | 54 ++++++++ src/app/pages/notificacoes/notificacoes.html | 41 ++++++ src/app/pages/notificacoes/notificacoes.scss | 130 +++++++++++++++++++ src/app/pages/notificacoes/notificacoes.ts | 48 +++++++ src/app/services/notifications.service.ts | 37 ++++++ 9 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 src/app/pages/notificacoes/notificacoes.html create mode 100644 src/app/pages/notificacoes/notificacoes.scss create mode 100644 src/app/pages/notificacoes/notificacoes.ts create mode 100644 src/app/services/notifications.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 5d2acd6..a6ea6af 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -12,6 +12,7 @@ import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; import { Dashboard } from './pages/dashboard/dashboard'; +import { Notificacoes } from './pages/notificacoes/notificacoes'; export const routes: Routes = [ { path: '', component: Home }, @@ -24,6 +25,7 @@ export const routes: Routes = [ { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, + { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] }, // ✅ rota correta { path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, diff --git a/src/app/app.ts b/src/app/app.ts index 93920fa..021813a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -34,6 +34,7 @@ export class AppComponent { '/vigencia', '/trocanumero', '/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard + '/notificacoes', ]; constructor( diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index ce0151e..de807c6 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -20,9 +20,49 @@
- +
+ + +
+
+ Notificações + Ver todas +
+ +
+
+ Carregando... +
+
+ Falha ao carregar notificações. +
+
+ Nenhuma notificação por aqui. +
+ +
+ + {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} + +
{{ n.titulo }}
+
{{ n.mensagem }}
+ +
+
+
+
+
+ +
+
+
+
diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss new file mode 100644 index 0000000..e2455b8 --- /dev/null +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -0,0 +1,130 @@ +:host { + display: block; +} + +.notificacoes-page { + width: 100%; +} + +.wrap { + padding: 24px 0 32px; +} + +.container { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 0 16px; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 20px; + + h2 { + font-size: 24px; + font-weight: 800; + margin: 0 0 4px; + } + + p { + margin: 0; + color: rgba(17, 18, 20, 0.6); + font-weight: 600; + } +} + +.state { + padding: 12px 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(0,0,0,0.08); + font-weight: 700; + color: rgba(17, 18, 20, 0.6); +} + +.state.warn { + color: #b45309; +} + +.notifications-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.notification-card { + background: #fff; + border-radius: 16px; + border: 1px solid rgba(0,0,0,0.08); + padding: 16px; + box-shadow: 0 18px 36px rgba(0,0,0,0.08); + + h3 { + margin: 12px 0 8px; + font-size: 16px; + font-weight: 800; + color: rgba(17, 18, 20, 0.92); + } + + p { + margin: 0 0 12px; + font-size: 13px; + color: rgba(17, 18, 20, 0.68); + } +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + background: rgba(3, 15, 170, 0.12); + color: #1f2937; +} + +.tag.warn { + background: rgba(227, 61, 207, 0.16); + color: #8b2a7d; +} + +.tag.danger { + background: rgba(239, 68, 68, 0.16); + color: #b91c1c; +} + +.date { + font-size: 12px; + color: rgba(17, 18, 20, 0.55); + font-weight: 700; +} + +.card-meta { + display: grid; + gap: 4px; + font-size: 12px; + color: rgba(17, 18, 20, 0.7); + font-weight: 700; +} + +.mark-read { + margin-top: 12px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid rgba(0,0,0,0.08); + background: #fff; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts new file mode 100644 index 0000000..be6168b --- /dev/null +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NotificationsService, NotificationDto } from '../../services/notifications.service'; + +@Component({ + selector: 'app-notificacoes', + standalone: true, + imports: [CommonModule], + templateUrl: './notificacoes.html', + styleUrls: ['./notificacoes.scss'], +}) +export class Notificacoes implements OnInit { + notifications: NotificationDto[] = []; + loading = false; + error = false; + + constructor(private notificationsService: NotificationsService) {} + + ngOnInit(): void { + this.loadNotifications(); + } + + markAsRead(notification: NotificationDto) { + if (notification.lida) return; + this.notificationsService.markAsRead(notification.id).subscribe({ + next: () => { + notification.lida = true; + notification.lidaEm = new Date().toISOString(); + }, + }); + } + + private loadNotifications() { + this.loading = true; + this.error = false; + this.notificationsService.list().subscribe({ + next: (data) => { + this.notifications = data || []; + this.loading = false; + }, + error: () => { + this.error = true; + this.loading = false; + }, + }); + } +} diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts new file mode 100644 index 0000000..a2ba36a --- /dev/null +++ b/src/app/services/notifications.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +export type NotificationTipo = 'AVencer' | 'Vencido'; + +export type NotificationDto = { + id: string; + tipo: NotificationTipo; + titulo: string; + mensagem: string; + data: string; + referenciaData?: string | null; + diasParaVencer?: number | null; + lida: boolean; + lidaEm?: string | null; + vigenciaLineId?: string | null; + cliente?: string | null; + linha?: string | null; +}; + +@Injectable({ providedIn: 'root' }) +export class NotificationsService { + private readonly baseUrl = `${environment.apiUrl}/notifications`; + + constructor(private http: HttpClient) {} + + list(): Observable { + return this.http.get(this.baseUrl); + } + + markAsRead(id: string): Observable { + return this.http.patch(`${this.baseUrl}/${id}/read`, {}); + } +} From cce651eef9b11c28d25f636b11cb2b5071fcd34a Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:06:41 -0300 Subject: [PATCH 09/46] =?UTF-8?q?Corrige=20base=20da=20API=20de=20notifica?= =?UTF-8?q?=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/services/notifications.service.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index a2ba36a..2852737 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -23,15 +23,18 @@ export type NotificationDto = { @Injectable({ providedIn: 'root' }) export class NotificationsService { - private readonly baseUrl = `${environment.apiUrl}/notifications`; + private readonly baseApi: string; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } list(): Observable { - return this.http.get(this.baseUrl); + return this.http.get(`${this.baseApi}/notifications`); } markAsRead(id: string): Observable { - return this.http.patch(`${this.baseUrl}/${id}/read`, {}); + return this.http.patch(`${this.baseApi}/notifications/${id}/read`, {}); } } From 8a12a2e0d07d5eec6411fc7da36494f0bbd26652 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:35:19 -0300 Subject: [PATCH 10/46] =?UTF-8?q?Aprimora=20UI=20das=20notifica=C3=A7?= =?UTF-8?q?=C3=B5es=20e=20adiciona=20filtros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.scss | 13 ++++++ src/app/pages/notificacoes/notificacoes.html | 33 +++++++++++++- src/app/pages/notificacoes/notificacoes.scss | 46 +++++++++++++++++++- src/app/pages/notificacoes/notificacoes.ts | 15 +++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index bd85f77..e9e85b8 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -191,6 +191,7 @@ justify-content: space-between; font-weight: 800; color: rgba(17, 18, 20, 0.9); + border-bottom: 1px solid rgba(0,0,0,0.06); } .see-all { @@ -222,6 +223,12 @@ border-radius: 12px; padding: 10px 12px; margin-bottom: 10px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(0,0,0,0.08); + } } .notification-tag { @@ -267,6 +274,12 @@ font-size: 12px; font-weight: 700; cursor: pointer; + color: rgba(17, 18, 20, 0.8); + + &:hover { + border-color: rgba(3, 15, 170, 0.35); + color: #030faa; + } } .options-menu { diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index d4ef48e..9453576 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -6,6 +6,32 @@

Notificações

Acompanhe vencimentos e avisos recentes.

+
+ + + +
Carregando notificações...
@@ -13,9 +39,12 @@
Nenhuma notificação encontrada.
+
+ Nenhuma notificação para o filtro selecionado. +
-
-
+
+
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index e2455b8..1e58776 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -19,9 +19,11 @@ .page-head { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; margin-bottom: 20px; + gap: 16px; + flex-wrap: wrap; h2 { font-size: 24px; @@ -36,6 +38,42 @@ } } +.filters { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.filter-btn { + border: 1px solid rgba(0,0,0,0.08); + background: #fff; + padding: 8px 14px; + border-radius: 999px; + font-weight: 700; + font-size: 12px; + color: rgba(17, 18, 20, 0.7); + cursor: pointer; + transition: all 0.2s ease; + + &.active { + border-color: rgba(3, 15, 170, 0.4); + background: rgba(3, 15, 170, 0.08); + color: #030faa; + } + + &.warning.active { + border-color: rgba(227, 61, 207, 0.45); + background: rgba(227, 61, 207, 0.12); + color: #8b2a7d; + } + + &.danger.active { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + } +} + .state { padding: 12px 14px; border-radius: 12px; @@ -61,6 +99,12 @@ border: 1px solid rgba(0,0,0,0.08); padding: 16px; box-shadow: 0 18px 36px rgba(0,0,0,0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 22px 44px rgba(0,0,0,0.12); + } h3 { margin: 12px 0 8px; diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index be6168b..e246808 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -12,6 +12,7 @@ import { NotificationsService, NotificationDto } from '../../services/notificati }) export class Notificacoes implements OnInit { notifications: NotificationDto[] = []; + filter: 'todas' | 'vencidas' | 'aVencer' = 'todas'; loading = false; error = false; @@ -31,6 +32,20 @@ export class Notificacoes implements OnInit { }); } + setFilter(value: 'todas' | 'vencidas' | 'aVencer') { + this.filter = value; + } + + get filteredNotifications() { + if (this.filter === 'vencidas') { + return this.notifications.filter(n => n.tipo === 'Vencido'); + } + if (this.filter === 'aVencer') { + return this.notifications.filter(n => n.tipo === 'AVencer'); + } + return this.notifications; + } + private loadNotifications() { this.loading = true; this.error = false; From 72a79479a8cded0101ab1b3eb9875d521b41f7f6 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:56:54 -0300 Subject: [PATCH 11/46] =?UTF-8?q?Refina=20layout=20dos=20cards=20de=20noti?= =?UTF-8?q?fica=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.html | 31 +++++++++-- src/app/components/header/header.scss | 49 +++++++++++++++++ src/app/components/header/header.ts | 56 +++++++++++++++++++- src/app/pages/notificacoes/notificacoes.html | 12 ++--- src/app/pages/notificacoes/notificacoes.scss | 28 ++++------ 5 files changed, 145 insertions(+), 31 deletions(-) diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index de807c6..6eeb294 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -50,11 +50,17 @@
- - {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} - -
{{ n.titulo }}
-
{{ n.mensagem }}
+
+ + {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} + + {{ n.linha || '-' }} +
+
+
Linha: {{ n.linha || '-' }}
+
Cliente: {{ n.cliente || '-' }}
+
{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}: {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
+
@@ -117,6 +123,21 @@ +
+
+
+ Vigência próxima + +
+
+ A linha {{ toastItem.linha || '-' }} vence em 5 dias. + +
+
+
+ diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index e9e85b8..70aa3da 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -242,6 +242,31 @@ background: rgba(3, 15, 170, 0.12); } +.notification-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.notification-line { + font-weight: 800; + font-size: 12px; + color: rgba(17, 18, 20, 0.65); +} + +.notification-info { + margin-top: 8px; + display: grid; + gap: 4px; + font-size: 12px; + color: rgba(17, 18, 20, 0.75); + + strong { + color: rgba(17, 18, 20, 0.9); + } +} + .notification-tag.warn { background: rgba(227, 61, 207, 0.16); color: #8b2a7d; @@ -282,6 +307,30 @@ } } +.notification-toast { + border-radius: 14px; + border: 1px solid rgba(0,0,0,0.08); + box-shadow: 0 18px 36px rgba(0,0,0,0.16); +} + +.notification-toast .toast-header { + border-bottom: 1px solid rgba(0,0,0,0.06); + font-weight: 800; +} + +.btn-aware { + display: inline-flex; + align-items: center; + margin-top: 10px; + padding: 6px 12px; + border-radius: 10px; + border: 1px solid rgba(0,0,0,0.1); + background: #fff; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + .options-menu { position: relative; display: flex; diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 78fca54..ed041d4 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, Inject } from '@angular/core'; +import { Component, HostListener, Inject, ElementRef, ViewChild } from '@angular/core'; import { RouterLink, Router, NavigationEnd } from '@angular/router'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; @@ -24,6 +24,8 @@ export class Header { notifications: NotificationDto[] = []; notificationsLoading = false; notificationsError = false; + private notificationsLoaded = false; + @ViewChild('notifToast') notifToast?: ElementRef; private readonly loggedPrefixes = [ '/geral', @@ -54,7 +56,13 @@ export class Header { this.menuOpen = false; this.optionsOpen = false; this.notificationsOpen = false; + if (this.isLoggedHeader) { + this.ensureNotificationsLoaded(); + } }); + if (this.isLoggedHeader) { + this.ensureNotificationsLoaded(); + } } private syncHeaderState(rawUrl: string) { @@ -137,6 +145,18 @@ export class Header { this.closeNotifications(); } + acknowledgeNotification(notification: NotificationDto) { + if (!isPlatformBrowser(this.platformId)) return; + const acknowledged = this.getAcknowledgedIds(); + acknowledged.add(notification.id); + localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged))); + } + + private ensureNotificationsLoaded() { + if (this.notificationsLoaded || this.notificationsLoading) return; + this.loadNotifications(); + } + private loadNotifications() { if (!isPlatformBrowser(this.platformId)) return; this.notificationsLoading = true; @@ -144,7 +164,9 @@ export class Header { this.notificationsService.list().subscribe({ next: (data) => { this.notifications = data || []; + this.notificationsLoaded = true; this.notificationsLoading = false; + this.maybeShowVigenciaToast(); }, error: () => { this.notificationsError = true; @@ -152,4 +174,36 @@ export class Header { }, }); } + + private async maybeShowVigenciaToast() { + if (!this.notifToast || !isPlatformBrowser(this.platformId)) return; + const pending = this.getPendingVigenciaToast(); + if (!pending) return; + + const bs = await import('bootstrap'); + const toast = new bs.Toast(this.notifToast.nativeElement, { autohide: false }); + toast.show(); + } + + get toastNotification() { + return this.getPendingVigenciaToast(); + } + + private getPendingVigenciaToast() { + const acknowledged = this.getAcknowledgedIds(); + return this.notifications.find( + n => n.tipo === 'AVencer' && n.diasParaVencer === 5 && !acknowledged.has(n.id) + ); + } + + private getAcknowledgedIds() { + if (!isPlatformBrowser(this.platformId)) return new Set(); + try { + const raw = localStorage.getItem('vigenciaAcknowledgedIds'); + const ids = raw ? (JSON.parse(raw) as string[]) : []; + return new Set(ids); + } catch { + return new Set(); + } + } } diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 9453576..9bddcb7 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -49,15 +49,13 @@ {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} - {{ n.data | date:'dd/MM/yyyy' }} + {{ n.linha || '-' }}
-

{{ n.titulo }}

-

{{ n.mensagem }}

- -
- Cliente: {{ n.cliente }} - Linha: {{ n.linha }} +
+
Linha: {{ n.linha || '-' }}
+
Cliente: {{ n.cliente || '-' }}
+
{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}: {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
+
+ {{ n.linha || '-' }} - {{ n.usuario || n.cliente || '-' }} +
Linha: {{ n.linha || '-' }}
+
Usuário: {{ n.usuario || '-' }}
Cliente: {{ n.cliente || '-' }}
{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}: {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 9bddcb7..74013a9 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -52,8 +52,13 @@ {{ n.linha || '-' }}
+
+ {{ n.linha || '-' }} - {{ n.usuario || n.cliente || '-' }} +
+
Linha: {{ n.linha || '-' }}
+
Usuário: {{ n.usuario || '-' }}
Cliente: {{ n.cliente || '-' }}
{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}: {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index b5d7fda..7381235 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -119,6 +119,12 @@ } } +.card-title { + margin-top: 10px; + font-weight: 800; + color: rgba(17, 18, 20, 0.92); +} + .card-head { display: flex; align-items: center; diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index 2852737..07e16e8 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -19,6 +19,7 @@ export type NotificationDto = { vigenciaLineId?: string | null; cliente?: string | null; linha?: string | null; + usuario?: string | null; }; @Injectable({ providedIn: 'root' }) From 173d8570c96a3b1036b9289acbf9253435570b66 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:23:19 -0300 Subject: [PATCH 13/46] =?UTF-8?q?Adiciona=20filtro=20de=20notifica=C3=A7?= =?UTF-8?q?=C3=B5es=20lidas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/pages/notificacoes/notificacoes.html | 8 ++++++++ src/app/pages/notificacoes/notificacoes.scss | 6 ++++++ src/app/pages/notificacoes/notificacoes.ts | 7 +++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 74013a9..a90bf0a 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -31,6 +31,14 @@ > Vencidas +
diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 7381235..e071c74 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -72,6 +72,12 @@ background: rgba(239, 68, 68, 0.12); color: #b91c1c; } + + &.neutral.active { + border-color: rgba(15, 23, 42, 0.35); + background: rgba(15, 23, 42, 0.08); + color: #0f172a; + } } .state { diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index e246808..6c2b0c1 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -12,7 +12,7 @@ import { NotificationsService, NotificationDto } from '../../services/notificati }) export class Notificacoes implements OnInit { notifications: NotificationDto[] = []; - filter: 'todas' | 'vencidas' | 'aVencer' = 'todas'; + filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas'; loading = false; error = false; @@ -32,7 +32,7 @@ export class Notificacoes implements OnInit { }); } - setFilter(value: 'todas' | 'vencidas' | 'aVencer') { + setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') { this.filter = value; } @@ -43,6 +43,9 @@ export class Notificacoes implements OnInit { if (this.filter === 'aVencer') { return this.notifications.filter(n => n.tipo === 'AVencer'); } + if (this.filter === 'lidas') { + return this.notifications.filter(n => n.lida); + } return this.notifications; } From 2a2ec3d48762d0ce186667b293b9426273886297 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:23:43 -0300 Subject: [PATCH 14/46] Atualiza estilo da logo para visual SaaS --- src/app/components/header/header.scss | 60 ++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 70aa3da..6c36e6f 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -51,24 +51,47 @@ gap: 10px; text-decoration: none; color: var(--text-main); + transition: transform 0.2s ease; .logo-icon { - width: 36px; - height: 36px; - background: linear-gradient(135deg, var(--brand-primary), #6A55FF); + width: 38px; + height: 38px; + background: conic-gradient( + from 210deg, + #1c38c9 0deg, + #3555ff 90deg, + #e33dcf 180deg, + #ff6b6b 250deg, + #2ecc71 320deg, + #1c38c9 360deg + ); color: #fff; - border-radius: 10px; + border-radius: 50%; display: grid; place-items: center; font-size: 18px; flex: 0 0 auto; + box-shadow: 0 10px 24px rgba(28, 56, 201, 0.25); } .logo-text { font-size: 20px; - font-weight: 700; - letter-spacing: -0.5px; - .highlight { color: var(--text-main); } + font-weight: 800; + letter-spacing: -0.4px; + text-transform: lowercase; + + .highlight { + background: linear-gradient(90deg, #1c38c9 0%, #6a55ff 45%, #e33dcf 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-transform: none; + margin-left: 4px; + font-weight: 700; + } + } + + &:hover { + transform: translateY(-1px); } } @@ -447,11 +470,19 @@ .side-logo-icon { width: 38px; height: 38px; - border-radius: 12px; + border-radius: 50%; display: grid; place-items: center; color: #fff; - background: linear-gradient(135deg, var(--brand-primary), #6A55FF); + background: conic-gradient( + from 210deg, + #1c38c9 0deg, + #3555ff 90deg, + #e33dcf 180deg, + #ff6b6b 250deg, + #2ecc71 320deg, + #1c38c9 360deg + ); i { font-size: 18px; } } @@ -459,7 +490,16 @@ font-weight: 900; font-size: 18px; letter-spacing: -0.4px; - .highlight { color: var(--text-main); } + text-transform: lowercase; + + .highlight { + background: linear-gradient(90deg, #1c38c9 0%, #6a55ff 45%, #e33dcf 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-transform: none; + margin-left: 4px; + font-weight: 700; + } } } From 91c695cc688bd70bc88e5f1c497d7b5ffa9cc707 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:38:45 -0300 Subject: [PATCH 15/46] Add Mureg delete confirmation modal --- src/app/pages/mureg/mureg.html | 44 +++++++++++++++++++++++++++++- src/app/pages/mureg/mureg.scss | 5 +++- src/app/pages/mureg/mureg.ts | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index 7c30f18..41b3367 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -184,6 +184,9 @@ + @@ -223,7 +226,7 @@ - + @@ -467,3 +470,42 @@ + + + + + diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss index 8ca24e1..b5fb326 100644 --- a/src/app/pages/mureg/mureg.scss +++ b/src/app/pages/mureg/mureg.scss @@ -267,6 +267,7 @@ color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer; &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); } + &.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); } } /* FOOTER */ @@ -278,11 +279,13 @@ .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } +.modal-card.modal-sm { width: min(480px, 100%); } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); } /* Adicionado */ + &.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } } @@ -307,4 +310,4 @@ div.box-body { padding: 16px; } .form-control { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); &:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; } -} \ No newline at end of file +} diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index 0572137..b6b05c5 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -129,6 +129,11 @@ export class Mureg implements AfterViewInit { editSaving = false; editModel: any = null; + // ====== DELETE MODAL ====== + deleteOpen = false; + deleteSaving = false; + deleteTarget: MuregRow | null = null; + // ====== CREATE MODAL ====== createOpen = false; createSaving = false; @@ -638,6 +643,50 @@ export class Mureg implements AfterViewInit { }); } + // ======================================================================= + // DELETE MODAL + // ======================================================================= + onDelete(row: MuregRow) { + this.deleteTarget = row; + this.deleteOpen = true; + this.deleteSaving = false; + } + + closeDelete() { + this.deleteOpen = false; + this.deleteTarget = null; + this.deleteSaving = false; + } + + confirmDelete() { + if (!this.deleteTarget?.id) return; + + this.deleteSaving = true; + const targetId = this.deleteTarget.id; + const currentGroup = this.expandedGroup; + + this.http.delete(`${this.apiBase}/${targetId}`).subscribe({ + next: async () => { + this.deleteSaving = false; + await this.showToast('Mureg excluída com sucesso!'); + this.closeDelete(); + this.loadForGroups(); + + if (currentGroup) { + setTimeout(() => { + this.expandedGroup = currentGroup; + this.toggleGroup(currentGroup); + }, 400); + } + }, + error: async (err) => { + this.deleteSaving = false; + const msg = this.extractApiMessage(err) ?? 'Erro ao excluir Mureg.'; + await this.showToast(msg); + } + }); + } + // ======================================================================= // Helpers // ======================================================================= From 058322521aec1fdb156558c74ec52637c883b97e Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:46:52 -0300 Subject: [PATCH 16/46] Add Mureg detail modal --- src/app/pages/mureg/mureg.html | 98 +++++++++++++++++++++++++++++++++- src/app/pages/mureg/mureg.scss | 14 +++++ src/app/pages/mureg/mureg.ts | 31 +++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index 41b3367..334239c 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -181,6 +181,9 @@
+ @@ -226,7 +229,7 @@
- + @@ -471,6 +474,99 @@ + + + + + diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss index b5fb326..01e44d5 100644 --- a/src/app/pages/mureg/mureg.scss +++ b/src/app/pages/mureg/mureg.scss @@ -267,6 +267,7 @@ color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer; &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); } + &.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); } &.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); } } @@ -279,6 +280,7 @@ .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } +.modal-card.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-card.modal-sm { width: min(480px, 100%); } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } @@ -297,6 +299,18 @@ div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0 div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; } div.box-body { padding: 16px; } +/* INFO GRID (detalhes) */ +.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0; } +.info-item { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 6px 10px; background: rgba(245, 245, 247, 0.5); border-radius: 12px; border: 1px solid rgba(0,0,0,0.03); transition: background 0.2s; + &:hover { background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } + &.span-2 { grid-column: span 2; } + .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; } + .val { font-size: 0.9rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; + &.fs-4 { font-size: 1.1rem !important; } + &.small-text { font-size: 0.75rem; font-family: monospace; } + } +} + /* EDIT FORM STYLES */ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index b6b05c5..fa633b6 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -129,6 +129,11 @@ export class Mureg implements AfterViewInit { editSaving = false; editModel: any = null; + // ====== DETAIL MODAL ====== + detailOpen = false; + detailLoading = false; + detailData: MuregDetailDto | null = null; + // ====== DELETE MODAL ====== deleteOpen = false; deleteSaving = false; @@ -643,6 +648,32 @@ export class Mureg implements AfterViewInit { }); } + // ======================================================================= + // DETAIL MODAL + // ======================================================================= + onView(row: MuregRow) { + this.detailOpen = true; + this.detailLoading = true; + this.detailData = null; + + this.http.get(`${this.apiBase}/${row.id}`).subscribe({ + next: (data) => { + this.detailData = data; + this.detailLoading = false; + }, + error: async () => { + this.detailLoading = false; + await this.showToast('Erro ao carregar detalhes da Mureg.'); + } + }); + } + + closeDetail() { + this.detailOpen = false; + this.detailLoading = false; + this.detailData = null; + } + // ======================================================================= // DELETE MODAL // ======================================================================= From 6ff62463e8b71fcc3d677e32dad547d382b08cc6 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:51:15 -0300 Subject: [PATCH 17/46] Adjust Mureg detail modal width --- src/app/pages/mureg/mureg.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss index 01e44d5..f320218 100644 --- a/src/app/pages/mureg/mureg.scss +++ b/src/app/pages/mureg/mureg.scss @@ -280,7 +280,7 @@ .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } -.modal-card.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } +.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } .modal-card.modal-sm { width: min(480px, 100%); } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } From 7877ef1ff8e996a487ce0ae460ff6f63f096a941 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:55:15 -0300 Subject: [PATCH 18/46] Remove Geral info from Mureg detail modal --- src/app/pages/mureg/mureg.html | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index 334239c..f688235 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -528,33 +528,7 @@ ICCID {{ detailData.iccid || '-' }} - - - - -
-
- Informações da Geral -
-
-
- Linha Atual na Geral - {{ detailData.linhaAtualNaGeral || '-' }} -
-
- Chip na Geral - {{ detailData.chipNaGeral || '-' }} -
-
- Conta na Geral - {{ detailData.contaNaGeral || '-' }} -
-
- Status na Geral - {{ detailData.statusNaGeral || '-' }} -
-
Skil {{ detailData.skil || '-' }}
From 128c573f0a0eddf4718f7d30e25aee4abfdaa935 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:59:34 -0300 Subject: [PATCH 19/46] Shrink Mureg detail modal --- src/app/pages/mureg/mureg.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss index f320218..3e30c8f 100644 --- a/src/app/pages/mureg/mureg.scss +++ b/src/app/pages/mureg/mureg.scss @@ -280,7 +280,7 @@ .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } -.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } +.modal-card.modal-xl-custom { width: min(920px, 90vw); max-height: 78vh; } .modal-card.modal-sm { width: min(480px, 100%); } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } @@ -291,7 +291,7 @@ } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } } -.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } +.modal-body { padding: 20px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } /* FORM & DETAILS */ .details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } @@ -300,14 +300,14 @@ div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-t div.box-body { padding: 16px; } /* INFO GRID (detalhes) */ -.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0; } -.info-item { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 6px 10px; background: rgba(245, 245, 247, 0.5); border-radius: 12px; border: 1px solid rgba(0,0,0,0.03); transition: background 0.2s; +.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 0; } +.info-item { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 5px 8px; background: rgba(245, 245, 247, 0.5); border-radius: 10px; border: 1px solid rgba(0,0,0,0.03); transition: background 0.2s; &:hover { background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } &.span-2 { grid-column: span 2; } - .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; } - .val { font-size: 0.9rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; - &.fs-4 { font-size: 1.1rem !important; } - &.small-text { font-size: 0.75rem; font-family: monospace; } + .lbl { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; } + .val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; + &.fs-4 { font-size: 1rem !important; } + &.small-text { font-size: 0.7rem; font-family: monospace; } } } From 6ab5b476e37c1d024a06b5a885b2479f1b77ba3a Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:08:41 -0300 Subject: [PATCH 20/46] Fix faturamento table scrolling --- src/app/pages/faturamento/faturamento.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 4326aa4..2371d12 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -97,7 +97,7 @@ position: relative; display: flex; flex-direction: column; - max-height: calc(100vh - 18px) !important; + max-height: calc(100vh - 80px) !important; min-height: 0; &::before { @@ -471,7 +471,8 @@ .groups-container { padding: 16px; overflow-y: auto; - height: 100%; + flex: 1; + min-height: 0; } .group-list { From 80a3926c256cc2c246aaff244a2393b49ee64476 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:20:49 -0300 Subject: [PATCH 21/46] Adjust faturamento table layout sizing --- src/app/pages/faturamento/faturamento.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 2371d12..7ba4df7 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -80,7 +80,7 @@ .container-fat { width: 100%; - max-width: 1180px; + max-width: 1240px; position: relative; z-index: 1; margin-top: var(--page-top-gap); @@ -97,8 +97,9 @@ position: relative; display: flex; flex-direction: column; - max-height: calc(100vh - 80px) !important; - min-height: 0; + height: auto !important; + min-height: 80vh; + max-height: none !important; &::before { content: ''; @@ -473,6 +474,7 @@ overflow-y: auto; flex: 1; min-height: 0; + height: 100%; } .group-list { From a476843ae0d09efb85bd25821dad2edf65e57b6d Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:34:31 -0300 Subject: [PATCH 22/46] Stack Total Vivo/Line KPI labels --- src/app/pages/faturamento/faturamento.html | 4 ++-- src/app/pages/faturamento/faturamento.scss | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 0beb345..ea6d0c3 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -131,7 +131,7 @@
-
+
Total Vivo @@ -139,7 +139,7 @@
-
+
Total Line diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 7ba4df7..b366b33 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -449,6 +449,21 @@ } } +.kpi-stack { + flex-direction: column; + align-items: flex-start; + gap: 6px; + + .lbl, + .val { + white-space: normal; + } + + .val { + line-height: 1.1; + } +} + .kpi-wide { min-width: 220px; padding: 14px 18px; From 1d2e2346208ccd5fb7d1cdc15852b3f84afb4387 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:48:23 -0300 Subject: [PATCH 23/46] Center stacked Vivo/Line KPIs --- src/app/pages/faturamento/faturamento.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index b366b33..9f50a8e 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -451,8 +451,9 @@ .kpi-stack { flex-direction: column; - align-items: flex-start; + align-items: center; gap: 6px; + text-align: center; .lbl, .val { From d76ca6fbc997dce3fbf92d98a87a36f7d7e3267a Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:57:47 -0300 Subject: [PATCH 24/46] Stack Lucro KPI label --- src/app/pages/faturamento/faturamento.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index ea6d0c3..f6ec934 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -147,7 +147,7 @@
-
+
Lucro From e4426d6b119f93921eff3470cad1342546d345e3 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:04:51 -0300 Subject: [PATCH 25/46] Compact Total Clientes/Linhas KPIs --- src/app/pages/faturamento/faturamento.html | 4 ++-- src/app/pages/faturamento/faturamento.scss | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index f6ec934..46764d3 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -115,7 +115,7 @@
-
+
Total Clientes @@ -123,7 +123,7 @@
-
+
Total Linhas diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 9f50a8e..ff7d4e7 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -465,6 +465,15 @@ } } +.kpi-compact { + padding: 8px 14px; + min-height: auto; + + .val { + font-size: 1.05rem; + } +} + .kpi-wide { min-width: 220px; padding: 14px 18px; From ce3eba920a8a946eaacf554d9f23729969aabd20 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:15:53 -0300 Subject: [PATCH 26/46] Reduce height of Clientes/Linhas KPIs --- src/app/pages/faturamento/faturamento.scss | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index ff7d4e7..4e76be3 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -466,11 +466,16 @@ } .kpi-compact { - padding: 8px 14px; - min-height: auto; + padding: 6px 12px; + min-height: 56px; + align-items: center; .val { - font-size: 1.05rem; + font-size: 1rem; + } + + .lbl { + font-size: 0.68rem; } } From b559c34ef96495b0c242f812fd5437659f133cc4 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:25:17 -0300 Subject: [PATCH 27/46] Refine faturamento inner table styling --- src/app/pages/faturamento/faturamento.html | 2 +- src/app/pages/faturamento/faturamento.scss | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 46764d3..637b502 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -229,7 +229,7 @@
-
+
Registros do Cliente Clique no “olho” para ver todos os detalhes
diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 4e76be3..d0d1f5a 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -585,6 +585,16 @@ to { opacity: 1; transform: translateY(0); } } +.group-actions-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #fff; + border-bottom: 1px solid rgba(17,18,20,0.06); + gap: 12px; +} + .chip-muted { display: inline-flex; align-items: center; @@ -598,7 +608,11 @@ border: 1px solid rgba(17,18,20,0.06); } -.inner-table-wrap { max-height: 520px; overflow: auto; } +.inner-table-wrap { + max-height: none; + height: auto; + overflow-y: visible; +} /* TABLE */ .table-wrap { overflow: auto; height: 100%; } @@ -624,6 +638,9 @@ text-transform: uppercase; white-space: nowrap; text-align: center !important; + transition: color 0.2s ease; + + &:hover { color: var(--brand); } } tbody tr { From 5c7cc3a8274dd41b1d08478c79b565d34a005f4f Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:40:57 -0300 Subject: [PATCH 28/46] Remove sort carets from faturamento table --- src/app/pages/faturamento/faturamento.html | 14 +++++++------- src/app/pages/faturamento/faturamento.scss | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 637b502..38c97f3 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -238,12 +238,12 @@ - @@ -254,19 +254,19 @@ diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index d0d1f5a..766119a 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -664,6 +664,7 @@ .sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } } .td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; } .empty-state { background: rgba(255,255,255,0.4); } +.th-item .th-content { justify-content: center; } /* ACTIONS */ .action-group { display: flex; justify-content: center; gap: 6px; } From 4b9796cb0ea995cd73387aac2cdf8b3bcc5ee351 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:46:02 -0300 Subject: [PATCH 29/46] Shrink compact faturamento KPIs --- src/app/pages/faturamento/faturamento.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 766119a..5da7ce0 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -466,16 +466,16 @@ } .kpi-compact { - padding: 6px 12px; - min-height: 56px; + padding: 4px 10px; + min-height: 46px; align-items: center; .val { - font-size: 1rem; + font-size: 0.95rem; } .lbl { - font-size: 0.68rem; + font-size: 0.64rem; } } From df1bc45d32f8105234c3754a3b5b985b97ea346f Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:50:37 -0300 Subject: [PATCH 30/46] Restore compact KPI sizing --- src/app/pages/faturamento/faturamento.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 5da7ce0..766119a 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -466,16 +466,16 @@ } .kpi-compact { - padding: 4px 10px; - min-height: 46px; + padding: 6px 12px; + min-height: 56px; align-items: center; .val { - font-size: 0.95rem; + font-size: 1rem; } .lbl { - font-size: 0.64rem; + font-size: 0.68rem; } } From 9f08fac60a0563b7b587801208a8f2eac2ce2674 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:56:48 -0300 Subject: [PATCH 31/46] Stack Clientes/Linhas KPI labels --- src/app/pages/faturamento/faturamento.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 38c97f3..aac77fe 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -115,7 +115,7 @@
-
+
Total Clientes @@ -123,7 +123,7 @@
-
+
Total Linhas From 864ec5baf037d3b617094097eb1b77da31c5e4d5 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:03:47 -0300 Subject: [PATCH 32/46] Tighten KPI label/value spacing --- src/app/pages/faturamento/faturamento.html | 6 +++--- src/app/pages/faturamento/faturamento.scss | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index aac77fe..af9e6c1 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -115,7 +115,7 @@
-
+
Total Clientes @@ -123,7 +123,7 @@
-
+
Total Linhas @@ -147,7 +147,7 @@
-
+
Lucro diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 766119a..ed96d9b 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -465,6 +465,10 @@ } } +.kpi-stack-tight { + gap: 2px; +} + .kpi-compact { padding: 6px 12px; min-height: 56px; From 3df7a41518f38054027ae2b85da3f84bca21b968 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes <155753879+eduardolopesx03@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:26:28 -0300 Subject: [PATCH 33/46] Ajustar modais de faturamento --- src/app/pages/faturamento/faturamento.html | 93 +++------------------- src/app/pages/faturamento/faturamento.scss | 30 +++++-- 2 files changed, 37 insertions(+), 86 deletions(-) diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index af9e6c1..ecab608 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -338,7 +338,7 @@ From 4d357a45306dd7ae267cb2e216426a2949aadd3b Mon Sep 17 00:00:00 2001 From: Eduardo Date: Sun, 25 Jan 2026 14:41:39 -0300 Subject: [PATCH 35/46] =?UTF-8?q?feat:=20utilizando=20boas=20pr=C3=A1ticas?= =?UTF-8?q?=20de=20seguran=C3=A7a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.routes.ts | 2 + src/app/components/header/header.html | 243 +++-- src/app/components/header/header.scss | 881 ++++++++++--------- src/app/components/header/header.ts | 129 ++- src/app/guards/auth.guard.ts | 9 + src/app/pages/login/login.ts | 11 + src/app/pages/notificacoes/notificacoes.html | 155 ++-- src/app/pages/notificacoes/notificacoes.scss | 278 +++--- src/app/pages/notificacoes/notificacoes.ts | 4 + src/app/pages/novo-usuario/novo-usuario.html | 48 + src/app/pages/novo-usuario/novo-usuario.scss | 126 +++ src/app/pages/novo-usuario/novo-usuario.ts | 11 + src/app/services/auth.service.ts | 43 + src/app/services/users.service.ts | 79 ++ src/styles.scss | 133 ++- 15 files changed, 1438 insertions(+), 714 deletions(-) create mode 100644 src/app/pages/novo-usuario/novo-usuario.html create mode 100644 src/app/pages/novo-usuario/novo-usuario.scss create mode 100644 src/app/pages/novo-usuario/novo-usuario.ts create mode 100644 src/app/services/users.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index a6ea6af..76381a6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -13,6 +13,7 @@ import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; import { Dashboard } from './pages/dashboard/dashboard'; import { Notificacoes } from './pages/notificacoes/notificacoes'; +import { NovoUsuario } from './pages/novo-usuario/novo-usuario'; export const routes: Routes = [ { path: '', component: Home }, @@ -26,6 +27,7 @@ export const routes: Routes = [ { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] }, + { path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard] }, // ✅ rota correta { path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index de06fe3..63a49f3 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -1,11 +1,10 @@
-
- @@ -27,71 +26,96 @@ aria-label="Notificações" (click)="toggleNotifications()" [attr.aria-expanded]="notificationsOpen" + [class.has-unread]="unreadCount > 0" > - - {{ unreadCount }} + +
- Notificações - Ver todas +
+ Notificações + {{ unreadCount }} nova(s) +
+ Ver tudo
-
+
- Carregando... -
-
- Falha ao carregar notificações. -
-
- Nenhuma notificação por aqui. +
+ Carregando...
-
-
- - {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} - - {{ n.linha || '-' }} +
+ + Falha ao carregar. +
+ +
+
+

Tudo limpo por aqui!

+
+ +
+
+
+ +
-
- {{ n.linha || '-' }} - {{ n.usuario || n.cliente || '-' }} + +
+
+ {{ n.linha || 'Sem Linha' }} + {{ n.referenciaData ? (n.referenciaData | date:'dd/MM') : '' }} +
+

+ {{ n.tipo === 'Vencido' ? 'Venceu' : 'Vence em' }} - {{ n.cliente || 'Cliente não ident.' }} +

+
+ {{ n.usuario }} +
-
-
Linha: {{ n.linha || '-' }}
-
Usuário: {{ n.usuario || '-' }}
-
Cliente: {{ n.cliente || '-' }}
-
{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}: {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
+ +
+
-
-
- +
+ +
@@ -99,23 +123,16 @@
- -
- -
-
- LineGestão -
+
+
LineGestão
- -
-
ITEM {{ sortBy==='item' && sortDir==='desc' ? '▼' : '▲' }}
+
+
ITEM
-
QTD LINHAS {{ sortBy==='qtdlinhas' && sortDir==='desc' ? '▼' : '▲' }}
+
QTD LINHAS
VIVO
-
FRANQUIA {{ sortBy==='franquiavivo' && sortDir==='desc' ? '▼' : '▲' }}
+
FRANQUIA
-
VALOR (R$) {{ sortBy==='valorcontratovivo' && sortDir==='desc' ? '▼' : '▲' }}
+
VALOR (R$)
-
FRANQUIA {{ sortBy==='franquialine' && sortDir==='desc' ? '▼' : '▲' }}
+
FRANQUIA
-
VALOR (R$) {{ sortBy==='valorcontratoline' && sortDir==='desc' ? '▼' : '▲' }}
+
VALOR (R$)
+ + + + + + + + + + + + + + + + +
UsuárioPermissãoStatusAções
+
+
{{ u.nome.charAt(0).toUpperCase() }}
+ +
+
+ {{ u.permissao }} + + + +
+ + +
+
+
+ +

Nenhum usuário encontrado.

+
+
+ + +
+ +
+
+
+
{{ target.nome.charAt(0).toUpperCase() }}
+
+

{{ target.nome }}

+ Editando perfil +
+
+ +
+
  • {{ err.message }}
+
+
{{ editUserSuccess }}
+ +
+
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ + + {{ editUserForm.get('ativo')?.value ? 'Ativo' : 'Inativo' }} + +
+
+
+
+ + +
+ +
+
+
+ +
+

Editar Usuário

+

Selecione um usuário na lista para visualizar e editar os detalhes.

+
+
+
+
+
+
- - - - - diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 3e31990..8d62ea0 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -1,578 +1,457 @@ -/* Variáveis de apoio */ +@use 'sass:color'; + +/* Variáveis */ $primary: #1c38c9; +$primary-hover: #152ca0; $danger: #ef4444; $warning: #f59e0b; +$success: #10b981; $text-main: #111827; $text-muted: #6b7280; -$bg-light: #f3f4f6; -$border-color: rgba(0,0,0,0.06); +$bg-light: #f9fafb; +$border-color: #e5e7eb; +/* Utils */ +* { box-sizing: border-box; } +.custom-scroll::-webkit-scrollbar { width: 6px; height: 6px; } +.custom-scroll::-webkit-scrollbar-track { background: transparent; } +.custom-scroll::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 10px; } +.custom-scroll::-webkit-scrollbar-thumb:hover { background: #9ca3af; } + +/* HEADER PRINCIPAL */ .app-header { - position: fixed; - top: 0; left: 0; width: 100%; - z-index: 1000; - padding: 14px 0; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border-bottom: 1px solid rgba(0,0,0,0.05); - transition: all 0.3s ease; - - &.scrolled { - padding: 10px 0; - background: rgba(255, 255, 255, 0.95); - box-shadow: 0 4px 20px rgba(0,0,0,0.03); - } + position: fixed; top: 0; left: 0; width: 100%; z-index: 1000; + padding: 14px 0; background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0,0,0,0.05); transition: all 0.3s ease; + &.scrolled { padding: 10px 0; box-shadow: 0 4px 20px rgba(0,0,0,0.03); } } -.header-inner { - display: flex; align-items: center; justify-content: space-between; -} - -.logged-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - width: 100%; -} - -/* --- LOGO & MENU --- */ +.header-inner { display: flex; align-items: center; justify-content: space-between; gap: 24px; } +.logged-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; } .left-logged { display: flex; align-items: center; gap: 16px; } .btn-icon { - background: transparent; - border: none; - width: 40px; height: 40px; - border-radius: 50%; - display: grid; place-items: center; - cursor: pointer; - transition: background 0.2s; - color: $text-main; - + background: transparent; border: none; width: 40px; height: 40px; border-radius: 50%; + display: grid; place-items: center; cursor: pointer; transition: background 0.2s; color: $text-main; &:hover { background: rgba(0,0,0,0.04); } i { font-size: 20px; } } .logo-area { - display: flex; align-items: center; gap: 10px; - text-decoration: none; color: #111827; - + display: flex; align-items: center; gap: 10px; text-decoration: none; color: #111827; .logo-icon { - width: 36px; height: 36px; - background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff); - color: #fff; - border-radius: 50%; - display: grid; place-items: center; - font-size: 18px; - box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2); + width: 36px; height: 36px; background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff); + color: #fff; border-radius: 50%; display: grid; place-items: center; font-size: 18px; box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2); } - .logo-text { font-size: 19px; font-weight: 700; letter-spacing: -0.5px; - .highlight { - background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - } + .highlight { background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } } } -.logged-actions { - display: flex; - align-items: center; - gap: 12px; - margin-left: auto; +.nav-links { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 1; } +.nav-links .nav-link { + display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s; + &:hover { color: $primary; } +} +.header-actions { display: flex; align-items: center; } +.btn-login-header { + display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px; + border: 1px solid rgba(28, 56, 201, 0.18); background: #fff; color: $primary; font-weight: 700; font-size: 13px; text-decoration: none; transition: all 0.2s; + &:hover { transform: translateY(-1px); background: rgba(28, 56, 201, 0.04); box-shadow: 0 4px 12px rgba(28, 56, 201, 0.15); } } -/* --- NOTIFICAÇÕES (Dropdown) --- */ -.notifications-menu { position: relative; } +@media (max-width: 900px) { .nav-links { display: none; } } +.logged-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; } +@media (min-width: 1200px) { + .header-inner.container { + max-width: none; + width: 100%; + padding-left: 28px; + padding-right: 28px; + } +} + +/* DROPDOWNS */ +.notifications-menu, .options-menu { position: relative; } +.notifications-dropdown, .options-dropdown { + position: absolute; top: calc(100% + 12px); right: -10px; width: 340px; background: #fff; border-radius: 16px; + box-shadow: 0 10px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05); z-index: 1200; transform-origin: top right; + animation: slideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1); overflow: hidden; +} +.options-dropdown { width: 220px; right: 0; padding: 6px; } +@keyframes slideDown { from { opacity: 0; transform: translateY(-10px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } + +.user-trigger { + display: flex; align-items: center; gap: 8px; padding: 4px; background: #fff; border: 1px solid $border-color; border-radius: 99px; cursor: pointer; transition: all 0.2s; + &:hover, &[aria-expanded="true"] { border-color: $primary; box-shadow: 0 0 0 2px rgba(28, 56, 201, 0.1); } + .user-avatar { width: 32px; height: 32px; background: $bg-light; border-radius: 50%; display: grid; place-items: center; color: $text-muted; } + .chevron { font-size: 10px; color: $text-muted; margin: 0 6px 0 2px; } +} +.options-item { + width: 100%; text-align: left; padding: 10px 12px; background: transparent; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; color: $text-main; display: flex; align-items: center; gap: 10px; cursor: pointer; + &:hover { background: $bg-light; } + &.danger { color: $danger; &:hover { background: rgba($danger, 0.05); } } +} +.divider { height: 1px; background: $border-color; margin: 4px 0; } + +/* NOTIFICAÇÕES */ .btn-bell { - position: relative; - - &.has-unread { - color: $primary; - background: rgba(28, 56, 201, 0.06); - } - + &.has-unread { color: $primary; background: rgba(28, 56, 201, 0.06); } .badge-pulse { - position: absolute; - top: 10px; right: 10px; - width: 8px; height: 8px; - background: $danger; - border-radius: 50%; - border: 2px solid #fff; - box-shadow: 0 0 0 0 rgba($danger, 0.7); - animation: pulse-red 2s infinite; + position: absolute; top: 10px; right: 10px; width: 8px; height: 8px; background: $danger; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 0 rgba($danger, 0.7); animation: pulse 2s infinite; } } +@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-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; } } -@keyframes pulse-red { - 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); } +.notification-item { + display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid $border-color; cursor: pointer; + &:hover { background: $bg-light; } + &.unread { background: rgba(28, 56, 201, 0.03); .notif-title { font-weight: 700; } .status-dot { width: 6px; height: 6px; background: $primary; border-radius: 50%; } } + .icon-circle { + width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: #f3f4f6; color: $text-muted; font-size: 16px; + &.danger { background-color: #fee2e2; color: #dc2626; } + &.warn { background-color: #fef3c7; color: #d97706; } + } + .notif-content { flex: 1; } + .notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 2px; } + .notif-date { font-size: 11px; color: $text-muted; } + .notif-desc { margin: 0; font-size: 12px; color: $text-muted; line-height: 1.3; } } -.notifications-dropdown { - position: absolute; - top: calc(100% + 12px); right: -10px; - width: 340px; - background: #fff; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.04); - z-index: 1200; - transform-origin: top right; - animation: slideDown 0.2s ease-out; +/* MODAIS GERAIS */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px); z-index: 1400; animation: fadeIn 0.2s; } +.modal-card { + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); + width: min(500px, calc(100vw - 32px)); background: #fff; border-radius: 16px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); z-index: 1450; display: flex; flex-direction: column; animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes scaleIn { from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid $border-color; h3 { margin: 0; font-size: 16px; font-weight: 600; color: $text-main; } } +.close-x { width: 32px; height: 32px; border-radius: 6px; &:hover { background: #f3f4f6; } } +.modal-body { padding: 20px; } +.modal-actions { padding: 16px 20px; display: flex; justify-content: flex-end; gap: 10px; background: $bg-light; border-radius: 0 0 16px 16px; } + +.form-field { + display: grid; gap: 6px; margin-bottom: 16px; + label { font-size: 13px; font-weight: 500; color: $text-main; } + input, select { width: 100%; height: 40px; border-radius: 8px; border: 1px solid #d1d5db; padding: 0 12px; font-size: 14px; transition: all 0.2s; &:focus { outline: none; border-color: $primary; box-shadow: 0 0 0 3px rgba(28, 56, 201, 0.1); } } +} +.field-error { font-size: 12px; color: $danger; margin-top: 2px; } +.form-alert { + padding: 12px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; + &.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } + &.success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } + ul { margin: 4px 0 0; padding-left: 16px; } +} +.btn-primary, .btn-secondary, .btn-ghost { height: 38px; padding: 0 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; display: inline-flex; align-items: center; justify-content: center; } +.btn-primary { background: $primary; color: #fff; &:hover:not(:disabled) { background: $primary-hover; } &:disabled { opacity: 0.7; cursor: not-allowed; } } +.btn-secondary { background: #fff; border: 1px solid $border-color; color: $text-main; &:hover { background: $bg-light; } } +.btn-ghost { background: transparent; color: $text-muted; &:hover { background: rgba(0,0,0,0.05); color: $text-main; } } + +/* ========================================================================== + MODAL EDITAR USUÁRIO - LAYOUT FINAL + ========================================================================== */ +.modal-card.manage-users-modal { + width: min(1200px, 95vw); + height: min(650px, 90vh); +} + +.manage-body { + padding: 0; + display: grid; + grid-template-columns: 50% 50%; + height: 100%; overflow: hidden; } -@keyframes slideDown { - from { opacity: 0; transform: translateY(-10px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.notifications-head { - padding: 16px; - border-bottom: 1px solid $border-color; - display: flex; align-items: center; justify-content: space-between; +/* Lado Esquerdo */ +.manage-left { + display: flex; flex-direction: column; + border-right: 1px solid $border-color; background: #fff; - - .head-title { - font-weight: 700; font-size: 15px; color: $text-main; - display: flex; align-items: center; gap: 8px; - } - - .badge-count { - background: $danger; color: #fff; - font-size: 10px; padding: 2px 6px; - border-radius: 99px; font-weight: 800; - } - - .see-all { - font-size: 12px; font-weight: 600; color: $primary; - text-decoration: none; - &:hover { text-decoration: underline; } + min-width: 0; +} +.manage-search { + padding: 12px 16px; border-bottom: 1px solid $border-color; + .search-input-wrapper { + position: relative; + i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: $text-muted; } + input { width: 100%; height: 36px; padding-left: 36px; padding-right: 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: $bg-light; font-size: 13px; &:focus { background: #fff; border-color: $primary; outline: none; } } } } -.notifications-body { - max-height: 380px; - overflow-y: auto; +.manage-table-wrap { + flex: 1; overflow-y: auto; position: relative; overflow-x: hidden; } -/* Scrollbar Bonito */ -.custom-scroll::-webkit-scrollbar { width: 5px; } -.custom-scroll::-webkit-scrollbar-track { background: transparent; } -.custom-scroll::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 10px; } -.custom-scroll::-webkit-scrollbar-thumb:hover { background: #d1d5db; } - -.notifications-empty { - padding: 40px 20px; - text-align: center; - color: $text-muted; +.manage-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; - .empty-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.5; } - p { margin: 0; font-size: 13px; font-weight: 500; } -} - -/* Item da Notificação */ -.notification-item { - display: flex; gap: 12px; - padding: 12px 16px; - border-bottom: 1px solid $border-color; - cursor: pointer; - transition: background 0.15s; - position: relative; - - &:hover { background: $bg-light; } - &:last-child { border-bottom: none; } - - /* Estilo Não Lido */ - &.unread { - background: rgba(28, 56, 201, 0.02); - &:hover { background: rgba(28, 56, 201, 0.05); } - - .notif-title { color: $text-main; font-weight: 700; } - .status-dot { - width: 8px; height: 8px; - background: $primary; - border-radius: 50%; - display: block; + thead { + position: sticky; top: 0; background: #fff; z-index: 10; + th { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; + color: $text-muted; font-weight: 600; padding: 10px 16px; + border-bottom: 1px solid $border-color; background: #f9fafb; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + /* Classes de alinhamento no TH */ + &.text-center { text-align: center; } + &.text-end { text-align: right; } + } + } + tbody tr { + border-bottom: 1px solid $border-color; transition: background 0.15s; cursor: pointer; + &:hover { background: #f9fafb; } + &.selected { background: rgba(28, 56, 201, 0.04); border-left: 3px solid $primary; } + td { + padding: 12px 16px; vertical-align: middle; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + + /* Classes de alinhamento no TD */ + &.text-center { text-align: center; overflow: visible; } + &.text-end { text-align: right; overflow: visible; } } } } -.icon-circle { - width: 36px; height: 36px; - border-radius: 10px; - display: grid; place-items: center; - background: #f3f4f6; color: $text-muted; - font-size: 16px; - - &.danger { background: rgba($danger, 0.1); color: $danger; } - &.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); } -} - -.notif-content { flex: 1; min-width: 0; } - -.notif-header { - display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; -} - -.notif-title { font-size: 13px; color: $text-main; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; } -.notif-date { font-size: 11px; color: $text-muted; } - -.notif-desc { - margin: 0; font-size: 12px; color: $text-muted; - line-height: 1.4; -} - -.notif-meta { - margin-top: 4px; font-size: 11px; color: rgba(0,0,0,0.4); - display: flex; align-items: center; gap: 4px; -} - -.notif-status { - display: flex; align-items: center; justify-content: center; - padding-left: 4px; -} - -/* --- USER OPTIONS (Dropdown) --- */ -.user-trigger { - display: flex; align-items: center; gap: 8px; - padding: 4px 8px 4px 4px; - background: #fff; - border: 1px solid $border-color; - border-radius: 99px; - cursor: pointer; - transition: all 0.2s; - - &:hover { border-color: rgba(0,0,0,0.2); box-shadow: 0 2px 8px rgba(0,0,0,0.05); } - - .user-avatar { - width: 32px; height: 32px; - background: $bg-light; - border-radius: 50%; - display: grid; place-items: center; - color: $text-muted; +.user-cell { + display: flex; align-items: center; gap: 10px; max-width: 100%; + .avatar-mini { + width: 32px; height: 32px; min-width: 32px; + background: $primary; color: #fff; border-radius: 50%; + display: grid; place-items: center; font-weight: 600; font-size: 12px; + } + .user-info { + display: flex; flex-direction: column; min-width: 0; + .u-name { font-size: 13px; font-weight: 500; color: $text-main; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .u-email { font-size: 11px; color: $text-muted; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } - .chevron { font-size: 10px; color: $text-muted; margin-right: 4px; } } -.options-menu { - position: relative; /* Essencial: Torna este o ponto de referência */ - display: flex; - align-items: center; -} +.badge-role { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; background: #eef2ff; color: $primary; border: 1px solid rgba(28, 56, 201, 0.1); } +.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: $success; box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); &.off { background: $text-muted; box-shadow: none; opacity: 0.5; } } -.options-dropdown { - position: absolute; - top: 100%; /* Cola no final do botão */ - right: 0; /* Alinha à direita do botão */ - margin-top: 10px; /* Dá o espaçamento visual */ +.actions-group { + display: flex; gap: 4px; + /* Centraliza os botões quando a célula é text-center */ + justify-content: center; - width: 200px; /* Sugestão: um pouco mais largo para caber bem os textos */ - background: #fff; - border-radius: 12px; - box-shadow: 0 10px 30px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04); - padding: 6px; - z-index: 1200; + .btn-action { + width: 28px; height: 28px; border-radius: 6px; border: none; background: transparent; color: $text-muted; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s; cursor: pointer; + &:hover { background: #e5e7eb; color: $text-main; } + &.edit:hover { color: $primary; background: rgba(28, 56, 201, 0.1); } + &.delete:hover { color: $danger; background: rgba(239, 68, 68, 0.1); } + } +} - /* Animação suave (Opcional) */ - transform-origin: top right; - animation: slideDown 0.2s ease-out; +.empty-state-list { padding: 40px 20px; text-align: center; color: $text-muted; i { font-size: 24px; opacity: 0.5; display: block; margin-bottom: 8px; } p { font-size: 13px; margin: 0; } } +.loading-state { padding: 40px; text-align: center; } +.list-footer { + padding: 10px 16px; border-top: 1px solid $border-color; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: $text-muted; background: #fff; + .pagination { display: flex; gap: 4px; } + .icon-only { width: 28px; height: 28px; padding: 0; } +} - .options-item { - width: 100%; text-align: left; - padding: 8px 12px; - background: transparent; border: none; - border-radius: 8px; - font-size: 13px; font-weight: 500; color: $text-main; - display: flex; align-items: center; gap: 10px; - cursor: pointer; +/* Lado Direito */ +.manage-right-wrapper { background: #fff; display: flex; flex-direction: column; height: 100%; overflow-y: auto; } +.manage-right { padding: 32px 40px; flex: 1; display: flex; flex-direction: column; } +.edit-header-info { + display: flex; align-items: center; gap: 16px; margin-bottom: 24px; + .avatar-large { width: 56px; height: 56px; background: linear-gradient(135deg, $primary, #4f46e5); color: #fff; font-size: 20px; font-weight: 600; border-radius: 50%; display: grid; place-items: center; box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2); } + .info-text h4 { margin: 0; font-size: 18px; color: $text-main; } + .info-text span { font-size: 13px; color: $text-muted; } +} + +/* AQUI: Placeholder CORRIGIDO */ +.manage-right.placeholder { + align-items: center; justify-content: center; text-align: center; height: 100%; padding: 32px; + background: #fff; /* Fundo branco explícito */ + cursor: default; /* Remove qualquer cursor de loading herdado */ + + .placeholder-content { + max-width: 320px; + margin: 0 auto; + animation: fadeIn 0.3s ease; + + .placeholder-icon { + font-size: 64px; + color: $text-muted; + opacity: 0.2; /* Estilo marca d'água */ + margin-bottom: 16px; + } - &:hover { background: $bg-light; } - &.danger { color: $danger; &:hover { background: rgba($danger, 0.05); } } + h3 { + font-size: 16px; + font-weight: 600; + color: $text-main; + margin-bottom: 8px; + } + + p { + font-size: 14px; + color: $text-muted; + line-height: 1.5; + font-weight: 400; + margin: 0; + } + } +} + +.refined-form { display: flex; flex-direction: column; gap: 16px; .form-row { display: flex; gap: 16px; &.two-col { display: grid; grid-template-columns: 1fr 1fr; } &.align-end { align-items: end; } } } +.toggle-wrapper { display: flex; align-items: center; gap: 12px; height: 40px; .toggle-status { font-size: 13px; color: $text-muted; font-weight: 500; &.active { color: $success; } } } +.switch { + position: relative; display: inline-block; width: 44px; height: 24px; + input { opacity: 0; width: 0; height: 0; } + .slider { position: absolute; cursor: pointer; inset: 0; background-color: #e5e7eb; transition: .4s; &.round { border-radius: 24px; } &.round:before { border-radius: 50%; } } + .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } + input:checked + .slider { background-color: $success; } + input:checked + .slider:before { transform: translateX(20px); } +} +.manage-actions-footer { margin-top: 32px; padding-top: 20px; border-top: 1px solid $border-color; display: flex; justify-content: flex-end; gap: 12px; } + +@media (max-width: 900px) { + .manage-body { grid-template-columns: 1fr; overflow-y: auto; } + .manage-left { height: 40%; border-right: none; border-bottom: 1px solid $border-color; } + .manage-right-wrapper { height: 60%; } + .manage-right { padding: 20px; } + .form-row.two-col { grid-template-columns: 1fr; } + .manage-table-wrap { overflow-x: auto; } +} + +@media (max-width: 1200px) { + .modal-card.manage-users-modal { + width: min(980px, 92vw); + height: min(600px, 86vh); + } + .modal-card { + width: min(460px, 92vw); + } +} + +@media (max-width: 900px) { + .modal-card.manage-users-modal { + width: min(720px, 92vw); + height: min(560px, 86vh); + } + .modal-card { + width: min(420px, 92vw); + } +} + +@media (max-width: 640px) { + .modal-card.manage-users-modal { + width: min(520px, 94vw); + height: min(520px, 84vh); + } + .modal-card { + width: min(360px, 94vw); + } +} + +/* ========================================================================== + AJUSTES PARA NOTEBOOKS / TELAS MENORES (SOLICITADO) + ========================================================================== */ +/* Adicionado max-width: 1440px e max-height: 800px para pegar notebooks padrão */ +@media (max-width: 1440px), (max-height: 800px) { + /* Modal Genérico (Novo Usuário) - Compacto */ + .modal-card { + /* Limita a altura para não sair da tela e habilita scroll interno */ + max-height: 95vh; } - .divider { height: 1px; background: $border-color; margin: 4px 0; } -} - -/* --- MODAL NOVO USUÁRIO --- */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.35); - z-index: 1400; -} - -.modal-card { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(720px, calc(100vw - 32px)); - background: #fff; - border-radius: 18px; - border: 1px solid rgba(15, 23, 42, 0.08); - box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25); - z-index: 1450; - display: flex; - flex-direction: column; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 20px; - border-bottom: 1px solid $border-color; - - h3 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: $text-main; - } -} - -.modal-body { - padding: 18px 20px 10px; -} - -.form-alert { - border-radius: 10px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; - line-height: 1.4; - ul { - margin: 6px 0 0; - padding-left: 18px; - } - &.error { - background: rgba($danger, 0.08); - color: darken($danger, 5%); - border: 1px solid rgba($danger, 0.25); - } - &.success { - background: rgba(#22c55e, 0.1); - color: #15803d; - border: 1px solid rgba(#22c55e, 0.25); - } -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - padding: 0 20px 18px; -} - -.close-x { - width: 34px; - height: 34px; -} - -.modal-card .user-form { - display: grid; - gap: 14px; -} - -.modal-card .form-field { - display: grid; - gap: 6px; - - label { - font-size: 13px; - font-weight: 600; - color: $text-main; + .modal-header { + padding: 12px 16px; + h3 { font-size: 15px; } } - input, - select { - height: 42px; - border-radius: 10px; - border: 1.5px solid #d7dbe6; - padding: 0 12px; - font-size: 14px; - color: $text-main; - background: #fff; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - } - - input:focus, - select:focus { - outline: none; - border-color: #2f6bff; - box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); - } - - &.has-error input, - &.has-error select { - border-color: $danger; - box-shadow: 0 0 0 3px rgba($danger, 0.12); - } -} - -.field-error { - font-size: 11px; - color: $danger; -} - -.modal-card .btn-primary, -.modal-card .btn-secondary { - height: 40px; - min-width: 110px; - border-radius: 10px; - border: none; - font-weight: 600; - font-size: 14px; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; -} - -.modal-card .btn-primary { - background: #2f6bff; - color: #fff; - box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2); -} - -.modal-card .btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; - box-shadow: none; -} - -.modal-card .btn-secondary { - background: #e2e8f0; - color: $text-main; -} - -.modal-card .btn-primary:hover, -.modal-card .btn-secondary:hover { - transform: translateY(-1px); -} - -@media (max-width: 768px) { - .modal-card { - width: min(520px, calc(100vw - 24px)); + .modal-body { + padding: 16px; + /* Essencial para que o conteúdo não estoure o modal quando encolhido */ + overflow-y: auto; } .modal-actions { - flex-direction: column; - align-items: stretch; + padding: 12px 16px; } - .modal-card .btn-primary, - .modal-card .btn-secondary { - width: 100%; + /* Compactar formulários */ + .form-field { + margin-bottom: 12px; + label { font-size: 12px; } + input, select { + height: 36px; + font-size: 13px; + } + } + + .form-alert { + padding: 10px; + margin-bottom: 12px; + font-size: 12px; + } + + /* Modal de Gestão (Editar Usuário) - MUITO COMPACTO (Solicitado) */ + .modal-card.manage-users-modal { + /* Reduzido consideravelmente */ + height: min(500px, 80vh); + width: min(900px, 92vw); + } + + .manage-right { + padding: 24px; + } + + .edit-header-info { + margin-bottom: 16px; + .avatar-large { + width: 48px; + height: 48px; + font-size: 16px; + } + .info-text h4 { + font-size: 16px; + } + } + + .refined-form { + gap: 12px; } } -/* --- MENU LATERAL --- */ -.menu-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.25); - z-index: 1050; -} +/* SIDE MENU */ +.menu-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1050; } .side-menu { - position: fixed; - top: 0; - left: 0; - height: 100vh; - width: 280px; - background: #fff; - box-shadow: 8px 0 24px rgba(0, 0, 0, 0.12); - transform: translateX(-100%); - transition: transform 0.25s ease; - z-index: 1100; - display: flex; - flex-direction: column; + position: fixed; top: 0; left: 0; height: 100vh; width: 260px; background: #fff; box-shadow: 4px 0 20px rgba(0,0,0,0.1); transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 1100; display: flex; flex-direction: column; + &.open { transform: translateX(0); } } - -.side-menu.open { - transform: translateX(0); -} - -.side-menu-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 16px; - border-bottom: 1px solid $border-color; -} - -.side-logo { - display: inline-flex; - align-items: center; - gap: 10px; - text-decoration: none; - color: #111827; -} - -.side-logo-icon { - width: 34px; - height: 34px; - border-radius: 50%; - background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff); - color: #fff; - display: grid; - place-items: center; - font-size: 16px; - box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2); -} - -.side-logo-text { - font-size: 16px; - font-weight: 700; - letter-spacing: -0.4px; - .highlight { - background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - } -} - +.side-menu-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid $border-color; } .close-btn { background: transparent; border: none; - width: 36px; - height: 36px; - border-radius: 50%; + box-shadow: none; + width: 32px; + height: 32px; + border-radius: 0; display: grid; place-items: center; cursor: pointer; color: $text-main; - transition: background 0.2s; - &:hover { background: rgba(0, 0, 0, 0.06); } + transition: color 0.2s ease; + &:hover { color: $primary; } } - -.side-menu-body { - padding: 12px; - display: flex; - flex-direction: column; - gap: 6px; -} - +.side-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: $text-main; font-weight: 700; .side-logo-icon { width: 32px; height: 32px; background: $primary; color: #fff; border-radius: 50%; display: grid; place-items: center; } } +.side-menu-body { padding: 16px; display: flex; flex-direction: column; gap: 4px; } .side-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 10px; - text-decoration: none; - color: $text-main; - font-weight: 600; - font-size: 14px; - transition: background 0.2s; - + padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px; &:hover { background: $bg-light; } - &.active { background: rgba(28, 56, 201, 0.1); color: $primary; } + &.active { background: rgba(28, 56, 201, 0.08); color: $primary; } } diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 41c9980..7825a8e 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -6,13 +6,14 @@ import { filter } from 'rxjs/operators'; import { AuthService } from '../../services/auth.service'; import { NotificationsService, NotificationDto } from '../../services/notifications.service'; import { UsersService, CreateUserPayload, ApiFieldError } from '../../services/users.service'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; +import { CustomSelectComponent } from '../custom-select/custom-select'; @Component({ selector: 'app-header', standalone: true, - imports: [RouterLink, CommonModule, ReactiveFormsModule], + imports: [RouterLink, CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent], templateUrl: './header.html', styleUrls: ['./header.scss'], }) @@ -23,6 +24,7 @@ export class Header { optionsOpen = false; notificationsOpen = false; createUserOpen = false; + manageUsersOpen = false; isLoggedHeader = false; isHome = false; isAdmin = false; @@ -37,6 +39,25 @@ export class Header { createUserErrors: ApiFieldError[] = []; createUserForbidden = false; createUserSuccess = ''; + readonly permissionOptions = [ + { value: 'admin', label: 'Administrador' }, + { value: 'gestor', label: 'Gestor' }, + ]; + + manageUsersLoading = false; + manageUsersErrors: ApiFieldError[] = []; + manageUsersSuccess = ''; + manageUsers: any[] = []; + manageSearch = ''; + managePage = 1; + managePageSize = 10; + manageTotal = 0; + + editUserForm: FormGroup; + editUserSubmitting = false; + editUserErrors: ApiFieldError[] = []; + editUserSuccess = ''; + editUserTarget: any | null = null; private readonly loggedPrefixes = [ '/geral', @@ -45,9 +66,10 @@ export class Header { '/dadosusuarios', '/vigencia', '/trocanumero', - '/dashboard', // ✅ ADICIONADO + '/dashboard', '/notificacoes', '/novo-usuario', + '/chips-controle-recebidos', ]; constructor( @@ -69,11 +91,18 @@ export class Header { { validators: this.passwordsMatchValidator } ); - // ✅ resolve no carregamento inicial + this.editUserForm = this.fb.group({ + nome: [''], + email: [''], + senha: [''], + confirmarSenha: [''], + permissao: [''], + ativo: [true], + }); + this.syncHeaderState(this.router.url); this.syncPermissions(); - // ✅ resolve em toda navegação this.router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((event) => { @@ -139,6 +168,19 @@ export class Header { this.resetCreateUserState(); } + openManageUsersModal() { + if (!this.isAdmin) return; + this.manageUsersOpen = true; + this.closeOptions(); + this.resetManageUsersState(); + this.fetchManageUsers(1); + } + + closeManageUsersModal() { + this.manageUsersOpen = false; + this.resetManageUsersState(); + } + toggleNotifications() { this.notificationsOpen = !this.notificationsOpen; if (this.notificationsOpen) { @@ -192,6 +234,7 @@ export class Header { this.closeOptions(); this.closeNotifications(); this.closeCreateUserModal(); + this.closeManageUsersModal(); } acknowledgeNotification(notification: NotificationDto) { @@ -268,6 +311,7 @@ export class Header { } this.createUserSubmitting = true; + this.setCreateFormDisabled(true); this.createUserErrors = []; this.createUserForbidden = false; this.createUserSuccess = ''; @@ -276,11 +320,13 @@ export class Header { this.usersService.create(payload).subscribe({ next: (created) => { this.createUserSubmitting = false; + this.setCreateFormDisabled(false); this.createUserSuccess = `Usuario ${created.nome} criado com sucesso.`; this.createUserForm.reset({ permissao: '' }); }, error: (err: HttpErrorResponse) => { this.createUserSubmitting = false; + this.setCreateFormDisabled(false); if (err.status === 401 || err.status === 403) { this.createUserForbidden = true; return; @@ -298,6 +344,173 @@ export class Header { }); } + fetchManageUsers(goToPage?: number) { + if (goToPage) this.managePage = goToPage; + this.manageUsersLoading = true; + this.manageUsersErrors = []; + this.manageUsersSuccess = ''; + + this.usersService + .list({ + search: this.manageSearch?.trim() || undefined, + page: this.managePage, + pageSize: this.managePageSize, + }) + .subscribe({ + next: (res) => { + this.manageUsers = res.items || []; + this.manageTotal = res.total || 0; + this.manageUsersLoading = false; + }, + error: () => { + this.manageUsers = []; + this.manageTotal = 0; + this.manageUsersLoading = false; + }, + }); + } + + onManageSearch() { + this.managePage = 1; + this.fetchManageUsers(); + } + + clearManageSearch() { + this.manageSearch = ''; + this.managePage = 1; + this.fetchManageUsers(); + } + + manageGoToPage(p: number) { + this.managePage = p; + this.fetchManageUsers(); + } + + get manageTotalPages(): number { + return Math.max(1, Math.ceil((this.manageTotal || 0) / (this.managePageSize || 10))); + } + + get managePageNumbers(): number[] { + const total = this.manageTotalPages; + const current = this.managePage; + const max = 5; + let start = Math.max(1, current - 2); + let end = Math.min(total, start + (max - 1)); + start = Math.max(1, end - (max - 1)); + const pages: number[] = []; + for (let i = start; i <= end; i++) pages.push(i); + return pages; + } + + openEditUser(user: any) { + this.editUserTarget = null; + this.editUserErrors = []; + this.editUserSuccess = ''; + this.editUserSubmitting = false; + this.setEditFormDisabled(false); + this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true }); + + this.usersService.getById(user.id).subscribe({ + next: (full) => { + this.editUserTarget = full; + this.editUserForm.reset({ + nome: full.nome ?? '', + email: full.email ?? '', + senha: '', + confirmarSenha: '', + permissao: full.permissao ?? '', + ativo: full.ativo ?? true, + }); + }, + error: () => { + this.editUserErrors = [{ message: 'Erro ao carregar usuario.' }]; + }, + }); + } + + cancelEditUser() { + this.editUserTarget = null; + this.editUserErrors = []; + this.editUserSuccess = ''; + this.editUserSubmitting = false; + this.setEditFormDisabled(false); + this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true }); + } + + submitEditUser() { + if (this.editUserSubmitting || !this.editUserTarget) return; + + const payload: any = {}; + const nome = (this.editUserForm.get('nome')?.value || '').toString().trim(); + const email = (this.editUserForm.get('email')?.value || '').toString().trim(); + const permissao = (this.editUserForm.get('permissao')?.value || '').toString().trim(); + const ativo = !!this.editUserForm.get('ativo')?.value; + + if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome; + if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email; + if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) payload.permissao = permissao; + if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo; + + const senha = (this.editUserForm.get('senha')?.value || '').toString(); + const confirmar = (this.editUserForm.get('confirmarSenha')?.value || '').toString(); + if (senha || confirmar) { + if (!senha || !confirmar) { + this.editUserErrors = [{ message: 'Para alterar a senha, preencha senha e confirmacao.' }]; + return; + } + if (senha.length < 6) { + this.editUserErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }]; + return; + } + if (senha !== confirmar) { + this.editUserErrors = [{ message: 'As senhas nao conferem.' }]; + return; + } + payload.senha = senha; + payload.confirmarSenha = confirmar; + } + + if (Object.keys(payload).length === 0) { + this.editUserErrors = [{ message: 'Nenhuma alteracao detectada.' }]; + return; + } + + this.editUserSubmitting = true; + this.setEditFormDisabled(true); + this.editUserErrors = []; + this.editUserSuccess = ''; + + this.usersService.update(this.editUserTarget.id, payload).subscribe({ + next: (updated) => { + this.editUserSubmitting = false; + this.setEditFormDisabled(false); + this.editUserSuccess = `Usuario ${updated.nome} atualizado.`; + this.editUserTarget = updated; + this.fetchManageUsers(this.managePage); + }, + error: (err: HttpErrorResponse) => { + this.editUserSubmitting = false; + this.setEditFormDisabled(false); + const apiErrors = err?.error?.errors; + if (Array.isArray(apiErrors)) { + this.editUserErrors = apiErrors.map((e: any) => ({ + field: e?.field, + message: e?.message || 'Erro ao atualizar usuario.', + })); + } else { + this.editUserErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }]; + } + }, + }); + } + + confirmDeleteUser(user: any) { + if (!confirm(`Excluir usuario ${user.nome}?`)) return; + this.usersService.update(user.id, { ativo: false }).subscribe({ + next: () => this.fetchManageUsers(this.managePage), + }); + } + hasFieldError(field: string): boolean { return this.getFieldErrors(field).length > 0; } @@ -318,13 +531,35 @@ export class Header { this.createUserForbidden = false; this.createUserSuccess = ''; this.createUserSubmitting = false; + this.setCreateFormDisabled(false); this.createUserForm.reset({ permissao: '' }); } + private resetManageUsersState() { + this.manageUsersErrors = []; + this.manageUsersSuccess = ''; + this.manageUsersLoading = false; + this.manageUsers = []; + this.manageSearch = ''; + this.managePage = 1; + this.manageTotal = 0; + this.cancelEditUser(); + } + private normalizeField(field?: string | null): string { return (field || '').trim().toLowerCase(); } + private setCreateFormDisabled(disabled: boolean) { + if (disabled) this.createUserForm.disable({ emitEvent: false }); + else this.createUserForm.enable({ emitEvent: false }); + } + + private setEditFormDisabled(disabled: boolean) { + if (disabled) this.editUserForm.disable({ emitEvent: false }); + else this.editUserForm.enable({ emitEvent: false }); + } + private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { const senha = group.get('senha')?.value; const confirmar = group.get('confirmarSenha')?.value; @@ -332,5 +567,3 @@ export class Header { return senha === confirmar ? null : { passwordsMismatch: true }; } } - - diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html new file mode 100644 index 0000000..092ce92 --- /dev/null +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html @@ -0,0 +1,485 @@ +
+
+
+ LineGestao + +
+
{{ toastMessage }}
+
+
+ +
+ + + + + +
+
+ + +
+
+
+ Gestão de Chips +
+ +
+
Chips Virgens e Recebidos
+ Importação e acompanhamento +
+ +
+
+ +
+ + +
+ +
+
+ +
+ +
+ + + +
+
+ +
+
+ + + + + + + + +
+ +
+ Itens por pág: +
+ + + +
+
+
+
+ + +
+ + +
+
+ +
+ +
+ Nenhum registro encontrado. +
+ +
+
+
+
+
{{ g.observacao }}
+
+ {{ g.total }} Registros +
+
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + +
ITEMNÚMERO DO CHIPOBSERVAÇÕESAÇÕES
{{ r.item }}{{ display(r.numeroDoChip) }}{{ display(r.observacoes) }} +
+ +
+
+
+
+ +
+
+ +
+
+ Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos +
+ +
+
+
+ + + +
+
+ +
+ +
+ Nenhum registro encontrado. +
+ +
+
+
+
+
{{ g.conteudo }}
+
+ {{ g.total }} Registros +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
ANONOTA FISCALDATA DA NFQTD.CONTEÚDO DA NFDATA DO RECEBIMENTOAÇÕES
{{ display(r.ano) }}{{ display(r.notaFiscal) }}{{ formatDate(r.dataDaNf) }}{{ display(r.quantidade) }}{{ display(r.conteudoDaNf) }}{{ formatDate(r.dataDoRecebimento) }} +
+ +
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ANONOTA FISCALCHIPSERIALNÚMERO DA LINHAVALOR UNIT.VALOR DA NFAÇÕES
{{ display(r.ano) }}{{ display(r.notaFiscal) }}{{ display(r.chip) }}{{ display(r.serial) }}{{ display(r.numeroDaLinha) }}{{ formatMoney(r.valorUnit) }}{{ formatMoney(r.valorDaNf) }} +
+ +
+
+
+
+
+
+
+
+ +
+
+ Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos +
+ +
+
+
+
+ + +
+
+
+ + + + + + + + + + + diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss new file mode 100644 index 0000000..b2ee8cf --- /dev/null +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss @@ -0,0 +1,519 @@ +/* ========================================================== */ +/* VARIÁVEIS E BASE */ +/* ========================================================== */ +:host { + --brand: #E33DCF; + --blue: #030FAA; + --text: #111214; + --muted: rgba(17, 18, 20, 0.65); + + --success-bg: rgba(25, 135, 84, 0.1); + --success-text: #198754; + --warn-bg: rgba(255, 193, 7, 0.15); + --warn-text: #b58100; + + --radius-xl: 22px; + --radius-lg: 16px; + --shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10); + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-border: 1px solid rgba(227, 61, 207, 0.16); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); + box-sizing: border-box; +} + +/* fallback: garante que o footer global não apareça nesta rota */ +:host ::ng-deep app-footer { + display: none !important; +} + +/* ========================================================== */ +/* LAYOUT PRINCIPAL (travado para não aparecer footer global) */ +/* ========================================================== */ +.chips-page { + min-height: 100vh; /* ✅ igual Mureg: ocupa 100% da tela */ + overflow-y: auto; + padding: 0 12px; + display: flex; + align-items: flex-start; + justify-content: center; + position: relative; + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); + + &::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(255, 255, 255, 0.25); + } +} + +.page-blob { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(34px); + opacity: 0.55; + z-index: 0; + background: radial-gradient(circle at 30% 30%, rgba(227,61,207,0.55), rgba(227,61,207,0.06)); + animation: floaty 10s ease-in-out infinite; + + &.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; } + &.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; } + &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; } + &.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: .45; } +} + +@keyframes floaty { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(18px, 10px) scale(1.03); } + 100% { transform: translate(0, 0) scale(1); } +} + +.container-chips { + width: 100%; + max-width: 1240px; + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 24px; /* ✅ remove aquele "200px" que ajudava o footer global a aparecer */ + display: flex; + min-height: 0; +} + +/* ========================================================== */ +/* CARD PRINCIPAL */ +/* ========================================================== */ +.chips-card { + width: 100%; + border-radius: var(--radius-xl); + overflow-y: auto; + background: var(--glass-bg); + border: var(--glass-border); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-card); + position: relative; + + display: flex; + flex-direction: column; + + height: auto; /* ✅ ocupa a altura útil (igual sensação do Mureg) */ + min-height: 70vh; + + &::before { + content: ''; + position: absolute; + inset: 1px; + border-radius: calc(var(--radius-xl) - 1px); + pointer-events: none; + border: 1px solid rgba(255, 255, 255, 0.65); + opacity: 0.75; + } +} + +.chips-header { + padding: 16px 24px; + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2)); + flex-shrink: 0; +} + +.header-row-top { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + text-align: center; + + .title-badge { justify-self: center; margin-bottom: 8px; } + } +} + +.title-badge { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(227, 61, 207, 0.22); + backdrop-filter: blur(10px); + color: var(--text); + font-size: 13px; + font-weight: 800; + + i { color: var(--brand); } +} + +.header-title { + justify-self: center; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.title { + font-size: 24px; + font-weight: 950; + letter-spacing: -0.3px; + color: var(--text); + margin-top: 10px; + margin-bottom: 0; +} + +.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; } + +/* ========================================================== */ +/* TABS E FILTROS */ +/* ========================================================== */ +.tab-row { display: flex; gap: 8px; justify-content: center; margin-top: 16px; } + +.tab-btn { + border: 1px solid rgba(17, 18, 20, 0.1); + background: rgba(255, 255, 255, 0.5); + color: var(--muted); + padding: 8px 16px; + border-radius: 12px; + font-weight: 800; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + + &:hover { color: var(--text); background: #fff; } + + &.active { + color: var(--brand); + border-color: rgba(227, 61, 207, 0.35); + background: #fff; + box-shadow: 0 6px 16px rgba(227, 61, 207, 0.15); + } +} + +.controls { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin-top: 16px; +} + +/* Pesquisa */ +.search-group { + max-width: 300px; + border-radius: 12px; + overflow-y: auto; + display: flex; + align-items: stretch; + background: #fff; + border: 1px solid rgba(17, 18, 20, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; + + &:focus-within { + border-color: var(--brand); + box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); + transform: translateY(-1px); + } + + .input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; } + .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &:focus { outline: none; } } + .btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; cursor: pointer; &:hover { color: #dc3545; } } +} + +/* Filtros */ +.filters-row { display: flex; gap: 16px; align-items: center; margin-top: 12px; justify-content: center; } +.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; } +.filter-tab { + border: none; background: transparent; padding: 6px 12px; border-radius: 8px; font-size: 0.8rem; font-weight: 800; color: var(--muted); transition: all 0.2s; + &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } +} + +/* Select */ +.select-wrapper { position: relative; display: inline-block; min-width: 90px; } +.select-glass { + appearance: none; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(17, 18, 20, 0.15); + border-radius: 12px; + color: var(--blue); + font-weight: 800; + font-size: 0.9rem; + padding: 8px 36px 8px 14px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + + &:hover { background: #fff; border-color: var(--blue); } +} + +/* ========================================================== */ +/* BODY (scroll interno igual Mureg) */ +/* ========================================================== */ +.chips-body { + padding: 0; + background: transparent; + flex: 1; + min-height: 70vh; + overflow: visible; + display: flex; + flex-direction: column; +} + +.content-scroll { + padding: 16px; + overflow: visible; + height: auto; + flex: 1; + min-height: 0; +} + +/* Lists / Groups */ +.group-list { display: flex; flex-direction: column; gap: 12px; } + +.group-card { + background: #fff; + border-radius: 16px; + border: 1px solid rgba(17, 18, 20, 0.08); + overflow: hidden; + transition: all 0.3s ease; + + &:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.1); } + &.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227, 61, 207, 0.12); } +} + +.group-header { + padding: 16px 24px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + background: linear-gradient(180deg, #fff, #fdfdfd); + + &:hover .group-toggle-icon { color: var(--brand); } +} + +.group-info { display: flex; flex-direction: column; gap: 6px; } +.group-title { margin: 0; font-weight: 800; color: var(--text); font-size: 1rem; } +.group-badges { display: flex; gap: 8px; } + +.badge-pill { + font-size: 0.7rem; + padding: 4px 10px; + border-radius: 999px; + font-weight: 800; + text-transform: uppercase; + background: rgba(3, 15, 170, 0.1); + color: var(--blue); +} + +.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; } +.group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); } + +.group-body-content { + border-top: 1px solid rgba(17, 18, 20, 0.06); + background: #fbfbfc; + animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); + padding: 0; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.empty-group { + background: rgba(255,255,255,0.7); + border: 1px dashed rgba(17,18,20,0.12); + border-radius: 16px; + padding: 18px; + text-align: center; + font-weight: 800; + color: var(--muted); +} + +/* Table */ +.table-wrap { overflow-x: auto; overflow-y: visible; height: auto; min-height: 0; } +.inner-table-wrap { max-height: none; } + +.table-section { padding: 6px 10px 12px; } +.table-section + .table-section { border-top: 1px dashed rgba(17, 18, 20, 0.12); margin-top: 8px; } +.section-label { + font-size: 0.7rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + padding: 8px 6px; +} + +.table-modern { + width: 100%; + border-collapse: separate; + border-spacing: 0; + + thead th { + position: sticky; + top: 0; + z-index: 10; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-bottom: 2px solid rgba(227, 61, 207, 0.15); + padding: 12px; + color: rgba(17, 18, 20, 0.7); + font-size: 0.8rem; + font-weight: 950; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + text-align: center !important; + + &:hover { color: var(--brand); } + } + + tbody tr { + transition: background-color 0.2s; + border-bottom: 1px solid rgba(17, 18, 20, 0.05); + + &:hover { background-color: rgba(227, 61, 207, 0.05); } + + td { border-bottom: 1px solid rgba(17, 18, 20, 0.04); } + } + + td { + padding: 12px; + vertical-align: middle; + white-space: nowrap; + font-size: 0.875rem; + color: var(--text); + text-align: center !important; + } +} + +.sort-caret { font-size: 0.75rem; color: rgba(17, 18, 20, 0.35); &.active { color: var(--brand); } } +.th-content { display: inline-flex; align-items: center; gap: 6px; justify-content: center; } + +.text-brand { color: var(--brand) !important; } +.font-monospace { font-family: 'JetBrains Mono', monospace; letter-spacing: -0.5px; } +.td-clip { max-width: 260px; overflow-y: auto; text-overflow: ellipsis; } +.row-clickable { cursor: pointer; } + +/* Paginação interna */ +.table-pagination { + padding: 12px 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.page-info { + font-weight: 800; + color: var(--muted); +} + +/* Ações na tabela (estilo Mureg) */ +.action-group { display: flex; justify-content: center; gap: 6px; } +.action-group .btn-icon { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(17,18,20,0.5); + transition: all 0.2s; + cursor: pointer; + &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } + &.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); } +} + +/* ========================================================== */ +/* FOOTER interno (igual Mureg) */ +/* ========================================================== */ +.chips-footer { + padding: 14px 24px; + border-top: 1px solid rgba(17, 18, 20, 0.06); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + flex-shrink: 0; + + @media (max-width: 768px) { + justify-content: center; + text-align: center; + } +} + +.pagination-modern .page-link { + color: var(--blue); + font-weight: 900; + border-radius: 10px; + border: 1px solid rgba(17,18,20,0.1); + background: rgba(255,255,255,0.6); + margin: 0 2px; + + &:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); } +} +.pagination-modern .page-item.active .page-link { + background-color: var(--blue); + border-color: var(--blue); + color: #fff; +} + +/* ========================================================== */ +/* MODAIS (mantidos) */ +/* ========================================================== */ +.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } +.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } +.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow-y: auto; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } +.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } +.modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; } +@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } + +.modal-header { + padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; + display: flex; justify-content: space-between; align-items: center; + + .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } + .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; + &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } + } + .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } +} + +.modal-body { padding: 20px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } + +.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } +div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow-y: auto; height: auto; display: flex; flex-direction: column; } +div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; } +div.box-body { padding: 16px; } + +.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0; } +.info-item { + display: flex; flex-direction: column; align-items: center; text-align: center; + padding: 6px 8px; background: rgba(245, 245, 247, 0.5); border-radius: 10px; border: 1px solid rgba(0,0,0,0.03); + &.span-2 { grid-column: span 2; } + .lbl { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; } + .val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; } +} + diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts new file mode 100644 index 0000000..769dee2 --- /dev/null +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts @@ -0,0 +1,440 @@ +import { Component, Inject, PLATFORM_ID, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir } from '../../services/chips-controle.service'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; + +// Interface para o Agrupamento +interface ChipGroup { + observacao: string; + total: number; + items: ChipVirgemListDto[]; +} + +interface ControleGroup { + conteudo: string; + total: number; + items: ControleRecebidoListDto[]; +} + +type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes'; +type ControleSortKey = + | 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha' + | 'valorUnit' | 'valorDaNf' | 'dataDaNf' | 'dataDoRecebimento' | 'quantidade' | 'isResumo'; + +@Component({ + selector: 'app-chips-controle-recebidos', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './chips-controle-recebidos.html', + styleUrls: ['./chips-controle-recebidos.scss'] +}) +export class ChipsControleRecebidos implements OnInit, OnDestroy { + activeTab: 'chips' | 'controle' = 'chips'; + + // --- Chips --- + chipsRows: ChipVirgemListDto[] = []; + chipsGroups: ChipGroup[] = []; + pagedChipsGroups: ChipGroup[] = []; + expandedGroupObservacao: string | null = null; + + chipsLoading = false; + chipsSearch = ''; + chipsPage = 1; + chipsPageSize = 10; + chipsTotal = 0; + chipsSortBy: ChipsSortKey = 'item'; + chipsSortDir: SortDir = 'asc'; + private chipsSearchTimer: any = null; + + // --- Controle --- + controleRows: ControleRecebidoListDto[] = []; + controleGroups: ControleGroup[] = []; + pagedControleGroups: ControleGroup[] = []; + expandedControleConteudo: string | null = null; + controleLoading = false; + controleSearch = ''; + controlePage = 1; + controlePageSize = 10; + controleTotal = 0; + controleSortBy: ControleSortKey = 'ano'; + controleSortDir: SortDir = 'desc'; + controleAno: number | '' = ''; + controleResumo: '' | 'true' | 'false' = ''; + private controleSearchTimer: any = null; + + // --- Opções --- + pageSizeOptions = [10, 20, 50, 100]; + anoOptions = [ + { label: 'Todos', value: '' }, + { label: '2022', value: 2022 }, + { label: '2023', value: 2023 }, + { label: '2024', value: 2024 }, + { label: '2025', value: 2025 } + ]; + + toastOpen = false; + toastMessage = ''; + toastType: 'success' | 'danger' = 'success'; + private toastTimer: any = null; + + chipDetailOpen = false; + chipDetailLoading = false; + chipDetailData: ChipVirgemListDto | null = null; + + controleDetailOpen = false; + controleDetailLoading = false; + controleDetailData: ControleRecebidoListDto | null = null; + + constructor( + @Inject(PLATFORM_ID) private platformId: object, + private service: ChipsControleService, + private http: HttpClient + ) {} + + ngOnInit(): void { + if (!isPlatformBrowser(this.platformId)) return; + this.fetchChips(); + this.fetchControle(); + } + + ngOnDestroy(): void { + if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer); + if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer); + if (this.toastTimer) clearTimeout(this.toastTimer); + } + + setTab(tab: 'chips' | 'controle') { + this.activeTab = tab; + + if (tab === 'chips') { + this.expandedGroupObservacao = null; + this.applyChipsPagination(); + this.closeControleDetail(); + } else { + this.expandedControleConteudo = null; + this.applyControlePagination(); + this.closeChipDetail(); + } + } + + // ===================== + // Chips Virgens + // ===================== + fetchChips() { + this.chipsLoading = true; + + this.service.getChipsVirgens({ + search: this.chipsSearch, + page: 1, + pageSize: 5000, + sortBy: this.chipsSortBy, + sortDir: this.chipsSortDir + }).subscribe({ + next: (res) => { + const items = (res as any)?.items ?? []; + this.chipsRows = items.map((x: any, idx: number) => this.normalizeChip(x, idx)); + this.buildChipsGroups(); + this.chipsTotal = this.chipsGroups.length; + this.applyChipsPagination(); + this.chipsLoading = false; + }, + error: () => { + this.chipsLoading = false; + this.showToast('Erro ao carregar Chips Virgens.', 'danger'); + } + }); + } + + private buildChipsGroups() { + const groupsMap = new Map(); + + this.chipsRows.forEach(row => { + const key = row.observacoes && row.observacoes.trim() !== '' + ? row.observacoes.trim() + : '(Sem Observações)'; + + if (!groupsMap.has(key)) groupsMap.set(key, []); + groupsMap.get(key)?.push(row); + }); + + this.chipsGroups = []; + groupsMap.forEach((items, key) => { + this.chipsGroups.push({ observacao: key, total: items.length, items }); + }); + + this.chipsGroups.sort((a, b) => a.observacao.localeCompare(b.observacao)); + this.expandedGroupObservacao = null; + } + + private applyChipsPagination() { + const start = (this.chipsPage - 1) * this.chipsPageSize; + const end = start + this.chipsPageSize; + this.pagedChipsGroups = this.chipsGroups.slice(start, end); + + if (this.expandedGroupObservacao && !this.pagedChipsGroups.some(g => g.observacao === this.expandedGroupObservacao)) { + this.expandedGroupObservacao = null; + } + } + + toggleGroup(obs: string) { + this.expandedGroupObservacao = this.expandedGroupObservacao === obs ? null : obs; + } + + openChipDetail(row: ChipVirgemListDto) { + if (!row?.id) return; + this.chipDetailOpen = true; + this.chipDetailLoading = true; + this.chipDetailData = null; + + this.service.getChipVirgemById(row.id).subscribe({ + next: (data) => { + this.chipDetailData = data ?? row; + this.chipDetailLoading = false; + }, + error: () => { + this.chipDetailLoading = false; + this.chipDetailData = row; + } + }); + } + + closeChipDetail() { + this.chipDetailOpen = false; + this.chipDetailLoading = false; + this.chipDetailData = null; + } + + onChipsSearch() { + if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer); + this.chipsSearchTimer = setTimeout(() => { + this.chipsPage = 1; + this.fetchChips(); + }, 300); + } + + clearChipsSearch() { + this.chipsSearch = ''; + this.chipsPage = 1; + this.fetchChips(); + } + + onChipsPageSizeChange() { + this.chipsPage = 1; + this.applyChipsPagination(); + } + + // ===================== + // Controle Recebidos + // ===================== + fetchControle() { + this.controleLoading = true; + + this.service.getControleRecebidos({ + search: this.controleSearch, + page: 1, + pageSize: 5000, + sortBy: this.controleSortBy, + sortDir: this.controleSortDir, + ano: this.controleAno, + isResumo: this.controleResumo + }).subscribe({ + next: (res) => { + const items = (res as any)?.items ?? []; + this.controleRows = items.map((x: any, idx: number) => this.normalizeControle(x, idx)); + this.buildControleGroups(); + this.controleTotal = this.controleGroups.length; + this.applyControlePagination(); + this.controleLoading = false; + }, + error: () => { + this.controleLoading = false; + this.showToast('Erro ao carregar Controle.', 'danger'); + } + }); + } + + onControleSearch() { + if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer); + this.controleSearchTimer = setTimeout(() => { + this.controlePage = 1; + this.fetchControle(); + }, 300); + } + + clearControleSearch() { + this.controleSearch = ''; + this.controlePage = 1; + this.fetchControle(); + } + + setControleSort(key: ControleSortKey) { + if (this.controleSortBy === key) { + this.controleSortDir = this.controleSortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.controleSortBy = key; + this.controleSortDir = 'asc'; + } + this.controlePage = 1; + this.fetchControle(); + } + + onControlePageSizeChange() { + this.controlePage = 1; + this.applyControlePagination(); + } + + onControleAnoChange() { + this.controlePage = 1; + this.fetchControle(); + } + + setControleResumo(val: '' | 'true' | 'false') { + this.controleResumo = val; + this.controlePage = 1; + this.fetchControle(); + } + + private buildControleGroups() { + const groupsMap = new Map(); + + this.controleRows.forEach(row => { + const key = row.conteudoDaNf && row.conteudoDaNf.trim() !== '' + ? row.conteudoDaNf.trim() + : '(Sem Conteúdo)'; + + if (!groupsMap.has(key)) groupsMap.set(key, []); + groupsMap.get(key)?.push(row); + }); + + this.controleGroups = []; + groupsMap.forEach((items, key) => { + this.controleGroups.push({ conteudo: key, total: items.length, items }); + }); + + this.controleGroups.sort((a, b) => a.conteudo.localeCompare(b.conteudo)); + this.expandedControleConteudo = null; + } + + private applyControlePagination() { + const start = (this.controlePage - 1) * this.controlePageSize; + const end = start + this.controlePageSize; + this.pagedControleGroups = this.controleGroups.slice(start, end); + + if (this.expandedControleConteudo && !this.pagedControleGroups.some(g => g.conteudo === this.expandedControleConteudo)) { + this.expandedControleConteudo = null; + } + } + + toggleControleGroup(conteudo: string) { + this.expandedControleConteudo = this.expandedControleConteudo === conteudo ? null : conteudo; + } + + openControleDetail(row: ControleRecebidoListDto) { + if (!row?.id) return; + this.controleDetailOpen = true; + this.controleDetailLoading = true; + this.controleDetailData = null; + + this.service.getControleRecebidoById(row.id).subscribe({ + next: (data) => { + this.controleDetailData = data ?? row; + this.controleDetailLoading = false; + }, + error: () => { + this.controleDetailLoading = false; + this.controleDetailData = row; + } + }); + } + + closeControleDetail() { + this.controleDetailOpen = false; + this.controleDetailLoading = false; + this.controleDetailData = null; + } + + // ===================== + // Paginação e Helpers + // ===================== + get activePage() { return this.activeTab === 'chips' ? this.chipsPage : this.controlePage; } + get activeTotal() { return this.activeTab === 'chips' ? this.chipsTotal : this.controleTotal; } + get activePageSize() { return this.activeTab === 'chips' ? this.chipsPageSize : this.controlePageSize; } + get activeTotalPages() { return Math.max(1, Math.ceil((this.activeTotal || 0) / (this.activePageSize || 10))); } + + get activePageStart() { return this.activeTotal === 0 ? 0 : (this.activePage - 1) * this.activePageSize + 1; } + get activePageEnd() { return this.activeTotal === 0 ? 0 : Math.min(this.activePage * this.activePageSize, this.activeTotal); } + + get activeLoading() { return this.activeTab === 'chips' ? this.chipsLoading : this.controleLoading; } // ✅ novo + + get activePageNumbers() { + const total = this.activeTotalPages; + const current = this.activePage; + const max = 5; + let start = Math.max(1, current - 2); + let end = Math.min(total, start + (max - 1)); + start = Math.max(1, end - (max - 1)); + const pages = []; + for (let i = start; i <= end; i++) pages.push(i); + return pages; + } + + goToPage(p: number) { + const target = Math.max(1, Math.min(this.activeTotalPages, p)); + + if (this.activeTab === 'chips') { + this.chipsPage = target; + this.applyChipsPagination(); + } else { + this.controlePage = target; + this.applyControlePagination(); + } + } + + normalizeChip(x: any, idx: number): ChipVirgemListDto { + return { + id: String(x.id || idx), + item: Number(x.item || 0), + numeroDoChip: x.numeroDoChip || x.NumeroDoChip, + observacoes: x.observacoes || x.Observacoes + }; + } + + normalizeControle(x: any, idx: number): ControleRecebidoListDto { + return { + id: String(x.id || idx), + ano: x.ano, + item: x.item, + notaFiscal: x.notaFiscal, + chip: x.chip, + serial: x.serial, + conteudoDaNf: x.conteudoDaNf, + numeroDaLinha: x.numeroDaLinha, + valorUnit: x.valorUnit, + valorDaNf: x.valorDaNf, + dataDaNf: x.dataDaNf, + dataDoRecebimento: x.dataDoRecebimento, + quantidade: x.quantidade, + isResumo: x.isResumo + }; + } + + display(val: any) { return val ? String(val) : '-'; } + formatMoney(val: any) { + if (!val) return '-'; + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val); + } + formatDate(val: any) { if (!val) return '-'; return new Date(val).toLocaleDateString('pt-BR'); } + isResumo(r: any) { return !!r.isResumo; } + getResumoItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => this.isResumo(r)); } + getDetalheItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => !this.isResumo(r)); } + trackById(idx: number, item: any) { return item.id; } + + showToast(msg: string, type: 'success' | 'danger') { + this.toastMessage = msg; + this.toastType = type; + this.toastOpen = true; + setTimeout(() => this.toastOpen = false, 3000); + } +} diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html index cc9f1b7..703d9f5 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.html +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -76,12 +76,8 @@
Itens por pág:
- - + +
@@ -206,4 +202,5 @@
-
\ No newline at end of file +
+ diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index c236b2c..554f286 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -1,7 +1,8 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; +import { HttpErrorResponse } from '@angular/common/http'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { DadosUsuariosService, @@ -16,10 +17,9 @@ type ViewMode = 'lines' | 'groups'; @Component({ selector: 'app-dados-usuarios', standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule], + imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './dados-usuarios.html', - styleUrls: ['./dados-usuarios.scss'], - providers: [DadosUsuariosService] + styleUrls: ['./dados-usuarios.scss'] }) export class DadosUsuarios implements OnInit { @@ -34,6 +34,7 @@ export class DadosUsuarios implements OnInit { // Paginação page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; // Ordenação @@ -226,4 +227,4 @@ export class DadosUsuarios implements OnInit { } hideToast() { this.toastOpen = false; } -} \ No newline at end of file +} diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 025f995..f529fd0 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -116,7 +116,7 @@
- Total Clientes + Clientes Faturados {{ kpiTotalClientes || 0 }} @@ -124,7 +124,7 @@
- Total Linhas + Linhas Faturadas {{ kpiTotalLinhas || 0 }} @@ -183,13 +183,8 @@
- - + +
@@ -452,3 +447,5 @@
+ + diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index b8ec83e..78d82bc 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -12,6 +12,7 @@ import { import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { BillingService, @@ -33,7 +34,7 @@ interface BillingClientGroup { @Component({ standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule], + imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent], templateUrl: './faturamento.html', styleUrls: ['./faturamento.scss'] }) @@ -68,6 +69,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { // pagina por CLIENTES (grupos) page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; // total de grupos // agrupamento @@ -489,15 +491,35 @@ export class Faturamento implements AfterViewInit, OnDestroy { let totalVivo = 0; let totalLine = 0; let totalLucro = 0; + const clientTotals = new Map(); for (const r of arr) { const c = (r.cliente ?? '').trim(); if (c) unique.add(c); totalLinhas += Number(r.qtdLinhas ?? 0) || 0; - totalVivo += Number(r.valorContratoVivo ?? 0) || 0; - totalLine += Number(r.valorContratoLine ?? 0) || 0; - totalLucro += Number((r as any).lucro ?? 0) || 0; + + const key = this.normalizeText(c); + if (!key) continue; + + const vivo = Number(r.valorContratoVivo ?? 0) || 0; + const line = Number(r.valorContratoLine ?? 0) || 0; + const lucro = Number((r as any).lucro ?? 0) || 0; + + const existing = clientTotals.get(key); + if (!existing) { + clientTotals.set(key, { vivo, line, lucro }); + } else { + if (!existing.vivo && vivo) existing.vivo = vivo; + if (!existing.line && line) existing.line = line; + if (!existing.lucro && lucro) existing.lucro = lucro; + } + } + + for (const vals of clientTotals.values()) { + totalVivo += vals.vivo; + totalLine += vals.line; + totalLucro += vals.lucro; } this.kpiTotalClientes = unique.size; diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index c2b9d32..251e62d 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -182,13 +182,7 @@
- - +
@@ -498,10 +492,7 @@
- +
@@ -560,10 +551,7 @@
- +
@@ -578,10 +566,7 @@
- +
@@ -897,7 +882,7 @@
-
+
@@ -910,7 +895,7 @@ Contrato & Plano
-
+
@@ -921,11 +906,11 @@ Status & Logística
-
+
-
+
@@ -978,3 +963,4 @@
+ diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 70e0bd2..05a0880 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -207,6 +207,7 @@ .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } } } .modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } +.modal-body .box-body { overflow: visible; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ @@ -231,8 +232,8 @@ .details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } } /* Caixas de Detalhes e Accordions simples */ -details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: fit-content; &:not([open]) { padding-bottom: 0; } } -div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; } +details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: fit-content; &:not([open]) { padding-bottom: 0; } } +div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: 100%; display: flex; flex-direction: column; } summary.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; cursor: pointer; list-style: none; user-select: none; i:not(.transition-icon) { color: var(--brand); margin-right: 8px; } &::-webkit-details-marker { display: none; } .transition-icon { margin-left: auto; transition: transform 0.3s ease; font-size: 1rem; color: var(--muted); } } details[open] summary .transition-icon { transform: rotate(180deg); color: var(--brand); } @@ -259,4 +260,4 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin /* === FORMULÁRIOS (GERAL) === */ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } } .form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } } -.form-control, .form-select { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); background-color: #fff; font-size: 0.9rem; font-weight: 500; color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; &:hover { border-color: rgba(17, 18, 20, 0.7); } &:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); outline: none; } &:disabled, &[readonly] { background-color: rgba(17, 18, 20, 0.04); border-color: rgba(17, 18, 20, 0.2); color: var(--muted); } } \ No newline at end of file +.form-control, .form-select { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); background-color: #fff; font-size: 0.9rem; font-weight: 500; color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; &:hover { border-color: rgba(17, 18, 20, 0.7); } &:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); outline: none; } &:disabled, &[readonly] { background-color: rgba(17, 18, 20, 0.04); border-color: rgba(17, 18, 20, 0.2); color: var(--muted); } } diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 9eefd14..4b919bc 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -13,10 +13,10 @@ import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, - HttpClientModule, HttpParams, HttpErrorResponse } from '@angular/common/http'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; @@ -53,6 +53,7 @@ interface ApiLineList { interface ApiLineDetail { id: string; item: number; + qtdLinhas?: number | null; conta?: string | null; linha?: string | null; chip?: string | null; @@ -99,7 +100,7 @@ interface ClientGroupDto { @Component({ standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule], + imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './geral.html', styleUrls: ['./geral.scss'] }) @@ -145,6 +146,7 @@ export class Geral implements AfterViewInit, OnDestroy { sortDir: SortDir = 'asc'; page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; detailOpen = false; @@ -200,6 +202,22 @@ export class Geral implements AfterViewInit, OnDestroy { 'TIM' ]; + get contaOptionsForEdit(): string[] { + return this.mergeOption(this.editModel?.conta, this.contaOptions); + } + + get planOptionsForEdit(): string[] { + return this.mergeOption(this.editModel?.planoContrato, this.planOptions); + } + + get statusOptionsForEdit(): string[] { + return this.mergeOption(this.editModel?.status, this.statusOptions); + } + + get skilOptionsForEdit(): string[] { + return this.mergeOption(this.editModel?.skil, this.skilOptions); + } + createModel: any = { cliente: '', docType: 'PF', @@ -1132,6 +1150,7 @@ export class Geral implements AfterViewInit, OnDestroy { lucro: this.toNullableNumber(this.createModel.lucro) }; + this.http.post(this.apiBase, payload).subscribe({ next: async () => { this.createSaving = false; @@ -1270,4 +1289,10 @@ export class Geral implements AfterViewInit, OnDestroy { const n = parseFloat(v.toString().replace(',', '.')); return Number.isNaN(n) ? null : n; } + + private mergeOption(current: any, list: string[]): string[] { + const v = (current ?? '').toString().trim(); + if (!v) return list; + return list.includes(v) ? list : [v, ...list]; + } } diff --git a/src/app/pages/home/home.ts b/src/app/pages/home/home.ts index d3b10b0..3c8cab9 100644 --- a/src/app/pages/home/home.ts +++ b/src/app/pages/home/home.ts @@ -1,13 +1,11 @@ import { Component, AfterViewInit, Inject, PLATFORM_ID } from '@angular/core'; import { CommonModule, isPlatformBrowser } from '@angular/common'; -import { FeatureCardComponent } from '../../components/feature-card/feature-card'; -import { CtaButtonComponent } from '../../components/cta-button/cta-button'; import { Router } from '@angular/router'; @Component({ selector: 'app-home', standalone: true, - imports: [CommonModule, FeatureCardComponent, CtaButtonComponent], + imports: [CommonModule], templateUrl: './home.html', styleUrls: ['./home.scss'], }) diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index f688235..58c0764 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -88,18 +88,7 @@
- - +
@@ -270,14 +259,7 @@
- + Carregando clientes... @@ -287,19 +269,7 @@
- + Carregando linhas... @@ -394,14 +364,7 @@
- + Carregando clientes... @@ -411,19 +374,7 @@
- + Carregando linhas... @@ -579,3 +530,4 @@
+ diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index fa633b6..ce66b51 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -9,8 +9,9 @@ import { } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { LinesService } from '../../services/lines.service'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente'; @@ -50,6 +51,7 @@ interface LineOptionDto { usuario: string | null; cliente?: string | null; skil?: string | null; + label?: string; } interface MuregDetailDto { @@ -73,7 +75,7 @@ interface MuregDetailDto { @Component({ standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule], + imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './mureg.html', styleUrls: ['./mureg.scss'] }) @@ -109,6 +111,7 @@ export class Mureg implements AfterViewInit { private searchTimer: any = null; page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; // ====== OPTIONS (GERAL) ====== @@ -411,7 +414,8 @@ export class Mureg implements AfterViewInit { chip: x.chip ?? null, usuario: x.usuario ?? null, cliente: x.cliente ?? null, - skil: x.skil ?? null + skil: x.skil ?? null, + label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}` })) .filter(x => !!String(x.linha ?? '').trim()); diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 4ef58c1..1bd47b3 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -1,3 +1,5 @@ +@use 'sass:color'; + /* Variáveis */ $bg-page: #f9fafb; $white: #ffffff; @@ -153,7 +155,7 @@ $border: #e5e7eb; font-weight: 800; letter-spacing: 0.5px; &.danger { background: rgba($danger, 0.1); color: $danger; } - &.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); } + &.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); } } .item-time { font-size: 12px; color: $text-secondary; font-weight: 500; } @@ -183,4 +185,4 @@ $border: #e5e7eb; /* Mobile optimization: show button usually only on hover desktop, always mobile */ @media(min-width: 768px) { opacity: 0.6; } -} \ No newline at end of file +} diff --git a/src/app/pages/novo-usuario/novo-usuario.html b/src/app/pages/novo-usuario/novo-usuario.html index f1c91a4..52c9939 100644 --- a/src/app/pages/novo-usuario/novo-usuario.html +++ b/src/app/pages/novo-usuario/novo-usuario.html @@ -1,48 +1,176 @@
-
-
-

Novo Usuário LineGestão

-

Preencha os dados para criar um novo usuário.

+
+
+
+

Novo Usuário LineGestão

+

Preencha os dados para criar um novo usuário.

+
+ +
+ Confira os campos: +
    +
  • {{ err.message }}
  • +
+
+
{{ createSuccess }}
+ +
+
+ + + Nome obrigatório. +
+ +
+ + + Email inválido. +
+ +
+ + + Senha inválida. +
+ +
+ + + As senhas não conferem. +
+ +
+ + + Selecione uma permissão. +
+ +
+ + +
+
-
-
- - +
+
+
+

Usuários

+

Gerencie permissões e status.

+
+
+ + + +
-
- - +
+
Carregando...
+ + + + + + + + + + + + + + + + + + + +
NomeEmailPermissãoStatusAções
{{ u.nome }}{{ u.email }}{{ u.permissao }} + + {{ u.ativo === false ? 'Inativo' : 'Ativo' }} + + + +
+
+ Nenhum usuario encontrado. +
-
- - + - -
- - -
- -
- - -
- -
- - -
- +
+ + + + + diff --git a/src/app/pages/novo-usuario/novo-usuario.scss b/src/app/pages/novo-usuario/novo-usuario.scss index 286da34..bd77823 100644 --- a/src/app/pages/novo-usuario/novo-usuario.scss +++ b/src/app/pages/novo-usuario/novo-usuario.scss @@ -12,6 +12,13 @@ place-items: center; } +.grid-shell { + width: 100%; + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1fr); +} + .form-card { width: min(720px, 100%); background: #ffffff; @@ -74,6 +81,174 @@ } } +.form-field.inline { + grid-template-columns: 1fr; +} + +.form-alert { + border-radius: 12px; + padding: 12px 14px; + font-size: 13px; + margin-bottom: 12px; +} + +.form-alert.error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + color: #b91c1c; +} + +.form-alert.success { + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.2); + color: #047857; +} + +.field-error { + color: #b91c1c; + font-size: 12px; +} + +.list-card { + width: min(900px, 100%); + background: #ffffff; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); + padding: 22px 22px 18px; +} + +.list-header { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + justify-content: space-between; + + h2 { + margin: 0; + font-size: 18px; + color: #0f172a; + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: #64748b; + } +} + +.list-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + + input { + height: 38px; + border-radius: 10px; + border: 1.5px solid #d7dbe6; + padding: 0 12px; + font-size: 13px; + min-width: 220px; + } +} + +.list-body { + margin-top: 16px; +} + +.users-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 10px 8px; + border-bottom: 1px solid #edf0f6; + text-align: left; + } + + th { + font-weight: 600; + color: #475569; + } +} + +.status-pill { + display: inline-flex; + padding: 4px 10px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + color: #047857; + font-weight: 600; + font-size: 12px; +} + +.status-pill.off { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.list-footer { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-top: 12px; + font-size: 12px; + color: #64748b; +} + +.pagination { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.btn-ghost { + height: 34px; + border-radius: 10px; + border: 1px solid #d7dbe6; + background: #fff; + padding: 0 10px; + font-size: 12px; + cursor: pointer; +} + +.btn-ghost.active { + background: #2f6bff; + border-color: #2f6bff; + color: #ffffff; +} + +.btn-link { + border: none; + background: transparent; + color: #2f6bff; + cursor: pointer; + font-weight: 600; +} + +.loading, +.empty { + padding: 18px 0; + text-align: center; + color: #64748b; +} + +.cap { + text-transform: capitalize; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; +} + .form-actions { display: flex; justify-content: flex-end; @@ -104,16 +279,75 @@ color: #0f172a; } +.btn-secondary, +.btn-ghost { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + .btn-primary:hover, -.btn-secondary:hover { +.btn-secondary:hover, +.btn-ghost:hover { transform: translateY(-1px); } +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.4); + z-index: 999; +} + +.modal-card { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(520px, 92vw); + background: #ffffff; + border-radius: 16px; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.2); + z-index: 1000; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px 10px; + border-bottom: 1px solid #edf0f6; +} + +.modal-body { + padding: 16px 18px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 0 18px 16px; +} + +.btn-icon { + background: transparent; + border: none; + cursor: pointer; +} + @media (max-width: 768px) { + .grid-shell { + gap: 18px; + } + .form-card { padding: 22px 20px 20px; } + .list-card { + padding: 20px 18px 16px; + } + .form-actions { flex-direction: column; align-items: stretch; @@ -124,3 +358,15 @@ width: 100%; } } + +@media (min-width: 980px) { + .grid-shell { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + align-items: start; + } + + .form-card, + .list-card { + width: 100%; + } +} diff --git a/src/app/pages/novo-usuario/novo-usuario.ts b/src/app/pages/novo-usuario/novo-usuario.ts index 761c481..3f6065c 100644 --- a/src/app/pages/novo-usuario/novo-usuario.ts +++ b/src/app/pages/novo-usuario/novo-usuario.ts @@ -1,11 +1,309 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms'; +import { UsersService, CreateUserPayload, UpdateUserPayload, UserDto, ApiFieldError } from '../../services/users.service'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; @Component({ selector: 'app-novo-usuario', standalone: true, - imports: [CommonModule], + imports: [CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent], templateUrl: './novo-usuario.html', styleUrls: ['./novo-usuario.scss'], }) -export class NovoUsuario {} +export class NovoUsuario implements OnInit { + createForm: FormGroup; + editForm: FormGroup; + + permissionOptions = [ + { value: 'admin', label: 'Administrador' }, + { value: 'gestor', label: 'Gestor' }, + ]; + + + createSubmitting = false; + editSubmitting = false; + + createErrors: ApiFieldError[] = []; + editErrors: ApiFieldError[] = []; + + createSuccess = ''; + editSuccess = ''; + + users: UserDto[] = []; + loading = false; + + search = ''; + page = 1; + pageSize = 10; + total = 0; + + editOpen = false; + private editBase: UserDto | null = null; + + constructor(private usersService: UsersService, private fb: FormBuilder) { + this.createForm = this.fb.group( + { + nome: ['', [Validators.required, Validators.minLength(2)]], + email: ['', [Validators.required, Validators.email]], + senha: ['', [Validators.required, Validators.minLength(6)]], + confirmarSenha: ['', [Validators.required, Validators.minLength(6)]], + permissao: ['', [Validators.required]], + }, + { validators: this.passwordsMatchValidator } + ); + + this.editForm = this.fb.group( + { + nome: [''], + email: [''], + senha: [''], + confirmarSenha: [''], + permissao: [''], + ativo: [true], + } + ); + } + + ngOnInit(): void { + this.fetchUsers(1); + } + + get totalPages(): number { + return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + } + + get pageNumbers(): number[] { + const total = this.totalPages; + const current = this.page; + const max = 5; + let start = Math.max(1, current - 2); + let end = Math.min(total, start + (max - 1)); + start = Math.max(1, end - (max - 1)); + const pages: number[] = []; + for (let i = start; i <= end; i++) pages.push(i); + return pages; + } + + fetchUsers(goToPage?: number) { + if (goToPage) this.page = goToPage; + this.loading = true; + this.usersService.list({ + search: this.search?.trim() || undefined, + page: this.page, + pageSize: this.pageSize, + }).subscribe({ + next: (res) => { + this.users = res.items || []; + this.total = res.total || 0; + this.loading = false; + }, + error: () => { + this.users = []; + this.total = 0; + this.loading = false; + } + }); + } + + onSearch() { + this.page = 1; + this.fetchUsers(); + } + + clearSearch() { + this.search = ''; + this.page = 1; + this.fetchUsers(); + } + + onPageSizeChange() { + this.page = 1; + this.fetchUsers(); + } + + goToPage(p: number) { + this.page = p; + this.fetchUsers(); + } + + submitCreate() { + if (this.createSubmitting) return; + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); + return; + } + + this.createSubmitting = true; + this.setCreateFormDisabled(true); + this.createErrors = []; + this.createSuccess = ''; + + const payload = this.createForm.value as CreateUserPayload; + this.usersService.create(payload).subscribe({ + next: (created) => { + this.createSubmitting = false; + this.setCreateFormDisabled(false); + this.createSuccess = `Usuario ${created.nome} criado com sucesso.`; + this.createForm.reset({ permissao: '' }); + this.fetchUsers(1); + }, + error: (err: HttpErrorResponse) => { + this.createSubmitting = false; + this.setCreateFormDisabled(false); + const apiErrors = err?.error?.errors; + if (Array.isArray(apiErrors)) { + this.createErrors = apiErrors.map((e: any) => ({ + field: e?.field, + message: e?.message || 'Erro ao criar usuario.', + })); + } else { + this.createErrors = [{ message: err?.error?.message || 'Erro ao criar usuario.' }]; + } + }, + }); + } + + openEdit(user: UserDto) { + this.editOpen = true; + this.editErrors = []; + this.editSuccess = ''; + this.editSubmitting = false; + this.setEditFormDisabled(false); + this.editBase = null; + this.editForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true }); + + this.usersService.getById(user.id).subscribe({ + next: (full) => { + this.editBase = full; + this.editForm.reset({ + nome: full.nome ?? '', + email: full.email ?? '', + senha: '', + confirmarSenha: '', + permissao: full.permissao ?? '', + ativo: full.ativo ?? true, + }); + }, + error: () => { + this.editErrors = [{ message: 'Erro ao carregar usuario.' }]; + }, + }); + } + + closeEdit() { + this.editOpen = false; + this.editErrors = []; + this.editSuccess = ''; + this.editSubmitting = false; + this.editBase = null; + this.setEditFormDisabled(false); + } + + submitEdit() { + if (this.editSubmitting || !this.editBase) return; + this.editErrors = []; + this.editSuccess = ''; + + const payload: UpdateUserPayload = {}; + const nome = (this.editForm.get('nome')?.value || '').toString().trim(); + const email = (this.editForm.get('email')?.value || '').toString().trim(); + const permissao = (this.editForm.get('permissao')?.value || '').toString().trim(); + const ativo = !!this.editForm.get('ativo')?.value; + + if (nome && nome !== (this.editBase.nome || '').trim()) payload.nome = nome; + if (email && email !== (this.editBase.email || '').trim()) payload.email = email; + if (permissao && permissao !== (this.editBase.permissao || '').trim()) payload.permissao = permissao as any; + if ((this.editBase.ativo ?? true) !== ativo) payload.ativo = ativo; + + const senha = (this.editForm.get('senha')?.value || '').toString(); + const confirmar = (this.editForm.get('confirmarSenha')?.value || '').toString(); + if (senha || confirmar) { + if (!senha || !confirmar) { + this.editErrors = [{ message: 'Para alterar a senha, preencha senha e confirmaçao.' }]; + return; + } + if (senha.length < 6) { + this.editErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }]; + return; + } + if (senha !== confirmar) { + this.editErrors = [{ message: 'As senhas nao conferem.' }]; + return; + } + payload.senha = senha; + payload.confirmarSenha = confirmar; + } + + if (Object.keys(payload).length === 0) { + this.editErrors = [{ message: 'Nenhuma alteraçao detectada.' }]; + return; + } + + this.editSubmitting = true; + this.setEditFormDisabled(true); + this.usersService.update(this.editBase.id, payload).subscribe({ + next: (updated) => { + this.editSubmitting = false; + this.setEditFormDisabled(false); + this.editSuccess = `Usuario ${updated.nome} atualizado.`; + this.fetchUsers(); + }, + error: (err: HttpErrorResponse) => { + this.editSubmitting = false; + this.setEditFormDisabled(false); + const apiErrors = err?.error?.errors; + if (Array.isArray(apiErrors)) { + this.editErrors = apiErrors.map((e: any) => ({ + field: e?.field, + message: e?.message || 'Erro ao atualizar usuario.', + })); + } else { + this.editErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }]; + } + } + }); + } + + hasCreateFieldError(field: string): boolean { + return this.getFieldErrors(this.createErrors, field).length > 0; + } + + hasEditFieldError(field: string): boolean { + return this.getFieldErrors(this.editErrors, field).length > 0; + } + + getFieldErrors(source: ApiFieldError[], field: string): string[] { + const key = this.normalizeField(field); + return source + .filter((e) => this.normalizeField(e.field) === key) + .map((e) => e.message || 'Erro'); + } + + get createPasswordMismatch(): boolean { + return !!this.createForm.errors?.['passwordsMismatch']; + } + + private normalizeField(field?: string | null): string { + return (field || '').trim().toLowerCase(); + } + + private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { + const senha = group.get('senha')?.value; + const confirmar = group.get('confirmarSenha')?.value; + if (!senha || !confirmar) return null; + return senha === confirmar ? null : { passwordsMismatch: true }; + } + + private setCreateFormDisabled(disabled: boolean) { + if (disabled) this.createForm.disable({ emitEvent: false }); + else this.createForm.enable({ emitEvent: false }); + } + + private setEditFormDisabled(disabled: boolean) { + if (disabled) this.editForm.disable({ emitEvent: false }); + else this.editForm.enable({ emitEvent: false }); + } +} diff --git a/src/app/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html index 650118c..0430e7a 100644 --- a/src/app/pages/troca-numero/troca-numero.html +++ b/src/app/pages/troca-numero/troca-numero.html @@ -85,13 +85,8 @@
Itens por pág:
- - + +
@@ -320,13 +315,7 @@
- + Carregando clientes... @@ -336,15 +325,7 @@
- + Carregando linhas... @@ -392,3 +373,5 @@
+ + diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts index e502e63..5c954c3 100644 --- a/src/app/pages/troca-numero/troca-numero.ts +++ b/src/app/pages/troca-numero/troca-numero.ts @@ -9,7 +9,8 @@ import { } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao'; @@ -49,11 +50,12 @@ interface LineOptionDto { cliente: string | null; usuario: string | null; skil: string | null; + label?: string; } @Component({ standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule], + imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './troca-numero.html', styleUrls: ['./troca-numero.scss'] }) @@ -92,6 +94,7 @@ export class TrocaNumero implements AfterViewInit { private searchTimer: any = null; page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; // ====== EDIT MODAL ====== @@ -357,7 +360,10 @@ export class TrocaNumero implements AfterViewInit { this.http.get(`${this.linesApiBase}/by-client`, { params }).subscribe({ next: (res) => { - this.linesFromClient = (res ?? []); + this.linesFromClient = (res ?? []).map((x) => ({ + ...x, + label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}` + })); this.loadingLines = false; this.cdr.detectChanges(); }, diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 193ead7..357cd5b 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -39,7 +39,7 @@ Total Vencidos {{ kpiTotalVencidos }}
-
+
Valor Total {{ kpiValorTotal | currency:'BRL' }}
@@ -56,12 +56,7 @@
Itens por pág: - +
@@ -216,4 +211,5 @@ - \ No newline at end of file + + diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss index 76d67aa..1a9a7f4 100644 --- a/src/app/pages/vigencia/vigencia.scss +++ b/src/app/pages/vigencia/vigencia.scss @@ -96,6 +96,14 @@ .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } .text-brand { color: var(--brand) !important; } } + + .kpi.kpi-stack { + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + text-align: center; + } } /* Controls */ @@ -175,4 +183,4 @@ .lg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .lg-modal-card { background: #ffffff; border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); width: 600px; overflow: hidden; animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } -@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } \ No newline at end of file +@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index e02f24c..d5dd528 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; type SortDir = 'asc' | 'desc'; type ToastType = 'success' | 'danger'; @@ -11,7 +12,7 @@ type ViewMode = 'lines' | 'groups'; @Component({ selector: 'app-vigencia', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './vigencia.html', styleUrls: ['./vigencia.scss'], }) @@ -27,6 +28,7 @@ export class VigenciaComponent implements OnInit { // Paginação page = 1; pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; total = 0; // Ordenação @@ -214,4 +216,4 @@ export class VigenciaComponent implements OnInit { this.toastTimer = setTimeout(() => this.toastOpen = false, 3000); } hideToast() { this.toastOpen = false; } -} \ No newline at end of file +} diff --git a/src/app/services/chips-controle.service.ts b/src/app/services/chips-controle.service.ts new file mode 100644 index 0000000..87374b7 --- /dev/null +++ b/src/app/services/chips-controle.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export type SortDir = 'asc' | 'desc'; + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface ChipVirgemListDto { + id: string; + item: number; + numeroDoChip: string | null; + observacoes: string | null; +} + +export interface ControleRecebidoListDto { + id: string; + ano: number | null; + item: number | null; + notaFiscal: string | null; + chip: string | null; + serial: string | null; + conteudoDaNf: string | null; + numeroDaLinha: string | null; + valorUnit: number | null; + valorDaNf: number | null; + dataDaNf: string | null; + dataDoRecebimento: string | null; + quantidade: number | null; + isResumo: boolean | null; +} + +@Injectable({ providedIn: 'root' }) +export class ChipsControleService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + getChipsVirgens(opts: { + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortDir?: SortDir; + }): Observable> { + let params = new HttpParams(); + if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim()); + + params = params.set('page', String(opts.page ?? 1)); + params = params.set('pageSize', String(opts.pageSize ?? 20)); + params = params.set('sortBy', (opts.sortBy ?? 'item').trim()); + params = params.set('sortDir', opts.sortDir ?? 'asc'); + + return this.http.get>(`${this.baseApi}/chips-virgens`, { params }); + } + + getChipVirgemById(id: string): Observable { + return this.http.get(`${this.baseApi}/chips-virgens/${id}`); + } + + getControleRecebidos(opts: { + ano?: number | string | null; + isResumo?: boolean | string | null; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortDir?: SortDir; + }): Observable> { + let params = new HttpParams(); + const ano = opts.ano ?? ''; + const resumo = opts.isResumo ?? ''; + + if (String(ano).trim()) params = params.set('ano', String(ano).trim()); + if (String(resumo).trim()) params = params.set('isResumo', String(resumo).trim()); + if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim()); + + params = params.set('page', String(opts.page ?? 1)); + params = params.set('pageSize', String(opts.pageSize ?? 20)); + params = params.set('sortBy', (opts.sortBy ?? 'ano').trim()); + params = params.set('sortDir', opts.sortDir ?? 'asc'); + + return this.http.get>(`${this.baseApi}/controle-recebidos`, { params }); + } + + getControleRecebidoById(id: string): Observable { + return this.http.get(`${this.baseApi}/controle-recebidos/${id}`); + } +} diff --git a/src/app/services/users.service.ts b/src/app/services/users.service.ts index 6fe8d0f..0bd3433 100644 --- a/src/app/services/users.service.ts +++ b/src/app/services/users.service.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; -export type UserPermission = 'admin' | 'gestor' | 'operador' | 'leitura'; +export type UserPermission = 'admin' | 'gestor'; export type UserDto = { id: string; @@ -40,8 +40,12 @@ export type UsersListParams = { }; export type UpdateUserPayload = { - permissao: UserPermission; - ativo: boolean; + nome?: string; + email?: string; + senha?: string; + confirmarSenha?: string; + permissao?: UserPermission; + ativo?: boolean; }; export type PagedResult = { @@ -73,6 +77,10 @@ export class UsersService { return this.http.get>(`${this.baseApi}/users`, { params: httpParams }); } + getById(id: string): Observable { + return this.http.get(`${this.baseApi}/users/${id}`); + } + update(id: string, payload: UpdateUserPayload): Observable { return this.http.patch(`${this.baseApi}/users/${id}`, payload); } diff --git a/src/styles.scss b/src/styles.scss index 570eb99..8cc0a47 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -23,6 +23,51 @@ body { -webkit-font-smoothing: antialiased; /* Garante scroll da página em todo o app */ overflow-y: auto !important; +/* Global select styling to match LineGestao UI */ +select, +select.form-select, +select.form-control { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + height: 42px; + border-radius: 10px; + border: 1.5px solid rgba(15, 23, 42, 0.12); + padding: 0 36px 0 12px; + font-size: 14px; + font-weight: 500; + color: var(--text-main); + background-color: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 20 20'%3E%3Cpath fill='%2364748B' d='M5.25 7.5 10 12.25 14.75 7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 14px 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +select:focus, +select.form-select:focus, +select.form-control:focus { + outline: none; + border-color: var(--brand-primary); + box-shadow: 0 0 0 3px var(--brand-soft); +} + +select:disabled, +select.form-select:disabled, +select.form-control:disabled { + background-color: #f1f5f9; + color: var(--text-muted); + cursor: not-allowed; +} + +select.form-select-sm, +select.form-control-sm { + height: 36px; + font-size: 13px; + padding-right: 32px; + background-position: right 10px center; +} } /* Utilitário de animação suave */ @@ -234,3 +279,4 @@ app-header .modal-card .btn-secondary:hover { width: 100%; } } + From 49cdaefddfc0d5c12c0728be104a9c25d5a23de1 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Mon, 9 Feb 2026 16:30:43 -0300 Subject: [PATCH 37/46] =?UTF-8?q?Minha=20altera=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/logo.png | Bin 31218 -> 157594 bytes src/app/app-title.strategy.ts | 19 + src/app/app.config.ts | 9 +- src/app/app.routes.ts | 33 +- src/app/app.ts | 29 +- .../custom-select/custom-select.scss | 27 +- src/app/components/header/header.html | 36 +- src/app/components/header/header.scss | 17 +- src/app/components/header/header.ts | 47 +- src/app/guards/admin.guard.ts | 27 + src/app/guards/auth.guard.ts | 8 +- src/app/interceptors/auth.interceptor.ts | 5 +- src/app/interceptors/session.interceptor.ts | 25 + .../chips-controle-recebidos.html | 284 +++- .../chips-controle-recebidos.scss | 202 ++- .../chips-controle-recebidos.ts | 360 ++++- .../pages/dados-usuarios/dados-usuarios.html | 245 ++- .../pages/dados-usuarios/dados-usuarios.scss | 195 ++- .../pages/dados-usuarios/dados-usuarios.ts | 367 ++++- src/app/pages/dashboard/dashboard.html | 508 ++++--- src/app/pages/dashboard/dashboard.scss | 865 +++++++---- src/app/pages/dashboard/dashboard.ts | 1333 ++++++++++++++--- src/app/pages/faturamento/faturamento.html | 131 +- src/app/pages/faturamento/faturamento.scss | 169 ++- src/app/pages/faturamento/faturamento.ts | 156 +- src/app/pages/geral/geral.html | 197 ++- src/app/pages/geral/geral.scss | 127 +- src/app/pages/geral/geral.ts | 991 ++++++++++-- src/app/pages/historico/historico.html | 259 ++++ src/app/pages/historico/historico.scss | 679 +++++++++ src/app/pages/historico/historico.ts | 275 ++++ src/app/pages/login/login.html | 3 +- src/app/pages/login/login.ts | 20 +- src/app/pages/notificacoes/notificacoes.html | 84 +- src/app/pages/notificacoes/notificacoes.scss | 154 +- src/app/pages/notificacoes/notificacoes.ts | 126 +- src/app/pages/novo-usuario/novo-usuario.html | 2 +- .../parcelamento-create-modal.html | 151 ++ .../parcelamento-create-modal.scss | 389 +++++ .../parcelamento-create-modal.ts | 253 ++++ ...parcelamento-detalhamento-anual-modal.html | 56 + ...parcelamento-detalhamento-anual-modal.scss | 202 +++ .../parcelamento-detalhamento-anual-modal.ts | 41 + .../parcelamentos-filters.html | 74 + .../parcelamentos-filters.scss | 300 ++++ .../parcelamentos-filters.ts | 36 + .../parcelamentos-kpis.html | 14 + .../parcelamentos-kpis.scss | 57 + .../parcelamentos-kpis/parcelamentos-kpis.ts | 20 + .../parcelamentos-table.html | 161 ++ .../parcelamentos-table.scss | 499 ++++++ .../parcelamentos-table.ts | 66 + .../pages/parcelamentos/parcelamentos.html | 251 ++++ .../pages/parcelamentos/parcelamentos.scss | 614 ++++++++ src/app/pages/parcelamentos/parcelamentos.ts | 1091 ++++++++++++++ src/app/pages/perfil/perfil.html | 158 ++ src/app/pages/perfil/perfil.scss | 295 ++++ src/app/pages/perfil/perfil.ts | 229 +++ src/app/pages/register/register.ts | 2 +- src/app/pages/resumo/resumo.html | 615 ++++++++ src/app/pages/resumo/resumo.scss | 790 ++++++++++ src/app/pages/resumo/resumo.ts | 1120 ++++++++++++++ src/app/pages/troca-numero/troca-numero.html | 2 +- src/app/pages/troca-numero/troca-numero.scss | 2 +- src/app/pages/vigencia/vigencia.html | 330 +++- src/app/pages/vigencia/vigencia.scss | 489 +++++- src/app/pages/vigencia/vigencia.ts | 365 ++++- src/app/services/auth.service.ts | 161 +- src/app/services/billing.ts | 28 + src/app/services/chips-controle.service.ts | 54 + src/app/services/dados-usuarios.service.ts | 49 +- src/app/services/historico.service.ts | 77 + src/app/services/lines.service.ts | 2 + src/app/services/notifications.service.ts | 31 +- src/app/services/parcelamentos.service.ts | 119 ++ src/app/services/plan-autofill.service.ts | 116 ++ src/app/services/profile.service.ts | 44 + src/app/services/resumo.service.ts | 129 ++ src/app/services/session-notice.service.ts | 110 ++ src/app/services/vigencia.service.ts | 34 +- src/index.html | 6 +- src/main.server.ts | 4 + src/main.ts | 4 + src/styles.scss | 49 +- 84 files changed, 16546 insertions(+), 1157 deletions(-) create mode 100644 src/app/app-title.strategy.ts create mode 100644 src/app/guards/admin.guard.ts create mode 100644 src/app/interceptors/session.interceptor.ts create mode 100644 src/app/pages/historico/historico.html create mode 100644 src/app/pages/historico/historico.scss create mode 100644 src/app/pages/historico/historico.ts create mode 100644 src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html create mode 100644 src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss create mode 100644 src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts create mode 100644 src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html create mode 100644 src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss create mode 100644 src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss create mode 100644 src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts create mode 100644 src/app/pages/parcelamentos/parcelamentos.html create mode 100644 src/app/pages/parcelamentos/parcelamentos.scss create mode 100644 src/app/pages/parcelamentos/parcelamentos.ts create mode 100644 src/app/pages/perfil/perfil.html create mode 100644 src/app/pages/perfil/perfil.scss create mode 100644 src/app/pages/perfil/perfil.ts create mode 100644 src/app/pages/resumo/resumo.html create mode 100644 src/app/pages/resumo/resumo.scss create mode 100644 src/app/pages/resumo/resumo.ts create mode 100644 src/app/services/historico.service.ts create mode 100644 src/app/services/parcelamentos.service.ts create mode 100644 src/app/services/plan-autofill.service.ts create mode 100644 src/app/services/profile.service.ts create mode 100644 src/app/services/resumo.service.ts create mode 100644 src/app/services/session-notice.service.ts diff --git a/public/logo.png b/public/logo.png index 54ca585bef67241e6b2d26dbfefd51fc888131ee..dc9fdf61f56818d619ff00cceeb64805dc33c2cf 100644 GIT binary patch literal 157594 zcmYg%bwHF|^ERQNq(Mq4pdcwA9ZQEO9ZE_lB`n>rgn)pApdivA9g-`tyC5aq9ZT2J zyGw3-@P5DNeV;$}pSjPOGv}IX&V8Sm*=QXtHA=DvWCR2Rlp5;FdISW7sO#S?k}FBy zBs_+I;0}R?vVwsx;V$-e&f_UVRx*&~gyg1{Y_Vy+&|49%kB<^R(i7bvO2FJ8@=GLC zCrUU>K74pP^p3@cKX0EXF;I5Beb#XFkovfwEu4?~PYPdOjf_Lh2aSezJwKwd_B|sZ z@Y6kWFRe_EgFWcNPD<0IQ6qHLsmh@WQtDV$DiyR&v^V)aXFxtZRE*e}WR@c1h|!+> zf)PYEce{?!US#+`&j^>s=}=}ST#Gec*&)jRr$9#ZTBx6VsxR$`fczu(HY1r0gRW}%NiYBZP1Z3=Fo_J)p=yuH zvt}jh7ynso>3P4r>9BTt@@VAv|3HZszczpIbN$8D_9{;Qt0I@8?k4u*=;VX?|7~@Q z_j)X3P>pGzWI+BO6YQ>#3k#min)SB+2g6^dV=KmV9yFXH;%_DX?Wm!D()2N73C zkzs$}m3Njt4_#>(I@6!l`j@K-oE3moVb^oDyhu#&*Ggq>U*ngYxaR%oUph;#vC_|6 zo&;UP|6dUFu9nXl9cKL(Dbg~pNRd6WSrrGp246ot^x9=2Pp(0+(K2MR|DXS+rLO%i zC0Mcwem z#5-%s+vxu=y3zv4_P*YggU^fEkbl{FWOs#BcXsV$-akC~54K#1*ISSuHA(�!{C( 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

&dV07 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 h00006Nklu+=;hq2h002ovPDHLkV1nl{Mj8MB diff --git a/src/app/app-title.strategy.ts b/src/app/app-title.strategy.ts new file mode 100644 index 0000000..8d2f179 --- /dev/null +++ b/src/app/app-title.strategy.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; + +@Injectable() +export class AppTitleStrategy extends TitleStrategy { + private readonly appName = 'LineGestão'; + + constructor(private readonly titleService: Title) { + super(); + } + + override updateTitle(routerState: RouterStateSnapshot): void { + const pageTitle = this.buildTitle(routerState); + this.titleService.setTitle( + pageTitle ? `${pageTitle} - ${this.appName}` : this.appName + ); + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e4018c5..a91a2a6 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,26 +1,31 @@ import { ApplicationConfig, + LOCALE_ID, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, TitleStrategy } from '@angular/router'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { authInterceptor } from './interceptors/auth.interceptor'; +import { sessionInterceptor } from './interceptors/session.interceptor'; +import { AppTitleStrategy } from './app-title.strategy'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), + { provide: LOCALE_ID, useValue: 'pt-BR' }, provideRouter(routes), + { provide: TitleStrategy, useClass: AppTitleStrategy }, provideClientHydration(withEventReplay()), // ✅ HttpClient com fetch + interceptor provideHttpClient( withFetch(), - withInterceptors([authInterceptor]) + withInterceptors([authInterceptor, sessionInterceptor]) ), ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ea4c859..0333c55 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,6 +8,7 @@ import { Mureg } from './pages/mureg/mureg'; import { Faturamento } from './pages/faturamento/faturamento'; import { authGuard } from './guards/auth.guard'; +import { adminGuard } from './guards/admin.guard'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; @@ -15,24 +16,32 @@ import { Dashboard } from './pages/dashboard/dashboard'; import { Notificacoes } from './pages/notificacoes/notificacoes'; import { NovoUsuario } from './pages/novo-usuario/novo-usuario'; import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos'; +import { Resumo } from './pages/resumo/resumo'; +import { Parcelamentos } from './pages/parcelamentos/parcelamentos'; +import { Historico } from './pages/historico/historico'; +import { Perfil } from './pages/perfil/perfil'; export const routes: Routes = [ { path: '', component: Home }, - { path: 'register', component: Register }, - { path: 'login', component: LoginComponent }, + { path: 'register', component: Register, title: 'Cadastro' }, + { path: 'login', component: LoginComponent, title: 'Login' }, - { path: 'geral', component: Geral, canActivate: [authGuard] }, - { path: 'mureg', component: Mureg, canActivate: [authGuard] }, - { path: 'faturamento', component: Faturamento, canActivate: [authGuard] }, - { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, - { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, - { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, - { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] }, - { path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard] }, - { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard] }, + { 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: '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: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard], title: 'Novo Usuário' }, + { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' }, + { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, + { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' }, + { path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' }, + { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, // ✅ rota correta - { path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, + { path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' }, // ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard { path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' }, diff --git a/src/app/app.ts b/src/app/app.ts index 3cea806..3ad4aab 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,10 +1,11 @@ // src/app/app.ts import { Component, Inject, PLATFORM_ID } from '@angular/core'; import { Router, NavigationEnd, RouterOutlet } from '@angular/router'; -import { CommonModule } from '@angular/common'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; import { Header } from './components/header/header'; import { FooterComponent } from './components/footer/footer'; +import { AuthService } from './services/auth.service'; @Component({ selector: 'app-root', @@ -36,10 +37,15 @@ export class AppComponent { '/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard '/notificacoes', '/chips-controle-recebidos', + '/resumo', + '/parcelamentos', + '/historico', + '/perfil', ]; constructor( private router: Router, + private authService: AuthService, @Inject(PLATFORM_ID) private platformId: object ) { this.router.events.subscribe((event) => { @@ -58,9 +64,30 @@ export class AppComponent { // ✅ footer some ao logar + também no login/register this.hideFooter = isLoggedRoute || this.isFullScreenPage; + + // Em SSR não existe storage do navegador. + if (!isPlatformBrowser(this.platformId)) return; + + if (isLoggedRoute && !this.hasValidSession()) { + this.router.navigateByUrl('/login'); + } } }); } + + private hasValidSession(): boolean { + const token = this.authService.token; + if (!token) return false; + + const payload = this.authService.getTokenPayload(); + const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId']; + if (!tenantId) { + this.authService.logout(); + return false; + } + + return true; + } } // ✅ SSR espera importar { App } de './app/app' diff --git a/src/app/components/custom-select/custom-select.scss b/src/app/components/custom-select/custom-select.scss index b6fed55..acfa098 100644 --- a/src/app/components/custom-select/custom-select.scss +++ b/src/app/components/custom-select/custom-select.scss @@ -3,24 +3,39 @@ width: 100%; } +:host(.form-control), +:host(.form-select), +:host(.select-glass) { + /* Reset Bootstrap field skin on host to avoid duplicate "field behind" effect. */ + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; + height: auto !important; + min-height: 0 !important; +} + .app-select { position: relative; width: 100%; } .app-select-trigger { + position: relative; width: 100%; height: 42px; border-radius: 10px; border: 1.5px solid rgba(15, 23, 42, 0.12); - padding: 0 36px 0 12px; + padding: 0 28px 0 12px; background: #fff; color: #0f172a; font-size: 14px; font-weight: 500; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; @@ -49,10 +64,12 @@ .app-select.sm .app-select-trigger { height: 36px; font-size: 13px; - padding-right: 32px; + padding-right: 24px; } .app-select-label { + flex: 1 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -60,6 +77,10 @@ .app-select-trigger i { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); color: #64748b; font-size: 12px; } diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 273f743..44eadb5 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -76,14 +76,27 @@

- {{ n.linha || 'Sem Linha' }} - {{ n.referenciaData ? (n.referenciaData | date:'dd/MM') : '' }} + + {{ n.linha || 'Sem Linha' }} + + {{ abbreviateName(n.cliente) }} +

- {{ n.tipo === 'Vencido' ? 'Venceu' : 'Vence em' }} - {{ n.cliente || 'Cliente não ident.' }} + {{ getVigenciaLabel(n) }}: + + {{ getVigenciaDate(n) }} +

-
- {{ n.usuario }} +
+
+ Usuário: + {{ abbreviateName(n.usuario) }} +
+
+ Conta: + {{ n.conta || '-' }} +
@@ -111,7 +124,7 @@
-
@@ -413,6 +426,9 @@ Dashboard + + Resumo + Geral @@ -422,8 +438,14 @@ Faturamento + + Parcelamentos + + + Histórico + - Dados de Usuários + Dados PF/PJ Vigência diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 8d62ea0..704ab16 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -119,9 +119,20 @@ $border-color: #e5e7eb; &.warn { background-color: #fef3c7; color: #d97706; } } .notif-content { flex: 1; } - .notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 2px; } - .notif-date { font-size: 11px; color: $text-muted; } - .notif-desc { margin: 0; font-size: 12px; color: $text-muted; line-height: 1.3; } + .notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 4px; } + .notif-title-line { font-weight: 700; color: $text-main; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } + .notif-line { font-weight: 800; flex: 0 0 auto; } + .notif-sep { color: $text-muted; flex: 0 0 auto; } + .notif-client { font-weight: 600; color: $text-muted; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; } + .notif-desc { margin: 2px 0 6px; font-size: 12px; color: $text-muted; line-height: 1.3; display: flex; align-items: center; gap: 4px; } + .notif-verb { font-weight: 600; color: $text-muted; } + .notif-date-strong { font-weight: 800; color: $text-main; } + .notif-date-strong.warn { color: #d97706; } + .notif-date-strong.danger { color: #dc2626; } + .notif-meta-lines { display: flex; flex-direction: column; gap: 4px; } + .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; } } /* MODAIS GERAIS */ diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 7825a8e..3b766ff 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -70,6 +70,10 @@ export class Header { '/notificacoes', '/novo-usuario', '/chips-controle-recebidos', + '/resumo', + '/parcelamentos', + '/historico', + '/perfil', ]; constructor( @@ -122,7 +126,9 @@ export class Header { } private syncHeaderState(rawUrl: string) { - const url = (rawUrl || '').split('?')[0].split('#')[0]; + let url = (rawUrl || '').split('?')[0].split('#')[0]; + if (url && !url.startsWith('/')) url = `/${url}`; + url = url.replace(/\/+$/, ''); this.isHome = (url === '/' || url === ''); @@ -156,6 +162,11 @@ export class Header { this.optionsOpen = false; } + goToProfile() { + this.closeOptions(); + this.router.navigate(['/perfil']); + } + openCreateUserModal() { if (!this.isAdmin) return; this.createUserOpen = true; @@ -203,6 +214,40 @@ export class Header { }); } + getVigenciaLabel(notification: NotificationDto): string { + return notification.tipo === 'Vencido' ? 'Venceu em' : 'Vence em'; + } + + getVigenciaDate(notification: NotificationDto): string { + const raw = + notification.dtTerminoFidelizacao ?? + notification.referenciaData ?? + notification.data; + if (!raw) return '-'; + return new Date(raw).toLocaleDateString('pt-BR'); + } + + abbreviateName(value?: string | null): string { + const name = (value ?? '').trim(); + if (!name) return '-'; + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length === 1) return parts[0]; + + const maxLen = 18; + const full = parts.join(' '); + if (full.length <= maxLen) return full; + + if (parts.length >= 3) { + const candidate = `${parts[0]} ${parts[1]} ${parts[2][0]}.`; + if (candidate.length <= maxLen) return candidate; + return `${parts[0]} ${parts[1][0]}.`; + } + + const two = `${parts[0]} ${parts[1]}`; + if (two.length <= maxLen) return two; + return `${parts[0]} ${parts[1][0]}.`; + } + get unreadCount() { return this.notifications.filter(n => !n.lida).length; } diff --git a/src/app/guards/admin.guard.ts b/src/app/guards/admin.guard.ts new file mode 100644 index 0000000..19118f4 --- /dev/null +++ b/src/app/guards/admin.guard.ts @@ -0,0 +1,27 @@ +import { inject, PLATFORM_ID } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; +import { AuthService } from '../services/auth.service'; + +export const adminGuard: CanActivateFn = () => { + const router = inject(Router); + const platformId = inject(PLATFORM_ID); + const authService = inject(AuthService); + + if (!isPlatformBrowser(platformId)) { + // Em SSR não há storage do usuário para validar sessão/perfil. + return true; + } + + const token = authService.token; + if (!token) { + return router.parseUrl('/login'); + } + + const isAdmin = authService.hasRole('admin'); + if (!isAdmin) { + return router.parseUrl('/dashboard'); + } + + return true; +}; diff --git a/src/app/guards/auth.guard.ts b/src/app/guards/auth.guard.ts index ba3018f..faa10ed 100644 --- a/src/app/guards/auth.guard.ts +++ b/src/app/guards/auth.guard.ts @@ -10,10 +10,12 @@ export const authGuard: CanActivateFn = () => { // SSR: não existe localStorage. Bloqueia e manda pro login. if (!isPlatformBrowser(platformId)) { - return router.parseUrl('/login'); + // Em SSR não existe acesso ao storage do usuário. + // Deixa renderizar e valida no browser após hidratação. + return true; } - const token = localStorage.getItem('token'); + const token = authService.token; if (!token) { return router.parseUrl('/login'); @@ -22,7 +24,7 @@ export const authGuard: CanActivateFn = () => { const payload = authService.getTokenPayload(); const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId']; if (!tenantId) { - localStorage.removeItem('token'); + authService.logout(); return router.parseUrl('/login'); } diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts index f45079f..69eb54a 100644 --- a/src/app/interceptors/auth.interceptor.ts +++ b/src/app/interceptors/auth.interceptor.ts @@ -1,10 +1,13 @@ import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AuthService } from '../services/auth.service'; export const authInterceptor: HttpInterceptorFn = (req, next) => { // ✅ SSR-safe if (typeof window === 'undefined') return next(req); - const token = localStorage.getItem('token'); + const authService = inject(AuthService); + const token = authService.token; if (!token) return next(req); return next( diff --git a/src/app/interceptors/session.interceptor.ts b/src/app/interceptors/session.interceptor.ts new file mode 100644 index 0000000..9d58e1b --- /dev/null +++ b/src/app/interceptors/session.interceptor.ts @@ -0,0 +1,25 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { catchError, throwError } from 'rxjs'; +import { SessionNoticeService } from '../services/session-notice.service'; + +export const sessionInterceptor: HttpInterceptorFn = (req, next) => { + const platformId = inject(PLATFORM_ID); + if (!isPlatformBrowser(platformId)) { + return next(req); + } + + const sessionNotice = inject(SessionNoticeService); + + return next(req).pipe( + catchError((err: HttpErrorResponse) => { + if (err?.status === 401) { + sessionNotice.handleUnauthorized(); + } else if (err?.status === 403) { + sessionNotice.handleForbidden(); + } + return throwError(() => err); + }) + ); +}; diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html index 092ce92..d65894f 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html @@ -34,7 +34,22 @@ Importação e acompanhamento
-
+
+ + +
@@ -80,14 +95,14 @@ @@ -169,7 +184,7 @@ ITEM NÚMERO DO CHIP OBSERVAÇÕES - AÇÕES + AÇÕES @@ -182,6 +197,12 @@ + +
@@ -258,7 +279,7 @@ QTD. CONTEÚDO DA NF DATA DO RECEBIMENTO - AÇÕES + AÇÕES @@ -274,6 +295,12 @@ + + @@ -295,7 +322,7 @@ NÚMERO DA LINHA VALOR UNIT. VALOR DA NF - AÇÕES + AÇÕES @@ -312,6 +339,12 @@ + + @@ -351,7 +384,7 @@ - + + + + + + + + + + + + + + + + + + + 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 b2ee8cf..0194b05 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss @@ -135,6 +135,7 @@ text-align: center; .title-badge { justify-self: center; margin-bottom: 8px; } + .header-actions { justify-self: center; } } } @@ -173,6 +174,22 @@ } .subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; } +.header-actions { + justify-self: end; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + white-space: nowrap; + } +} /* ========================================================== */ /* TABS E FILTROS */ @@ -213,9 +230,9 @@ /* Pesquisa */ .search-group { - max-width: 300px; + max-width: 270px; border-radius: 12px; - overflow-y: auto; + overflow: hidden; display: flex; align-items: stretch; background: #fff; @@ -261,6 +278,37 @@ &:hover { background: #fff; border-color: var(--blue); } } +.btn-brand { + background-color: var(--brand); + border-color: var(--brand); + color: #fff; + font-weight: 900; + border-radius: 12px; + transition: transform 0.2s, box-shadow 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); + filter: brightness(1.05); + } +} + +.btn-glass { + border-radius: 12px; + font-weight: 900; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(3, 15, 170, 0.24); + color: var(--blue); + transition: all 0.2s ease; + + &:hover { + background: #fff; + border-color: var(--brand); + color: var(--brand); + transform: translateY(-1px); + } +} + /* ========================================================== */ /* BODY (scroll interno igual Mureg) */ /* ========================================================== */ @@ -412,6 +460,7 @@ .font-monospace { font-family: 'JetBrains Mono', monospace; letter-spacing: -0.5px; } .td-clip { max-width: 260px; overflow-y: auto; text-overflow: ellipsis; } .row-clickable { cursor: pointer; } +.actions-col { min-width: 152px; } /* Paginação interna */ .table-pagination { @@ -428,7 +477,14 @@ } /* Ações na tabela (estilo Mureg) */ -.action-group { display: flex; justify-content: center; gap: 6px; } +.action-group { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + white-space: nowrap; +} .action-group .btn-icon { width: 32px; height: 32px; @@ -443,6 +499,8 @@ cursor: pointer; &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); } + &.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); } + &.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); } } /* ========================================================== */ @@ -485,7 +543,7 @@ /* ========================================================== */ .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } -.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow-y: auto; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } +.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; } .modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } .modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @@ -495,13 +553,61 @@ display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } - .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; + .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; background: rgba(3, 15, 170, 0.1); color: var(--blue); &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } + &.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } + &.success { background: var(--success-bg); color: var(--success-text); } + &.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } } -.modal-body { padding: 20px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } +.modal-body { padding: 20px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } } +.modal-body .box-body { overflow: visible; } +.modal-footer { flex-shrink: 0; } +.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; } +.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); } +.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); } +.modal-card.create-modal .edit-sections { gap: 14px; } +.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); } +.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); } +.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); } +.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); } +.modal-card.create-modal .form-control, +.modal-card.create-modal .form-select { min-height: 40px; } +.modal-card.create-modal .form-check-input { + width: 1.05rem; + height: 1.05rem; + border-color: rgba(17, 18, 20, 0.32); + + &:focus { box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); } + &:checked { background-color: var(--brand); border-color: var(--brand); } +} +.modal-card.create-modal .modal-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 14px 20px !important; + background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95)); +} +.modal-card.create-modal .modal-footer .btn { + border-radius: 12px; + font-weight: 900; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; +} +.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; } + +@media (max-width: 700px) { + .modal-card { border-radius: 16px; } + .modal-header { padding: 12px 16px; } + .modal-body { padding: 16px; } + .modal-card.create-modal .modal-footer { flex-direction: column-reverse; } + .modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; } +} .details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow-y: auto; height: auto; display: flex; flex-direction: column; } @@ -517,3 +623,87 @@ div.box-body { padding: 16px; } .val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; } } +.edit-sections { display: grid; gap: 12px; } +.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); } + +summary.box-header { + cursor: pointer; + user-select: none; + list-style: none; + + i:not(.transition-icon) { color: var(--brand); margin-right: 6px; } + &::-webkit-details-marker { display: none; } +} + +.transition-icon { color: var(--muted); transition: transform 0.25s ease, color 0.25s ease; } +details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); } + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + @media (max-width: 700px) { + grid-template-columns: 1fr; + } +} + +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + + &.span-2 { grid-column: span 2; } + + label { + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.64); + } +} + +.form-control, +.form-select { + border-radius: 10px; + border: 1px solid rgba(17,18,20,0.15); + background: #fff; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease; + + &:hover { border-color: rgba(17, 18, 20, 0.36); } + &:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227,61,207,0.15); + outline: none; + transform: translateY(-1px); + } +} + +.confirm-delete { + border: 1px solid rgba(220, 53, 69, 0.16); + background: #fff; + border-radius: 14px; + padding: 18px 16px; + display: flex; + align-items: center; + gap: 12px; + + p { font-weight: 700; color: rgba(17, 18, 20, 0.85); } +} + +.confirm-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(220, 53, 69, 0.12); + color: #dc3545; + flex-shrink: 0; +} + diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts index 769dee2..2877707 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts @@ -2,8 +2,9 @@ import { Component, Inject, PLATFORM_ID, OnInit, OnDestroy } from '@angular/core import { CommonModule, isPlatformBrowser } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; -import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir } from '../../services/chips-controle.service'; +import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir, UpdateChipVirgemRequest, UpdateControleRecebidoRequest, CreateChipVirgemRequest, CreateControleRecebidoRequest } from '../../services/chips-controle.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { AuthService } from '../../services/auth.service'; // Interface para o Agrupamento interface ChipGroup { @@ -18,6 +19,13 @@ interface ControleGroup { items: ControleRecebidoListDto[]; } +interface ChipVirgemCreateModel { + id: string; + item: number | null; + numeroDoChip: string | null; + observacoes: string | null; +} + type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes'; type ControleSortKey = | 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha' @@ -82,19 +90,45 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { chipDetailOpen = false; chipDetailLoading = false; chipDetailData: ChipVirgemListDto | null = null; + chipCreateOpen = false; + chipCreateSaving = false; + chipCreateModel: ChipVirgemCreateModel | null = null; + chipEditOpen = false; + chipEditSaving = false; + chipEditModel: ChipVirgemListDto | null = null; + chipEditingId: string | null = null; + chipDeleteOpen = false; + chipDeleteTarget: ChipVirgemListDto | null = null; controleDetailOpen = false; controleDetailLoading = false; controleDetailData: ControleRecebidoListDto | null = null; + controleCreateOpen = false; + controleCreateSaving = false; + controleCreateModel: ControleRecebidoListDto | null = null; + controleCreateDataNf = ''; + controleCreateRecebimento = ''; + controleEditOpen = false; + controleEditSaving = false; + controleEditModel: ControleRecebidoListDto | null = null; + controleEditDataNf = ''; + controleEditRecebimento = ''; + controleEditingId: string | null = null; + controleDeleteOpen = false; + controleDeleteTarget: ControleRecebidoListDto | null = null; + + isAdmin = false; constructor( @Inject(PLATFORM_ID) private platformId: object, private service: ChipsControleService, - private http: HttpClient + private http: HttpClient, + private authService: AuthService ) {} ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; + this.isAdmin = this.authService.hasRole('admin'); this.fetchChips(); this.fetchControle(); } @@ -200,6 +234,118 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { }); } + openChipCreate() { + if (!this.isAdmin) return; + this.chipCreateModel = { + id: '', + item: null, + numeroDoChip: '', + observacoes: '' + }; + this.chipCreateOpen = true; + this.chipCreateSaving = false; + } + + closeChipCreate() { + this.chipCreateOpen = false; + this.chipCreateSaving = false; + this.chipCreateModel = null; + } + + saveChipCreate() { + if (!this.chipCreateModel) return; + this.chipCreateSaving = true; + + const payload: CreateChipVirgemRequest = { + item: this.toNullableNumber(this.chipCreateModel.item), + numeroDoChip: this.chipCreateModel.numeroDoChip, + observacoes: this.chipCreateModel.observacoes + }; + + this.service.createChipVirgem(payload).subscribe({ + next: () => { + this.chipCreateSaving = false; + this.closeChipCreate(); + this.fetchChips(); + this.showToast('Chip criado com sucesso!', 'success'); + }, + error: () => { + this.chipCreateSaving = false; + this.showToast('Erro ao criar chip.', 'danger'); + } + }); + } + + openChipEdit(row: ChipVirgemListDto) { + if (!this.isAdmin) return; + this.service.getChipVirgemById(row.id).subscribe({ + next: (data) => { + this.chipEditingId = data.id; + this.chipEditModel = { ...data }; + this.chipEditOpen = true; + }, + error: () => this.showToast('Erro ao abrir edição.', 'danger') + }); + } + + closeChipEdit() { + this.chipEditOpen = false; + this.chipEditSaving = false; + this.chipEditModel = null; + this.chipEditingId = null; + } + + saveChipEdit() { + if (!this.chipEditModel || !this.chipEditingId) return; + this.chipEditSaving = true; + const payload: UpdateChipVirgemRequest = { + item: this.toNullableNumber(this.chipEditModel.item), + numeroDoChip: this.chipEditModel.numeroDoChip, + observacoes: this.chipEditModel.observacoes + }; + this.service.updateChipVirgem(this.chipEditingId, payload).subscribe({ + next: () => { + this.chipEditSaving = false; + this.closeChipEdit(); + this.fetchChips(); + this.showToast('Chip atualizado!', 'success'); + }, + error: () => { + this.chipEditSaving = false; + this.showToast('Erro ao salvar.', 'danger'); + } + }); + } + + openChipDelete(row: ChipVirgemListDto) { + if (!this.isAdmin) return; + this.chipDeleteTarget = row; + this.chipDeleteOpen = true; + } + + cancelChipDelete() { + this.chipDeleteOpen = false; + this.chipDeleteTarget = null; + } + + confirmChipDelete() { + if (!this.chipDeleteTarget) return; + const id = this.chipDeleteTarget.id; + this.service.removeChipVirgem(id).subscribe({ + next: () => { + this.chipDeleteOpen = false; + this.chipDeleteTarget = null; + this.fetchChips(); + this.showToast('Chip removido.', 'success'); + }, + error: () => { + this.chipDeleteOpen = false; + this.chipDeleteTarget = null; + this.showToast('Erro ao remover.', 'danger'); + } + }); + } + closeChipDetail() { this.chipDetailOpen = false; this.chipDetailLoading = false; @@ -349,6 +495,216 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { }); } + openControleCreate() { + if (!this.isAdmin) return; + this.controleCreateModel = { + id: '', + ano: new Date().getFullYear(), + item: null, + notaFiscal: '', + chip: '', + serial: '', + conteudoDaNf: '', + numeroDaLinha: '', + valorUnit: null, + valorDaNf: null, + dataDaNf: null, + dataDoRecebimento: null, + quantidade: null, + isResumo: false + } as ControleRecebidoListDto; + this.controleCreateDataNf = ''; + this.controleCreateRecebimento = ''; + this.controleCreateOpen = true; + this.controleCreateSaving = false; + } + + closeControleCreate() { + this.controleCreateOpen = false; + this.controleCreateSaving = false; + this.controleCreateModel = null; + this.controleCreateDataNf = ''; + this.controleCreateRecebimento = ''; + } + + onControleCreateValueChange() { + if (!this.controleCreateModel) return; + this.recalculateControleTotals(this.controleCreateModel); + } + + onControleEditValueChange() { + if (!this.controleEditModel) return; + this.recalculateControleTotals(this.controleEditModel); + } + + onControleCreateDateChange() { + if (!this.controleCreateModel) return; + if (!this.controleCreateModel.ano && this.controleCreateDataNf) { + const year = new Date(this.controleCreateDataNf).getFullYear(); + if (Number.isFinite(year)) this.controleCreateModel.ano = year; + } + } + + onControleEditDateChange() { + if (!this.controleEditModel) return; + if (!this.controleEditModel.ano && this.controleEditDataNf) { + const year = new Date(this.controleEditDataNf).getFullYear(); + if (Number.isFinite(year)) this.controleEditModel.ano = year; + } + } + + private recalculateControleTotals(model: ControleRecebidoListDto) { + const quantidade = this.toNullableNumber(model.quantidade); + const valorUnit = this.toNullableNumber(model.valorUnit); + const valorDaNf = this.toNullableNumber(model.valorDaNf); + + if (quantidade != null && valorUnit != null && (valorDaNf == null || valorDaNf === 0)) { + model.valorDaNf = Number((valorUnit * quantidade).toFixed(2)); + } else if (quantidade != null && valorDaNf != null && (valorUnit == null || valorUnit === 0)) { + model.valorUnit = Number((valorDaNf / quantidade).toFixed(2)); + } + } + + saveControleCreate() { + if (!this.controleCreateModel) return; + this.recalculateControleTotals(this.controleCreateModel); + this.controleCreateSaving = true; + + const payload: CreateControleRecebidoRequest = { + ano: this.toNullableNumber(this.controleCreateModel.ano), + item: this.toNullableNumber(this.controleCreateModel.item), + notaFiscal: this.controleCreateModel.notaFiscal, + chip: this.controleCreateModel.chip, + serial: this.controleCreateModel.serial, + conteudoDaNf: this.controleCreateModel.conteudoDaNf, + numeroDaLinha: this.controleCreateModel.numeroDaLinha, + valorUnit: this.toNullableNumber(this.controleCreateModel.valorUnit), + valorDaNf: this.toNullableNumber(this.controleCreateModel.valorDaNf), + dataDaNf: this.dateInputToIso(this.controleCreateDataNf), + dataDoRecebimento: this.dateInputToIso(this.controleCreateRecebimento), + quantidade: this.toNullableNumber(this.controleCreateModel.quantidade), + isResumo: this.controleCreateModel.isResumo ?? false + }; + + this.service.createControleRecebido(payload).subscribe({ + next: () => { + this.controleCreateSaving = false; + this.closeControleCreate(); + this.fetchControle(); + this.showToast('Recebimento criado com sucesso!', 'success'); + }, + error: () => { + this.controleCreateSaving = false; + this.showToast('Erro ao criar recebimento.', 'danger'); + } + }); + } + + openControleEdit(row: ControleRecebidoListDto) { + if (!this.isAdmin) return; + this.service.getControleRecebidoById(row.id).subscribe({ + next: (data) => { + this.controleEditingId = data.id; + this.controleEditModel = { ...data }; + this.controleEditDataNf = this.toDateInput(data.dataDaNf); + this.controleEditRecebimento = this.toDateInput(data.dataDoRecebimento); + this.controleEditOpen = true; + }, + error: () => this.showToast('Erro ao abrir edição.', 'danger') + }); + } + + closeControleEdit() { + this.controleEditOpen = false; + this.controleEditSaving = false; + this.controleEditModel = null; + this.controleEditDataNf = ''; + this.controleEditRecebimento = ''; + this.controleEditingId = null; + } + + saveControleEdit() { + if (!this.controleEditModel || !this.controleEditingId) return; + this.recalculateControleTotals(this.controleEditModel); + this.controleEditSaving = true; + const payload: UpdateControleRecebidoRequest = { + ano: this.toNullableNumber(this.controleEditModel.ano), + item: this.toNullableNumber(this.controleEditModel.item), + notaFiscal: this.controleEditModel.notaFiscal, + chip: this.controleEditModel.chip, + serial: this.controleEditModel.serial, + conteudoDaNf: this.controleEditModel.conteudoDaNf, + numeroDaLinha: this.controleEditModel.numeroDaLinha, + valorUnit: this.toNullableNumber(this.controleEditModel.valorUnit), + valorDaNf: this.toNullableNumber(this.controleEditModel.valorDaNf), + dataDaNf: this.dateInputToIso(this.controleEditDataNf), + dataDoRecebimento: this.dateInputToIso(this.controleEditRecebimento), + quantidade: this.toNullableNumber(this.controleEditModel.quantidade), + isResumo: this.controleEditModel.isResumo ?? false + }; + this.service.updateControleRecebido(this.controleEditingId, payload).subscribe({ + next: () => { + this.controleEditSaving = false; + this.closeControleEdit(); + this.fetchControle(); + this.showToast('Registro atualizado!', 'success'); + }, + error: () => { + this.controleEditSaving = false; + this.showToast('Erro ao salvar.', 'danger'); + } + }); + } + + openControleDelete(row: ControleRecebidoListDto) { + if (!this.isAdmin) return; + this.controleDeleteTarget = row; + this.controleDeleteOpen = true; + } + + cancelControleDelete() { + this.controleDeleteOpen = false; + this.controleDeleteTarget = null; + } + + confirmControleDelete() { + if (!this.controleDeleteTarget) return; + const id = this.controleDeleteTarget.id; + this.service.removeControleRecebido(id).subscribe({ + next: () => { + this.controleDeleteOpen = false; + this.controleDeleteTarget = null; + this.fetchControle(); + this.showToast('Registro removido.', 'success'); + }, + error: () => { + this.controleDeleteOpen = false; + this.controleDeleteTarget = null; + this.showToast('Erro ao remover.', 'danger'); + } + }); + } + + private toDateInput(value: string | null): string { + if (!value) return ''; + const d = new Date(value); + if (isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 10); + } + + private dateInputToIso(value: string): string | null { + if (!value) return null; + const d = new Date(`${value}T00:00:00`); + if (isNaN(d.getTime())) return null; + return d.toISOString(); + } + + private toNullableNumber(value: any): number | null { + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + return Number.isNaN(n) ? null : n; + } + closeControleDetail() { this.controleDetailOpen = false; this.controleDetailLoading = false; diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html index 703d9f5..d3de0ff 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.html +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -22,16 +22,19 @@
- DADOS USUÁRIOS + DADOS PF/PJ
-
GESTÃO DE USUÁRIOS
- Base de dados agrupada por cliente +
GESTÃO DE USUÁRIOS PF/PJ
+ Base de dados separada por pessoa física e jurídica
+
@@ -51,10 +54,10 @@
- Com CPF + {{ tipoFilter === 'PJ' ? 'Com CNPJ' : 'Com CPF' }} - {{ kpiComCpf || 0 }} + {{ tipoFilter === 'PJ' ? (kpiComCnpj || 0) : (kpiComCpf || 0) }}
@@ -67,9 +70,17 @@
+
+ + +
- +
@@ -99,7 +110,8 @@
{{ g.cliente }}
{{ g.totalRegistros }} Registros - {{ g.comCpf }} CPF + {{ g.comCpf }} CPF + {{ g.comCnpj }} CNPJ {{ g.comEmail }} Email
@@ -122,10 +134,10 @@ ITEM LINHA - CPF + {{ tipoFilter === 'PJ' ? 'CNPJ' : 'CPF' }} E-MAIL CELULAR - AÇÕES + AÇÕES @@ -135,12 +147,14 @@ {{ r.item }} {{ r.linha }} - {{ r.cpf || '-' }} + {{ tipoFilter === 'PJ' ? (r.cnpj || '-') : (r.cpf || '-') }} {{ r.email || '-' }} {{ r.celular || '-' }}
+ +
@@ -167,9 +181,9 @@ - -
+ +
+ + +
+
+
Modo
+
+ + + +
+
+ +
+
Serviços
+
+ +
+
+ + +
+
Total Clientes - - - {{ kpiTotalClientes || 0 }} + + + {{ kpiTotalClientes || 0 }}
Total Linhas - - - {{ kpiTotalLinhas || 0 }} + + + {{ kpiTotalLinhas || 0 }}
Ativas - - - {{ kpiAtivas || 0 }} + + + {{ kpiAtivas || 0 }}
Bloqueadas - - - {{ kpiBloqueadas || 0 }} + + + {{ kpiBloqueadas || 0 }}
@@ -204,14 +290,14 @@
{{ group.cliente }}
-
- {{ group.totalLinhas }} Linhas - {{ group.ativos }} Ativas - {{ group.bloqueados }} Bloq. -
-
-
+
+ {{ group.totalLinhas }} linhas + {{ group.ativos }} ativas + {{ group.bloqueados }} bloqueadas
+
+
+
@@ -253,7 +339,7 @@ - +
@@ -370,7 +456,7 @@ - +
@@ -428,9 +514,8 @@ @@ -615,6 +734,7 @@
+
@@ -698,6 +818,10 @@ Chip (ICCID) {{ detailData.chip || '-' }} +
+ Tipo de Chip + {{ detailData.tipoDeChip || '-' }} +
@@ -744,6 +868,14 @@ Data Bloqueio {{ formatDateBr(detailData.dataBloqueio) }} +
+ Efetivação Serviço + {{ formatDateBr(detailData.dtEfetivacaoServico) }} +
+
+ Término Fidelização + {{ formatDateBr(detailData.dtTerminoFidelizacao) }} +
@@ -817,6 +949,7 @@
Vivo News+ {{ formatMoney(financeData.vivoNewsPlus) }}
Travel Mundo {{ formatMoney(financeData.vivoTravelMundo) }}
Gestão Disp. {{ formatMoney(financeData.vivoGestaoDispositivo) }}
+
Vivo Sync {{ formatMoney(financeData.vivoSync) }}
Total Vivo {{ formatMoney(financeData.valorContratoVivo) }}
@@ -882,9 +1015,11 @@
+
+
@@ -895,7 +1030,7 @@ Contrato & Plano
-
+
@@ -910,6 +1045,8 @@
+
+
@@ -928,6 +1065,7 @@
+
@@ -963,4 +1101,3 @@ - diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 05a0880..4151b81 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -41,13 +41,13 @@ /* 2. LAYOUT DA PÁGINA (Vertical Destravado) */ /* ========================================================== */ .geral-page { - min-height: 100vh; - padding: 0 12px var(--page-bottom-gap); + min-height: 100dvh; + padding: var(--page-top-gap) 12px var(--page-bottom-gap); display: flex; align-items: flex-start; justify-content: center; position: relative; - overflow-y: auto; /* Scroll na janela */ + overflow: visible; background: radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), @@ -80,7 +80,7 @@ max-width: 1100px; /* Largura controlada */ position: relative; z-index: 1; - margin-top: var(--page-top-gap); + margin-top: 0; margin-bottom: var(--page-bottom-gap); margin-left: auto; margin-right: auto; } @@ -138,9 +138,123 @@ .dropdown-list { overflow-y: auto; max-height: 300px; } .dropdown-item-custom { padding: 10px 16px; font-size: 0.85rem; color: var(--text); cursor: pointer; border-bottom: 1px solid rgba(0,0,0,0.03); transition: background 0.1s; &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } } +.additional-filter-wrap { + position: relative; +} + +.btn-additional-filter { + min-width: 160px; + max-width: 230px; +} + +.additional-dropdown { + width: min(420px, calc(100vw - 24px)); + max-height: 460px; + padding: 10px; + overflow: auto; + gap: 10px; +} + +.additional-dropdown-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.additional-dropdown-title { + font-size: 0.74rem; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.6); +} + +.additional-dropdown-footer { + padding-top: 4px; + display: flex; + justify-content: flex-start; +} + +.additional-mode-tabs { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.additional-mode-btn { + border: 1px solid rgba(17, 18, 20, 0.12); + background: rgba(255, 255, 255, 0.7); + color: var(--muted); + font-weight: 800; + font-size: 0.8rem; + border-radius: 999px; + padding: 6px 12px; + transition: all 0.2s ease; + + &:hover { + border-color: var(--blue); + color: var(--blue); + background: #fff; + } + + &.active { + border-color: var(--brand); + color: var(--brand); + background: #fff; + box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.additional-services-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.additional-chip-btn { + border: 1px solid rgba(17, 18, 20, 0.12); + background: rgba(255, 255, 255, 0.68); + color: var(--muted); + font-weight: 700; + font-size: 0.78rem; + border-radius: 999px; + padding: 5px 10px; + transition: all 0.2s ease; + + &:hover { + border-color: var(--blue); + color: var(--blue); + background: #fff; + } + + &.active { + border-color: var(--brand); + color: var(--brand); + background: rgba(227, 61, 207, 0.12); + } + + &.clear { + color: var(--blue); + border-color: rgba(3, 15, 170, 0.18); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + /* KPIs */ .geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } } .kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(8px); transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.02); &:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1); background: #fff; border-color: var(--brand); } .lbl { font-size: 0.72rem; font-weight: 900; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); &.text-success { color: var(--success-text) !important; } &.text-danger { color: var(--danger-text) !important; } } .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } } +.kpi .val-loading { font-size: 0.86rem; font-weight: 900; color: var(--muted); display: inline-flex; align-items: center; } + +/* Insights */ /* Controls */ .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } @@ -166,6 +280,10 @@ .group-info { display: flex; flex-direction: column; gap: 6px; } .group-badges { display: flex; gap: 8px; flex-wrap: wrap; } .badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase; &.total { background: rgba(3,15,170,0.1); color: var(--blue); } &.ok { background: var(--success-bg); color: var(--success-text); } } +.group-tags { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-pill { font-size: 0.65rem; padding: 4px 8px; border-radius: 999px; font-weight: 800; text-transform: uppercase; background: rgba(3,15,170,0.08); color: var(--blue); border: 1px solid rgba(3,15,170,0.16); } +.tag-pill.active { background: var(--success-bg); color: var(--success-text); border-color: rgba(25,135,84,0.22); } +.tag-pill.blocked { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(220,53,69,0.22); } .group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; } .client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); } .group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); } @@ -209,6 +327,7 @@ .modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body .box-body { overflow: visible; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } +.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; } /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 4b919bc..8fc761c 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -16,10 +16,16 @@ import { HttpParams, HttpErrorResponse } from '@angular/common/http'; +import { NavigationEnd, Router } from '@angular/router'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { PlanAutoFillService } from '../../services/plan-autofill.service'; +import { AuthService } from '../../services/auth.service'; +import { firstValueFrom, Subscription, filter } from 'rxjs'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; +type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; +type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; interface LineRow { id: string; @@ -48,6 +54,12 @@ interface ApiLineList { vencConta: string | null; status?: string | null; skil?: string | null; + gestaoVozDados?: number | null; + skeelo?: number | null; + vivoNewsPlus?: number | null; + vivoTravelMundo?: number | null; + vivoSync?: number | null; + vivoGestaoDispositivo?: number | null; } interface ApiLineDetail { @@ -57,6 +69,7 @@ interface ApiLineDetail { conta?: string | null; linha?: string | null; chip?: string | null; + tipoDeChip?: string | null; cliente?: string | null; usuario?: string | null; planoContrato?: string | null; @@ -68,6 +81,8 @@ interface ApiLineDetail { solicitante?: string | null; dataEntregaOpera?: string | null; dataEntregaCliente?: string | null; + dtEfetivacaoServico?: string | null; + dtTerminoFidelizacao?: string | null; vencConta?: string | null; franquiaVivo?: number | null; @@ -77,6 +92,7 @@ interface ApiLineDetail { vivoNewsPlus?: number | null; vivoTravelMundo?: number | null; vivoGestaoDispositivo?: number | null; + vivoSync?: number | null; valorContratoVivo?: number | null; franquiaLine?: number | null; @@ -98,6 +114,12 @@ interface ClientGroupDto { bloqueados: number; } +interface AccountCompanyOption { + empresa: string; + contas: string[]; +} + + @Component({ standalone: true, imports: [CommonModule, FormsModule, CustomSelectComponent], @@ -118,11 +140,15 @@ export class Geral implements AfterViewInit, OnDestroy { constructor( @Inject(PLATFORM_ID) private platformId: object, private http: HttpClient, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private planAutoFill: PlanAutoFillService, + private authService: AuthService, + private router: Router ) {} private readonly apiBase = 'https://localhost:7205/api/lines'; loading = false; + isAdmin = false; rows: LineRow[] = []; clientGroups: ClientGroupDto[] = []; @@ -132,12 +158,23 @@ export class Geral implements AfterViewInit, OnDestroy { searchTerm = ''; filterSkil: 'ALL' | 'PF' | 'PJ' | 'RESERVA' = 'ALL'; + additionalMode: AdditionalMode = 'ALL'; + selectedAdditionalServices: AdditionalServiceKey[] = []; + readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [ + { key: 'gvd', label: 'Gestão Voz e Dados' }, + { key: 'skeelo', label: 'Skeelo' }, + { key: 'news', label: 'Vivo News Plus' }, + { key: 'travel', label: 'Vivo Travel Mundo' }, + { key: 'sync', label: 'Vivo Sync' }, + { key: 'dispositivo', label: 'Vivo Gestão Dispositivo' } + ]; clientsList: string[] = []; loadingClientsList = false; selectedClients: string[] = []; showClientMenu = false; + showAdditionalMenu = false; clientSearchTerm = ''; viewMode: 'GROUPS' | 'TABLE' = 'GROUPS'; @@ -164,8 +201,14 @@ export class Geral implements AfterViewInit, OnDestroy { private editingId: string | null = null; private searchTimer: any = null; + private navigationSub?: Subscription; + private keepPageOnNextGroupsLoad = false; private searchResolvedClient: string | null = null; + private kpiRequestVersion = 0; + private groupsRequestVersion = 0; + private linesRequestVersion = 0; + private clientsRequestVersion = 0; loadingKpis = false; kpiTotalClientes = 0; @@ -176,7 +219,7 @@ export class Geral implements AfterViewInit, OnDestroy { readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS']; readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA']; - readonly planOptions = [ + planOptions = [ 'SMART EMPRESAS 0.2GB TE', 'SMART EMPRESAS 0.5GB TE', 'SMART EMPRESAS 2GB D', @@ -190,20 +233,35 @@ export class Geral implements AfterViewInit, OnDestroy { 'M2M 50MB' ]; - readonly contaOptions = [ - '0172593311', - '0172593840', - '0430237019', - '0435288088', - '0437488125', - '0449508564', - '0455371844', - 'CLARO', - 'TIM' + private readonly fallbackAccountCompanies: AccountCompanyOption[] = [ + { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840'] }, + { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844'] }, + { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] }, + { empresa: 'TIM LINE MÓVEL', contas: ['0072046192'] } ]; + accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; + loadingAccountCompanies = false; + + get contaEmpresaOptions(): string[] { + return this.accountCompanies.map((x) => x.empresa); + } + + get contaOptionsForCreate(): string[] { + return this.getContasByEmpresa(this.createModel?.contaEmpresa); + } + + get contaEmpresaOptionsForEdit(): string[] { + return this.mergeOption(this.editModel?.contaEmpresa, this.contaEmpresaOptions); + } + get contaOptionsForEdit(): string[] { - return this.mergeOption(this.editModel?.conta, this.contaOptions); + const empresaSelecionada = (this.editModel?.contaEmpresa ?? '').toString().trim(); + const baseOptions = empresaSelecionada + ? this.getContasByEmpresa(empresaSelecionada) + : this.getAllContas(); + + return this.mergeOption(this.editModel?.conta, baseOptions); } get planOptionsForEdit(): string[] { @@ -222,8 +280,10 @@ export class Geral implements AfterViewInit, OnDestroy { cliente: '', docType: 'PF', docNumber: '', + contaEmpresa: '', linha: '', chip: '', + tipoDeChip: '', usuario: '', status: '', planoContrato: '', @@ -237,6 +297,8 @@ export class Geral implements AfterViewInit, OnDestroy { dataBloqueio: '', dataEntregaOpera: '', dataEntregaCliente: '', + dtEfetivacaoServico: '', + dtTerminoFidelizacao: '', franquiaVivo: null, valorPlanoVivo: null, gestaoVozDados: null, @@ -244,6 +306,7 @@ export class Geral implements AfterViewInit, OnDestroy { vivoNewsPlus: null, vivoTravelMundo: null, vivoGestaoDispositivo: null, + vivoSync: null, valorContratoVivo: null, franquiaLine: null, franquiaGestao: null, @@ -257,6 +320,25 @@ export class Geral implements AfterViewInit, OnDestroy { return this.viewMode === 'GROUPS'; } + get isKpiLoading(): boolean { + return this.loading || this.loadingKpis; + } + + get hasAdditionalFiltersApplied(): boolean { + return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0; + } + + get additionalModeLabel(): string { + if (this.additionalMode === 'WITH') return 'Com adicionais'; + if (this.additionalMode === 'WITHOUT') return 'Sem adicionais'; + return 'Todos os adicionais'; + } + + get additionalSelectedLabels(): string[] { + return this.selectedAdditionalServices + .map((key) => this.additionalServiceOptions.find((x) => x.key === key)?.label ?? key); + } + // ✅ fecha dropdown ao clicar fora @HostListener('document:click', ['$event']) onDocumentClick(ev: MouseEvent) { @@ -265,33 +347,57 @@ export class Geral implements AfterViewInit, OnDestroy { // Se modal estiver aberto, não mexe no dropdown por clique no overlay if (this.anyModalOpen()) return; - if (!this.showClientMenu) return; + if (!this.showClientMenu && !this.showAdditionalMenu) return; const target = ev.target as HTMLElement | null; if (!target) return; - const inside = !!target.closest('.client-filter-wrap'); - if (!inside) { + const insideClient = !!target.closest('.client-filter-wrap'); + const insideAdditional = !!target.closest('.additional-filter-wrap'); + let changed = false; + + if (this.showClientMenu && !insideClient) { this.showClientMenu = false; + changed = true; + } + + if (this.showAdditionalMenu && !insideAdditional) { + this.showAdditionalMenu = false; + changed = true; + } + + if (changed) { this.cdr.detectChanges(); } } // ✅ ESC fecha dropdown OU modal (sem conflito) @HostListener('document:keydown', ['$event']) - onDocumentKeydown(ev: KeyboardEvent) { + onDocumentKeydown(ev: Event) { if (!isPlatformBrowser(this.platformId)) return; - if (ev.key === 'Escape') { + const keyboard = ev as KeyboardEvent; + if (keyboard.key === 'Escape') { if (this.anyModalOpen()) { - ev.preventDefault(); - ev.stopPropagation(); + keyboard.preventDefault(); + keyboard.stopPropagation(); this.closeAllModals(); return; } + let changed = false; + if (this.showClientMenu) { this.showClientMenu = false; - ev.stopPropagation(); + changed = true; + } + + if (this.showAdditionalMenu) { + this.showAdditionalMenu = false; + changed = true; + } + + if (changed) { + keyboard.stopPropagation(); this.cdr.detectChanges(); } } @@ -299,16 +405,20 @@ export class Geral implements AfterViewInit, OnDestroy { ngOnDestroy(): void { if (this.searchTimer) clearTimeout(this.searchTimer); + this.navigationSub?.unsubscribe(); } async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; + this.isAdmin = this.authService.hasRole('admin'); this.initAnimations(); setTimeout(() => { this.refreshData(); this.loadClients(); + this.loadPlanRules(); + this.loadAccountCompanies(); const state = history.state; if (state && state.toastMessage) { @@ -319,6 +429,17 @@ export class Geral implements AfterViewInit, OnDestroy { this.showToast(msg); } }); + + this.navigationSub = this.router.events + .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) + .subscribe((event) => { + const url = (event.urlAfterRedirects || '').toLowerCase(); + if (!url.includes('/geral')) return; + + this.searchResolvedClient = null; + this.loadClients(); + this.refreshData(); + }); } private initAnimations() { @@ -329,6 +450,43 @@ export class Geral implements AfterViewInit, OnDestroy { }, 100); } + private async loadPlanRules() { + try { + await this.planAutoFill.load(); + const extraPlans = this.planAutoFill.getPlanOptions(); + if (extraPlans.length > 0) { + this.planOptions = this.mergeOptionList(this.planOptions, extraPlans); + this.cdr.detectChanges(); + } + } catch { + // silencioso: segue com a lista estática + } + } + + private loadAccountCompanies() { + this.loadingAccountCompanies = true; + + this.http.get(`${this.apiBase}/account-companies`).subscribe({ + next: (data) => { + const normalized = this.normalizeAccountCompanies(data); + this.accountCompanies = + normalized.length > 0 ? normalized : [...this.fallbackAccountCompanies]; + this.loadingAccountCompanies = false; + + this.syncContaEmpresaSelection(this.createModel); + this.syncContaEmpresaSelection(this.editModel); + this.cdr.detectChanges(); + }, + error: () => { + this.accountCompanies = [...this.fallbackAccountCompanies]; + this.loadingAccountCompanies = false; + + this.syncContaEmpresaSelection(this.createModel); + this.syncContaEmpresaSelection(this.editModel); + } + }); + } + // ============================================================ // ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock // ============================================================ @@ -381,7 +539,16 @@ export class Geral implements AfterViewInit, OnDestroy { // ============================================================ - refreshData() { + private withNoCache(params: HttpParams): HttpParams { + return params.set('_ts', Date.now().toString()); + } + + refreshData(opts?: { keepCurrentPage?: boolean }) { + const keepCurrentPage = !!opts?.keepCurrentPage; + this.keepPageOnNextGroupsLoad = keepCurrentPage; + if (!keepCurrentPage && this.filterSkil === 'RESERVA') { + this.page = 1; + } this.searchResolvedClient = null; this.loadKpis(); this.viewMode = 'GROUPS'; @@ -405,20 +572,21 @@ export class Geral implements AfterViewInit, OnDestroy { const s = (term ?? '').trim(); if (!s) return Promise.resolve(null); - let params = new HttpParams().set('page', '1').set('pageSize', '1').set('search', s); - - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); + const pageSize = this.hasAdditionalFiltersApplied ? '500' : '1'; + let params = new HttpParams().set('page', '1').set('pageSize', pageSize).set('search', s); + params = this.applyBaseFilters(params); if (this.selectedClients.length > 0) { this.selectedClients.forEach((c) => (params = params.append('client', c))); } return new Promise((resolve) => { - this.http.get>(this.apiBase, { params }).subscribe({ + this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({ next: (res) => { - const first = (res.items ?? [])[0]; + const source = this.hasAdditionalFiltersApplied + ? this.applyAdditionalFiltersClientSide(res.items ?? []) + : (res.items ?? []); + const first = source[0]; const client = (first?.cliente ?? '').trim(); resolve(client || null); }, @@ -464,31 +632,43 @@ export class Geral implements AfterViewInit, OnDestroy { } private loadOnlyThisClientGroup(clientName: string): Promise { + const requestVersion = ++this.groupsRequestVersion; this.loading = true; - let params = new HttpParams().set('page', '1').set('pageSize', '9999'); + if (this.hasAdditionalFiltersApplied) { + return this.loadOnlyThisClientGroupFromLines(clientName, requestVersion); + } - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); + let params = new HttpParams().set('page', '1').set('pageSize', '9999'); + params = this.applyBaseFilters(params); params = params.append('client', clientName); return new Promise((resolve) => { - this.http.get>(`${this.apiBase}/groups`, { params }).subscribe({ + this.http.get>(`${this.apiBase}/groups`, { params: this.withNoCache(params) }).subscribe({ next: (res) => { + if (requestVersion !== this.groupsRequestVersion) { + resolve(); + return; + } + let items = res.items || []; items = items.filter( (g) => (g.cliente || '').trim().toUpperCase() === (clientName || '').trim().toUpperCase() ); - this.clientGroups = items; + this.clientGroups = this.sortGroupsWithReservaFirst(items); this.total = items.length; this.loading = false; this.cdr.detectChanges(); resolve(); }, error: () => { + if (requestVersion !== this.groupsRequestVersion) { + resolve(); + return; + } + this.loading = false; this.showToast('Erro ao carregar grupos.'); resolve(); @@ -497,34 +677,112 @@ export class Geral implements AfterViewInit, OnDestroy { }); } + private async loadOnlyThisClientGroupFromLines(clientName: string, requestVersion: number): Promise { + try { + const lines = await this.fetchLinesForGrouping(); + if (requestVersion !== this.groupsRequestVersion) return; + + const target = (clientName || '').trim().toUpperCase(); + let groups = this.buildGroupsFromLines(lines); + groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); + + this.clientGroups = this.sortGroupsWithReservaFirst(groups); + this.total = groups.length; + this.loading = false; + this.cdr.detectChanges(); + } catch { + if (requestVersion !== this.groupsRequestVersion) return; + this.loading = false; + this.showToast('Erro ao carregar grupos.'); + } + } + private loadClients() { + const requestVersion = ++this.clientsRequestVersion; this.loadingClientsList = true; this.clientsList = []; + if (this.hasAdditionalFiltersApplied) { + void this.loadClientsFromLines(requestVersion); + return; + } + let params = new HttpParams(); + params = this.applyBaseFilters(params); - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); - - this.http.get(`${this.apiBase}/clients`, { params }).subscribe({ + this.http.get(`${this.apiBase}/clients`, { params: this.withNoCache(params) }).subscribe({ next: (data) => { + if (requestVersion !== this.clientsRequestVersion) return; this.clientsList = data || []; this.loadingClientsList = false; }, error: () => { + if (requestVersion !== this.clientsRequestVersion) return; this.loadingClientsList = false; console.error('Erro ao carregar lista de clientes para o filtro.'); } }); } + private async loadClientsFromLines(requestVersion: number): Promise { + try { + let baseParams = new HttpParams() + .set('sortBy', 'cliente') + .set('sortDir', 'asc'); + baseParams = this.applyBaseFilters(baseParams); + + const pageSize = 5000; + let page = 1; + let expectedTotal = 0; + const allLines: ApiLineList[] = []; + + while (page <= 500) { + const params = baseParams + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom( + this.http.get>(this.apiBase, { params: this.withNoCache(params) }) + ); + + const items = response?.items ?? []; + expectedTotal = this.toInt(response?.total); + allLines.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && allLines.length >= expectedTotal) break; + + page += 1; + } + + if (requestVersion !== this.clientsRequestVersion) return; + + const filteredLines = this.applyAdditionalFiltersClientSide(allLines); + const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : ''; + const clients = filteredLines + .map((x) => ((x.cliente ?? '').toString().trim()) || fallbackClient) + .filter((x) => !!x); + + this.clientsList = Array.from(new Set(clients)).sort((a, b) => + a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }) + ); + this.loadingClientsList = false; + } catch { + if (requestVersion !== this.clientsRequestVersion) return; + this.loadingClientsList = false; + console.error('Erro ao carregar lista de clientes para o filtro.'); + } + } + setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') { - if (this.filterSkil === type) return; + const isSameFilter = this.filterSkil === type; this.expandedGroup = null; this.groupLines = []; - this.filterSkil = type; + if (!isSameFilter) { + this.filterSkil = type; + } this.selectedClients = []; this.clientSearchTerm = ''; @@ -536,69 +794,281 @@ export class Geral implements AfterViewInit, OnDestroy { this.refreshData(); } - private loadKpis() { - this.loadingKpis = true; + setAdditionalMode(mode: AdditionalMode) { + if (this.additionalMode === mode) return; - let params = new HttpParams().set('page', '1').set('pageSize', '99999'); + this.additionalMode = mode; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); - - if (this.searchResolvedClient) { - params = params.append('client', this.searchResolvedClient); - } else { - if (this.searchTerm) params = params.set('search', this.searchTerm); - - if (this.selectedClients.length > 0) { - this.selectedClients.forEach((c) => (params = params.append('client', c))); - } - } - - this.http.get>(`${this.apiBase}/groups`, { params }).subscribe({ - next: (res) => { - let allGroups = res.items || []; - - if (this.searchResolvedClient) { - const target = (this.searchResolvedClient || '').trim().toUpperCase(); - allGroups = allGroups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); - } else if (this.selectedClients.length > 0) { - allGroups = allGroups.filter((g) => - this.selectedClients.some( - (selected) => - (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase() - ) - ); - } - - this.kpiTotalClientes = allGroups.length; - this.kpiTotalLinhas = allGroups.reduce((acc, g) => acc + (g.totalLinhas || 0), 0); - this.kpiAtivas = allGroups.reduce((acc, g) => acc + (g.ativos || 0), 0); - this.kpiBloqueadas = allGroups.reduce((acc, g) => acc + (g.bloqueados || 0), 0); - - this.loadingKpis = false; - this.cdr.detectChanges(); - }, - error: () => { - this.loadingKpis = false; - } - }); + this.loadClients(); + this.refreshData(); } + toggleAdditionalService(key: AdditionalServiceKey) { + const idx = this.selectedAdditionalServices.indexOf(key); + if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1); + else this.selectedAdditionalServices.push(key); + + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + + isAdditionalServiceSelected(key: AdditionalServiceKey): boolean { + return this.selectedAdditionalServices.includes(key); + } + + clearAdditionalFilters() { + this.additionalMode = 'ALL'; + this.selectedAdditionalServices = []; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + + private applyBaseFilters(params: HttpParams): HttpParams { + let next = params; + + if (this.filterSkil === 'PF') next = next.set('skil', 'PESSOA FÍSICA'); + else if (this.filterSkil === 'PJ') next = next.set('skil', 'PESSOA JURÍDICA'); + else if (this.filterSkil === 'RESERVA') next = next.set('skil', 'RESERVA'); + + if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with'); + else if (this.additionalMode === 'WITHOUT') next = next.set('additionalMode', 'without'); + + if (this.selectedAdditionalServices.length > 0) { + next = next.set('additionalServices', this.selectedAdditionalServices.join(',')); + } + + return next; + } + + private getAdditionalValue(line: ApiLineList, key: AdditionalServiceKey): number { + const raw = key === 'gvd' + ? line.gestaoVozDados + : key === 'skeelo' + ? line.skeelo + : key === 'news' + ? line.vivoNewsPlus + : key === 'travel' + ? line.vivoTravelMundo + : key === 'sync' + ? line.vivoSync + : line.vivoGestaoDispositivo; + + const n = this.toNullableNumber(raw); + return n ?? 0; + } + + private hasAnyAdditional(line: ApiLineList): boolean { + return (this.getAdditionalValue(line, 'gvd') > 0) || + (this.getAdditionalValue(line, 'skeelo') > 0) || + (this.getAdditionalValue(line, 'news') > 0) || + (this.getAdditionalValue(line, 'travel') > 0) || + (this.getAdditionalValue(line, 'sync') > 0) || + (this.getAdditionalValue(line, 'dispositivo') > 0); + } + + private matchesAdditionalFilters(line: ApiLineList): boolean { + const selected = this.selectedAdditionalServices; + const hasSelected = selected.length > 0; + + if (hasSelected) { + if (this.additionalMode === 'WITHOUT') { + return selected.every((svc) => this.getAdditionalValue(line, svc) <= 0); + } + + // WITH e também ALL com serviços selecionados + return selected.some((svc) => this.getAdditionalValue(line, svc) > 0); + } + + if (this.additionalMode === 'WITH') { + return this.hasAnyAdditional(line); + } + + if (this.additionalMode === 'WITHOUT') { + return !this.hasAnyAdditional(line); + } + + return true; + } + + private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] { + if (!Array.isArray(lines) || lines.length === 0) return []; + return lines.filter((line) => this.matchesAdditionalFilters(line)); + } + + private loadKpis() { + const requestVersion = ++this.kpiRequestVersion; + this.loadingKpis = true; + this.cdr.detectChanges(); + + void this.loadKpisInternal(requestVersion); + } + + private async loadKpisInternal(requestVersion: number) { + try { + const groups = await this.fetchAllGroupsForKpis(); + if (requestVersion !== this.kpiRequestVersion) return; + + if (groups.length === 0) { + await this.loadKpisFromLines(requestVersion); + return; + } + + this.applyKpisFromGroups(groups); + } catch { + if (requestVersion !== this.kpiRequestVersion) return; + await this.loadKpisFromLines(requestVersion); + return; + } + + if (requestVersion !== this.kpiRequestVersion) return; + this.loadingKpis = false; + this.cdr.detectChanges(); + } + + private async fetchAllGroupsForKpis(): Promise { + if (this.hasAdditionalFiltersApplied) { + const lines = await this.fetchLinesForGrouping(); + let groups = this.buildGroupsFromLines(lines); + + if (this.searchResolvedClient) { + const target = (this.searchResolvedClient || '').trim().toUpperCase(); + groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); + } + + if (this.selectedClients.length > 0) { + groups = groups.filter((g) => + this.selectedClients.some( + (selected) => (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase() + ) + ); + } + + return groups; + } + + let baseParams = new HttpParams(); + baseParams = this.applyBaseFilters(baseParams); + + if (!this.searchResolvedClient && this.searchTerm) { + baseParams = baseParams.set('search', this.searchTerm); + } + + const pageSize = 2000; + let page = 1; + let expectedTotal = 0; + const allGroups: ClientGroupDto[] = []; + + while (page <= 500) { + const params = baseParams + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom( + this.http.get>(`${this.apiBase}/groups`, { params: this.withNoCache(params) }) + ); + + const items = response?.items ?? []; + expectedTotal = this.toInt(response?.total); + allGroups.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && allGroups.length >= expectedTotal) break; + + page += 1; + } + + if (this.searchResolvedClient) { + const target = (this.searchResolvedClient || '').trim().toUpperCase(); + return allGroups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); + } + + if (this.selectedClients.length > 0) { + return allGroups.filter((g) => + this.selectedClients.some( + (selected) => (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase() + ) + ); + } + + return allGroups; + } + + private async loadKpisFromLines(requestVersion: number = this.kpiRequestVersion) { + try { + const lines = await this.fetchLinesForGrouping(); + let groups = this.buildGroupsFromLines(lines); + + if (this.searchResolvedClient) { + const target = (this.searchResolvedClient || '').trim().toUpperCase(); + groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); + } else if (this.selectedClients.length > 0) { + groups = groups.filter((g) => + this.selectedClients.some( + (selected) => + (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase() + ) + ); + } + + if (requestVersion !== this.kpiRequestVersion) return; + this.applyKpisFromGroups(groups); + } catch { + if (requestVersion !== this.kpiRequestVersion) return; + this.applyKpisFromGroups([]); + } finally { + if (requestVersion !== this.kpiRequestVersion) return; + this.loadingKpis = false; + this.cdr.detectChanges(); + } + } + + private applyKpisFromGroups(groups: ClientGroupDto[]): void { + const safe = Array.isArray(groups) ? groups : []; + this.kpiTotalClientes = safe.length; + this.kpiTotalLinhas = safe.reduce((acc, group) => acc + this.toInt(group?.totalLinhas), 0); + this.kpiAtivas = safe.reduce((acc, group) => acc + this.toInt(group?.ativos), 0); + this.kpiBloqueadas = safe.reduce((acc, group) => acc + this.toInt(group?.bloqueados), 0); + } + + private loadGroups() { + const requestVersion = ++this.groupsRequestVersion; this.loading = true; const hasSelection = this.selectedClients.length > 0; const hasResolved = !!this.searchResolvedClient; + const keepCurrentPage = this.keepPageOnNextGroupsLoad; + this.keepPageOnNextGroupsLoad = false; + + if (!keepCurrentPage && this.filterSkil === 'RESERVA' && !hasSelection && !hasResolved) { + this.page = 1; + } + + if (this.hasAdditionalFiltersApplied) { + void this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion); + return; + } const pageToLoad = (hasSelection || hasResolved) ? '1' : String(this.page); const sizeToLoad = (hasSelection || hasResolved) ? '9999' : String(this.pageSize); let params = new HttpParams().set('page', pageToLoad).set('pageSize', sizeToLoad); - - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); + params = this.applyBaseFilters(params); if (!hasResolved && this.searchTerm) params = params.set('search', this.searchTerm); @@ -608,8 +1078,10 @@ export class Geral implements AfterViewInit, OnDestroy { this.selectedClients.forEach((c) => (params = params.append('client', c))); } - this.http.get>(`${this.apiBase}/groups`, { params }).subscribe({ + this.http.get>(`${this.apiBase}/groups`, { params: this.withNoCache(params) }).subscribe({ next: (res) => { + if (requestVersion !== this.groupsRequestVersion) return; + let items = res.items || []; if (hasResolved) { @@ -628,17 +1100,141 @@ export class Geral implements AfterViewInit, OnDestroy { this.total = res.total; } - this.clientGroups = items; + if (items.length === 0) { + this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion); + return; + } + + this.clientGroups = this.sortGroupsWithReservaFirst(items); this.loading = false; this.cdr.detectChanges(); }, error: () => { - this.loading = false; - this.showToast('Erro ao carregar grupos.'); + if (requestVersion !== this.groupsRequestVersion) return; + this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion); } }); } + private async loadGroupsFromLines(hasSelection: boolean, hasResolved: boolean, requestVersion: number) { + try { + const lines = await this.fetchLinesForGrouping(); + if (requestVersion !== this.groupsRequestVersion) return; + let groups = this.buildGroupsFromLines(lines); + + if (hasResolved) { + const target = (this.searchResolvedClient || '').trim().toUpperCase(); + groups = groups.filter((g) => (g.cliente || '').trim().toUpperCase() === target); + } else if (hasSelection) { + groups = groups.filter((g) => + this.selectedClients.some( + (selected) => + (selected || '').trim().toUpperCase() === (g.cliente || '').trim().toUpperCase() + ) + ); + } + + this.total = groups.length; + + if (!hasSelection && !hasResolved) { + const start = Math.max(0, (this.page - 1) * this.pageSize); + groups = groups.slice(start, start + this.pageSize); + } + + this.clientGroups = this.sortGroupsWithReservaFirst(groups); + } catch { + if (requestVersion !== this.groupsRequestVersion) return; + this.clientGroups = []; + this.total = 0; + this.showToast('Erro ao carregar grupos.'); + } finally { + if (requestVersion !== this.groupsRequestVersion) return; + this.loading = false; + this.cdr.detectChanges(); + } + } + + private async fetchLinesForGrouping(): Promise { + let baseParams = new HttpParams() + .set('sortBy', 'cliente') + .set('sortDir', 'asc'); + baseParams = this.applyBaseFilters(baseParams); + + if (this.searchResolvedClient) { + baseParams = baseParams.set('client', this.searchResolvedClient); + } else if (this.searchTerm) { + baseParams = baseParams.set('search', this.searchTerm); + } + + const pageSize = 5000; + let page = 1; + let expectedTotal = 0; + const allLines: ApiLineList[] = []; + + while (page <= 500) { + const params = baseParams + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom( + this.http.get>(this.apiBase, { params: this.withNoCache(params) }) + ); + + const items = response?.items ?? []; + expectedTotal = this.toInt(response?.total); + allLines.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && allLines.length >= expectedTotal) break; + + page += 1; + } + + return this.applyAdditionalFiltersClientSide(allLines); + } + + private buildGroupsFromLines(lines: ApiLineList[]): ClientGroupDto[] { + const grouped = new Map(); + const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE'; + + for (const row of lines ?? []) { + const client = ((row?.cliente ?? '').toString().trim()) || fallbackClient; + const key = client.toUpperCase(); + + let group = grouped.get(key); + if (!group) { + group = { + cliente: client, + totalLinhas: 0, + ativos: 0, + bloqueados: 0 + }; + grouped.set(key, group); + } + + group.totalLinhas += 1; + + const status = ((row?.status ?? '').toString().trim()).toLowerCase(); + if (status.includes('ativo')) group.ativos += 1; + if (status.includes('bloque') || status.includes('perda') || status.includes('roubo')) { + group.bloqueados += 1; + } + } + + return this.sortGroupsWithReservaFirst(Array.from(grouped.values())); + } + + private sortGroupsWithReservaFirst(groups: ClientGroupDto[]): ClientGroupDto[] { + const list = Array.isArray(groups) ? [...groups] : []; + return list.sort((a, b) => { + const aReserva = (a?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; + const bReserva = (b?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; + if (aReserva !== bReserva) return aReserva ? -1 : 1; + return (a?.cliente || '').localeCompare((b?.cliente || ''), 'pt-BR', { sensitivity: 'base' }); + }); + } + toggleGroup(clientName: string) { if (this.expandedGroup === clientName) { this.expandedGroup = null; @@ -653,6 +1249,7 @@ export class Geral implements AfterViewInit, OnDestroy { } fetchGroupLines(clientName: string, search?: string) { + const requestVersion = ++this.linesRequestVersion; this.groupLines = []; this.loadingLines = true; @@ -662,16 +1259,15 @@ export class Geral implements AfterViewInit, OnDestroy { .set('pageSize', '500') .set('sortBy', 'item') .set('sortDir', 'asc'); - - if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA'); - else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA'); + params = this.applyBaseFilters(params); if (search) params = params.set('search', search); - this.http.get>(this.apiBase, { params }).subscribe({ + this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({ next: (res) => { - this.groupLines = (res.items ?? []).map((x) => ({ + if (requestVersion !== this.linesRequestVersion) return; + const filteredItems = this.applyAdditionalFiltersClientSide(res.items ?? []); + this.groupLines = filteredItems.map((x) => ({ id: x.id, item: String(x.item ?? ''), linha: x.linha ?? '', @@ -684,6 +1280,7 @@ export class Geral implements AfterViewInit, OnDestroy { this.loadingLines = false; }, error: () => { + if (requestVersion !== this.linesRequestVersion) return; this.loadingLines = false; this.showToast('Erro ao carregar linhas do grupo.'); } @@ -691,13 +1288,28 @@ export class Geral implements AfterViewInit, OnDestroy { } toggleClientMenu() { + if (!this.showClientMenu) this.showAdditionalMenu = false; this.showClientMenu = !this.showClientMenu; } + toggleAdditionalMenu() { + if (!this.showAdditionalMenu) this.showClientMenu = false; + this.showAdditionalMenu = !this.showAdditionalMenu; + } + closeClientDropdown() { this.showClientMenu = false; } + closeAdditionalDropdown() { + this.showAdditionalMenu = false; + } + + closeFilterDropdowns() { + this.showClientMenu = false; + this.showAdditionalMenu = false; + } + selectClient(client: string | null) { if (client === null) { this.selectedClients = []; @@ -762,7 +1374,7 @@ export class Geral implements AfterViewInit, OnDestroy { goToPage(p: number) { this.page = Math.max(1, Math.min(this.totalPages, p)); - this.refreshData(); + this.refreshData({ keepCurrentPage: true }); } trackById(_: number, row: LineRow) { @@ -823,12 +1435,19 @@ export class Geral implements AfterViewInit, OnDestroy { } async onImportExcel() { + if (!this.isAdmin) { + await this.showToast('Você não tem permissão para importar planilha.'); + return; + } + if (!this.excelInput?.nativeElement) return; this.excelInput.nativeElement.value = ''; this.excelInput.nativeElement.click(); } onExcelSelected(ev: Event) { + if (!this.isAdmin) return; + const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -888,6 +1507,7 @@ export class Geral implements AfterViewInit, OnDestroy { this.http.get(`${this.apiBase}/${r.id}`).subscribe({ next: (d) => { this.editModel = this.toEditModel(d); + this.syncContaEmpresaSelection(this.editModel); this.cdr.detectChanges(); }, error: async () => { @@ -897,6 +1517,30 @@ export class Geral implements AfterViewInit, OnDestroy { }); } + onPlanoChange(isEdit: boolean) { + const model = isEdit ? this.editModel : this.createModel; + if (!model) return; + + const plan = (model.planoContrato ?? '').toString().trim(); + if (!plan) return; + + const suggestion = this.planAutoFill.suggest(plan); + if (!suggestion) return; + + if (suggestion.franquiaGb != null) { + model.franquiaVivo = suggestion.franquiaGb; + if (model.franquiaLine === null || model.franquiaLine === undefined || model.franquiaLine === '') { + model.franquiaLine = suggestion.franquiaGb; + } + } + + if (suggestion.valorPlano != null) { + model.valorPlanoVivo = suggestion.valorPlano; + } + + this.onFinancialChange(isEdit); + } + calculateFinancials(model: any) { if (!model) return; @@ -912,8 +1556,9 @@ export class Geral implements AfterViewInit, OnDestroy { const news = parse(model.vivoNewsPlus); const travel = parse(model.vivoTravelMundo); const gestaoDisp = parse(model.vivoGestaoDispositivo); + const vivoSync = parse(model.vivoSync); - const totalVivo = valorPlano + gestaoVoz + skeelo + news + travel + gestaoDisp; + const totalVivo = valorPlano + gestaoVoz + skeelo + news + travel + gestaoDisp + vivoSync; model.valorContratoVivo = parseFloat(totalVivo.toFixed(2)); const totalLineManual = parse(model.valorContratoLine); @@ -934,12 +1579,16 @@ export class Geral implements AfterViewInit, OnDestroy { this.editSaving = true; this.calculateFinancials(this.editModel); + const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel; + const payload: UpdateMobileLineRequest = { - ...this.editModel, + ...editModelPayload, item: this.toInt(this.editModel.item), dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio), dataEntregaOpera: this.dateInputToIso(this.editModel.dataEntregaOpera), dataEntregaCliente: this.dateInputToIso(this.editModel.dataEntregaCliente), + dtEfetivacaoServico: this.dateInputToIso(this.editModel.dtEfetivacaoServico), + dtTerminoFidelizacao: this.dateInputToIso(this.editModel.dtTerminoFidelizacao), vencConta: (this.editModel.vencConta ?? '').toString(), franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo), valorPlanoVivo: this.toNullableNumber(this.editModel.valorPlanoVivo), @@ -948,13 +1597,15 @@ export class Geral implements AfterViewInit, OnDestroy { vivoNewsPlus: this.toNullableNumber(this.editModel.vivoNewsPlus), vivoTravelMundo: this.toNullableNumber(this.editModel.vivoTravelMundo), vivoGestaoDispositivo: this.toNullableNumber(this.editModel.vivoGestaoDispositivo), + vivoSync: this.toNullableNumber(this.editModel.vivoSync), valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo), franquiaLine: this.toNullableNumber(this.editModel.franquiaLine), franquiaGestao: this.toNullableNumber(this.editModel.franquiaGestao), locacaoAp: this.toNullableNumber(this.editModel.locacaoAp), valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine), desconto: this.toNullableNumber(this.editModel.desconto), - lucro: this.toNullableNumber(this.editModel.lucro) + lucro: this.toNullableNumber(this.editModel.lucro), + tipoDeChip: (this.editModel.tipoDeChip ?? '').toString() }; this.http.put(`${this.apiBase}/${this.editingId}`, payload).subscribe({ @@ -985,6 +1636,11 @@ export class Geral implements AfterViewInit, OnDestroy { } async onRemover(r: LineRow, fromGroup = false) { + if (!this.isAdmin) { + await this.showToast('Apenas administradores podem remover linhas.'); + return; + } + if (!confirm(`Remover linha ${r.linha}?`)) return; this.loading = true; @@ -1028,6 +1684,8 @@ export class Geral implements AfterViewInit, OnDestroy { if (this.filterSkil === 'PJ') this.createModel.skil = 'PESSOA JURÍDICA'; else if (this.filterSkil === 'RESERVA') this.createModel.skil = 'RESERVA'; + this.syncContaEmpresaSelection(this.createModel); + this.createOpen = true; this.cdr.detectChanges(); } @@ -1037,8 +1695,10 @@ export class Geral implements AfterViewInit, OnDestroy { cliente: '', docType: 'PF', docNumber: '', + contaEmpresa: '', linha: '', chip: '', + tipoDeChip: '', usuario: '', status: '', planoContrato: '', @@ -1052,6 +1712,8 @@ export class Geral implements AfterViewInit, OnDestroy { dataBloqueio: '', dataEntregaOpera: '', dataEntregaCliente: '', + dtEfetivacaoServico: '', + dtTerminoFidelizacao: '', franquiaVivo: null, valorPlanoVivo: null, gestaoVozDados: null, @@ -1059,6 +1721,7 @@ export class Geral implements AfterViewInit, OnDestroy { vivoNewsPlus: null, vivoTravelMundo: null, vivoGestaoDispositivo: null, + vivoSync: null, valorContratoVivo: null, franquiaLine: null, franquiaGestao: null, @@ -1070,6 +1733,19 @@ export class Geral implements AfterViewInit, OnDestroy { this.createSaving = false; } + onContaEmpresaChange(isEdit: boolean) { + const model = isEdit ? this.editModel : this.createModel; + if (!model) return; + + const contas = this.getContasByEmpresa(model.contaEmpresa); + const selectedConta = (model.conta ?? '').toString().trim(); + + if (!selectedConta) return; + + const hasMatch = contas.some((c) => this.sameConta(c, selectedConta)); + if (!hasMatch) model.conta = ''; + } + onDocTypeChange() { this.createModel.docNumber = ''; this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA'; @@ -1104,6 +1780,10 @@ export class Geral implements AfterViewInit, OnDestroy { } } + if (!this.createModel.contaEmpresa) { + this.showToast('Selecione a Empresa (Conta).'); + return; + } if (!this.createModel.conta) { this.showToast('Selecione uma Conta.'); return; @@ -1124,16 +1804,28 @@ export class Geral implements AfterViewInit, OnDestroy { this.showToast('Selecione um Plano.'); return; } + if (!this.createModel.dtEfetivacaoServico) { + this.showToast('A Dt. Efetivação Serviço é obrigatória.'); + return; + } + if (!this.createModel.dtTerminoFidelizacao) { + this.showToast('A Dt. Término Fidelização é obrigatória.'); + return; + } this.createSaving = true; this.calculateFinancials(this.createModel); + const { contaEmpresa: _contaEmpresa, ...createModelPayload } = this.createModel; + const payload: CreateMobileLineRequest = { - ...this.createModel, + ...createModelPayload, item: Number(this.createModel.item), dataBloqueio: this.dateInputToIso(this.createModel.dataBloqueio), dataEntregaOpera: this.dateInputToIso(this.createModel.dataEntregaOpera), dataEntregaCliente: this.dateInputToIso(this.createModel.dataEntregaCliente), + dtEfetivacaoServico: this.dateInputToIso(this.createModel.dtEfetivacaoServico), + dtTerminoFidelizacao: this.dateInputToIso(this.createModel.dtTerminoFidelizacao), franquiaVivo: this.toNullableNumber(this.createModel.franquiaVivo), valorPlanoVivo: this.toNullableNumber(this.createModel.valorPlanoVivo), gestaoVozDados: this.toNullableNumber(this.createModel.gestaoVozDados), @@ -1141,13 +1833,15 @@ export class Geral implements AfterViewInit, OnDestroy { vivoNewsPlus: this.toNullableNumber(this.createModel.vivoNewsPlus), vivoTravelMundo: this.toNullableNumber(this.createModel.vivoTravelMundo), vivoGestaoDispositivo: this.toNullableNumber(this.createModel.vivoGestaoDispositivo), + vivoSync: this.toNullableNumber(this.createModel.vivoSync), valorContratoVivo: this.toNullableNumber(this.createModel.valorContratoVivo), franquiaLine: this.toNullableNumber(this.createModel.franquiaLine), franquiaGestao: this.toNullableNumber(this.createModel.franquiaGestao), locacaoAp: this.toNullableNumber(this.createModel.locacaoAp), valorContratoLine: this.toNullableNumber(this.createModel.valorContratoLine), desconto: this.toNullableNumber(this.createModel.desconto), - lucro: this.toNullableNumber(this.createModel.lucro) + lucro: this.toNullableNumber(this.createModel.lucro), + tipoDeChip: (this.createModel.tipoDeChip ?? '').toString() }; @@ -1239,6 +1933,8 @@ export class Geral implements AfterViewInit, OnDestroy { dataBloqueio: this.isoToDateInput(d.dataBloqueio), dataEntregaOpera: this.isoToDateInput(d.dataEntregaOpera), dataEntregaCliente: this.isoToDateInput(d.dataEntregaCliente), + dtEfetivacaoServico: this.isoToDateInput(d.dtEfetivacaoServico), + dtTerminoFidelizacao: this.isoToDateInput(d.dtTerminoFidelizacao), franquiaVivo: d.franquiaVivo ?? null, valorPlanoVivo: d.valorPlanoVivo ?? null, @@ -1247,6 +1943,7 @@ export class Geral implements AfterViewInit, OnDestroy { vivoNewsPlus: d.vivoNewsPlus ?? null, vivoTravelMundo: d.vivoTravelMundo ?? null, vivoGestaoDispositivo: d.vivoGestaoDispositivo ?? null, + vivoSync: d.vivoSync ?? null, valorContratoVivo: d.valorContratoVivo ?? null, franquiaLine: d.franquiaLine ?? null, @@ -1255,7 +1952,9 @@ export class Geral implements AfterViewInit, OnDestroy { valorContratoLine: d.valorContratoLine ?? null, desconto: d.desconto ?? null, - lucro: d.lucro ?? null + lucro: d.lucro ?? null, + tipoDeChip: d.tipoDeChip ?? '', + contaEmpresa: this.findEmpresaByConta(d.conta) }; } @@ -1295,4 +1994,80 @@ export class Geral implements AfterViewInit, OnDestroy { if (!v) return list; return list.includes(v) ? list : [v, ...list]; } + + private mergeOptionList(base: string[], extra: string[]): string[] { + const result: string[] = [...base]; + const seen = new Set(base.map((x) => x.trim()).filter(Boolean)); + + extra.forEach((raw) => { + const v = (raw ?? '').toString().trim(); + if (!v || seen.has(v)) return; + seen.add(v); + result.push(v); + }); + + return result; + } + + private normalizeAccountCompanies(data: AccountCompanyOption[] | null | undefined): AccountCompanyOption[] { + if (!Array.isArray(data)) return []; + + const result: AccountCompanyOption[] = []; + + data.forEach((item) => { + const empresa = (item?.empresa ?? '').toString().trim(); + if (!empresa) return; + + const contas = this.mergeOptionList([], (item?.contas ?? []).map((x) => (x ?? '').toString().trim())); + result.push({ empresa, contas }); + }); + + return result; + } + + private getAllContas(): string[] { + const all = this.accountCompanies.flatMap((x) => x.contas ?? []); + return this.mergeOptionList([], all); + } + + private getContasByEmpresa(empresa: any): string[] { + const target = (empresa ?? '').toString().trim(); + if (!target) return []; + + const found = this.accountCompanies.find((x) => + x.empresa.localeCompare(target, 'pt-BR', { sensitivity: 'base' }) === 0 + ); + return found ? [...found.contas] : []; + } + + private findEmpresaByConta(conta: any): string { + const target = this.normalizeConta(conta); + if (!target) return ''; + + const found = this.accountCompanies.find((group) => + (group.contas ?? []).some((c) => this.sameConta(c, target)) + ); + return found?.empresa ?? ''; + } + + private normalizeConta(value: any): string { + const raw = (value ?? '').toString().trim(); + if (!raw) return ''; + if (!/^\d+$/.test(raw)) return raw.toUpperCase(); + const noLeadingZero = raw.replace(/^0+/, ''); + return noLeadingZero || '0'; + } + + private sameConta(a: any, b: any): boolean { + return this.normalizeConta(a) === this.normalizeConta(b); + } + + private syncContaEmpresaSelection(model: any) { + if (!model) return; + + const empresaAtual = (model.contaEmpresa ?? '').toString().trim(); + if (empresaAtual) return; + + model.contaEmpresa = this.findEmpresaByConta(model.conta); + } } diff --git a/src/app/pages/historico/historico.html b/src/app/pages/historico/historico.html new file mode 100644 index 0000000..fcc32b6 --- /dev/null +++ b/src/app/pages/historico/historico.html @@ -0,0 +1,259 @@ +
+ +
+ +
+ + + + + +
+
+
+
+
+ Auditoria +
+ +
+
Histórico
+ Registros de alterações feitas no sistema. +
+ +
+ +
+
+ +
+
+
+ + Filtros +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ +
+ + + + + +
+
+
+
+
+ +
+
+
+ +
+ + + +
+ Nenhum log encontrado para os filtros atuais. +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Data/HoraUsuárioPáginaAçãoItem/Entidade
{{ formatDateTime(log.occurredAtUtc) }} +
+ {{ displayUserName(log) }} + {{ log.userEmail || '-' }} +
+
{{ log.page || '-' }} + {{ formatAction(log.action) }} + +
+
+ {{ displayEntity(log) }} +
+ +
+ {{ log.entityId }} +
+
+
+
+ Mudanças +
+
+
+
+ {{ change.field }} + + {{ changeTypeLabel(change.changeType) }} + +
+
+ {{ formatChangeValue(change.oldValue) }} + + {{ formatChangeValue(change.newValue) }} +
+
+
+ +
Sem mudanças registradas.
+
+
+ +
+
+ Detalhes técnicos +
+
+
+ Método + {{ log.requestMethod || '-' }} +
+
+ Endpoint + {{ log.requestPath || '-' }} +
+
+ IP + {{ log.ipAddress }} +
+
+
+
+
+
+
+ + +
+
+
diff --git a/src/app/pages/historico/historico.scss b/src/app/pages/historico/historico.scss new file mode 100644 index 0000000..3dbbf74 --- /dev/null +++ b/src/app/pages/historico/historico.scss @@ -0,0 +1,679 @@ +:host { + --brand: #E33DCF; + --blue: #030FAA; + --text: #111214; + --muted: rgba(17, 18, 20, 0.65); + + --success-bg: rgba(25, 135, 84, 0.12); + --success-text: #198754; + --info-bg: rgba(3, 15, 170, 0.1); + --info-text: #030FAA; + --danger-bg: rgba(220, 53, 69, 0.12); + --danger-text: #dc3545; + + --radius-xl: 22px; + --radius-lg: 16px; + --shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10); + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-border: 1px solid rgba(227, 61, 207, 0.16); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); + box-sizing: border-box; +} + +.historico-page { + min-height: 100vh; + padding: 0 12px; + display: flex; + align-items: flex-start; + justify-content: center; + position: relative; + overflow-y: auto; + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); + + &::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(255, 255, 255, 0.25); + } +} + +.page-blob { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(34px); + opacity: 0.55; + z-index: 0; + background: radial-gradient(circle at 30% 30%, rgba(227,61,207,0.55), rgba(227,61,207,0.06)); + animation: floaty 10s ease-in-out infinite; + &.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; } + &.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; } + &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; } + &.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: .45; } +} + +@keyframes floaty { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(18px, 10px) scale(1.03); } + 100% { transform: translate(0, 0) scale(1); } +} + +.container-geral-responsive { + width: 100%; + max-width: 1400px !important; + width: 98% !important; + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 200px; +} + +.geral-card { + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--glass-bg); + border: var(--glass-border); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-card); + position: relative; + display: flex; + flex-direction: column; + min-height: 80vh; + &::before { + content: ''; + position: absolute; + inset: 1px; + border-radius: calc(var(--radius-xl) - 1px); + pointer-events: none; + border: 1px solid rgba(255, 255, 255, 0.65); + opacity: 0.75; + } +} + +.geral-header { + padding: 16px 24px; + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + background: linear-gradient(180deg, rgba(227,61,207,0.06), rgba(255,255,255,0.2)); + flex-shrink: 0; +} + +.header-row-top { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + text-align: center; + gap: 16px; + .title-badge { justify-self: center; margin-bottom: 8px; } + .header-actions { justify-self: center; } + } +} + +.title-badge { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(227, 61, 207, 0.22); + backdrop-filter: blur(10px); + color: var(--text); + font-size: 13px; + font-weight: 800; + i { color: var(--brand); } +} + +.header-title { + justify-self: center; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.title { + font-size: 26px; + font-weight: 950; + letter-spacing: -0.3px; + color: var(--text); + margin-top: 10px; + margin-bottom: 0; +} + +.subtitle { + color: rgba(17, 18, 20, 0.65); + font-weight: 700; +} + +.header-actions { + justify-self: end; +} + +.btn-brand { + background-color: var(--brand); + border-color: var(--brand); + color: #fff; + font-weight: 900; + border-radius: 12px; + transition: transform 0.2s, box-shadow 0.2s; + &:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); filter: brightness(1.05); } +} + +.filters-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 14px; + box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08); +} + +.filters-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.filters-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 900; + font-size: 14px; + color: rgba(17, 18, 20, 0.82); +} + +.filters-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.filter-field { + display: grid; + gap: 6px; + + label { + font-size: 11px; + font-weight: 800; + color: rgba(17, 18, 20, 0.6); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + input { + height: 40px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.15); + padding: 0 12px; + font-size: 14px; + background: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + + input:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12); + } +} + +.filter-search { + grid-column: span 2; +} + +.search-group { + max-width: 270px; + border-radius: 12px; + overflow: hidden; + display: flex; + align-items: stretch; + background: #fff; + border: 1px solid rgba(17, 18, 20, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; + + &:focus-within { + border-color: var(--brand); + box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); + transform: translateY(-1px); + } + + .input-group-text { + background: transparent; + border: none; + color: rgba(17, 18, 20, 0.5); + padding-left: 14px; + padding-right: 8px; + display: flex; + align-items: center; + } + + .form-control { + border: none; + background: transparent; + height: auto; + padding: 10px 0; + font-size: 0.9rem; + color: var(--text); + box-shadow: none; + + &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } + &:focus { outline: none; } + } + + .btn-clear { + border: none; + background: transparent; + color: rgba(17, 18, 20, 0.45); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + cursor: pointer; + transition: color 0.2s ease; + + &:hover { color: #dc3545; } + } +} + +.btn-primary, +.btn-ghost { + height: 38px; + border-radius: 10px; + border: none; + font-weight: 700; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 14px; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.btn-primary { + background: var(--blue); + color: #fff; + box-shadow: 0 8px 18px rgba(3, 15, 170, 0.18); +} + +.btn-ghost { + background: #fff; + color: rgba(17, 18, 20, 0.86); + border: 1px solid rgba(15, 23, 42, 0.12); +} + +.select-wrapper { position: relative; display: inline-block; min-width: 90px; } + +.select-glass { + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(17, 18, 20, 0.12); + border-radius: 10px; + color: var(--blue); + font-weight: 800; +} + +.geral-body { + padding: 0; + background: transparent; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.table-wrap { + overflow-x: auto; + overflow-y: auto; + height: 100%; +} + +.table-modern { + width: 100%; + min-width: 1200px !important; + border-collapse: separate; + border-spacing: 0; + + thead th { + position: sticky; + top: 0; + z-index: 10; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-bottom: 2px solid rgba(227, 61, 207, 0.15); + padding: 12px; + color: rgba(17, 18, 20, 0.7); + font-size: 0.8rem; + font-weight: 950; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; + text-align: center; + } + + tbody tr { + transition: background-color 0.2s; + border-bottom: 1px solid rgba(17,18,20,0.05); + + &:hover { + background-color: rgba(227, 61, 207, 0.05); + } + + td { border-bottom: 1px solid rgba(17,18,20,0.04); } + } + + td { + padding: 12px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + color: var(--text); + text-align: center; + } +} + +.table-modern th:nth-child(5), +.table-modern td:nth-child(5) { + text-align: left; +} + +.table-modern th:nth-child(2), +.table-modern td:nth-child(2) { + min-width: 260px; +} + +.table-row-item.expanded { + background: rgba(227, 61, 207, 0.06); +} + +.td-clip { + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +.empty-group { + background: rgba(255,255,255,0.7); + border: 1px dashed rgba(17,18,20,0.12); + border-radius: 16px; + padding: 18px; + text-align: center; + font-weight: 800; + color: var(--muted); + margin: 16px; +} + +.user-cell { + display: grid; + gap: 2px; + justify-items: center; +} + +.user-name { + font-weight: 800; + color: var(--text); +} + +.user-email { + color: rgba(17, 18, 20, 0.55); + font-size: 0.75rem; +} + +.entity-cell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.entity-label { + font-weight: 700; + color: var(--text); +} + +.entity-id { + display: block; + font-size: 0.75rem; + color: rgba(17, 18, 20, 0.5); + word-break: break-all; +} + +.expand-btn { + border: none; + background: rgba(3, 15, 170, 0.08); + color: var(--blue); + width: 32px; + height: 32px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, background 0.15s ease; + &:hover { transform: translateY(-1px); background: rgba(227, 61, 207, 0.12); color: var(--brand); } +} + +.badge-action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.action-create { + background: var(--success-bg); + color: var(--success-text); +} + +.action-update { + background: var(--info-bg); + color: var(--info-text); +} + +.action-delete { + background: var(--danger-bg); + color: var(--danger-text); +} + +.action-default { + background: rgba(17,18,20,0.08); + color: rgba(17,18,20,0.7); +} + +.details-row td { + background: rgba(255, 255, 255, 0.95); + padding: 0 12px 16px; + white-space: normal; + overflow: visible; +} + +.details-panel { + border-radius: 16px; + border: 1px solid rgba(17, 18, 20, 0.08); + background: #fff; + padding: 16px; + display: grid; + gap: 16px; +} + +.details-section { + display: grid; + gap: 12px; +} + +.section-title { + font-weight: 900; + font-size: 0.9rem; + color: var(--text); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.changes-list { + display: grid; + gap: 10px; +} + +.change-item { + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.9); + display: grid; + gap: 6px; +} + +.change-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.change-field { + font-weight: 800; + color: var(--text); +} + +.change-type { + font-size: 0.7rem; + font-weight: 900; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 999px; + letter-spacing: 0.04em; +} + +.change-added { background: var(--success-bg); color: var(--success-text); } +.change-removed { background: var(--danger-bg); color: var(--danger-text); } +.change-modified { background: var(--info-bg); color: var(--info-text); } + +.change-values { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 0.8rem; + color: rgba(17, 18, 20, 0.7); +} + +.change-values .old { + color: rgba(17, 18, 20, 0.6); +} + +.change-values .new { + color: var(--text); + font-weight: 700; +} + +.empty-state { + background: rgba(17, 18, 20, 0.04); + border: 1px dashed rgba(17, 18, 20, 0.1); + border-radius: 12px; + padding: 12px; + font-weight: 700; + color: rgba(17, 18, 20, 0.6); +} + +.tech-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.tech-item { + display: grid; + gap: 4px; +} + +.tech-label { + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.55); +} + +.tech-value { + font-weight: 700; + color: var(--text); + word-break: break-word; +} + +.geral-footer { + padding: 12px 20px 18px; + border-top: 1px solid rgba(17,18,20,0.06); + background: rgba(255, 255, 255, 0.6); + display: grid; + gap: 12px; +} + +.footer-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.pagination-modern .page-link { + color: var(--blue); + font-weight: 900; + border-radius: 10px; + border: 1px solid rgba(17,18,20,0.1); + background: rgba(255,255,255,0.6); + margin: 0 2px; + &:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); } +} + +.pagination-modern .page-item.active .page-link { + background-color: var(--blue); + border-color: var(--blue); + color: #fff; +} + +.text-brand { color: var(--brand) !important; } + +@media (max-width: 1100px) { + .filters-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 900px) { + .filters-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 700px) { + .filters-grid { grid-template-columns: 1fr; } + .search-group { width: 100%; max-width: 100%; } + .entity-cell { flex-direction: column; align-items: flex-start; } + .expand-btn { align-self: flex-end; } +} diff --git a/src/app/pages/historico/historico.ts b/src/app/pages/historico/historico.ts new file mode 100644 index 0000000..e929105 --- /dev/null +++ b/src/app/pages/historico/historico.ts @@ -0,0 +1,275 @@ +import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { HistoricoService, AuditLogDto, AuditChangeType, HistoricoQuery } from '../../services/historico.service'; + +interface SelectOption { + value: string; + label: string; +} + +@Component({ + selector: 'app-historico', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './historico.html', + styleUrls: ['./historico.scss'], +}) +export class Historico implements OnInit { + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + logs: AuditLogDto[] = []; + loading = false; + error = false; + errorMsg = ''; + toastMessage = ''; + + expandedLogId: string | null = null; + + page = 1; + pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; + total = 0; + + filterPageName = ''; + filterAction = ''; + filterUserId = ''; + filterSearch = ''; + dateFrom = ''; + dateTo = ''; + + readonly pageOptions: SelectOption[] = [ + { value: '', label: 'Todas as páginas' }, + { value: 'Geral', label: 'Geral' }, + { value: 'Mureg', label: 'Mureg' }, + { value: 'Faturamento', label: 'Faturamento' }, + { value: 'Parcelamentos', label: 'Parcelamentos' }, + { value: 'Dados e Usuários', label: 'Dados PF/PJ' }, + { value: 'Vigência', label: 'Vigência' }, + { value: 'Chips Virgens e Recebidos', label: 'Chips Virgens e Recebidos' }, + { value: 'Troca de número', label: 'Troca de número' }, + ]; + + readonly actionOptions: SelectOption[] = [ + { value: '', label: 'Todas as ações' }, + { value: 'CREATE', label: 'Criação' }, + { value: 'UPDATE', label: 'Atualização' }, + { value: 'DELETE', label: 'Exclusão' }, + ]; + + private searchTimer: any = null; + + constructor( + private historicoService: HistoricoService, + private cdr: ChangeDetectorRef, + @Inject(PLATFORM_ID) private platformId: object + ) {} + + ngOnInit(): void { + this.fetch(1); + } + + refresh(): void { + this.fetch(1); + } + + applyFilters(): void { + this.page = 1; + this.fetch(); + } + + clearFilters(): void { + this.filterPageName = ''; + this.filterAction = ''; + this.filterUserId = ''; + this.filterSearch = ''; + this.dateFrom = ''; + this.dateTo = ''; + this.page = 1; + this.fetch(); + } + + clearSearch(): void { + this.filterSearch = ''; + this.page = 1; + this.fetch(); + } + + onSearchChange(): void { + if (this.searchTimer) clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => { + this.page = 1; + this.fetch(); + }, 300); + } + + onPageSizeChange(): void { + this.page = 1; + this.fetch(); + } + + goToPage(p: number): void { + this.page = Math.max(1, Math.min(this.totalPages, p)); + this.fetch(); + } + + get totalPages(): number { + return Math.ceil((this.total || 0) / this.pageSize) || 1; + } + + get pageNumbers(): number[] { + const total = this.totalPages; + const current = this.page; + const max = 5; + let start = Math.max(1, current - 2); + let end = Math.min(total, start + (max - 1)); + start = Math.max(1, end - (max - 1)); + + const pages: number[] = []; + for (let i = start; i <= end; i++) pages.push(i); + return pages; + } + + get pageStart(): number { + return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + } + + get pageEnd(): number { + if (this.total === 0) return 0; + return Math.min(this.page * this.pageSize, this.total); + } + + toggleDetails(log: AuditLogDto, event?: Event): void { + if (event) event.stopPropagation(); + this.expandedLogId = this.expandedLogId === log.id ? null : log.id; + } + + formatDateTime(value?: string | null): string { + if (!value) return '-'; + const d = new Date(value); + if (isNaN(d.getTime())) return '-'; + return d.toLocaleString('pt-BR'); + } + + displayUserName(log: AuditLogDto): string { + const name = (log.userName || '').trim(); + return name ? name : 'SISTEMA'; + } + + displayEntity(log: AuditLogDto): string { + const label = (log.entityLabel || '').trim(); + if (label) return label; + return log.entityName || '-'; + } + + formatAction(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (!value) return '-'; + if (value === 'CREATE') return 'Criação'; + if (value === 'UPDATE') return 'Atualização'; + if (value === 'DELETE') return 'Exclusão'; + return 'Outro'; + } + + actionClass(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (value === 'CREATE') return 'action-create'; + if (value === 'UPDATE') return 'action-update'; + if (value === 'DELETE') return 'action-delete'; + return 'action-default'; + } + + changeTypeLabel(type?: AuditChangeType | string | null): string { + if (!type) return 'Alterado'; + if (type === 'added') return 'Adicionado'; + if (type === 'removed') return 'Removido'; + return 'Alterado'; + } + + changeTypeClass(type?: AuditChangeType | string | null): string { + if (type === 'added') return 'change-added'; + if (type === 'removed') return 'change-removed'; + if (type === 'modified') return 'change-modified'; + return 'change-modified'; + } + + formatChangeValue(value?: string | null): string { + if (value === undefined || value === null || value === '') return '-'; + return String(value); + } + + trackByLog(_: number, log: AuditLogDto): string { + return log.id; + } + + trackByField(_: number, change: { field: string }): string { + return change.field; + } + + private fetch(goToPage?: number): void { + if (goToPage) this.page = goToPage; + this.loading = true; + this.error = false; + this.errorMsg = ''; + this.expandedLogId = null; + + const query: HistoricoQuery = { + page: this.page, + pageSize: this.pageSize, + pageName: this.filterPageName || undefined, + action: this.filterAction || undefined, + userId: this.filterUserId?.trim() || undefined, + search: this.filterSearch?.trim() || undefined, + dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, + dateTo: this.toIsoDate(this.dateTo, true) || undefined, + }; + + this.historicoService.list(query).subscribe({ + next: (res) => { + this.logs = res.items || []; + this.total = res.total || 0; + this.page = res.page || this.page; + this.pageSize = res.pageSize || this.pageSize; + this.loading = false; + }, + error: (err: HttpErrorResponse) => { + this.error = true; + if (err?.status === 403) { + this.errorMsg = 'Acesso restrito.'; + } else { + this.errorMsg = 'Erro ao carregar histórico. Tente novamente.'; + } + this.loading = false; + }, + }); + } + + private toIsoDate(value: string, endOfDay: boolean): string | null { + if (!value) return null; + const time = endOfDay ? '23:59:59' : '00:00:00'; + const date = new Date(`${value}T${time}`); + if (isNaN(date.getTime())) return null; + return date.toISOString(); + } + + private async showToast(message: string) { + if (!isPlatformBrowser(this.platformId)) return; + this.toastMessage = message; + this.cdr.detectChanges(); + if (!this.successToast?.nativeElement) return; + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { + autohide: true, + delay: 3000 + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/pages/login/login.html b/src/app/pages/login/login.html index 5e9715b..e66cde4 100644 --- a/src/app/pages/login/login.html +++ b/src/app/pages/login/login.html @@ -50,7 +50,6 @@ Lembrar de mim -
Esqueceu a senha? + +
+
+ + + Mostrando {{ filteredNotifications.length }} notificações + • {{ selectedIds.size }} selecionada(s) + +
+
+ + +
+
@@ -46,11 +79,6 @@
- -
- Mostrando {{ filteredNotifications.length }} notificações -
-
+ +
@@ -68,25 +101,34 @@

{{ n.linha || 'Linha Desconhecida' }} + + {{ n.cliente || '-' }} +

+
+ Efetivação: {{ formatDateLabel(n.dtEfetivacaoServico) }} + Término: {{ formatDateLabel(n.dtTerminoFidelizacao) }} +
+
+ +
+
+ Conta + {{ n.conta || '-' }} +
+
+ Usuário + {{ n.usuario || '-' }} +
+
+ Plano + {{ n.planoContrato || '-' }} +
+
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} - - - {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }} - +
- -

- Cliente: {{ n.cliente || '-' }} • Usuário: {{ n.usuario || '-' }} -

- -

- A vigência desta linha expirou. Verifique a renovação imediatamente. -

-

- A vigência irá expirar em breve. Programe-se. -

@@ -107,4 +149,4 @@
- \ No newline at end of file + diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 1bd47b3..8aa8fcf 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -30,6 +30,74 @@ $border: #e5e7eb; p { color: $text-secondary; font-size: 16px; margin-bottom: 24px; } } +.bulk-left { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.bulk-actions-bar { + margin-top: 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.bulk-count { + font-size: 12px; + font-weight: 700; + color: $text-secondary; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.bulk-selected { + color: $text-main; +} + +.select-all { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + color: $text-secondary; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + + input { + width: 20px; + height: 20px; + accent-color: $primary; + } +} + +.bulk-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.bulk-btn { + background: $white; + border: 1px solid $border; + padding: 8px 12px; + border-radius: 10px; + font-size: 12px; + font-weight: 700; + color: $text-main; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + cursor: pointer; + + &:hover { border-color: $primary; color: $primary; } + &:disabled { opacity: 0.6; cursor: default; } + + &.ghost { background: transparent; } +} + /* FILTROS (Estilo Tabs/Pills) */ .filters-bar { display: inline-flex; @@ -93,10 +161,7 @@ $border: #e5e7eb; display: flex; flex-direction: column; gap: 12px; } -.list-header-actions { - font-size: 12px; font-weight: 600; color: $text-secondary; text-transform: uppercase; letter-spacing: 0.5px; - margin-bottom: 8px; padding-left: 8px; -} +/* list-header-actions removido */ .list-item { background: $white; @@ -125,6 +190,24 @@ $border: #e5e7eb; } } +.item-select { + margin-left: 8px; + margin-right: 8px; + min-width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + align-self: center; + + input { + width: 20px; + height: 20px; + accent-color: $primary; + } +} + .status-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; } @@ -141,33 +224,66 @@ $border: #e5e7eb; .item-content { flex: 1; min-width: 0; } .content-top { - display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; - margin-bottom: 6px; + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: 10px; + margin-bottom: 10px; } .item-title { - font-size: 16px; font-weight: 700; color: $text-main; margin: 0; - display: flex; align-items: center; gap: 8px; + font-size: 16px; + font-weight: 800; + color: $text-main; + margin: 0; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; } +.separator { color: $text-secondary; } +.item-client { font-weight: 600; color: $text-secondary; } + +.date-stack { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-end; + min-width: 170px; + text-align: right; +} + +.date-pill { + font-size: 11px; + font-weight: 700; + padding: 4px 8px; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.4px; + &.green { background: rgba($success, 0.12); color: $success; } + &.red { background: rgba($danger, 0.12); color: $danger; } +} + +.item-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 16px; +} + +.meta-row { display: flex; flex-direction: column; gap: 2px; } +.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: $text-secondary; font-weight: 700; } +.meta-value { font-size: 13px; font-weight: 600; color: $text-main; } + .badge-tag { - font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; + font-size: 10px; text-transform: uppercase; padding: 4px 8px; border-radius: 999px; font-weight: 800; letter-spacing: 0.5px; + width: fit-content; &.danger { background: rgba($danger, 0.1); color: $danger; } &.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); } } -.item-time { font-size: 12px; color: $text-secondary; font-weight: 500; } - -.item-details { - font-size: 13px; color: $text-secondary; margin: 0 0 4px; -} - -.item-message { - font-size: 13px; color: $text-secondary; margin: 0; opacity: 0.8; -} - .item-actions { margin-left: 12px; align-self: center; } diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index 9a5bce1..930bf7d 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -15,6 +15,9 @@ export class Notificacoes implements OnInit { filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas'; loading = false; error = false; + bulkLoading = false; + exportLoading = false; + selectedIds = new Set(); constructor(private notificationsService: NotificationsService) {} @@ -34,6 +37,7 @@ export class Notificacoes implements OnInit { setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') { this.filter = value; + this.clearSelection(); } get filteredNotifications() { @@ -49,6 +53,11 @@ export class Notificacoes implements OnInit { return this.notifications; } + formatDateLabel(date?: string | null): string { + if (!date) return '-'; + return new Date(date).toLocaleDateString('pt-BR'); + } + private loadNotifications() { this.loading = true; this.error = false; @@ -65,6 +74,119 @@ export class Notificacoes implements OnInit { } countByType(tipo: 'Vencido' | 'AVencer'): number { - return this.notifications.filter(n => n.tipo === tipo && !n.lida).length; -} + return this.notifications.filter(n => n.tipo === tipo && !n.lida).length; + } + + markAllAsRead() { + if (this.filter === 'lidas' || this.bulkLoading) return; + this.bulkLoading = true; + + const filterParam = this.getFilterParam(); + const ids = Array.from(this.selectedIds); + this.notificationsService.markAllAsRead(filterParam, ids.length ? ids : undefined).subscribe({ + next: () => { + const now = new Date().toISOString(); + this.notifications = this.notifications.map((n) => { + if (ids.length ? ids.includes(n.id) : this.shouldMarkRead(n)) { + return { ...n, lida: true, lidaEm: now }; + } + return n; + }); + this.clearSelection(); + this.bulkLoading = false; + }, + error: () => { + this.bulkLoading = false; + } + }); + } + + exportNotifications() { + if (this.filter === 'lidas' || this.exportLoading) return; + this.exportLoading = true; + + const filterParam = this.getFilterParam(); + const ids = Array.from(this.selectedIds); + this.notificationsService.export(filterParam, ids.length ? ids : undefined).subscribe({ + next: (res) => { + const blob = res.body; + if (!blob) { + this.exportLoading = false; + return; + } + + const filename = this.extractFilename(res.headers.get('content-disposition')) || this.buildDefaultFilename(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + this.clearSelection(); + this.exportLoading = false; + }, + error: () => { + this.exportLoading = false; + } + }); + } + + isSelected(notification: NotificationDto): boolean { + return this.selectedIds.has(notification.id); + } + + toggleSelection(notification: NotificationDto) { + if (this.selectedIds.has(notification.id)) { + this.selectedIds.delete(notification.id); + } else { + this.selectedIds.add(notification.id); + } + } + + get isAllSelected(): boolean { + const list = this.filteredNotifications; + return list.length > 0 && list.every(n => this.selectedIds.has(n.id)); + } + + toggleSelectAll() { + const list = this.filteredNotifications; + if (this.isAllSelected) { + this.clearSelection(); + return; + } + list.forEach(n => this.selectedIds.add(n.id)); + } + + clearSelection() { + this.selectedIds.clear(); + } + + private getFilterParam(): string | undefined { + if (this.filter === 'aVencer') return 'a-vencer'; + if (this.filter === 'vencidas') return 'vencidas'; + if (this.filter === 'todas') return undefined; + return undefined; + } + + private shouldMarkRead(n: NotificationDto): boolean { + if (this.filter === 'todas') return true; + if (this.filter === 'aVencer') return n.tipo === 'AVencer'; + if (this.filter === 'vencidas') return n.tipo === 'Vencido'; + return false; + } + + private extractFilename(contentDisposition: string | null): string | null { + if (!contentDisposition) return null; + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); + const normalMatch = contentDisposition.match(/filename=\"?([^\";]+)\"?/i); + return normalMatch?.[1] ?? null; + } + + private buildDefaultFilename(): string { + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '').slice(0, 14); + return `notificacoes-${stamp}.xlsx`; + } } diff --git a/src/app/pages/novo-usuario/novo-usuario.html b/src/app/pages/novo-usuario/novo-usuario.html index 52c9939..6436379 100644 --- a/src/app/pages/novo-usuario/novo-usuario.html +++ b/src/app/pages/novo-usuario/novo-usuario.html @@ -63,7 +63,7 @@

Gerencie permissões e status.

- +
diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html new file mode 100644 index 0000000..fd6c79b --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html @@ -0,0 +1,151 @@ +
+
+
+ + + + + +
+
diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss new file mode 100644 index 0000000..f66ede4 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss @@ -0,0 +1,389 @@ +:host { + display: block; + --brand: var(--pg-primary, #1f4fd6); + --blue: var(--pg-primary-strong, #153caa); + --focus-ring: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); +} + +.lg-backdrop { + position: fixed; + inset: 0; + background: + radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.15), rgba(15, 23, 42, 0.66) 42%), + rgba(15, 23, 42, 0.58); + z-index: 9990; + backdrop-filter: blur(4px); +} + +.lg-modal { + position: fixed; + inset: 0; + z-index: 9995; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.lg-modal-card { + width: min(1040px, 96vw); + max-height: 92vh; + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 18px; + border: 1px solid var(--pg-border, #dbe3ef); + background: #fff; + box-shadow: var(--pg-shadow-lg, 0 24px 56px rgba(15, 23, 42, 0.25)); + animation: pop-up 0.24s ease; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--pg-border, #dbe3ef); + background: linear-gradient(180deg, #f4f8ff, #ffffff 85%); +} + +.modal-title { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 0.98rem; + font-weight: 800; + color: var(--pg-text, #0f172a); + + .icon-bg { + width: 34px; + height: 34px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(31, 79, 214, 0.12); + color: var(--blue); + } +} + +.btn-icon { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text-soft, #64748b); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: var(--brand); + color: var(--blue); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + } +} + +.modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 18px; + background: linear-gradient(180deg, #f8fbff, #ffffff 82%); + display: grid; + gap: 16px; +} + +.form-section { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: 14px; + padding: 12px; + background: #fff; + display: grid; + gap: 12px; +} + +.section-head { + display: grid; + gap: 3px; + + h4 { + margin: 0; + font-size: 0.9rem; + color: var(--pg-text, #0f172a); + font-weight: 800; + } + + small { + color: var(--pg-text-soft, #64748b); + font-size: 12px; + font-weight: 600; + } +} + +.form-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.form-field { + display: grid; + gap: 6px; + + label { + color: var(--pg-text-soft, #64748b); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + } + + input { + height: 40px; + border-radius: 10px; + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text, #0f172a); + padding: 0 12px; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: var(--brand); + box-shadow: var(--focus-ring); + } + } + + .error { + color: var(--pg-danger, #c52929); + font-size: 11px; + font-weight: 700; + } +} + +.competencia-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-items: center; +} + +.select-glass { + background: #fff; + border: 1px solid var(--pg-border-strong, #c8d4e4); + border-radius: 10px; + color: var(--pg-text, #0f172a); + font-weight: 700; +} + +.final-box { + min-height: 40px; + border-radius: 10px; + border: 1px dashed var(--pg-border-strong, #c8d4e4); + background: #f8fbff; + color: var(--pg-text-muted, #475569); + padding: 0 12px; + display: flex; + align-items: center; + font-weight: 700; +} + +.preview-card { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: 14px; + padding: 12px; + display: grid; + gap: 10px; + background: #fff; +} + +.preview-head h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 800; + color: var(--pg-text, #0f172a); +} + +.preview-head small { + color: var(--pg-text-soft, #64748b); + font-size: 12px; + font-weight: 600; +} + +.preview-table { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: 10px; + max-height: 240px; + overflow: auto; +} + +.preview-table table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: 460px; +} + +.preview-table th, +.preview-table td { + padding: 8px 10px; + border-bottom: 1px solid #e9eef6; + font-size: 12px; + color: var(--pg-text, #0f172a); +} + +.preview-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f5f9ff; + color: var(--pg-text-soft, #64748b); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; + font-weight: 800; +} + +.preview-table .empty { + text-align: center; + color: var(--pg-text-muted, #475569); + font-weight: 700; +} + +.inline-input { + width: 100%; + height: 32px; + border-radius: 8px; + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text, #0f172a); + text-align: right; + padding: 0 8px; + font-size: 12px; + + &:focus { + outline: none; + border-color: var(--brand); + box-shadow: var(--focus-ring); + } +} + +.todo-note { + margin: 0; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(197, 41, 41, 0.28); + background: rgba(197, 41, 41, 0.1); + color: var(--pg-danger, #c52929); + font-size: 12px; + font-weight: 700; +} + +.modal-footer { + border-top: 1px solid var(--pg-border, #dbe3ef); + padding: 12px 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + background: #fff; +} + +.btn-primary, +.btn-ghost { + height: 38px; + border-radius: 10px; + border: 1px solid transparent; + padding: 0 14px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + } + + &:disabled { + opacity: 0.62; + cursor: not-allowed; + } +} + +.btn-primary { + color: #fff; + border-color: var(--blue); + background: linear-gradient(140deg, var(--brand), var(--blue)); + box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28); +} + +.btn-ghost { + color: var(--pg-text, #0f172a); + background: #fff; + border-color: var(--pg-border-strong, #c8d4e4); +} + +.btn-primary:hover, +.btn-ghost:hover { + transform: translateY(-1px); +} + +.btn-ghost:hover { + border-color: var(--brand); + color: var(--blue); +} + +@keyframes pop-up { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 940px) { + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .modal-header, + .modal-body, + .modal-footer { + padding-left: 12px; + padding-right: 12px; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .modal-footer { + justify-content: stretch; + } + + .modal-footer .btn-primary, + .modal-footer .btn-ghost { + flex: 1; + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts new file mode 100644 index 0000000..ab003b4 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts @@ -0,0 +1,253 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../../../components/custom-select/custom-select'; + +export type MonthOption = { value: number; label: string }; + +export type ParcelamentoCreateModel = { + anoRef: number | null; + linha: string; + cliente: string; + item: number | null; + qtParcelas: string; + parcelaAtual: number | null; + totalParcelas: number | null; + valorCheio: string; + desconto: string; + valorComDesconto: string; + competenciaAno: number | null; + competenciaMes: number | null; + monthValues: Array<{ competencia: string; valor: string }>; +}; + +type PreviewRow = { + competencia: string; + label: string; + parcela: number; + valor: string; +}; + +@Component({ + selector: 'app-parcelamento-create-modal', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './parcelamento-create-modal.html', + styleUrls: ['./parcelamento-create-modal.scss'], +}) +export class ParcelamentoCreateModalComponent implements OnChanges { + @Input() open = false; + @Input() monthOptions: MonthOption[] = []; + @Input() model!: ParcelamentoCreateModel; + @Input() title = 'Novo Parcelamento'; + @Input() submitLabel = 'Salvar'; + @Input() loading = false; + @Input() errorMessage = ''; + + @Output() close = new EventEmitter(); + @Output() save = new EventEmitter(); + + touched = false; + previewRows: PreviewRow[] = []; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['model'] && this.model) { + this.syncMonthValues(); + return; + } + if (changes['open'] && this.model) { + this.rebuildPreviewRows(); + } + } + + onValueChange(): void { + const cheio = this.toNumber(this.model.valorCheio); + const desconto = this.toNumber(this.model.desconto); + if (cheio === null) { + this.model.valorComDesconto = ''; + this.syncMonthValues(); + return; + } + const calc = Math.max(0, cheio - (desconto ?? 0)); + this.model.valorComDesconto = this.formatInput(calc); + this.syncMonthValues(); + } + + onCompetenciaChange(): void { + this.syncMonthValues(); + } + + onValorComDescontoChange(): void { + this.syncMonthValues(); + } + + onParcelaChange(): void { + this.syncQtParcelas(); + this.syncMonthValues(); + } + + onQtParcelasChange(): void { + const parsed = this.parseQtParcelas(this.model.qtParcelas); + if (parsed) { + this.model.parcelaAtual = parsed.atual; + this.model.totalParcelas = parsed.total; + } + this.syncMonthValues(); + } + + get competenciaFinalLabel(): string { + if (this.model.monthValues?.length) { + const last = this.model.monthValues[this.model.monthValues.length - 1]; + return this.formatCompetenciaLabel(last.competencia); + } + const total = this.model.totalParcelas ?? 0; + const ano = this.model.competenciaAno ?? 0; + const mes = this.model.competenciaMes ?? 0; + if (!total || !ano || !mes) return '-'; + + const index = (mes - 1) + (total - 1); + const finalAno = ano + Math.floor(index / 12); + const finalMes = (index % 12) + 1; + return `${String(finalMes).padStart(2, '0')}/${finalAno}`; + } + + onPreviewValueChange(competencia: string, value: string): void { + const list = this.model.monthValues ?? []; + const item = list.find((entry) => entry.competencia === competencia); + if (item) item.valor = value ?? ''; + + const row = this.previewRows.find((entry) => entry.competencia === competencia); + if (row) row.valor = value ?? ''; + } + + trackByPreview(_: number, row: PreviewRow): string { + return row.competencia; + } + + get isValid(): boolean { + return !!( + this.model.anoRef && + this.model.item && + this.model.linha?.trim() && + this.model.cliente?.trim() && + this.model.totalParcelas && + this.model.totalParcelas > 0 && + this.model.valorCheio && + this.model.competenciaAno && + this.model.competenciaMes + ); + } + + onSave(): void { + this.touched = true; + if (!this.isValid) return; + this.save.emit(this.model); + } + + private syncQtParcelas(): void { + const atual = this.model.parcelaAtual; + const total = this.model.totalParcelas; + if (atual && total) { + this.model.qtParcelas = `${atual}/${total}`; + } + } + + private syncMonthValues(): void { + const total = this.model.totalParcelas ?? 0; + const ano = this.model.competenciaAno ?? 0; + const mes = this.model.competenciaMes ?? 0; + if (!total || !ano || !mes) { + this.model.monthValues = []; + this.previewRows = []; + return; + } + + const existing = new Map(); + (this.model.monthValues ?? []).forEach((m) => { + if (m?.competencia) existing.set(m.competencia, m.valor ?? ''); + }); + + const valorTotal = this.toNumber(this.model.valorComDesconto) ?? this.toNumber(this.model.valorCheio); + const valorParcela = valorTotal !== null ? valorTotal / total : null; + const defaultValor = valorParcela !== null ? this.formatInput(valorParcela) : ''; + + const list: Array<{ competencia: string; valor: string }> = []; + for (let i = 0; i < total; i++) { + const index = (mes - 1) + i; + const y = ano + Math.floor(index / 12); + const m = (index % 12) + 1; + const competencia = `${y}-${String(m).padStart(2, '0')}-01`; + list.push({ + competencia, + valor: existing.get(competencia) ?? defaultValor, + }); + } + + this.model.monthValues = list; + this.rebuildPreviewRows(); + } + + private rebuildPreviewRows(): void { + const list = this.model?.monthValues ?? []; + if (!list.length) { + this.previewRows = []; + return; + } + + this.previewRows = list.slice(0, 36).map((item, idx) => ({ + competencia: item.competencia, + label: this.formatCompetenciaLabel(item.competencia), + parcela: idx + 1, + valor: item.valor ?? '', + })); + } + + private formatCompetenciaLabel(value: string): string { + const match = value.match(/^(\d{4})-(\d{2})/); + if (!match) return value || '-'; + return `${match[2]}/${match[1]}`; + } + + private parseQtParcelas(raw: string | null | undefined): { atual: number; total: number } | null { + if (!raw) return null; + const parts = raw.split('/'); + if (parts.length < 2) return null; + const atualStr = this.onlyDigits(parts[0]); + const totalStr = this.onlyDigits(parts[1]); + if (!atualStr || !totalStr) return null; + return { atual: Number(atualStr), total: Number(totalStr) }; + } + + private toNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const raw = String(value).trim(); + if (!raw) return null; + let cleaned = raw.replace(/[^\d,.-]/g, ''); + if (cleaned.includes(',') && cleaned.includes('.')) { + if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { + cleaned = cleaned.replace(/,/g, ''); + } + } else if (cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { + cleaned = cleaned.replace(/,/g, ''); + } + const n = Number(cleaned); + return Number.isNaN(n) ? null : n; + } + + private onlyDigits(value: string): string { + let out = ''; + for (const ch of value ?? '') { + if (ch >= '0' && ch <= '9') out += ch; + } + return out; + } + + private formatInput(value: number): string { + return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html new file mode 100644 index 0000000..1f152f3 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html @@ -0,0 +1,56 @@ +
+
+
+ + + + + +
+
diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss new file mode 100644 index 0000000..8b610ab --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss @@ -0,0 +1,202 @@ +:host { + --brand: #E33DCF; + --blue: #030FAA; + --focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16); +} + +.lg-backdrop { + position: fixed; + inset: 0; + background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.2), rgba(0, 0, 0, 0.56) 42%); + z-index: 9990; + backdrop-filter: blur(5px); +} + +.lg-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 9995; + padding: 16px; +} + +.lg-modal-card { + background: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.88); + border-radius: 20px; + box-shadow: 0 30px 62px -16px rgba(0, 0, 0, 0.42); + width: min(1200px, 98vw); + overflow: hidden; + animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.1), rgba(255, 255, 255, 0.95) 72%); +} + +.modal-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 900; + + .icon-bg { + width: 32px; + height: 32px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(3, 15, 170, 0.1); + color: var(--blue); + } +} + +.modal-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + select { + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 10px; + padding: 6px 10px; + font-weight: 800; + background: rgba(255, 255, 255, 0.92); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: var(--brand); + box-shadow: var(--focus-ring); + } + } +} + +.btn-icon { + width: 34px; + height: 34px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); + color: rgba(17, 18, 20, 0.58); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: rgba(227, 61, 207, 0.26); + background: #fff; + color: var(--brand); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + } +} + +.modal-body { + padding: 16px; + background: linear-gradient(180deg, rgba(248, 249, 251, 0.98), rgba(255, 255, 255, 0.98)); +} + +.annual-table { + overflow-x: auto; + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 12px; + background: #fff; +} + +.annual-table table { + border-collapse: collapse; + min-width: 1100px; + width: 100%; + font-size: 12px; +} + +.annual-table th, +.annual-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + white-space: nowrap; +} + +.annual-table thead th { + background: #f8f9fb; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + color: rgba(17, 18, 20, 0.6); +} + +.sticky-col { + position: sticky; + left: 0; + background: #fff; + z-index: 2; + box-shadow: 2px 0 0 rgba(17, 18, 20, 0.04); +} + +.col-1 { left: 0; min-width: 180px; } +.col-2 { left: 180px; min-width: 140px; } +.col-3 { left: 320px; min-width: 120px; } +.col-4 { left: 440px; min-width: 120px; text-align: right; } +.col-5 { left: 560px; min-width: 80px; } + +.modal-footer { + padding: 14px 20px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + display: flex; + justify-content: flex-end; + background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96)); +} + +.btn-primary { + height: 38px; + border-radius: 10px; + border: 1px solid #030faa; + font-weight: 700; + font-size: 12px; + cursor: pointer; + padding: 0 14px; + background: linear-gradient(135deg, #1543ff, #030faa); + color: #fff; + box-shadow: 0 10px 20px rgba(3, 15, 170, 0.24); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(3, 15, 170, 0.28); + filter: brightness(1.04); + } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + } +} + +.empty-state { + text-align: center; + padding: 24px; + font-weight: 700; + color: rgba(17, 18, 20, 0.6); +} + +@keyframes popUp { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts new file mode 100644 index 0000000..99a923f --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts @@ -0,0 +1,41 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +export type AnnualMonthValue = { + month: number; + label: string; + value: number | null; +}; + +export type AnnualRow = { + cliente: string; + linha: string; + item: string; + total: number; + parcelasLabel: string; + months: AnnualMonthValue[]; +}; + +@Component({ + selector: 'app-parcelamento-detalhamento-anual-modal', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './parcelamento-detalhamento-anual-modal.html', + styleUrls: ['./parcelamento-detalhamento-anual-modal.scss'], +}) +export class ParcelamentoDetalhamentoAnualModalComponent { + @Input() open = false; + @Input() years: number[] = []; + @Input() selectedYear: number | null = null; + @Input() data: AnnualRow | null = null; + + @Output() close = new EventEmitter(); + @Output() yearChange = new EventEmitter(); + + onYearChange(value: unknown): void { + const year = Number(value); + if (!Number.isFinite(year)) return; + this.yearChange.emit(year); + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html new file mode 100644 index 0000000..98e4cfd --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html @@ -0,0 +1,74 @@ +
+
+
+
+ + Filtros da listagem +
+ Use os campos abaixo para refinar a consulta sem alterar os dados. +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ Informe ano e mes. +
+
+ +
+ + +
+ + {{ chip.label }}: {{ chip.value }} + +
+
+
diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss new file mode 100644 index 0000000..5b97ec9 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss @@ -0,0 +1,300 @@ +:host { + display: block; + min-width: 0; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +.filters-card { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: var(--pg-radius-md, 14px); + padding: 16px; + display: grid; + gap: 14px; + background: rgba(255, 255, 255, 0.95); + box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08)); + overflow: hidden; +} + +.filters-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.filters-head > * { + min-width: 0; +} + +.filters-title-wrap { + display: grid; + gap: 6px; + flex: 1 1 360px; + min-width: 0; + + small { + color: var(--pg-text-soft, #64748b); + font-size: 12px; + font-weight: 600; + } +} + +.filters-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--pg-text, #0f172a); + font-weight: 800; + + i { + color: var(--pg-primary, #1f4fd6); + } +} + +.filters-actions { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.btn-primary, +.btn-ghost { + height: 38px; + border-radius: var(--pg-radius-sm, 10px); + border: 1px solid transparent; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } + + &:disabled { + opacity: 0.62; + cursor: not-allowed; + transform: none; + box-shadow: none; + } +} + +.btn-primary { + color: #fff; + border-color: var(--pg-primary-strong, #153caa); + background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa)); + box-shadow: 0 10px 20px rgba(31, 79, 214, 0.25); +} + +.btn-ghost { + color: var(--pg-text, #0f172a); + background: #fff; + border-color: var(--pg-border-strong, #c8d4e4); +} + +.btn-primary:hover, +.btn-ghost:hover { + transform: translateY(-1px); +} + +.btn-ghost:hover { + border-color: var(--pg-primary, #1f4fd6); + color: var(--pg-primary-strong, #153caa); +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + min-width: 0; +} + +.filter-field { + display: grid; + gap: 6px; + min-width: 0; + + label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + color: var(--pg-text-soft, #64748b); + } + + input { + width: 100%; + height: 40px; + border-radius: var(--pg-radius-sm, 10px); + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text, #0f172a); + padding: 0 12px; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: var(--pg-primary, #1f4fd6); + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } + + &:disabled { + background: #f5f8fd; + color: var(--pg-text-soft, #64748b); + cursor: not-allowed; + } + } +} + +.competencia-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + align-items: center; + min-width: 0; +} + +.competencia-row > * { + min-width: 0; + width: 100%; +} + +.filters-meta { + border-top: 1px dashed var(--pg-border, #dbe3ef); + padding-top: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.search-box { + display: inline-flex; + align-items: center; + gap: 8px; + width: min(420px, 100%); + min-width: 0; + border: 1px solid var(--pg-border-strong, #c8d4e4); + border-radius: var(--pg-radius-sm, 10px); + background: #fff; + padding: 0 12px; + height: 40px; + + i { + color: var(--pg-text-soft, #64748b); + } + + input { + width: 100%; + border: none; + outline: none; + font-size: 13px; + color: var(--pg-text, #0f172a); + background: transparent; + } + + &:focus-within { + border-color: var(--pg-primary, #1f4fd6); + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } +} + +.filter-chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + border: 1px solid rgba(31, 79, 214, 0.2); + background: rgba(31, 79, 214, 0.1); + padding: 4px 10px; + color: var(--pg-text-muted, #475569); + font-size: 11px; + font-weight: 700; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + strong { + color: var(--pg-primary-strong, #153caa); + } +} + +.hint { + font-size: 12px; + color: var(--pg-text-muted, #475569); +} + +.hint.warn { + color: var(--pg-warning, #b4690e); + font-weight: 700; +} + +.select-glass { + display: block; + width: 100%; + min-width: 0; + background: #fff; + border: 1px solid var(--pg-border-strong, #c8d4e4); + border-radius: var(--pg-radius-sm, 10px); + color: var(--pg-text, #0f172a); + font-weight: 700; +} + +@media (max-width: 1100px) { + .filters-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .filters-actions { + width: 100%; + } + + .filters-actions .btn-primary, + .filters-actions .btn-ghost { + flex: 1; + } + + .filters-grid { + grid-template-columns: 1fr; + } + + .filters-meta { + align-items: stretch; + } + + .search-box { + width: 100%; + min-width: 0; + } + + .filter-chips { + justify-content: flex-start; + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts new file mode 100644 index 0000000..546a7bd --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts @@ -0,0 +1,36 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../../../components/custom-select/custom-select'; + +export type MonthOption = { value: number; label: string }; + +export type ParcelamentosFiltersModel = { + anoRef: string; + linha: string; + cliente: string; + competenciaAno: string; + competenciaMes: number | ''; + search: string; +}; + +export type FilterChip = { label: string; value: string }; + +@Component({ + selector: 'app-parcelamentos-filters', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './parcelamentos-filters.html', + styleUrls: ['./parcelamentos-filters.scss'], +}) +export class ParcelamentosFiltersComponent { + @Input() filters!: ParcelamentosFiltersModel; + @Input() monthOptions: MonthOption[] = []; + @Input() loading = false; + @Input() competenciaInvalid = false; + @Input() activeChips: FilterChip[] = []; + + @Output() apply = new EventEmitter(); + @Output() clear = new EventEmitter(); + @Output() searchChange = new EventEmitter(); +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html new file mode 100644 index 0000000..dbe1c2b --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html @@ -0,0 +1,14 @@ +
+
+ {{ k?.label }} + + {{ k?.value }} + + {{ k?.hint }} +
+
diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss new file mode 100644 index 0000000..26dad0c --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss @@ -0,0 +1,57 @@ +:host { + display: block; + min-width: 0; +} + +.parcelamentos-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(196px, 1fr)); + gap: 12px; +} + +.kpi-card { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: var(--pg-radius-md, 14px); + background: linear-gradient(180deg, #ffffff, #f8fbff); + padding: 14px 15px; + display: grid; + gap: 6px; + box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08)); +} + +.kpi-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + color: var(--pg-text-soft, #64748b); +} + +.kpi-value { + font-size: 1.2rem; + line-height: 1.2; + font-weight: 800; + color: var(--pg-text, #0f172a); +} + +.kpi-hint { + font-size: 12px; + font-weight: 600; + color: var(--pg-text-muted, #475569); +} + +.tone-brand { + color: var(--pg-primary, #1f4fd6); +} + +.tone-success { + color: #1c7a3e; +} + +.tone-danger { + color: #b42323; +} + +.tone-info { + color: #1f4fd6; +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts new file mode 100644 index 0000000..6a90665 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ParcelamentoKpi = { + label: string; + value: string; + hint?: string; + tone?: 'brand' | 'success' | 'danger' | 'info' | 'muted'; +}; + +@Component({ + selector: 'app-parcelamentos-kpis', + standalone: true, + imports: [CommonModule], + templateUrl: './parcelamentos-kpis.html', + styleUrls: ['./parcelamentos-kpis.scss'], +}) +export class ParcelamentosKpisComponent { + @Input() cards: ParcelamentoKpi[] = []; +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html new file mode 100644 index 0000000..db3af8f --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html @@ -0,0 +1,161 @@ +
+
+
+

Carteira de Parcelamentos

+ Visualizacao paginada com filtros e acoes por registro +
+ +
+ +
+
+ +
+
+
+ Carregando parcelamentos... + Aguarde enquanto os dados sao atualizados. +
+
+
+ + + +
+
+
+ +
+
+
+ Falha ao carregar dados + {{ errorMessage }} +
+
+ +
+
+
+ Nenhum parcelamento encontrado + Altere os filtros para tentar novamente. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ano ref.LinhaClienteStatusParcela atualValor cheioDescontoValor c/ descontoAcoes
{{ row.anoRef ?? '-' }}{{ row.linha || '-' }}{{ row.cliente || '-' }} + + {{ row.statusLabel }} + + {{ row.progressLabel || '-' }} + {{ row.valorCheioNumber === null || row.valorCheioNumber === undefined + ? '-' : (row.valorCheioNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }} + + {{ row.descontoNumber === null || row.descontoNumber === undefined + ? '-' : (row.descontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }} + + {{ row.valorComDescontoNumber === null || row.valorComDescontoNumber === undefined + ? '-' : (row.valorComDescontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }} + +
+ + + + + +
+
+
+ + +
diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss new file mode 100644 index 0000000..f1c8de2 --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss @@ -0,0 +1,499 @@ +:host { + display: block; + min-width: 0; +} + +.table-card { + border: 1px solid var(--pg-border, #dbe3ef); + border-radius: var(--pg-radius-md, 14px); + overflow: hidden; + background: #fff; + box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08)); +} + +.table-head { + padding: 14px 16px; + border-bottom: 1px solid var(--pg-border, #dbe3ef); + background: linear-gradient(180deg, #f8fbff, #ffffff 75%); + display: grid; + gap: 12px; +} + +.table-head-left { + display: grid; + gap: 4px; + + h3 { + margin: 0; + font-size: 1rem; + font-weight: 800; + color: var(--pg-text, #0f172a); + } + + small { + color: var(--pg-text-soft, #64748b); + font-size: 12px; + font-weight: 600; + } +} + +.segmented { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.segment-btn { + border: 1px solid var(--pg-border-strong, #c8d4e4); + border-radius: 999px; + background: #fff; + color: var(--pg-text-muted, #475569); + padding: 7px 12px; + font-size: 12px; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 7px; + cursor: pointer; + transition: all 0.2s ease; + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } +} + +.segment-btn:hover { + border-color: var(--pg-primary, #1f4fd6); + color: var(--pg-primary-strong, #153caa); +} + +.segment-btn.active { + background: rgba(31, 79, 214, 0.12); + border-color: rgba(31, 79, 214, 0.3); + color: var(--pg-primary-strong, #153caa); +} + +.segment-btn .count { + border-radius: 999px; + padding: 2px 7px; + font-size: 11px; + font-weight: 800; + background: rgba(15, 23, 42, 0.08); + color: var(--pg-text-muted, #475569); +} + +.table-state { + padding: 24px 16px; + display: grid; + gap: 12px; + justify-items: center; + text-align: center; +} + +.state-icon { + width: 44px; + height: 44px; + border-radius: 12px; + border: 1px solid var(--pg-border, #dbe3ef); + background: var(--pg-surface-alt, #f8fafc); + color: var(--pg-text-muted, #475569); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.15rem; +} + +.state-copy { + display: grid; + gap: 4px; + + strong { + color: var(--pg-text, #0f172a); + font-size: 0.92rem; + font-weight: 800; + } + + span { + color: var(--pg-text-soft, #64748b); + font-size: 0.82rem; + font-weight: 600; + } +} + +.table-state.error .state-icon { + color: var(--pg-danger, #c52929); + border-color: rgba(197, 41, 41, 0.32); + background: rgba(197, 41, 41, 0.08); +} + +.table-state.empty .state-icon { + color: var(--pg-warning, #b4690e); + border-color: rgba(180, 105, 14, 0.32); + background: rgba(180, 105, 14, 0.1); +} + +.skeleton-group { + width: min(760px, 100%); + display: grid; + gap: 8px; +} + +.skeleton-row { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr 1fr; +} + +.skeleton-line { + height: 10px; + border-radius: 999px; + background: linear-gradient(90deg, #e6edf8, #dbe6f3, #e6edf8); + background-size: 240px 100%; + animation: shimmer 1.3s infinite linear; +} + +.parcelamentos-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; +} + +.table-modern { + width: max-content; + min-width: 1120px; + border-collapse: separate; + border-spacing: 0; + + thead th { + position: sticky; + top: 0; + z-index: 2; + padding: 11px 10px; + border-bottom: 1px solid var(--pg-border-strong, #c8d4e4); + background: #f5f9ff; + color: var(--pg-text-soft, #64748b); + font-size: 0.67rem; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 800; + white-space: nowrap; + text-align: left; + } + + tbody tr { + transition: background 0.18s ease; + } + + tbody tr:nth-child(even) { + background: #f9fbff; + } + + tbody tr:hover { + background: rgba(31, 79, 214, 0.08); + } + + td { + padding: 11px 10px; + border-bottom: 1px solid #e9eef6; + font-size: 0.84rem; + color: var(--pg-text, #0f172a); + white-space: nowrap; + text-align: left; + vertical-align: middle; + } +} + +.table-row { + cursor: default; +} + +.nowrap { + white-space: nowrap; +} + +.col-ano { + width: 90px; +} + +.col-linha { + width: 138px; +} + +.col-cliente { + width: 260px; + max-width: 260px; +} + +.col-status { + width: 110px; +} + +.col-parcela { + width: 130px; +} + +.col-valor { + width: 150px; + text-align: right; +} + +.col-acoes { + width: 152px; + min-width: 152px; + text-align: center; +} + +.table-modern thead .col-ano, +.table-modern thead .col-linha, +.table-modern thead .col-cliente, +.table-modern thead .col-status, +.table-modern thead .col-parcela, +.table-modern thead .col-acoes, +.table-modern tbody .col-ano, +.table-modern tbody .col-linha, +.table-modern tbody .col-cliente, +.table-modern tbody .col-status, +.table-modern tbody .col-parcela, +.table-modern tbody .col-acoes { + text-align: center; +} + +.text-end { + text-align: right; +} + +.text-center { + text-align: center; +} + +.text-blue { + color: var(--pg-primary-strong, #153caa); +} + +.text-danger { + color: var(--pg-danger, #c52929); +} + +.text-muted { + color: var(--pg-text-muted, #475569); +} + +.fw-bold { + font-weight: 700; +} + +.fw-black { + font-weight: 800; +} + +.money-strong { + color: var(--pg-primary, #1f4fd6); + font-weight: 800; +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #d5dfef; + background: #f2f6fc; + color: #334155; + font-size: 11px; + font-weight: 800; +} + +.status-ativos { + background: rgba(28, 122, 62, 0.14); + color: #1c7a3e; + border-color: rgba(28, 122, 62, 0.28); +} + +.status-futuros { + background: rgba(31, 79, 214, 0.12); + color: #1f4fd6; + border-color: rgba(31, 79, 214, 0.28); +} + +.status-finalizados { + background: rgba(197, 41, 41, 0.12); + color: #b42323; + border-color: rgba(197, 41, 41, 0.25); +} + +.action-group { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-wrap: nowrap; +} + +.btn-icon { + width: 34px; + height: 34px; + border: 1px solid #cfd9e9; + border-radius: 10px; + background: #eff4ff; + color: var(--pg-primary-strong, #153caa); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.18s ease; + + &:hover { + transform: translateY(-1px); + border-color: var(--pg-primary, #1f4fd6); + background: #e3edff; + } + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } +} + +.btn-icon.ghost { + background: #f4f6fb; + color: #475569; +} + +.btn-icon.danger { + background: rgba(197, 41, 41, 0.12); + color: #b42323; + border-color: rgba(197, 41, 41, 0.22); +} + +.table-footer { + border-top: 1px solid var(--pg-border, #dbe3ef); + background: #fff; + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.page-info { + color: var(--pg-text-muted, #475569); + font-size: 12px; + font-weight: 700; +} + +.pagination { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-ghost.icon-only, +.btn-page { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text, #0f172a); + font-size: 12px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.btn-page.active { + color: #fff; + border-color: var(--pg-primary-strong, #153caa); + background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa)); +} + +.page-size { + display: inline-flex; + align-items: center; + gap: 8px; + + span { + color: var(--pg-text-soft, #64748b); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + } +} + +.select-glass { + min-width: 76px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--pg-border-strong, #c8d4e4); + background: #fff; + color: var(--pg-text, #0f172a); + font-weight: 700; + padding: 0 8px; +} + +@keyframes shimmer { + 0% { + background-position: -120px 0; + } + + 100% { + background-position: 120px 0; + } +} + +@media (max-width: 1180px) { + .table-modern { + min-width: 1020px; + } + + .col-cliente { + width: 210px; + max-width: 210px; + } +} + +@media (max-width: 760px) { + .table-head, + .table-footer { + padding-left: 12px; + padding-right: 12px; + } + + .segmented { + width: 100%; + } + + .segment-btn { + flex: 1; + justify-content: space-between; + min-width: 0; + } + + .table-footer { + justify-content: center; + } + + .page-size { + width: 100%; + justify-content: center; + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts new file mode 100644 index 0000000..fa8d84e --- /dev/null +++ b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts @@ -0,0 +1,66 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ParcelamentoListItem } from '../../../../services/parcelamentos.service'; + +export type ParcelamentoSegment = 'todos' | 'ativos' | 'futuros' | 'finalizados'; + +export type ParcelamentoViewItem = ParcelamentoListItem & { + status: 'ativos' | 'futuros' | 'finalizados'; + statusLabel: string; + progressLabel: string; + valorParcela?: number | null; + valorCheioNumber?: number | null; + descontoNumber?: number | null; + valorComDescontoNumber?: number | null; +}; + +@Component({ + selector: 'app-parcelamentos-table', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './parcelamentos-table.html', + styleUrls: ['./parcelamentos-table.scss'], +}) +export class ParcelamentosTableComponent { + @Input() items: ParcelamentoViewItem[] = []; + @Input() loading = false; + @Input() errorMessage = ''; + @Input() isAdmin = false; + + @Input() segment: ParcelamentoSegment = 'todos'; + @Input() segmentCounts: Record = { + todos: 0, + ativos: 0, + futuros: 0, + finalizados: 0, + }; + + @Input() page = 1; + @Input() pageNumbers: number[] = []; + @Input() pageStart = 0; + @Input() pageEnd = 0; + @Input() total = 0; + @Input() pageSize = 10; + @Input() pageSizeOptions: number[] = []; + + @Output() segmentChange = new EventEmitter(); + @Output() detail = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() remove = new EventEmitter(); + @Output() pageChange = new EventEmitter(); + @Output() pageSizeChange = new EventEmitter(); + + readonly segments: Array<{ key: ParcelamentoSegment; label: string }> = [ + { key: 'todos', label: 'Lista geral' }, + { key: 'ativos', label: 'Ativos' }, + { key: 'futuros', label: 'Futuros' }, + { key: 'finalizados', label: 'Finalizados' }, + ]; + + skeletonRows = Array.from({ length: 6 }); + + trackById(_: number, item: ParcelamentoViewItem): string { + return item.id; + } +} diff --git a/src/app/pages/parcelamentos/parcelamentos.html b/src/app/pages/parcelamentos/parcelamentos.html new file mode 100644 index 0000000..619e398 --- /dev/null +++ b/src/app/pages/parcelamentos/parcelamentos.html @@ -0,0 +1,251 @@ +
+
+
+ + + + + + + + + +
+
+
+ + +
+
+
+ + + + + +
+
+ + + + + + + + +
+
+ +
diff --git a/src/app/pages/parcelamentos/parcelamentos.scss b/src/app/pages/parcelamentos/parcelamentos.scss new file mode 100644 index 0000000..6c25851 --- /dev/null +++ b/src/app/pages/parcelamentos/parcelamentos.scss @@ -0,0 +1,614 @@ +:host { + --pg-font-sans: 'IBM Plex Sans', 'Source Sans 3', 'Manrope', 'Segoe UI', sans-serif; + + --pg-primary: #1f4fd6; + --pg-primary-strong: #153caa; + --pg-primary-soft: rgba(31, 79, 214, 0.12); + --pg-primary-soft-2: rgba(31, 79, 214, 0.18); + + --pg-text: #0f172a; + --pg-text-muted: #475569; + --pg-text-soft: #64748b; + + --pg-bg: #f3f6fb; + --pg-surface: #ffffff; + --pg-surface-alt: #f8fafc; + + --pg-border: #dbe3ef; + --pg-border-strong: #c8d4e4; + + --pg-warning: #b4690e; + --pg-warning-soft: rgba(180, 105, 14, 0.14); + --pg-danger: #c52929; + --pg-danger-soft: rgba(197, 41, 41, 0.12); + --pg-success: #1c7a3e; + + --pg-radius-sm: 10px; + --pg-radius-md: 14px; + --pg-radius-lg: 18px; + + --pg-shadow-sm: 0 8px 18px rgba(15, 23, 42, 0.08); + --pg-shadow-md: 0 16px 32px rgba(15, 23, 42, 0.12); + --pg-shadow-lg: 0 24px 56px rgba(15, 23, 42, 0.25); + + --pg-focus-ring: 0 0 0 3px rgba(31, 79, 214, 0.22); + + --brand: var(--pg-primary); + --blue: var(--pg-primary-strong); + --text: var(--pg-text); + --muted: var(--pg-text-muted); + --focus-ring: var(--pg-focus-ring); + + display: block; + color: var(--pg-text); + font-family: var(--pg-font-sans); +} + +.parcelamentos-page { + min-height: 100vh; + padding: 0 12px 72px; + display: flex; + justify-content: center; + position: relative; + background: + radial-gradient(1100px 450px at 10% -10%, rgba(31, 79, 214, 0.11), transparent 60%), + radial-gradient(900px 420px at 100% 0%, rgba(30, 64, 175, 0.07), transparent 58%), + linear-gradient(180deg, #f9fbff 0%, var(--pg-bg) 75%); +} + +.container-geral-responsive { + width: 100%; + max-width: 1280px; + position: relative; + z-index: 1; + margin-top: 28px; +} + +.parcelamentos-shell { + display: grid; + gap: 16px; + min-width: 0; +} + +.parcelamentos-shell > * { + min-width: 0; +} + +.page-header { + display: grid; + gap: 14px; + border: 1px solid var(--pg-border); + border-radius: var(--pg-radius-lg); + padding: 18px 20px; + background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.94)); + box-shadow: var(--pg-shadow-sm); +} + +.page-header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: grid; + gap: 8px; +} + +.title-badge { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + padding: 6px 11px; + border-radius: 999px; + background: var(--pg-primary-soft); + border: 1px solid var(--pg-primary-soft-2); + color: var(--pg-primary-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + + i { + color: var(--pg-primary); + } +} + +.header-title h2 { + margin: 0; + font-size: clamp(1.35rem, 2vw, 1.65rem); + font-weight: 800; + letter-spacing: -0.02em; +} + +.header-title p { + margin: 4px 0 0; + font-size: 0.92rem; + color: var(--pg-text-muted); + font-weight: 600; +} + +.header-actions { + display: inline-flex; + gap: 10px; + flex-wrap: wrap; +} + +.header-highlights { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.highlight-card { + border: 1px solid var(--pg-border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.84); + padding: 10px 12px; + display: grid; + gap: 4px; + + span { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pg-text-soft); + font-weight: 700; + } + + strong { + font-size: 0.95rem; + color: var(--pg-text); + font-weight: 800; + } +} + +.btn-primary, +.btn-ghost, +.btn-danger { + height: 40px; + border-radius: var(--pg-radius-sm); + border: 1px solid transparent; + font-size: 0.82rem; + font-weight: 700; + cursor: pointer; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease; + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring); + } + + &:disabled { + opacity: 0.62; + cursor: not-allowed; + transform: none; + box-shadow: none; + } +} + +.btn-primary { + color: #fff; + background: linear-gradient(140deg, var(--pg-primary), var(--pg-primary-strong)); + border-color: var(--pg-primary-strong); + box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28); +} + +.btn-ghost { + color: var(--pg-text); + background: #fff; + border-color: var(--pg-border-strong); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08); +} + +.btn-danger { + color: #fff; + background: linear-gradient(145deg, #cf3131, #a91f1f); + border-color: #a91f1f; + box-shadow: 0 10px 20px rgba(169, 31, 31, 0.24); +} + +.btn-primary:hover, +.btn-ghost:hover, +.btn-danger:hover { + transform: translateY(-1px); +} + +.btn-ghost:hover { + border-color: var(--pg-primary); + color: var(--pg-primary-strong); +} + +.lg-backdrop { + position: fixed; + inset: 0; + background: + radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%), + rgba(15, 23, 42, 0.6); + z-index: 9990; + backdrop-filter: blur(4px); +} + +.lg-modal { + position: fixed; + inset: 0; + z-index: 9995; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.lg-modal-card { + width: min(1180px, 98vw); + max-height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--pg-surface); + border: 1px solid var(--pg-border); + border-radius: 18px; + box-shadow: var(--pg-shadow-lg); + animation: pop-up 0.24s ease; +} + +.modal-compact { + width: min(560px, 96vw); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--pg-border); + background: linear-gradient(180deg, rgba(244, 248, 255, 0.96), rgba(255, 255, 255, 0.96)); +} + +.modal-title { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 800; + color: var(--pg-text); +} + +.icon-bg { + width: 34px; + height: 34px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--pg-primary-soft); + color: var(--pg-primary-strong); +} + +.icon-bg.danger-soft { + background: var(--pg-danger-soft); + color: var(--pg-danger); +} + +.modal-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-icon { + width: 34px; + height: 34px; + border: 1px solid var(--pg-border-strong); + border-radius: 10px; + background: #fff; + color: var(--pg-text-soft); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--pg-primary-strong); + border-color: var(--pg-primary); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: var(--pg-focus-ring); + } +} + +.modal-body { + padding: 18px; + background: linear-gradient(180deg, #f8fbff, #ffffff 80%); + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.modal-footer { + padding: 14px 18px; + border-top: 1px solid var(--pg-border); + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 10px; + background: #fff; +} + +.detail-state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 180px; + color: var(--pg-text-muted); + font-weight: 700; +} + +.detail-state.error { + color: var(--pg-danger); +} + +.text-brand { + color: var(--pg-primary); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.detail-card { + border: 1px solid var(--pg-border); + border-radius: 12px; + padding: 11px 12px; + background: #fff; + display: grid; + gap: 6px; + color: var(--pg-text-muted); + + small { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.66rem; + font-weight: 800; + } + + span { + color: var(--pg-text); + font-size: 0.88rem; + font-weight: 700; + } +} + +.detail-card.highlight { + grid-column: span 2; + background: linear-gradient(180deg, #ffffff, #f4f8ff); +} + +.detail-strong { + font-weight: 800; +} + +.text-blue { + color: var(--pg-primary-strong); +} + +.money-strong { + color: var(--pg-primary); +} + +.text-danger { + color: var(--pg-danger); +} + +.status-pill { + width: fit-content; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--pg-primary-soft-2); + background: var(--pg-primary-soft); + color: var(--pg-primary-strong); + font-size: 0.74rem; + font-weight: 800; +} + +.annual-section { + margin-top: 16px; + border: 1px solid var(--pg-border); + border-radius: 14px; + padding: 12px; + background: #fff; + display: grid; + gap: 10px; +} + +.annual-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.section-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 800; + color: var(--pg-text); +} + +.annual-table-shell { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid var(--pg-border); + border-radius: 12px; +} + +.annual-table { + width: max-content; + min-width: 1220px; + border-collapse: separate; + border-spacing: 0; + font-size: 12px; + + th, + td { + border-bottom: 1px solid #e7edf6; + padding: 8px 10px; + white-space: nowrap; + } + + thead th { + position: sticky; + top: 0; + z-index: 2; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--pg-text-soft); + background: #f6f9fe; + font-size: 10px; + font-weight: 800; + } +} + +.annual-section .sticky-col { + position: sticky; + left: 0; + z-index: 3; + background: #fff; + box-shadow: 2px 0 0 #eef3fb; +} + +.annual-section .col-1 { + left: 0; + min-width: 90px; +} + +.annual-section .col-2 { + left: 90px; + min-width: 128px; +} + +.annual-empty { + min-height: 92px; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--pg-border-strong); + border-radius: 10px; + font-size: 0.84rem; + font-weight: 700; + color: var(--pg-text-muted); + background: var(--pg-surface-alt); +} + +.confirm-delete { + min-height: 150px; + display: grid; + align-content: center; + justify-items: center; + gap: 10px; + text-align: center; + color: var(--pg-text-muted); + + p { + margin: 0; + color: var(--pg-text); + font-weight: 600; + } +} + +.confirm-icon { + width: 54px; + height: 54px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--pg-danger); + border: 1px solid rgba(197, 41, 41, 0.22); + background: rgba(197, 41, 41, 0.1); + font-size: 1.15rem; +} + +@keyframes pop-up { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 1100px) { + .header-highlights { + grid-template-columns: 1fr; + } + + .detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .container-geral-responsive { + margin-top: 18px; + } + + .page-header, + .modal-body, + .modal-header, + .modal-footer { + padding-left: 14px; + padding-right: 14px; + } + + .header-actions { + width: 100%; + } + + .header-actions .btn-primary, + .header-actions .btn-ghost { + flex: 1; + } + + .detail-grid { + grid-template-columns: 1fr; + } + + .detail-card.highlight { + grid-column: span 1; + } + + .modal-footer { + justify-content: stretch; + } + + .modal-footer .btn-primary, + .modal-footer .btn-ghost, + .modal-footer .btn-danger { + flex: 1; + } +} diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts new file mode 100644 index 0000000..d601b91 --- /dev/null +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -0,0 +1,1091 @@ +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { environment } from '../../../environments/environment'; +import { finalize, Subscription, timeout } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; +import { + ParcelamentosService, + ParcelamentoListItem, + ParcelamentoDetail, + ParcelamentoDetailResponse, + ParcelamentoParcela, + ParcelamentoAnnualRow, + ParcelamentoAnnualMonth, + ParcelamentoUpsertRequest, + ParcelamentoMonthInput, +} from '../../services/parcelamentos.service'; +import { + ParcelamentosKpisComponent, + ParcelamentoKpi, +} from './components/parcelamentos-kpis/parcelamentos-kpis'; +import { + ParcelamentosFiltersComponent, + ParcelamentosFiltersModel, + FilterChip, +} from './components/parcelamentos-filters/parcelamentos-filters'; +import { + ParcelamentosTableComponent, + ParcelamentoSegment, + ParcelamentoViewItem, +} from './components/parcelamentos-table/parcelamentos-table'; +import { + ParcelamentoCreateModalComponent, + ParcelamentoCreateModel, +} from './components/parcelamento-create-modal/parcelamento-create-modal'; + +type MonthOption = { value: number; label: string }; +type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados'; +type AnnualMonthValue = { month: number; label: string; value: number | null }; +type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] }; + +@Component({ + selector: 'app-parcelamentos', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ParcelamentosKpisComponent, + ParcelamentosFiltersComponent, + ParcelamentosTableComponent, + ParcelamentoCreateModalComponent, + ], + templateUrl: './parcelamentos.html', + styleUrls: ['./parcelamentos.scss'], +}) +export class Parcelamentos implements OnInit, OnDestroy { + loading = false; + errorMessage = ''; + + debugMode = !environment.production; + + items: ParcelamentoListItem[] = []; + total = 0; + page = 1; + pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; + + filters: ParcelamentosFiltersModel = { + anoRef: '', + linha: '', + cliente: '', + competenciaAno: '', + competenciaMes: '', + search: '', + }; + + activeSegment: ParcelamentoSegment = 'todos'; + segmentCounts: Record = { + todos: 0, + ativos: 0, + futuros: 0, + finalizados: 0, + }; + + viewItems: ParcelamentoViewItem[] = []; + kpiCards: ParcelamentoKpi[] = []; + activeChips: FilterChip[] = []; + + isAdmin = false; + + detailOpen = false; + detailLoading = false; + detailError = ''; + selectedDetail: ParcelamentoDetail | null = null; + + annualRows: AnnualRow[] = []; + readonly annualMonthHeaders = this.buildAnnualMonthHeaders(); + + private detailRequestSub?: Subscription; + private detailRequestToken = 0; + private detailGuardTimer?: ReturnType; + + debugYearGroups: { year: number; months: Array<{ label: string; value: string }> }[] = []; + + createOpen = false; + createSaving = false; + createError = ''; + createModel: ParcelamentoCreateModel = this.buildCreateModel(); + + editOpen = false; + editLoading = false; + editSaving = false; + editError = ''; + editModel: ParcelamentoCreateModel | null = null; + editId: string | null = null; + + deleteOpen = false; + deleteLoading = false; + deleteError = ''; + deleteTarget: ParcelamentoViewItem | null = null; + + readonly monthOptions: MonthOption[] = [ + { value: 1, label: '01 - Janeiro' }, + { value: 2, label: '02 - Fevereiro' }, + { value: 3, label: '03 - Marco' }, + { value: 4, label: '04 - Abril' }, + { value: 5, label: '05 - Maio' }, + { value: 6, label: '06 - Junho' }, + { value: 7, label: '07 - Julho' }, + { value: 8, label: '08 - Agosto' }, + { value: 9, label: '09 - Setembro' }, + { value: 10, label: '10 - Outubro' }, + { value: 11, label: '11 - Novembro' }, + { value: 12, label: '12 - Dezembro' }, + ]; + + constructor( + private parcelamentosService: ParcelamentosService, + private authService: AuthService + ) {} + + ngOnInit(): void { + this.syncPermissions(); + this.load(); + } + + ngOnDestroy(): void { + this.cancelDetailRequest(); + } + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.detailOpen) this.closeDetails(); + if (this.createOpen) this.closeCreateModal(); + if (this.editOpen) this.closeEditModal(); + if (this.deleteOpen) this.cancelDelete(); + } + + private syncPermissions(): void { + this.isAdmin = this.authService.hasRole('admin'); + } + + get totalPages(): number { + return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + } + + get pageNumbers(): number[] { + const total = this.totalPages; + const current = this.page; + const max = 5; + let start = Math.max(1, current - 2); + let end = Math.min(total, start + (max - 1)); + start = Math.max(1, end - (max - 1)); + const pages: number[] = []; + for (let i = start; i <= end; i++) pages.push(i); + return pages; + } + + get pageStart(): number { + return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + } + + get pageEnd(): number { + return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); + } + + get competenciaInvalid(): boolean { + const ano = this.parseNumber(this.filters.competenciaAno); + const mes = this.parseNumber(this.filters.competenciaMes); + const hasAno = ano !== null; + const hasMes = mes !== null; + return (hasAno || hasMes) && !(hasAno && hasMes); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + + const anoRef = this.parseNumber(this.filters.anoRef); + const competenciaAno = this.parseNumber(this.filters.competenciaAno); + const competenciaMes = this.parseNumber(this.filters.competenciaMes); + const sendCompetencia = competenciaAno !== null && competenciaMes !== null; + + this.parcelamentosService + .list({ + anoRef: anoRef ?? undefined, + linha: this.filters.linha?.trim() || undefined, + cliente: this.filters.cliente?.trim() || undefined, + competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined, + competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined, + page: this.page, + pageSize: this.pageSize, + }) + .subscribe({ + next: (res) => { + try { + const anyRes: any = res ?? {}; + const items = Array.isArray(anyRes.items) + ? anyRes.items.filter(Boolean) + : Array.isArray(anyRes.Items) + ? anyRes.Items.filter(Boolean) + : []; + this.items = items; + this.total = typeof anyRes.total === 'number' + ? anyRes.total + : (typeof anyRes.Total === 'number' ? anyRes.Total : 0); + this.loading = false; + this.updateDerived(); + } catch (e) { + console.error('Erro ao processar parcelamentos', e); + this.items = []; + this.total = 0; + this.loading = false; + this.errorMessage = 'Erro ao processar parcelamentos.'; + this.updateDerivedSafe(); + } + }, + error: () => { + this.items = []; + this.total = 0; + this.loading = false; + this.errorMessage = 'Erro ao carregar parcelamentos.'; + this.updateDerivedSafe(); + }, + }); + } + + applyFilters(): void { + if (this.competenciaInvalid) { + this.errorMessage = 'Informe ano e mes para filtrar competencia.'; + return; + } + this.page = 1; + this.load(); + } + + clearFilters(): void { + this.filters = { + anoRef: '', + linha: '', + cliente: '', + competenciaAno: '', + competenciaMes: '', + search: '', + }; + this.page = 1; + this.errorMessage = ''; + this.load(); + } + + refresh(): void { + this.load(); + } + + onPageSizeChange(size: number): void { + this.pageSize = size; + this.page = 1; + this.load(); + } + + goToPage(p: number): void { + this.page = Math.max(1, Math.min(this.totalPages, p)); + this.load(); + } + + setSegment(segment: ParcelamentoSegment): void { + this.activeSegment = segment; + this.updateDerivedSafe(); + } + + onSearchChange(term: string): void { + this.filters.search = term; + this.updateDerivedSafe(); + } + + openDetails(item: ParcelamentoListItem): void { + const id = this.getItemId(item); + if (!id) { + this.detailOpen = true; + this.detailLoading = false; + this.detailError = 'Registro sem identificador para carregar detalhes.'; + this.selectedDetail = null; + this.annualRows = []; + return; + } + + this.cancelDetailRequest(); + const currentToken = ++this.detailRequestToken; + + this.detailOpen = true; + this.detailLoading = true; + this.detailError = ''; + this.selectedDetail = null; + this.startDetailGuard(currentToken, item); + + this.detailRequestSub = this.parcelamentosService + .getById(id) + .pipe( + timeout(15000), + finalize(() => { + if (!this.isCurrentDetailRequest(currentToken)) return; + this.clearDetailGuard(); + this.detailLoading = false; + }) + ) + .subscribe({ + next: (res) => { + if (!this.isCurrentDetailRequest(currentToken)) return; + try { + this.selectedDetail = this.normalizeDetail(res); + this.prepareAnnual(this.selectedDetail); + this.debugYearGroups = this.buildDebugYearGroups(this.selectedDetail); + } catch { + this.applyDetailFallback(item); + } + this.detailLoading = false; + }, + error: () => { + if (!this.isCurrentDetailRequest(currentToken)) return; + this.applyDetailFallback(item); + this.detailLoading = false; + }, + }); + } + + closeDetails(): void { + this.cancelDetailRequest(); + + this.detailOpen = false; + this.detailLoading = false; + this.detailError = ''; + this.selectedDetail = null; + this.debugYearGroups = []; + this.annualRows = []; + } + + openCreateModal(): void { + this.createModel = this.buildCreateModel(); + this.createError = ''; + this.createOpen = true; + } + + closeCreateModal(): void { + this.createOpen = false; + this.createSaving = false; + this.createError = ''; + } + + saveNewParcelamento(model: ParcelamentoCreateModel): void { + if (this.createSaving) return; + this.createSaving = true; + this.createError = ''; + const payload = this.buildUpsertPayload(model); + this.parcelamentosService.create(payload) + .pipe(finalize(() => (this.createSaving = false))) + .subscribe({ + next: () => { + this.createOpen = false; + this.load(); + }, + error: () => { + this.createError = 'Erro ao salvar parcelamento.'; + }, + }); + } + + openEdit(item: ParcelamentoListItem): void { + const id = this.getItemId(item); + if (!id) return; + this.editOpen = true; + this.editLoading = true; + this.editError = ''; + this.editModel = this.buildCreateModel(); + this.editId = id; + + this.parcelamentosService + .getById(id) + .pipe( + timeout(15000), + finalize(() => (this.editLoading = false)) + ) + .subscribe({ + next: (res) => { + const detail = this.normalizeDetail(res); + this.editModel = this.buildEditModel(detail); + }, + error: () => { + this.editError = 'Erro ao carregar dados para editar.'; + }, + }); + } + + closeEditModal(): void { + this.editOpen = false; + this.editLoading = false; + this.editSaving = false; + this.editError = ''; + this.editModel = null; + this.editId = null; + } + + saveEditParcelamento(model: ParcelamentoCreateModel): void { + if (this.editSaving || !this.editModel || !this.editId) return; + this.editSaving = true; + this.editError = ''; + const payload = this.buildUpsertPayload(model); + this.parcelamentosService + .update(this.editId, payload) + .pipe(finalize(() => (this.editSaving = false))) + .subscribe({ + next: () => { + this.editOpen = false; + this.load(); + }, + error: () => { + this.editError = 'Erro ao atualizar parcelamento.'; + }, + }); + } + + openDelete(item: ParcelamentoViewItem): void { + if (!this.isAdmin) return; + this.deleteTarget = item; + this.deleteError = ''; + this.deleteOpen = true; + } + + cancelDelete(): void { + this.deleteOpen = false; + this.deleteLoading = false; + this.deleteError = ''; + this.deleteTarget = null; + } + + confirmDelete(): void { + if (!this.deleteTarget || this.deleteLoading) return; + const id = this.getItemId(this.deleteTarget); + if (!id) return; + this.deleteLoading = true; + this.deleteError = ''; + this.parcelamentosService + .delete(id) + .pipe(finalize(() => (this.deleteLoading = false))) + .subscribe({ + next: () => { + this.cancelDelete(); + this.load(); + }, + error: () => { + this.deleteError = 'Erro ao excluir parcelamento.'; + }, + }); + } + + displayQtParcelas(item: ParcelamentoListItem): string { + const atual = this.toNumber(item.parcelaAtual); + const total = this.toNumber(item.totalParcelas); + if (atual !== null && total !== null) return `${atual}/${total}`; + const raw = (item.qtParcelas ?? '').toString().trim(); + if (raw) return raw; + return '-'; + } + + formatMoney(value: any): string { + const n = this.toNumber(value); + if (n === null) return '-'; + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); + } + + formatNumber(value: any): string { + const n = this.toNumber(value); + if (n === null) return '-'; + return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(n); + } + + formatCompetencia(value: any): string { + const date = this.parseCompetenciaDate(value); + if (!date) return value ? String(value) : '-'; + const label = new Intl.DateTimeFormat('pt-BR', { month: 'long' }).format(date); + const cap = label.charAt(0).toUpperCase() + label.slice(1); + return `${cap}/${date.getFullYear()}`; + } + + formatCompetenciaShort(value: any): string { + const date = this.parseCompetenciaDate(value); + if (!date) return value ? String(value) : '-'; + let label = new Intl.DateTimeFormat('pt-BR', { month: 'short' }).format(date); + label = label.replace('.', ''); + const cap = label.charAt(0).toUpperCase() + label.slice(1); + return `${cap}/${date.getFullYear()}`; + } + + private buildAnnualMonthHeaders(): Array<{ month: number; label: string }> { + return Array.from({ length: 12 }, (_, idx) => { + const date = new Date(2000, idx, 1); + let label = new Intl.DateTimeFormat('pt-BR', { month: 'short' }).format(date); + label = label.replace('.', ''); + label = label.charAt(0).toUpperCase() + label.slice(1); + return { month: idx + 1, label }; + }); + } + + get detailStatus(): string { + if (!this.selectedDetail) return '-'; + const atual = this.toNumber(this.selectedDetail.parcelaAtual); + const total = this.toNumber(this.selectedDetail.totalParcelas); + if (atual !== null && total !== null) return `${atual}/${total}`; + const raw = (this.selectedDetail.qtParcelas ?? '').toString().trim(); + return raw || '-'; + } + + private parseCompetenciaDate(value: any): Date | null { + if (!value) return null; + const raw = String(value); + const match = raw.match(/^(\d{4})-(\d{2})/); + if (match) { + const year = Number(match[1]); + const month = Number(match[2]); + return new Date(year, month - 1, 1); + } + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return null; + return d; + } + + private updateDerived(): void { + const base = (this.items || []).map((item) => this.toViewItem(item)); + const searched = this.applySearch(base, this.filters.search); + + this.segmentCounts = { + todos: searched.length, + ativos: searched.filter((i) => i.status === 'ativos').length, + futuros: searched.filter((i) => i.status === 'futuros').length, + finalizados: searched.filter((i) => i.status === 'finalizados').length, + }; + + this.viewItems = + this.activeSegment === 'todos' + ? searched + : searched.filter((i) => i.status === this.activeSegment); + + this.kpiCards = this.buildKpis(searched); + this.activeChips = this.buildActiveChips(); + } + + private updateDerivedSafe(): void { + try { + this.updateDerived(); + } catch (e) { + console.error('Erro ao atualizar parcelamentos', e); + this.viewItems = []; + this.kpiCards = []; + this.activeChips = []; + this.segmentCounts = { + todos: 0, + ativos: 0, + futuros: 0, + finalizados: 0, + }; + } + } + + private buildKpis(list: ParcelamentoViewItem[]): ParcelamentoKpi[] { + const totalContratado = list.reduce((sum, item) => sum + (item.valorComDescontoNumber ?? item.valorCheioNumber ?? 0), 0); + const totalCheio = list.reduce((sum, item) => sum + (item.valorCheioNumber ?? 0), 0); + const totalDesconto = list.reduce((sum, item) => sum + (item.descontoNumber ?? 0), 0); + + const parcelasEmAberto = list.reduce((sum, item) => { + const total = this.toNumber(item.totalParcelas); + const atual = this.toNumber(item.parcelaAtual); + if (total === null || atual === null) return sum; + return sum + Math.max(0, total - atual); + }, 0); + + const competenciaAno = this.parseNumber(this.filters.competenciaAno); + const competenciaMes = this.parseNumber(this.filters.competenciaMes); + const totalMensalEstimado = + competenciaAno !== null && competenciaMes !== null + ? list.reduce((sum, item) => sum + (item.valorParcela ?? 0), 0) + : null; + + const anoRef = this.parseNumber(this.filters.anoRef); + const totalAnual = anoRef !== null || competenciaAno !== null ? totalContratado : null; + + return [ + { + label: 'Total mensal (estimado)', + value: totalMensalEstimado !== null ? this.formatMoney(totalMensalEstimado) : '-', + hint: competenciaAno && competenciaMes ? 'Baseado nas parcelas da lista' : 'Selecione competencia', + tone: 'brand', + }, + { + label: 'Total anual (ano selecionado)', + value: totalAnual !== null ? this.formatMoney(totalAnual) : '-', + hint: anoRef || competenciaAno ? 'Baseado nos contratos filtrados' : 'Selecione ano', + }, + { + label: 'Parcelamentos ativos', + value: this.formatNumber(this.segmentCounts.ativos), + hint: 'Status atual', + tone: 'success', + }, + { + label: 'Parcelas em aberto', + value: this.formatNumber(parcelasEmAberto), + hint: 'Estimado por parcela atual', + }, + { + label: 'Valor total contratado', + value: this.formatMoney(totalContratado || totalCheio), + hint: totalDesconto ? `Desconto total: ${this.formatMoney(totalDesconto)}` : undefined, + }, + ]; + } + + private buildActiveChips(): FilterChip[] { + const chips: FilterChip[] = []; + if (this.filters.anoRef) chips.push({ label: 'AnoRef', value: this.filters.anoRef }); + if (this.filters.linha) chips.push({ label: 'Linha', value: this.filters.linha }); + if (this.filters.cliente) chips.push({ label: 'Cliente', value: this.filters.cliente }); + const ano = this.filters.competenciaAno; + const mes = this.filters.competenciaMes; + if (ano && mes) { + chips.push({ label: 'Competencia', value: `${String(mes).padStart(2, '0')}/${ano}` }); + } + if (this.filters.search) chips.push({ label: 'Busca', value: this.filters.search }); + return chips; + } + + private toViewItem(item: ParcelamentoListItem): ParcelamentoViewItem { + const id = this.getItemId(item) ?? ''; + const status = this.resolveStatus(item); + const valorCheio = this.toNumber(item.valorCheio); + const desconto = this.toNumber(item.desconto); + const valorComDesconto = this.toNumber(item.valorComDesconto); + return { + ...item, + id, + status, + statusLabel: this.statusLabel(status), + progressLabel: this.displayQtParcelas(item), + valorParcela: this.computeParcelaValue(item), + valorCheioNumber: valorCheio, + descontoNumber: desconto, + valorComDescontoNumber: valorComDesconto ?? (valorCheio !== null && desconto !== null ? Math.max(0, valorCheio - desconto) : null), + }; + } + + private applySearch(list: ParcelamentoViewItem[], term: string): ParcelamentoViewItem[] { + const search = this.normalizeText(term); + if (!search) return list; + return list.filter((item) => { + const payload = [ + item.anoRef, + item.item, + item.linha, + item.cliente, + item.qtParcelas, + ] + .map((v) => (v ?? '').toString()) + .join(' '); + return this.normalizeText(payload).includes(search); + }); + } + + private resolveStatus(item: ParcelamentoListItem): ParcelamentoStatus { + const total = this.toNumber(item.totalParcelas); + const atual = this.toNumber(item.parcelaAtual); + if (total !== null && atual !== null) { + if (atual >= total) return 'finalizados'; + if (atual <= 0) return 'futuros'; + return 'ativos'; + } + + const parsed = this.parseQtParcelas(item.qtParcelas); + if (parsed) { + if (parsed.atual >= parsed.total) return 'finalizados'; + return 'ativos'; + } + + return 'ativos'; + } + + private statusLabel(status: ParcelamentoStatus): string { + if (status === 'finalizados') return 'Finalizado'; + if (status === 'futuros') return 'Futuro'; + return 'Ativo'; + } + + private computeParcelaValue(item: ParcelamentoListItem): number | null { + const totalParcelas = this.toNumber(item.totalParcelas) ?? this.parseQtParcelas(item.qtParcelas)?.total ?? null; + if (!totalParcelas) return null; + const total = this.toNumber(item.valorComDesconto) ?? this.toNumber(item.valorCheio); + if (total === null) return null; + return total / totalParcelas; + } + + private parseQtParcelas(value: any): { atual: number; total: number } | null { + if (!value) return null; + const raw = String(value); + const parts = raw.split('/'); + if (parts.length < 2) return null; + const atualStr = this.onlyDigits(parts[0]); + const totalStr = this.onlyDigits(parts[1]); + if (!atualStr || !totalStr) return null; + return { atual: Number(atualStr), total: Number(totalStr) }; + } + + private prepareAnnual(detail: ParcelamentoDetail): void { + this.annualRows = this.buildAnnualRows(detail); + } + + private buildAnnualRows(detail: ParcelamentoDetail): AnnualRow[] { + const fromApi = this.mapAnnualRows(detail.annualRows ?? []); + if (fromApi.length) return fromApi; + return this.buildAnnualRowsFromParcelas(detail.parcelasMensais ?? []); + } + + private mapAnnualRows(rawRows?: ParcelamentoAnnualRow[] | null): AnnualRow[] { + const rows = (rawRows ?? []) + .filter((row): row is ParcelamentoAnnualRow => !!row) + .map((row) => { + const year = this.parseNumber(row.year); + if (year === null) return null; + + const monthMap = new Map(); + (row.months ?? []).forEach((m) => { + const month = this.parseNumber(m.month); + if (month === null) return; + const value = this.toNumber(m.valor); + monthMap.set(month, value); + }); + + const months = this.annualMonthHeaders.map((h) => ({ + month: h.month, + label: h.label, + value: monthMap.get(h.month) ?? null, + })); + + const total = this.toNumber(row.total) ?? months.reduce((sum, m) => sum + (m.value ?? 0), 0); + return { year, total, months }; + }) + .filter((row): row is AnnualRow => !!row) + .sort((a, b) => a.year - b.year); + + return rows; + } + + private buildAnnualRowsFromParcelas(parcelas: ParcelamentoParcela[]): AnnualRow[] { + const map = new Map>(); + + (parcelas ?? []).forEach((p) => { + const parsed = this.parseCompetenciaParts(p.competencia); + if (!parsed) return; + const value = this.toNumber(p.valor) ?? 0; + if (!map.has(parsed.year)) map.set(parsed.year, new Map()); + const monthMap = map.get(parsed.year)!; + monthMap.set(parsed.month, (monthMap.get(parsed.month) ?? 0) + value); + }); + + return Array.from(map.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([year, monthMap]) => { + const months = this.annualMonthHeaders.map((h) => ({ + month: h.month, + label: h.label, + value: monthMap.get(h.month) ?? null, + })); + const total = months.reduce((sum, m) => sum + (m.value ?? 0), 0); + return { year, total, months }; + }); + } + + private parseCompetenciaParts(value: any): { year: number; month: number } | null { + const date = this.parseCompetenciaDate(value); + if (!date) return null; + return { year: date.getFullYear(), month: date.getMonth() + 1 }; + } + + private normalizeDetail(res: ParcelamentoDetailResponse): ParcelamentoDetail { + const payload = (res ?? {}) as any; + const parcelasRaw = + payload.parcelasMensais ?? + payload.ParcelasMensais ?? + payload.parcelas ?? + payload.Parcelas ?? + payload.monthValues ?? + payload.MonthValues ?? + []; + + const parcelasMensais = Array.isArray(parcelasRaw) + ? parcelasRaw + .filter((p) => !!p && typeof p === 'object') + .map((p: any) => ({ + competencia: (p.competencia ?? p.Competencia ?? '').toString(), + valor: p.valor ?? p.Valor ?? null, + })) + .filter((p) => !!p.competencia) + : []; + + const annualRowsRaw = + payload.annualRows ?? + payload.AnnualRows ?? + payload.detalhamentoAnual ?? + payload.DetalhamentoAnual ?? + []; + + const annualRows: ParcelamentoAnnualRow[] = []; + if (Array.isArray(annualRowsRaw)) { + annualRowsRaw.forEach((row: any) => { + if (!row || typeof row !== 'object') return; + const year = this.parseNumber(row.year ?? row.Year); + if (year === null) return; + const monthsRaw = row.months ?? row.Months ?? []; + const months: ParcelamentoAnnualMonth[] = []; + if (Array.isArray(monthsRaw)) { + monthsRaw.forEach((m: any) => { + if (!m || typeof m !== 'object') return; + const month = this.parseNumber(m.month ?? m.Month); + if (month === null) return; + const valor = (m.valor ?? m.Valor ?? m.value ?? m.Value ?? null) as number | string | null; + months.push({ + month, + valor, + }); + }); + } + const total = (row.total ?? row.Total ?? null) as number | string | null; + annualRows.push({ + year, + total, + months, + }); + }); + } + + return { + id: payload.id ?? payload.Id ?? '', + anoRef: payload.anoRef ?? payload.AnoRef ?? null, + item: payload.item ?? payload.Item ?? null, + linha: payload.linha ?? payload.Linha ?? null, + cliente: payload.cliente ?? payload.Cliente ?? null, + qtParcelas: payload.qtParcelas ?? payload.QtParcelas ?? null, + parcelaAtual: payload.parcelaAtual ?? payload.ParcelaAtual ?? null, + totalParcelas: payload.totalParcelas ?? payload.TotalParcelas ?? null, + valorCheio: payload.valorCheio ?? payload.ValorCheio ?? null, + desconto: payload.desconto ?? payload.Desconto ?? null, + valorComDesconto: payload.valorComDesconto ?? payload.ValorComDesconto ?? null, + parcelasMensais, + annualRows, + }; + } + + private buildCreateModel(): ParcelamentoCreateModel { + const now = new Date(); + return { + anoRef: now.getFullYear(), + linha: '', + cliente: '', + item: null, + qtParcelas: '', + parcelaAtual: null, + totalParcelas: 12, + valorCheio: '', + desconto: '', + valorComDesconto: '', + competenciaAno: now.getFullYear(), + competenciaMes: now.getMonth() + 1, + monthValues: [], + }; + } + + private buildEditModel(detail: ParcelamentoDetail): ParcelamentoCreateModel { + const parcelas = (detail.parcelasMensais ?? []) + .filter((p) => !!p && !!p.competencia) + .map((p) => ({ + competencia: p.competencia, + valor: this.normalizeInputValue(p.valor), + })) + .sort((a, b) => a.competencia.localeCompare(b.competencia)); + + const firstCompetencia = parcelas.length ? parcelas[0].competencia : ''; + const compParts = firstCompetencia.match(/^(\d{4})-(\d{2})/); + const competenciaAno = compParts ? Number(compParts[1]) : null; + const competenciaMes = compParts ? Number(compParts[2]) : null; + + return { + anoRef: detail.anoRef ?? null, + linha: detail.linha ?? '', + cliente: detail.cliente ?? '', + item: this.toNumber(detail.item) ?? null, + qtParcelas: detail.qtParcelas ?? '', + parcelaAtual: this.toNumber(detail.parcelaAtual), + totalParcelas: this.toNumber(detail.totalParcelas), + valorCheio: this.normalizeInputValue(detail.valorCheio), + desconto: this.normalizeInputValue(detail.desconto), + valorComDesconto: this.normalizeInputValue(detail.valorComDesconto), + competenciaAno, + competenciaMes, + monthValues: parcelas, + }; + } + + private buildUpsertPayload(model: ParcelamentoCreateModel): ParcelamentoUpsertRequest { + const monthValues: ParcelamentoMonthInput[] = (model.monthValues ?? []) + .filter((m) => !!m && !!m.competencia) + .map((m) => ({ + competencia: m.competencia, + valor: this.toNumber(m.valor), + })); + + return { + anoRef: this.toNumber(model.anoRef), + item: this.toNumber(model.item), + linha: model.linha?.trim() || null, + cliente: model.cliente?.trim() || null, + qtParcelas: model.qtParcelas?.trim() || null, + parcelaAtual: this.toNumber(model.parcelaAtual), + totalParcelas: this.toNumber(model.totalParcelas), + valorCheio: this.toNumber(model.valorCheio), + desconto: this.toNumber(model.desconto), + valorComDesconto: this.toNumber(model.valorComDesconto), + monthValues, + }; + } + + private normalizeInputValue(value: any): string { + const n = this.toNumber(value); + if (n === null) return ''; + return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); + } + + private parseNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isNaN(n) ? null : n; + } + + private toNumber(value: any): number | null { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const raw = String(value).trim(); + if (!raw) return null; + + let cleaned = raw.replace(/[^\d,.-]/g, ''); + + if (cleaned.includes(',') && cleaned.includes('.')) { + if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { + cleaned = cleaned.replace(/,/g, ''); + } + } else if (cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { + cleaned = cleaned.replace(/,/g, ''); + } + + const n = Number(cleaned); + return Number.isNaN(n) ? null : n; + } + + private normalizeText(value: any): string { + return (value ?? '') + .toString() + .trim() + .toUpperCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + } + + private onlyDigits(value: string): string { + let out = ''; + for (const ch of value ?? '') { + if (ch >= '0' && ch <= '9') out += ch; + } + return out; + } + + private getItemId(item: ParcelamentoListItem | null | undefined): string | null { + if (!item) return null; + const raw = (item as any).id ?? (item as any).Id ?? (item as any).parcelamentoId ?? (item as any).ParcelamentoId; + if (raw === null || raw === undefined) return null; + const value = String(raw).trim(); + return value ? value : null; + } + + private cancelDetailRequest(): void { + this.clearDetailGuard(); + this.detailRequestToken++; + if (this.detailRequestSub) { + this.detailRequestSub.unsubscribe(); + this.detailRequestSub = undefined; + } + } + + private isCurrentDetailRequest(token: number): boolean { + return token === this.detailRequestToken; + } + + private startDetailGuard(token: number, item: ParcelamentoListItem): void { + this.clearDetailGuard(); + this.detailGuardTimer = setTimeout(() => { + if (!this.isCurrentDetailRequest(token)) return; + this.cancelDetailRequest(); + this.applyDetailFallback(item); + this.detailLoading = false; + }, 20000); + } + + private clearDetailGuard(): void { + if (this.detailGuardTimer) { + clearTimeout(this.detailGuardTimer); + this.detailGuardTimer = undefined; + } + } + + private applyDetailFallback(item: ParcelamentoListItem): void { + this.detailError = ''; + this.selectedDetail = this.buildFallbackDetail(item); + this.prepareAnnual(this.selectedDetail); + this.debugYearGroups = this.buildDebugYearGroups(this.selectedDetail); + this.detailLoading = false; + } + + private buildFallbackDetail(item: ParcelamentoListItem): ParcelamentoDetail { + return { + id: this.getItemId(item) ?? '', + anoRef: item.anoRef ?? null, + item: item.item ?? null, + linha: item.linha ?? null, + cliente: item.cliente ?? null, + qtParcelas: item.qtParcelas ?? null, + parcelaAtual: item.parcelaAtual ?? null, + totalParcelas: item.totalParcelas ?? null, + valorCheio: item.valorCheio ?? null, + desconto: item.desconto ?? null, + valorComDesconto: item.valorComDesconto ?? null, + parcelasMensais: [], + }; + } + + private buildDebugYearGroups(detail: ParcelamentoDetail | null): { year: number; months: Array<{ label: string; value: string }> }[] { + if (!detail) return []; + const parcels = (detail.parcelasMensais ?? []).filter( + (p): p is ParcelamentoParcela => !!p && typeof p === 'object' + ); + const map = new Map>(); + parcels.forEach((p) => { + const parsed = this.parseCompetenciaParts(p.competencia); + if (!parsed) return; + if (!map.has(parsed.year)) map.set(parsed.year, new Map()); + map.get(parsed.year)!.set(parsed.month, p); + }); + + return Array.from(map.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([year, monthMap]) => ({ + year, + months: Array.from({ length: 12 }, (_, idx) => { + const month = idx + 1; + const item = monthMap.get(month); + return { + label: `${month.toString().padStart(2, '0')}/${year}`, + value: this.formatMoney(item?.valor), + }; + }), + })); + } + +} diff --git a/src/app/pages/perfil/perfil.html b/src/app/pages/perfil/perfil.html new file mode 100644 index 0000000..7052be6 --- /dev/null +++ b/src/app/pages/perfil/perfil.html @@ -0,0 +1,158 @@ +
+ + + + + +
+
+
+
+
+ PERFIL +
+ +
+
MEU PERFIL
+ Atualize seus dados e credenciais de acesso +
+ +
+
+
+ +
+
+
+
+

Informação de perfil

+
+ +
+ {{ profileError }} +
+
+ {{ profileSuccess }} +
+ +
+
+
+ + + + Nome é obrigatório. + + + Nome deve ter pelo menos 2 caracteres. + +
+ +
+ + + + Email é obrigatório. + + + Email inválido. + +
+
+ +
+ +
+
+
+ +
+
+

Atualizar senha

+
+ +
+ {{ passwordError }} +
+
+ {{ passwordSuccess }} +
+ +
+
+
+ + + + Credencial atual é obrigatória. + +
+ +
+ + + + Nova credencial é obrigatória. + + + Nova credencial deve ter pelo menos 8 caracteres. + +
+ +
+ + + + Confirmação da nova credencial é obrigatória. + + + A nova credencial e a confirmação precisam ser iguais. + +
+
+ +
+ +
+
+
+
+
+
+
+
diff --git a/src/app/pages/perfil/perfil.scss b/src/app/pages/perfil/perfil.scss new file mode 100644 index 0000000..5cbcc41 --- /dev/null +++ b/src/app/pages/perfil/perfil.scss @@ -0,0 +1,295 @@ +:host { + --brand: #E33DCF; + --blue: #030FAA; + --text: #111214; + --muted: rgba(17, 18, 20, 0.65); + --success-bg: rgba(25, 135, 84, 0.1); + --success-text: #198754; + --danger-bg: rgba(220, 53, 69, 0.1); + --danger-text: #dc3545; + --radius-xl: 22px; + --shadow-card: 0 22px 46px rgba(17, 18, 20, 0.1); + --glass-bg: rgba(255, 255, 255, 0.82); + --glass-border: 1px solid rgba(227, 61, 207, 0.16); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); + box-sizing: border-box; +} + +.perfil-page { + min-height: 100vh; + padding: 0 12px; + display: flex; + justify-content: center; + position: relative; + overflow-y: auto; + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); +} + +.page-blob { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(34px); + opacity: 0.55; + z-index: 0; + background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.55), rgba(227, 61, 207, 0.06)); + animation: floaty 10s ease-in-out infinite; + + &.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; } + &.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; } + &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; } + &.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: 0.45; } +} + +@keyframes floaty { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(18px, 10px) scale(1.03); } + 100% { transform: translate(0, 0) scale(1); } +} + +.container-geral-responsive { + width: 100%; + max-width: 1180px; + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 120px; +} + +.geral-card { + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--glass-bg); + border: var(--glass-border); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + min-height: 80vh; +} + +.geral-header { + padding: 16px 24px; + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2)); +} + +.header-row-top { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; +} + +.title-badge { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(227, 61, 207, 0.22); + backdrop-filter: blur(10px); + color: var(--text); + font-size: 13px; + font-weight: 800; + + i { color: var(--brand); } +} + +.header-title { + justify-self: center; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.title { + font-size: 26px; + font-weight: 950; + letter-spacing: -0.3px; + color: var(--text); + margin-top: 10px; +} + +.subtitle { + color: var(--muted); + font-weight: 700; +} + +.header-actions { + justify-self: end; + min-height: 1px; +} + +.geral-body { + padding: 18px; +} + +.perfil-sections { + display: grid; + gap: 14px; +} + +.perfil-section-card { + background: #ffffff; + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 16px; + box-shadow: 0 4px 12px rgba(17, 18, 20, 0.06); + padding: 18px 20px; +} + +.section-header { + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 1rem; + font-weight: 900; + color: var(--text); + letter-spacing: 0.02em; + text-transform: uppercase; + } +} + +.form-alert { + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + font-weight: 700; + margin-bottom: 10px; + + &.error { + background: var(--danger-bg); + border: 1px solid rgba(220, 53, 69, 0.2); + color: var(--danger-text); + } + + &.success { + background: var(--success-bg); + border: 1px solid rgba(25, 135, 84, 0.2); + color: var(--success-text); + } +} + +.profile-form { + display: grid; + gap: 12px; +} + +.form-grid { + display: grid; + gap: 12px; +} + +.form-field { + display: grid; + gap: 6px; + + label { + font-size: 0.78rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + } +} + +.form-control { + height: 40px; + border-radius: 10px; + border: 1px solid rgba(17, 18, 20, 0.16); + background: rgba(255, 255, 255, 0.86); + color: var(--text); + font-size: 14px; + padding: 0 12px; + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s; + + &:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); + background: #fff; + } +} + +.field-error { + color: var(--danger-text); + font-size: 12px; + font-weight: 700; + line-height: 1.2; +} + +.section-actions { + display: flex; + justify-content: flex-end; +} + +.btn-brand { + background-color: var(--brand); + border-color: var(--brand); + color: #fff; + font-weight: 900; + border-radius: 12px; + transition: transform 0.2s, box-shadow 0.2s; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); + filter: brightness(1.05); + } + + &:disabled { + opacity: 0.72; + cursor: not-allowed; + transform: none; + } +} + +@media (max-width: 768px) { + .container-geral-responsive { + margin-top: 16px; + margin-bottom: 64px; + } + + .header-row-top { + grid-template-columns: 1fr; + text-align: center; + gap: 10px; + } + + .title-badge { + justify-self: center; + } + + .header-actions { + display: none; + } + + .geral-header { + padding: 14px 16px; + } + + .geral-body { + padding: 12px; + } + + .perfil-section-card { + padding: 14px; + } + + .section-actions { + justify-content: stretch; + } + + .section-actions .btn { + width: 100%; + } +} diff --git a/src/app/pages/perfil/perfil.ts b/src/app/pages/perfil/perfil.ts new file mode 100644 index 0000000..7907856 --- /dev/null +++ b/src/app/pages/perfil/perfil.ts @@ -0,0 +1,229 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; + +import { ProfileService } from '../../services/profile.service'; +import { AuthService } from '../../services/auth.service'; + +@Component({ + selector: 'app-perfil', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './perfil.html', + styleUrls: ['./perfil.scss'], +}) +export class Perfil implements OnInit { + profileForm: FormGroup; + passwordForm: FormGroup; + + loadingProfile = false; + savingProfile = false; + savingPassword = false; + + profileSuccess = ''; + profileError = ''; + passwordSuccess = ''; + passwordError = ''; + + constructor( + private fb: FormBuilder, + private profileService: ProfileService, + private authService: AuthService + ) { + this.profileForm = this.fb.group({ + nome: ['', [Validators.required, Validators.minLength(2)]], + email: ['', [Validators.required, Validators.email]], + }); + + this.passwordForm = this.fb.group( + { + credencialAtual: ['', [Validators.required]], + novaCredencial: ['', [Validators.required, Validators.minLength(8)]], + confirmarNovaCredencial: ['', [Validators.required]], + }, + { validators: this.passwordsMatchValidator } + ); + } + + ngOnInit(): void { + this.loadProfile(); + } + + onSaveProfile(): void { + if (this.savingProfile) return; + this.profileSuccess = ''; + this.profileError = ''; + + if (this.profileForm.invalid) { + this.profileForm.markAllAsTouched(); + return; + } + + this.savingProfile = true; + this.setProfileFormDisabled(true); + const payload = { + nome: String(this.profileForm.get('nome')?.value ?? '').trim(), + email: String(this.profileForm.get('email')?.value ?? '').trim(), + }; + + this.profileService.updateProfile(payload).subscribe({ + next: (updated) => { + this.savingProfile = false; + this.setProfileFormDisabled(false); + this.profileSuccess = 'Perfil atualizado com sucesso.'; + this.profileForm.patchValue({ + nome: updated.nome ?? '', + email: updated.email ?? '', + }); + this.profileForm.markAsPristine(); + this.authService.updateUserProfile({ + nome: updated.nome ?? '', + email: updated.email ?? '', + }); + }, + error: (err: HttpErrorResponse) => { + this.savingProfile = false; + this.setProfileFormDisabled(false); + this.profileError = this.extractErrorMessage(err, 'Não foi possível atualizar o perfil.'); + }, + }); + } + + onChangePassword(): void { + if (this.savingPassword) return; + this.passwordSuccess = ''; + this.passwordError = ''; + + if (this.passwordForm.invalid) { + this.passwordForm.markAllAsTouched(); + return; + } + + this.savingPassword = true; + this.setPasswordFormDisabled(true); + const payload = { + credencialAtual: String(this.passwordForm.get('credencialAtual')?.value ?? ''), + novaCredencial: String(this.passwordForm.get('novaCredencial')?.value ?? ''), + confirmarNovaCredencial: String(this.passwordForm.get('confirmarNovaCredencial')?.value ?? ''), + }; + + this.profileService.changePassword(payload).subscribe({ + next: () => { + this.savingPassword = false; + this.setPasswordFormDisabled(false); + this.passwordSuccess = 'Credencial atualizada com sucesso.'; + this.passwordForm.reset(); + }, + error: (err: HttpErrorResponse) => { + this.savingPassword = false; + this.setPasswordFormDisabled(false); + this.passwordError = this.extractErrorMessage(err, 'Não foi possível atualizar a credencial.'); + }, + }); + } + + hasProfileFieldError(field: string, error?: string): boolean { + const control = this.profileForm.get(field); + if (!control) return false; + if (error) return !!(control.touched && control.hasError(error)); + return !!(control.touched && control.invalid); + } + + hasPasswordFieldError(field: string, error?: string): boolean { + const control = this.passwordForm.get(field); + if (!control) return false; + if (error) return !!(control.touched && control.hasError(error)); + return !!(control.touched && control.invalid); + } + + get passwordMismatch(): boolean { + const confirmTouched = this.passwordForm.get('confirmarNovaCredencial')?.touched; + return !!(confirmTouched && this.passwordForm.errors?.['passwordMismatch']); + } + + private loadProfile(): void { + this.loadingProfile = true; + this.profileSuccess = ''; + this.profileError = ''; + this.setProfileFormDisabled(true); + + this.profileService.getMe().subscribe({ + next: (me) => { + this.loadingProfile = false; + this.profileForm.patchValue({ + nome: me.nome ?? '', + email: me.email ?? '', + }); + this.setProfileFormDisabled(false); + }, + error: (err: HttpErrorResponse) => { + this.loadingProfile = false; + this.setProfileFormDisabled(false); + if (err.status === 404) { + const authProfile = this.authService.currentUserProfile; + if (authProfile) { + this.profileForm.patchValue({ + nome: authProfile.nome ?? '', + email: authProfile.email ?? '', + }); + } + } + this.profileError = this.extractErrorMessage(err, 'Não foi possível carregar os dados do perfil.'); + }, + }); + } + + private setProfileFormDisabled(disabled: boolean): void { + if (disabled) { + this.profileForm.disable({ emitEvent: false }); + return; + } + this.profileForm.enable({ emitEvent: false }); + } + + private setPasswordFormDisabled(disabled: boolean): void { + if (disabled) { + this.passwordForm.disable({ emitEvent: false }); + return; + } + this.passwordForm.enable({ emitEvent: false }); + } + + private extractErrorMessage(err: HttpErrorResponse, fallback: string): string { + if (err.status === 404) { + return 'API de perfil não encontrada (404). Reinicie/atualize o back-end com os novos endpoints de perfil.'; + } + + const apiError = err?.error; + + if (Array.isArray(apiError?.errors) && apiError.errors.length) { + const msg = apiError.errors[0]?.message; + if (msg) return String(msg); + } + + if (typeof apiError?.message === 'string' && apiError.message.trim()) { + return apiError.message.trim(); + } + + if (typeof apiError === 'string' && apiError.trim()) { + return apiError.trim(); + } + + return fallback; + } + + private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { + const nova = group.get('novaCredencial')?.value; + const confirmar = group.get('confirmarNovaCredencial')?.value; + if (!nova || !confirmar) return null; + return nova === confirmar ? null : { passwordMismatch: true }; + } +} diff --git a/src/app/pages/register/register.ts b/src/app/pages/register/register.ts index ecd2601..7dfbf25 100644 --- a/src/app/pages/register/register.ts +++ b/src/app/pages/register/register.ts @@ -78,7 +78,7 @@ export class Register { this.isSubmitting = false; // Se você não quer manter "logado" após cadastrar: - localStorage.removeItem('token'); + this.authService.logout(); await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.'); diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html new file mode 100644 index 0000000..7464d04 --- /dev/null +++ b/src/app/pages/resumo/resumo.html @@ -0,0 +1,615 @@ +
+
+
+ +
+
+ Dashboard +
+

Resumo Gerencial

+
+
+ Atualizando dados... +
+
+ {{ errorMessage }} +
+
+ Atualizado +
+
+
+

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

+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+

Planos & Contratos

+

Performance financeira agrupada por modalidade de plano.

+
+
+
+ Total Linhas + {{ formatNumber(planosTotals?.totalLinhasTotal) }} +
+
+ Valor Total + {{ formatMoney(planosTotals?.valorTotal) }} +
+
+ Contratos + {{ formatMoney(contratosTotals?.valorTotal) }} +
+
+
+
+ +
+
+
+

Top Planos (Valor)

+

Os planos com maior representatividade financeira.

+
+
+ +
+
+
+
+

Top Planos (Volume)

+

Quantidade de linhas ativas por tipo de plano.

+
+
+ +
+
+
+ +
+ +
+

Macrophony - Planos

+ Detalhamento granular dos planos e suas variações. +
+
+
+
+
+ + +
+ +
+ + +
+
+ +
+
+ +

Nenhum dado encontrado.

+
+ +
+
+
+ +
+ +
+
{{ group.plano }}
+
+ GB {{ group.gbLabel }} + {{ formatNumber(group.totalLinhas) }} linhas +
+
+ +
+
+ Valor Total + {{ formatMoney(group.valorTotal) }} +
+
+ Média Un. + {{ formatMoney(group.valorUnitMedio) }} +
+
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
Plano / VariaçãoFranquiaValor Un.LinhasTotal
+
+ {{ row.planoContrato || '-' }} + +
+
{{ formatGb(row.gb) }}{{ formatMoney(row.valorIndividualComSvas) }}{{ formatNumber(row.totalLinhas) }}{{ formatMoney(row.valorTotal) }}
+
+
+
+
+ + + +
+
+ Total de Linhas + {{ formatNumber(planosTotals.totalLinhasTotal) }} +
+
+ Valor Total Global + {{ formatMoney(planosTotals.valorTotal) }} +
+
+
+
+ +
+ +
+

Resumo de Contratos

+ Visão consolidada por tipo de contrato vigente. +
+
+
+ +
+
+ +
+
+
+
+

Clientes & Performance

+

Analise a rentabilidade e custos por cliente.

+
+
+
+ Total Linhas + {{ formatNumber(clientesTotals?.qtdLinhasTotal) }} +
+
+ Receita Line + {{ formatMoney(clientesTotals?.valorContratoLine) }} +
+
+ Lucro Total + {{ formatMoney(clientesTotals?.lucro) }} +
+
+
+
+ +
+
+
+

Top Clientes (Lucratividade)

+

Clientes ordenados pelo maior retorno financeiro.

+
+
+ +
+
+
+ +
+ +
+

Detalhamento Vivo x Line Móvel

+ Comparativo de custos, receitas e margem por cliente. +
+
+
+ +
+ +
+ +
+
+
+
+

Totais Line

+

Consolidado entre Pessoa Física (PF) e Jurídica (PJ).

+
+
+
+ PF Linhas + {{ formatNumber(findLineTotal(['PF','PESSOA FISICA'])?.qtdLinhas) }} +
+
+ PJ Linhas + {{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }} +
+
+ Lucro Consolidado + {{ formatMoney(clientesTotals?.lucro) }} +
+
+
+
+ +
+
+
+

Distribuição PF vs PJ

+

Proporção da base de linhas ativas.

+
+
+ +
+
+
+ +
+ +
+

Detalhamento Totais

+ Tabela analítica dos totais processados. +
+
+
+ +
+ +
+ +
+

Distribuição por GB

+ Tabela GB / QTD / SOMA importada da aba RESUMO. +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
GBQTDSOMA
{{ formatGb(row.gb) }}{{ formatNumber(row.qtd) }}{{ formatMoney(row.soma) }}
Total{{ formatNumber(gbDistribuicaoTotalLinhas) }}{{ formatMoney(gbDistribuicaoSomaTotal) }}
+
+
+
+
+ +
+
+
+
+

Estoque de Reserva

+

Monitoramento de linhas disponíveis por DDD.

+
+
+
+ Linhas em Estoque + {{ formatNumber(reservaTotals?.qtdLinhasTotal) }} +
+
+ Custo de Reserva + {{ formatNumber(reservaTotals?.total) }} +
+
+
+
+ +
+
+
+

Concentração por DDD

+

Regiões com maior volume de linhas em reserva.

+
+
+ +
+
+
+ +
+ +
+

Detalhamento por DDD

+ Lista completa de estoque agrupada geograficamente. +
+
+
+ +
+
+
+
+ + +
+
+ + +
+ + +
+
+ +
+
+

Nenhum registro encontrado.

+
+
+
+
+ +
+
+
{{ item.title }}
+
{{ item.subtitle }}
+
+
+
+ {{ metric.label }} + {{ metric.value }} +
+
+
+ +
+
+ +
+
+ + + + + + + + + + + +
+ {{ col.label }} +
+ + {{ formatCell(col, row) }} + + {{ formatCell(col, row) }} + +
+
+
+
+
+ +
+
+ Receita Line + {{ formatMoney(clientesTotals.valorContratoLine) }} +
+
+ Custo Vivo + {{ formatMoney(clientesTotals.valorContratoVivo) }} +
+
+ Lucro Líquido + {{ formatMoney(clientesTotals.lucro) }} +
+
+ + +
+ +
+
+
+
+

{{ group.detailGroup?.title }}

+ +
+
+
+ + + + + + + + + + + +
+ {{ col.label }} +
+ {{ formatCell(col, row) }} +
+
+
+
+
+
+ +
+
+
+
+
+ Detalhes do Plano +

{{ macrophonyDetailGroup?.plano }}

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
VariaçãoGBValor Un.Total LinhasValor Total
{{ row.planoContrato || '-' }}{{ formatGb(row.gb) }}{{ formatMoney(row.valorIndividualComSvas) }}{{ formatNumber(row.totalLinhas) }}{{ formatMoney(row.valorTotal) }}
Total deste grupo{{ formatNumber(macrophonyDetailGroup.totalLinhas) }}{{ formatMoney(macrophonyDetailGroup.valorTotal) }}
+
+
+
+
+
diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss new file mode 100644 index 0000000..f740f85 --- /dev/null +++ b/src/app/pages/resumo/resumo.scss @@ -0,0 +1,790 @@ +:host { + /* Tokens de Cor - Enterprise Palette */ + --brand: #e33dcf; + --brand-hover: #c92bb6; + --brand-soft: rgba(227, 61, 207, 0.08); + --brand-gradient: linear-gradient(135deg, #e33dcf 0%, #b0249d 100%); + + --blue: #030faa; + --blue-soft: rgba(3, 15, 170, 0.06); + + --text-main: #0f172a; + --text-sec: #64748b; + --text-light: #94a3b8; + + --border: #e2e8f0; + --surface: #ffffff; + --bg-page: #f8fafc; + + --success: #10b981; + --danger: #ef4444; + --warning: #f59e0b; + + /* Elevation & Depth */ + --shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02); + --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --shadow-elevated: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); + --shadow-modal: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + + --focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.2); + + display: block; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text-main); + background: var(--bg-page); +} + +.resumo-page { + min-height: 100vh; + padding-bottom: 80px; +} + +.wrap { padding-top: 32px; } + +.resumo-container { + width: 100%; + max-width: 1360px; /* Ligeiramente mais largo para dashboard */ + margin: 0 auto; + padding: 0 24px; + + @media (max-width: 768px) { padding: 0 16px; } +} + +/* Animações */ +[data-animate] { + opacity: 1; + transform: translateY(0); + transition: opacity 0.5s ease-out, transform 0.5s ease-out; +} +:host(.animate-ready) [data-animate] { + opacity: 0; + transform: translateY(15px); +} +:host(.animate-ready) [data-animate].is-visible { + opacity: 1; + transform: translateY(0); +} + +/* Header */ +.page-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; + gap: 16px; + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + } +} + +.title-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.badge-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 99px; + background: white; + border: 1px solid var(--border); + color: var(--brand); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + box-shadow: var(--shadow-sm); + width: fit-content; +} + +.flex-title { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.page-title { + margin: 0; + font-size: 28px; + font-weight: 800; + letter-spacing: -0.03em; + color: var(--text-main); +} + +.page-subtitle { + margin: 0; + font-size: 14px; + color: var(--text-sec); + max-width: 600px; +} + +/* Status Indicators */ +.status-wrapper { + display: flex; + gap: 12px; +} +.status { + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 99px; + + &.loading { background: #eff6ff; color: var(--blue); } + &.error { background: #fef2f2; color: var(--danger); } + &.success { background: #f0fdf4; color: var(--success); } +} +.spin { animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* Botões */ +.btn-ghost { + border: 1px solid var(--border); + background: white; + color: var(--text-main); + padding: 8px 16px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm); + + &:hover:not(:disabled) { + border-color: var(--brand); + color: var(--brand); + transform: translateY(-1px); + box-shadow: var(--shadow-card); + } + &:disabled { opacity: 0.6; cursor: wait; } +} + +.btn-icon-text { + @extend .btn-ghost; + padding: 6px 12px; + font-size: 12px; +} + +.btn-icon { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid transparent; + background: rgba(0,0,0,0.03); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: rgba(0,0,0,0.06); + color: var(--brand); + } +} + +.btn-mini { + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + border: 1px solid var(--border); + background: white; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + color: var(--text-sec); + + &:hover { + border-color: var(--brand); + color: var(--brand); + } +} + +/* Tabs */ +.tab-bar { + display: flex; + gap: 6px; + padding: 4px; + border-radius: var(--radius-md); + background: white; + border: 1px solid var(--border); + margin-bottom: 24px; + width: fit-content; + box-shadow: var(--shadow-sm); + overflow-x: auto; +} + +.tab-btn { + border: none; + background: transparent; + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + font-size: 13px; + color: var(--text-sec); + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + + &:hover { color: var(--text-main); background: #f1f5f9; } + &.active { + background: var(--brand-soft); + color: var(--brand); + font-weight: 700; + } +} + +/* Hero Section */ +.section-hero { + background: white; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--shadow-card); +} + +.hero-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + + @media (max-width: 900px) { + flex-direction: column; + align-items: flex-start; + } +} + +.hero-text h3 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 700; +} +.hero-text p { + margin: 0; + color: var(--text-sec); + font-size: 13px; +} + +.hero-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + width: 100%; + max-width: 600px; +} + +.kpi-card { + background: #f8fafc; + border: 1px solid var(--border); + padding: 12px 16px; + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: 4px; + transition: transform 0.2s; + + &:hover { + border-color: #cbd5e1; + background: white; + box-shadow: var(--shadow-sm); + } + + &.highlight { + background: linear-gradient(to bottom right, #f8fafc, #fff); + border-left: 3px solid var(--success); + } +} + +.kpi-lbl { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-sec); + letter-spacing: 0.04em; +} + +.kpi-val { + font-size: 18px; + font-weight: 700; + color: var(--text-main); + font-feature-settings: "tnum"; +} + +/* Grids */ +.section-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 24px; + margin-bottom: 24px; +} + +.planos-charts .chart-card { + grid-column: span 6; + @media (max-width: 960px) { grid-column: span 12; } +} + +.full-chart .chart-card { + grid-column: span 12; +} + +.chart-card { + background: white; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + height: 100%; +} + +.chart-area { + position: relative; + height: 300px; + width: 100%; + margin-top: 16px; +} + +.card-header-clean h3 { + margin: 0; + font-size: 15px; + font-weight: 700; +} +.card-header-clean p { + margin: 2px 0 0; + font-size: 12px; + color: var(--text-sec); +} + +/* Details/Summary Sections */ +.section-card { + background: white; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + margin-bottom: 24px; + transition: box-shadow 0.2s; + + &:hover { box-shadow: var(--shadow-elevated); } +} + +summary { + list-style: none; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 24px; + background: white; + border-bottom: 1px solid transparent; + transition: background 0.2s; + + &:hover { background: #fcfcfc; } + &::-webkit-details-marker { display: none; } +} + +details[open] summary { + border-bottom-color: var(--border); + background: #f8fafc; +} + +.summary-content h4 { + margin: 0 0 2px; + font-size: 15px; + font-weight: 700; + color: var(--text-main); +} +.summary-content span { + font-size: 13px; + color: var(--text-sec); +} +.summary-icon { + color: var(--text-sec); + transition: transform 0.3s ease; +} +details[open] .summary-icon { transform: rotate(180deg); } + +/* Tabelas e Listas */ +.table-tools { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + border-bottom: 1px solid var(--border); + gap: 16px; + flex-wrap: wrap; + background: white; +} + +.search-box { + display: flex; + align-items: center; + gap: 10px; + background: #f1f5f9; + padding: 8px 12px; + border-radius: 8px; + width: 300px; + max-width: 100%; + border: 1px solid transparent; + transition: all 0.2s; + + &:focus-within { + background: white; + border-color: var(--brand); + box-shadow: 0 0 0 2px var(--brand-soft); + } + + input { + border: none; + background: transparent; + width: 100%; + outline: none; + font-size: 13px; + } + i { color: var(--text-sec); } +} + +.tools-right { + display: flex; + align-items: center; + gap: 12px; +} + +.select-label { + font-size: 12px; + font-weight: 600; + color: var(--text-sec); + display: flex; + align-items: center; + gap: 8px; + + select { + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 24px 4px 8px; + font-size: 12px; + background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 6px center / 10px; + appearance: none; + cursor: pointer; + + &:hover { border-color: var(--text-light); } + &:focus { outline: none; border-color: var(--brand); } + } +} + +.divider-v { + width: 1px; + height: 24px; + background: var(--border); +} + +/* Listas Agrupadas */ +.macrophony-row, .grouped-row { + display: grid; + grid-template-columns: 40px minmax(200px, 1.5fr) 2fr auto; + gap: 16px; + padding: 16px 24px; + align-items: center; + border-bottom: 1px solid var(--border); + background: white; + transition: background 0.1s; + cursor: pointer; + + &:hover { background: #f8fafc; } + + @media (max-width: 768px) { + grid-template-columns: 40px 1fr; + gap: 8px; + } +} + +.group-toggle { + width: 32px; + height: 32px; + border-radius: 6px; + border: 1px solid var(--border); + background: white; + color: var(--text-sec); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { border-color: var(--brand); color: var(--brand); } +} + +.group-title { + font-weight: 700; + font-size: 14px; + color: var(--text-main); +} + +.group-meta { + display: flex; + gap: 6px; + margin-top: 4px; +} + +.group-metrics { + display: flex; + justify-content: flex-end; + gap: 24px; + + @media (max-width: 768px) { + grid-column: 2; + justify-content: flex-start; + margin-top: 8px; + } +} + +.metric { + display: flex; + flex-direction: column; + align-items: flex-end; + + @media (max-width: 768px) { align-items: flex-start; } +} + +.metric .lbl { + font-size: 10px; + text-transform: uppercase; + font-weight: 700; + color: var(--text-light); + letter-spacing: 0.05em; +} +.metric strong { + font-size: 14px; + color: var(--text-main); +} + +.badge-tag { + background: #f1f5f9; + color: var(--text-main); + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + border: 1px solid transparent; + + &.secondary { background: white; border-color: var(--border); color: var(--text-sec); } +} + +/* Tabelas Internas */ +.macrophony-details, .grouped-details { + background: #f8fafc; + border-bottom: 1px solid var(--border); + box-shadow: inset 0 2px 4px rgba(0,0,0,0.02); + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.table-wrap { + overflow-x: auto; + + &.is-nested { padding: 16px 24px; } +} + +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; + + &.compact td, &.compact th { padding: 8px 12px; } + + th { + text-align: center; + font-size: 11px; + text-transform: uppercase; + font-weight: 700; + color: var(--text-sec); + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: rgba(248, 250, 252, 0.95); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; + } + + td { + text-align: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + color: var(--text-main); + transition: background 0.1s; + } + + tr:last-child td { border-bottom: none; } + tr:hover td { background: white; } + + /* Auxiliares */ + .text-right { text-align: center; } + .text-center { text-align: center; } + .num-font { font-family: 'Roboto Mono', monospace; font-size: 12px; } + .font-bold { font-weight: 700; } + .text-brand { color: var(--brand); } + .text-success { color: var(--success); } + .text-danger { color: var(--danger); } +} + +.diff-row td { background: #fff5f9; } + +/* Footers */ +.table-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + background: white; + border-top: 1px solid var(--border); + + @media (max-width: 600px) { + flex-direction: column; + gap: 12px; + } +} + +.table-count { + font-size: 12px; + color: var(--text-sec); +} + +.pagination { + display: flex; + gap: 4px; +} + +.page-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1px solid var(--border); + background: white; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.2s; + + &:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); } + &.active { background: var(--brand); color: white; border-color: var(--brand); } + &:disabled { opacity: 0.5; cursor: default; } +} + +/* Resumo Bars (Totais) */ +.macrophony-summary, .grouped-summary-bar { + display: flex; + gap: 24px; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +.summary-item, .sum-col { + display: flex; + flex-direction: column; + + span { font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; } + strong { font-size: 16px; color: var(--text-main); } + + &.highlight strong { color: var(--brand); } +} + +/* Modais */ +.macrophony-modal, .grouped-modal { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.macrophony-backdrop, .grouped-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(4px); + z-index: 90; +} + +.macrophony-card, .grouped-card { + background: white; + width: 100%; + max-width: 800px; + max-height: 85vh; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-modal); + display: flex; + flex-direction: column; + z-index: 101; + overflow: hidden; + animation: modalUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalUp { + from { opacity: 0; transform: translateY(20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.detail-head { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: flex-start; + background: white; + + h4 { margin: 0; font-size: 18px; font-weight: 700; } + .detail-super { display: block; font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; margin-bottom: 4px; } +} + +.macrophony-modal-body, .grouped-modal-body { + overflow-y: auto; + padding: 0; +} + +/* Utils */ +.hide-mobile { + @media (max-width: 600px) { display: none; } +} + +.empty-state { + padding: 40px; + text-align: center; + color: var(--text-sec); + + i { font-size: 32px; margin-bottom: 12px; display: block; opacity: 0.5; } + p { margin: 0; font-size: 14px; } +} diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts new file mode 100644 index 0000000..6901ad5 --- /dev/null +++ b/src/app/pages/resumo/resumo.ts @@ -0,0 +1,1120 @@ +import { + Component, + ChangeDetectorRef, + Inject, + PLATFORM_ID, + AfterViewInit, + OnInit, + ViewChild, + ElementRef, + OnDestroy, + HostBinding +} from '@angular/core'; +import { isPlatformBrowser, CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import Chart from 'chart.js/auto'; +import type { ChartConfiguration, ChartData, ScriptableContext, TooltipItem } from 'chart.js'; +import { + ResumoService, + ResumoResponse, + MacrophonyPlan, + MacrophonyTotals, + VivoLineResumo, + VivoLineTotals, + ClienteEspecial, + PlanoContratoResumo, + PlanoContratoTotal, + LineTotal, + GbDistribuicao, + ReservaLine, + ReservaPorDdd, + ReservaTotal +} from '../../services/resumo.service'; + +type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva'; + +// Configurações de UI +const ANIMATION_DELAY = 60; +const CHART_THEME = { + brand: '#e33dcf', + brandLight: '#fce7f9', + blue: '#030faa', + blueLight: '#eef2ff', + success: '#10b981', + fontFamily: "'Inter', sans-serif" +}; + +// ... (Interfaces e Types mantidos sem alteração na estrutura de dados) ... +// Para brevidade, mantive as definições de Tipos iguais ao original, focando nas mudanças visuais da classe. +type TableColumn = { + key: string; + label: string; + type?: 'text' | 'number' | 'money' | 'gb'; + align?: 'left' | 'right' | 'center'; + sortable?: boolean; + value: (row: T) => any; + display?: (value: any, row: T) => string; + tone?: boolean; + badge?: boolean; + icon?: (row: T) => string | null; + title?: (row: T) => string; +}; +// ... (Demais tipos TableView, TableState, GroupedTableState mantidos) ... +type TableView = { rows: T[]; total: number; totalPages: number; pageStart: number; pageEnd: number; pageNumbers: number[]; sorted: T[]; }; +type TableState = { key: string; label: string; data: T[]; columns: TableColumn[]; search: string; page: number; pageSize: number; pageSizeOptions: number[]; sortKey: string; sortDir: 'asc' | 'desc'; compact: boolean; view: TableView | null; }; +type MacrophonyGroup = { key: string; plano: string; gbLabel: string; totalLinhas: number; valorTotal: number; valorUnitMedio: number | null; rows: MacrophonyPlan[]; }; +type GroupMetric = { label: string; value: string; tone?: string }; +type GroupItem = { key: string; title: string; subtitle?: string; rows: T[]; metrics: GroupMetric[]; }; +type GroupedTableState = { key: string; label: string; table: TableState; groupBy: (row: T) => string; groupTitle: (rows: T[]) => string; groupSubtitle?: (rows: T[]) => string | undefined; groupMetrics: (rows: T[]) => GroupMetric[]; groupSort?: (a: GroupItem, b: GroupItem) => number; search: string; page: number; pageSize: number; pageSizeOptions: number[]; compact: boolean; open: Set; groups: GroupItem[]; filtered: GroupItem[]; view: GroupItem[]; detailOpen: boolean; detailGroup: GroupItem | null; }; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './resumo.html', + styleUrls: ['./resumo.scss'] +}) +export class Resumo implements OnInit, AfterViewInit, OnDestroy { + @HostBinding('class.animate-ready') animateReady = false; + + loading = false; + errorMessage = ''; + resumo: ResumoResponse | null = null; + + activeTab: ResumoTab = 'planos'; + readonly tabs = [ + { key: 'planos' as ResumoTab, label: 'Planos', icon: 'bi bi-collection' }, + { key: 'clientes' as ResumoTab, label: 'Clientes', icon: 'bi bi-people' }, + { key: 'totais' as ResumoTab, label: 'Totais Line', icon: 'bi bi-bar-chart' }, + { key: 'reserva' as ResumoTab, label: 'Reserva', icon: 'bi bi-box-seam' }, + ]; + + @ViewChild('chartPlanos') chartPlanosRef?: ElementRef; + @ViewChild('chartPlanosLinhas') chartPlanosLinhasRef?: ElementRef; + @ViewChild('chartClientes') chartClientesRef?: ElementRef; + @ViewChild('chartTotais') chartTotaisRef?: ElementRef; + @ViewChild('chartReserva') chartReservaRef?: ElementRef; + + private charts: { [key: string]: Chart | undefined } = {}; + private viewReady = false; + private dataReady = false; + + // Estados de Tabela e Grupo + macrophonyGroups: MacrophonyGroup[] = []; + macrophonyView: MacrophonyGroup[] = []; + macrophonySearch = ''; + macrophonyPage = 1; + macrophonyPageSize = 5; + macrophonyPageOptions = [5, 10, 20]; + macrophonyCompact = false; + macrophonyOpen: Set = new Set(); + macrophonyDetailOpen = false; + macrophonyDetailGroup: MacrophonyGroup | null = null; + private macrophonyFiltered: MacrophonyGroup[] = []; + + tableMacrophony!: TableState; + tablePlanoContrato!: TableState; + tableClientes!: TableState; + tableClientesEspeciais!: TableState; + tableTotaisLine!: TableState; + tableReserva!: TableState; + + groupPlanoContrato!: GroupedTableState; + groupClientes!: GroupedTableState; + groupClientesEspeciais!: GroupedTableState; + groupTotaisLine!: GroupedTableState; + groupReserva!: GroupedTableState; + + constructor( + @Inject(PLATFORM_ID) private platformId: object, + private resumoService: ResumoService, + private route: ActivatedRoute, + private router: Router, + private cdr: ChangeDetectorRef + ) { + this.initTables(); + this.initGroupTables(); + // Default chart configuration for Enterprise look + Chart.defaults.font.family = CHART_THEME.fontFamily; + Chart.defaults.color = '#64748b'; + Chart.defaults.scale.grid.color = '#f1f5f9'; + } + + ngOnInit(): void { + this.route.queryParamMap.subscribe((params) => { + const tab = params.get('tab'); + if (this.isValidTab(tab)) { + this.activeTab = tab as ResumoTab; + this.deferChartBuild(); + this.deferAnimate(); + } + }); + this.loadResumo(); + } + + ngAfterViewInit(): void { + this.viewReady = true; + this.animateReady = isPlatformBrowser(this.platformId); + if (this.animateReady) this.animateIn(); + this.tryBuildCharts(); + } + + ngOnDestroy(): void { + Object.values(this.charts).forEach(c => c?.destroy()); + } + + setTab(tab: ResumoTab): void { + if (this.activeTab === tab) return; + this.activeTab = tab; + this.router.navigate([], { queryParams: { tab }, queryParamsHandling: 'merge' }); + this.deferChartBuild(); + this.deferAnimate(); + } + + refresh(): void { + this.loadResumo(); + } + + // --- Helpers de UI --- + trackByIndex(index: number) { return index; } + + // Util para criar Gradiente no Chart.js + private createGradient(ctx: CanvasRenderingContext2D, colorStart: string, colorEnd: string) { + const gradient = ctx.createLinearGradient(0, 0, 0, 400); + gradient.addColorStop(0, colorStart); + gradient.addColorStop(1, colorEnd); + return gradient; + } + + // --- Lógica de Gráficos (Visual) --- + private buildChartsForActiveTab(): void { + if (!this.resumo) return; + this.destroyCharts(); + + if (this.activeTab === 'planos') { + this.buildChartPlanos(); + this.buildChartPlanosLinhas(); + } else if (this.activeTab === 'clientes') { + this.buildChartClientes(); + } else if (this.activeTab === 'totais') { + this.buildChartTotais(); + } else if (this.activeTab === 'reserva') { + this.buildChartReserva(); + } + } + + private buildChartPlanos() { + const canvas = this.chartPlanosRef?.nativeElement; + if (!canvas) return; + + const data = this.macrophonyGroups + .map(g => ({ label: g.plano, valor: g.valorTotal })) + .sort((a, b) => b.valor - a.valor) + .slice(0, 10); + + const ctx = canvas.getContext('2d'); + const bg = ctx ? this.createGradient(ctx, CHART_THEME.brand, '#b0249d') : CHART_THEME.brand; + + this.charts['planos'] = new Chart(canvas, { + type: 'bar', + data: { + labels: data.map(d => d.label.length > 20 ? d.label.slice(0, 20) + '...' : d.label), + datasets: [{ + label: 'Valor Total', + data: data.map(d => d.valor), + backgroundColor: bg, + borderRadius: 4, + barPercentage: 0.6, + minBarLength: 8, + }] + }, + options: this.getCommonChartOptions('currency') + }); + } + + private buildChartPlanosLinhas() { + const canvas = this.chartPlanosLinhasRef?.nativeElement; + if (!canvas) return; + + const data = this.macrophonyGroups + .map(g => ({ label: g.plano, linhas: g.totalLinhas })) + .sort((a, b) => b.linhas - a.linhas) + .slice(0, 10); + + const ctx = canvas.getContext('2d'); + const bg = ctx ? this.createGradient(ctx, CHART_THEME.blue, '#2563eb') : CHART_THEME.blue; + + this.charts['planosLinhas'] = new Chart(canvas, { + type: 'bar', + data: { + labels: data.map(d => d.label.length > 20 ? d.label.slice(0, 20) + '...' : d.label), + datasets: [{ + label: 'Total Linhas', + data: data.map(d => d.linhas), + backgroundColor: bg, + borderRadius: 4, + barPercentage: 0.6, + minBarLength: 8, + }] + }, + options: this.getCommonChartOptions('number') + }); + } + + private buildChartClientes() { + const canvas = this.chartClientesRef?.nativeElement; + if (!canvas) return; + + const data = (this.resumo?.vivoLineResumos ?? []) + .map(c => ({ label: c.cliente, lucro: this.toNumber(c.lucro) ?? 0 })) + .sort((a, b) => b.lucro - a.lucro) + .slice(0, 10); + + 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), + backgroundColor: CHART_THEME.success, + borderRadius: 4, + barPercentage: 0.7, + }] + }, + options: { + ...this.getCommonChartOptions('currency'), + indexAxis: 'y' + } + }); + } + + private buildChartTotais() { + const canvas = this.chartTotaisRef?.nativeElement; + if (!canvas) return; + + const pf = this.toNumber(this.findLineTotal(['PF', 'PESSOA FISICA'])?.qtdLinhas) ?? 0; + const pj = this.toNumber(this.findLineTotal(['PJ', 'PESSOA JURIDICA'])?.qtdLinhas) ?? 0; + + this.charts['totais'] = new Chart(canvas, { + type: 'doughnut', + data: { + labels: ['Pessoa Física', 'Pessoa Jurídica'], + datasets: [{ + data: [pf, pj], + backgroundColor: [CHART_THEME.brand, CHART_THEME.blue], + borderWidth: 0, + hoverOffset: 4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '75%', + plugins: { + legend: { position: 'bottom', labels: { usePointStyle: true, padding: 20 } }, + tooltip: { + backgroundColor: '#1e293b', + padding: 12, + callbacks: { label: (c) => ` ${c.label}: ${this.formatNumber(c.raw)} linhas` } + } + } + } + }); + } + + private buildChartReserva() { + const canvas = this.chartReservaRef?.nativeElement; + if (!canvas) return; + + const data = this.getReservaPorDddChartData() + .sort((a, b) => b.totalLinhas - a.totalLinhas) + .slice(0, 15); + + this.charts['reserva'] = new Chart(canvas, { + type: 'bar', + data: { + labels: data.map(d => d.label), + datasets: [{ + label: 'Linhas em Estoque', + data: data.map(d => d.totalLinhas), + backgroundColor: CHART_THEME.blue, + borderRadius: 2, + minBarLength: 10 + }] + }, + options: { + ...this.getCommonChartOptions('number'), + plugins: { + ...this.getCommonChartOptions('number')?.plugins, + tooltip: { + ...this.getCommonChartOptions('number')?.plugins?.tooltip, + callbacks: { + label: (ctx: TooltipItem<'bar'>) => ` ${this.formatNumber(ctx.raw)} linhas` + } + } + } + } + }); + } + + private getCommonChartOptions(format: 'currency' | 'number'): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'nearest', intersect: true }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: '#1e293b', + titleFont: { size: 13 }, + bodyFont: { size: 13, weight: 'bold' }, + padding: 12, + cornerRadius: 8, + displayColors: false, + callbacks: { + label: (ctx: TooltipItem<'bar'>) => { + const val = ctx.raw as number; + return format === 'currency' ? this.formatMoney(val) : this.formatNumber(val); + } + } + } + }, + scales: { + y: { + beginAtZero: true, + grid: { color: '#f1f5f9', drawBorder: false } as any, + ticks: { font: { size: 11 }, maxTicksLimit: 6 } + }, + x: { + grid: { display: false } as any, + ticks: { font: { size: 11 } } + } + } + } as any; + } + + // --- Demais Métodos Funcionais (Mantidos para garantir contrato) --- + + private destroyCharts(): void { + Object.keys(this.charts).forEach(key => { + this.charts[key]?.destroy(); + this.charts[key] = undefined; + }); + } + + // (Mantendo lógica de carga de dados original para não quebrar regras de negócio) + private loadResumo(): void { + this.loading = true; + this.errorMessage = ''; + this.dataReady = false; + this.resumoService.getResumo().subscribe({ + next: (data) => { + this.resumo = data ? this.normalizeResumo(data) : null; + this.loading = false; + this.dataReady = true; + this.bindTables(); + this.cdr.detectChanges(); + this.tryBuildCharts(); + }, + error: () => { + this.loading = false; + this.errorMessage = 'Não foi possível carregar os dados.'; + this.cdr.detectChanges(); + } + }); + } + + // Animação de entrada + private animateIn(): void { + if (!isPlatformBrowser(this.platformId)) return; + setTimeout(() => { + document.querySelectorAll('[data-animate]').forEach((el, i) => { + setTimeout(() => el.classList.add('is-visible'), i * ANIMATION_DELAY); + }); + }, 100); + } + + // ... (Restante da lógica de Macrophony, Tabelas e Formatações mantidas exatamente como no original para segurança funcional) ... + // Apenas garantindo que métodos usados no HTML (formatMoney, toggleMacrophonyCompact, etc.) existam. + + // Exemplo de Formatação Enterprise + formatMoney(value: any): string { + const n = this.toNumber(value); + if (n === null) return '-'; + // Usando narrowSymbol para R$ sem espaço extra se desejado, mas padrão pt-BR é seguro + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); + } + + formatNumber(value: any): string { + const n = this.toNumber(value); + if (n === null) return '-'; + return new Intl.NumberFormat('pt-BR').format(n); + } + + formatGb(value: any): string { + if (value == null) return '-'; + const n = this.toNumber(value); + return n == null ? String(value) : `${new Intl.NumberFormat('pt-BR').format(n)} GB`; + } + + formatCell(column: TableColumn, row: T): string { + const value = column.value(row); + if (column.display) return column.display(value, row); + if (value === null || value === undefined || value === '') return '-'; + + switch (column.type) { + case 'money': + return this.formatMoney(value); + case 'number': + return this.formatNumber(value); + case 'gb': + return this.formatGb(value); + default: + return String(value); + } + } + + // (Omitindo repetição de código boilerplate de tabelas/paginação que não mudou a lógica, apenas a view consome diferente) + // ... [Inclua aqui todo o restante dos métodos: initTables, buildMacrophonyGroups, updateMacrophonyView, exportCsv, etc.] ... + + // Lógica crítica de retry do Chart + private tryBuildCharts(): void { + if (!isPlatformBrowser(this.platformId) || !this.viewReady || !this.dataReady) return; + requestAnimationFrame(() => this.buildChartsForActiveTab()); + } + + private deferChartBuild(): void { + if (!this.viewReady || !this.dataReady) return; + setTimeout(() => this.tryBuildCharts(), 50); + } + + private deferAnimate(): void { + if (!this.animateReady) return; + setTimeout(() => this.animateIn(), 50); + } + + private isValidTab(tab: string | null): tab is ResumoTab { + return ['planos', 'clientes', 'totais', 'reserva'].includes(tab || ''); + } + + // Métodos obrigatórios para o template funcionar + toggleMacrophonyCompact() { this.macrophonyCompact = !this.macrophonyCompact; } + onMacrophonySearch() { this.macrophonyPage = 1; this.updateMacrophonyView(); } + onMacrophonyPageSizeChange() { this.macrophonyPage = 1; this.updateMacrophonyView(); } + isMacrophonyOpen(key: string) { return this.macrophonyOpen.has(key); } + toggleMacrophonyGroup(key: string) { if (this.macrophonyOpen.has(key)) this.macrophonyOpen.delete(key); else this.macrophonyOpen.add(key); } + openMacrophonyDetail(g: MacrophonyGroup) { this.macrophonyDetailGroup = g; this.macrophonyDetailOpen = true; } + closeMacrophonyDetail() { this.macrophonyDetailOpen = false; this.macrophonyDetailGroup = null; } + goToMacrophonyPage(p: number) { this.macrophonyPage = p; this.updateMacrophonyView(); } + + onGroupedSearch(g: GroupedTableState) { g.page = 1; this.updateGroupView(g); } + toggleGroupedCompact(g: GroupedTableState) { g.compact = !g.compact; } + exportGroupedCsv(g: GroupedTableState, file: string) { this.exportCsv(g.table, file); } + isGroupedOpen(g: GroupedTableState, key: string) { return g.open.has(key); } + toggleGroupedOpen(g: GroupedTableState, key: string) { if (g.open.has(key)) g.open.delete(key); else g.open.add(key); } + openGroupedDetail(g: GroupedTableState, item: GroupItem) { g.detailGroup = item; g.detailOpen = true; } + closeGroupedDetail(g: GroupedTableState) { g.detailOpen = false; g.detailGroup = null; } + getGroupedPageStart(g: GroupedTableState) { return g.filtered.length ? ((g.page - 1) * g.pageSize + 1) : 0; } + getGroupedPageEnd(g: GroupedTableState) { return g.filtered.length ? Math.min(g.page * g.pageSize, g.filtered.length) : 0; } + getGroupedPageNumbers(g: GroupedTableState) { + const total = this.getGroupedTotalPages(g); + if (total <= 1) return [1]; + const current = Math.min(Math.max(g.page, 1), total); + const start = Math.max(1, current - 2); + const end = Math.min(total, start + 4); + const adjustedStart = Math.max(1, end - 4); + return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i); + } + getGroupedTotalPages(g: GroupedTableState) { return Math.max(1, Math.ceil(g.filtered.length / g.pageSize)); } + goToGroupedPage(g: GroupedTableState, p: number) { + g.page = Math.min(this.getGroupedTotalPages(g), Math.max(1, p)); + this.updateGroupView(g); + } + getTableRowClass(_: TableState, __: T) { return false; } + getToneClass(v: any) { + const n = this.toNumber(v); + if (n === null || n === 0) return ''; + return n > 0 ? 'text-success' : 'text-danger'; + } + isVivoTravel(v: any) { + if (v === true || v === 1) return true; + const normalized = String(v ?? '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'sim'; + } + + private initTables() { + 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 }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'valorTotal', + sortDir: 'desc', + compact: false, + view: null, + }; + + 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 }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'valorTotal', + sortDir: 'desc', + compact: false, + view: null, + }; + + 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 }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'lucro', + sortDir: 'desc', + compact: false, + view: null, + }; + + 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 }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'valor', + sortDir: 'desc', + compact: false, + view: null, + }; + + 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 }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'qtdLinhas', + sortDir: 'desc', + compact: false, + view: null, + }; + + this.tableReserva = { + key: 'reserva', + label: 'Reserva', + data: [], + columns: [ + { key: 'ddd', label: 'DDD', type: 'text', value: (r) => r.ddd ?? '-' }, + { key: 'franquiaGb', label: 'Franquia', type: 'gb', value: (r) => r.franquiaGb }, + { key: 'qtdLinhas', label: 'Linhas', type: 'number', align: 'right', value: (r) => r.qtdLinhas }, + ], + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + sortKey: 'qtdLinhas', + sortDir: 'desc', + compact: false, + view: null, + }; + } + + private initGroupTables() { + this.groupPlanoContrato = this.createGroupedTableState( + 'grupoPlanoContrato', + 'Resumo de Contratos', + this.tablePlanoContrato, + (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) + ); + + this.groupClientes = this.createGroupedTableState( + 'grupoClientes', + 'Clientes', + this.tableClientes, + (row) => (row.cliente ?? '-').toString(), + (rows) => (rows[0]?.cliente ?? '-').toString(), + (rows) => `${this.formatNumber(this.sumGroup(rows, (r) => r.qtdLinhas))} linhas`, + (rows) => { + const receita = this.sumGroup(rows, (r) => r.valorContratoLine); + const custo = this.sumGroup(rows, (r) => r.valorContratoVivo); + const lucro = this.sumGroup(rows, (r) => r.lucro); + return [ + { label: 'Receita', value: this.formatMoney(receita) }, + { label: 'Custo', value: this.formatMoney(custo) }, + { 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) + ); + + this.groupClientesEspeciais = this.createGroupedTableState( + 'grupoClientesEspeciais', + 'Clientes Especiais', + this.tableClientesEspeciais, + (row) => (row.nome ?? '-').toString(), + (rows) => (rows[0]?.nome ?? '-').toString(), + undefined, + (rows) => { + 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) + ); + + this.groupTotaisLine = this.createGroupedTableState( + 'grupoTotaisLine', + 'Totais Line', + this.tableTotaisLine, + (row) => (row.tipo ?? '-').toString(), + (rows) => (rows[0]?.tipo ?? '-').toString(), + 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) }, + ]; + }, + (a, b) => this.sumGroup(b.rows, (r) => r.qtdLinhas) - this.sumGroup(a.rows, (r) => r.qtdLinhas) + ); + + this.groupReserva = this.createGroupedTableState( + 'grupoReserva', + 'Reserva', + this.tableReserva, + (row) => String(row.ddd ?? '-'), + (rows) => `DDD ${rows[0]?.ddd ?? '-'}`, + undefined, + (rows) => { + const linhas = this.sumGroup(rows, (r) => r.qtdLinhas); + return [ + { label: 'Total de Linhas', value: this.formatNumber(linhas) }, + ]; + }, + (a, b) => this.sumGroup(b.rows, (r) => r.qtdLinhas) - this.sumGroup(a.rows, (r) => r.qtdLinhas) + ); + } + + private createGroupedTableState( + key: string, + label: string, + table: TableState, + groupBy: (row: T) => string, + groupTitle: (rows: T[]) => string, + groupSubtitle: ((rows: T[]) => string | undefined) | undefined, + groupMetrics: (rows: T[]) => GroupMetric[], + groupSort?: (a: GroupItem, b: GroupItem) => number, + ): GroupedTableState { + return { + key, + label, + table, + groupBy, + groupTitle, + groupSubtitle, + groupMetrics, + groupSort, + search: '', + page: 1, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + compact: false, + open: new Set(), + groups: [], + filtered: [], + view: [], + detailOpen: false, + detailGroup: null, + }; + } + + private normalizeResumo(data: ResumoResponse): ResumoResponse { + return { + ...data, + macrophonyPlans: Array.isArray(data.macrophonyPlans) ? data.macrophonyPlans : [], + vivoLineResumos: Array.isArray(data.vivoLineResumos) ? data.vivoLineResumos : [], + clienteEspeciais: Array.isArray(data.clienteEspeciais) ? data.clienteEspeciais : [], + planoContratoResumos: Array.isArray(data.planoContratoResumos) ? data.planoContratoResumos : [], + lineTotais: Array.isArray(data.lineTotais) ? data.lineTotais : [], + gbDistribuicao: Array.isArray(data.gbDistribuicao) ? data.gbDistribuicao : [], + reservaLines: Array.isArray(data.reservaLines) ? data.reservaLines : [], + reservaPorDdd: Array.isArray(data.reservaPorDdd) ? data.reservaPorDdd : [], + }; + } + + private bindTables() { + const resumo = this.resumo; + if (!resumo) { + this.tableMacrophony.data = []; + this.tablePlanoContrato.data = []; + this.tableClientes.data = []; + this.tableClientesEspeciais.data = []; + this.tableTotaisLine.data = []; + this.tableReserva.data = []; + this.macrophonyGroups = []; + this.macrophonyFiltered = []; + this.macrophonyView = []; + this.updateGroupView(this.groupPlanoContrato); + this.updateGroupView(this.groupClientes); + this.updateGroupView(this.groupClientesEspeciais); + this.updateGroupView(this.groupTotaisLine); + this.updateGroupView(this.groupReserva); + return; + } + + this.tableMacrophony.data = resumo.macrophonyPlans ?? []; + this.tablePlanoContrato.data = this.buildPlanoContratoResumoConsolidado(resumo.planoContratoResumos ?? []); + this.tableClientes.data = resumo.vivoLineResumos ?? []; + this.tableClientesEspeciais.data = resumo.clienteEspeciais ?? []; + this.tableTotaisLine.data = resumo.lineTotais ?? []; + this.tableReserva.data = resumo.reservaLines ?? []; + + this.updateMacrophonyView(); + this.updateGroupView(this.groupPlanoContrato); + this.updateGroupView(this.groupClientes); + this.updateGroupView(this.groupClientesEspeciais); + this.updateGroupView(this.groupTotaisLine); + this.updateGroupView(this.groupReserva); + } + + private updateMacrophonyView() { + const rows = this.tableMacrophony.data ?? []; + const byPlano = new Map(); + for (const row of rows) { + const key = (row.planoContrato ?? 'Sem Plano').toString(); + const list = byPlano.get(key) ?? []; + list.push(row); + byPlano.set(key, list); + } + + const groups: MacrophonyGroup[] = Array.from(byPlano.entries()).map(([key, items]) => { + const totalLinhas = this.sumGroup(items, (r) => r.totalLinhas); + const valorTotal = this.sumGroup(items, (r) => r.valorTotal); + const valorUnit = totalLinhas > 0 ? (valorTotal / totalLinhas) : null; + const gbLabel = (items.find((i) => (i.gb ?? '').toString().trim())?.gb ?? '-').toString(); + return { + key, + plano: key, + gbLabel, + totalLinhas, + valorTotal, + valorUnitMedio: valorUnit, + rows: [...items].sort((a, b) => (this.toNumber(b.valorTotal) ?? 0) - (this.toNumber(a.valorTotal) ?? 0)), + }; + }); + + groups.sort((a, b) => b.valorTotal - a.valorTotal); + this.macrophonyGroups = groups; + + const search = this.normalizeText(this.macrophonySearch); + this.macrophonyFiltered = !search + ? groups + : groups.filter((group) => + this.normalizeText(group.plano).includes(search) || + this.normalizeText(group.gbLabel).includes(search) + ); + + const totalPages = Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); + this.macrophonyPage = Math.min(totalPages, Math.max(1, this.macrophonyPage)); + const start = (this.macrophonyPage - 1) * this.macrophonyPageSize; + const end = start + this.macrophonyPageSize; + this.macrophonyView = this.macrophonyFiltered.slice(start, end); + + if (this.macrophonyDetailGroup && !this.macrophonyFiltered.some((g) => g.key === this.macrophonyDetailGroup?.key)) { + this.closeMacrophonyDetail(); + } + } + + private updateGroupView(group: GroupedTableState) { + const data = group.table.data ?? []; + const map = new Map(); + + data.forEach((row) => { + const key = group.groupBy(row) || 'Sem agrupamento'; + const rows = map.get(key) ?? []; + rows.push(row); + map.set(key, rows); + }); + + const groups = Array.from(map.entries()).map(([key, rows]) => ({ + key, + title: group.groupTitle(rows), + subtitle: group.groupSubtitle ? group.groupSubtitle(rows) : undefined, + rows, + metrics: group.groupMetrics(rows), + })) as GroupItem[]; + + if (group.groupSort) groups.sort(group.groupSort); + group.groups = groups; + + const search = this.normalizeText(group.search); + group.filtered = !search + ? groups + : groups.filter((g) => + this.normalizeText(g.title).includes(search) || + this.normalizeText(g.subtitle).includes(search) + ); + + const totalPages = Math.max(1, Math.ceil(group.filtered.length / group.pageSize)); + group.page = Math.min(totalPages, Math.max(1, group.page)); + const start = (group.page - 1) * group.pageSize; + const end = start + group.pageSize; + group.view = group.filtered.slice(start, end); + + if (group.detailGroup && !group.filtered.some((g) => g.key === group.detailGroup?.key)) { + group.detailGroup = null; + group.detailOpen = false; + } + } + + private normalizeText(value: any): string { + return (value ?? '') + .toString() + .trim() + .toUpperCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + } + + private sumGroup(rows: T[], getter: (row: T) => any): number { + return rows.reduce((acc, row) => acc + (this.toNumber(getter(row)) ?? 0), 0); + } + + private buildPlanoContratoResumoConsolidado(rows: PlanoContratoResumo[]): PlanoContratoResumo[] { + const grouped = new Map< + string, + { + planoContrato: string; + gb: number | null; + totalLinhas: number; + valorTotal: number; + valorIndividualFallback: number | null; + } + >(); + + rows.forEach((row) => { + const planoContrato = (row.planoContrato ?? '-').toString().trim() || '-'; + const key = this.normalizeText(planoContrato); + const gb = + this.extractGbFromPlanName(planoContrato) ?? + this.toNumber(row.gb ?? row.franquiaGb); + const totalLinhas = this.toNumber(row.totalLinhas) ?? 0; + const valorTotal = this.toNumber(row.valorTotal) ?? 0; + const valorIndividual = this.toNumber(row.valorIndividualComSvas); + + const current = grouped.get(key); + if (!current) { + grouped.set(key, { + planoContrato, + gb, + totalLinhas, + valorTotal, + valorIndividualFallback: valorIndividual, + }); + return; + } + + current.totalLinhas += totalLinhas; + current.valorTotal += valorTotal; + current.gb ??= gb; + current.valorIndividualFallback ??= valorIndividual; + }); + + return Array.from(grouped.values()) + .map((item) => { + const valorIndividualComSvas = + item.totalLinhas > 0 + ? item.valorTotal / item.totalLinhas + : item.valorIndividualFallback; + + return { + planoContrato: item.planoContrato, + gb: item.gb, + franquiaGb: item.gb, + valorIndividualComSvas, + totalLinhas: item.totalLinhas, + valorTotal: item.valorTotal, + } satisfies PlanoContratoResumo; + }) + .sort((a, b) => (this.toNumber(b.valorTotal) ?? 0) - (this.toNumber(a.valorTotal) ?? 0)); + } + + private extractGbFromPlanName(planoContrato: string | null | undefined): number | null { + if (!planoContrato) return null; + + const match = planoContrato + .toUpperCase() + .match(/(\d+(?:[.,]\d+)?)\s*(GB|MB)\b/); + + if (!match) return null; + + const value = Number(match[1].replace(',', '.')); + if (!Number.isFinite(value)) return null; + + return match[2] === 'MB' ? value / 1000 : value; + } + + private toNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const raw = String(value).trim(); + if (!raw) return null; + + let normalized = raw.replace(/[^\d,.-]/g, ''); + if (normalized.includes(',') && normalized.includes('.')) { + if (normalized.lastIndexOf(',') > normalized.lastIndexOf('.')) { + normalized = normalized.replace(/\./g, '').replace(',', '.'); + } else { + normalized = normalized.replace(/,/g, ''); + } + } else if (normalized.includes(',')) { + normalized = normalized.replace(/\./g, '').replace(',', '.'); + } + + const parsed = Number(normalized); + return Number.isNaN(parsed) ? null : parsed; + } + + private exportCsv(table: TableState, filename: string) { + if (!isPlatformBrowser(this.platformId)) return; + const rows = table.data ?? []; + const columns = table.columns ?? []; + const header = columns.map((c) => c.label); + const body = rows.map((row) => + columns.map((column) => { + const value = this.formatCell(column, row); + const escaped = String(value).replace(/"/g, '""'); + return `"${escaped}"`; + }) + ); + + const csv = [header.join(';'), ...body.map((line) => line.join(';'))].join('\n'); + const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${filename}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + private getReservaPorDddChartData(): Array<{ label: string; totalLinhas: number }> { + const resumo = this.resumo; + if (!resumo) return []; + + const porDdd = resumo.reservaPorDdd ?? []; + if (porDdd.length) { + return porDdd.map((item) => ({ + label: String(item.ddd ?? '-'), + totalLinhas: this.toNumber(item.totalLinhas) ?? 0, + })); + } + + const fromLines = resumo.reservaLines ?? []; + const map = new Map(); + fromLines.forEach((row) => { + const key = String(row.ddd ?? '-'); + const qtd = this.toNumber(row.qtdLinhas) ?? 0; + map.set(key, (map.get(key) ?? 0) + qtd); + }); + return Array.from(map.entries()).map(([label, totalLinhas]) => ({ label, totalLinhas })); + } + + exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); } + findLineTotal(k: string[]): LineTotal | null { + const keys = k.map((item) => item.toUpperCase()); + const list = Array.isArray(this.resumo?.lineTotais) ? this.resumo?.lineTotais : []; + for (const item of list) { + const tipo = (item?.tipo ?? '').toString().toUpperCase(); + if (keys.some((key) => tipo.includes(key))) return item; + } + return null; + } + + get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; } + get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); } + get macrophonyFilteredGroups() { return this.macrophonyFiltered; } + get macrophonyPageNumbers() { + const total = this.macrophonyTotalPages; + if (total <= 1) return [1]; + const current = Math.min(Math.max(this.macrophonyPage, 1), total); + const start = Math.max(1, current - 2); + const end = Math.min(total, start + 4); + const adjustedStart = Math.max(1, end - 4); + return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i); + } + get macrophonyTotalPages() { return Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); } + get planosTotals() { return this.resumo?.macrophonyTotals; } + get contratosTotals() { return this.resumo?.planoContratoTotal; } + get clientesTotals() { return this.resumo?.vivoLineTotals; } + get gbDistribuicaoRows(): GbDistribuicao[] { return this.resumo?.gbDistribuicao ?? []; } + get gbDistribuicaoTotalLinhas(): number { + const fromTotal = this.toNumber(this.resumo?.gbDistribuicaoTotal?.totalLinhas); + if (fromTotal !== null) return fromTotal; + return this.gbDistribuicaoRows.reduce((acc, row) => acc + (this.toNumber(row.qtd) ?? 0), 0); + } + get gbDistribuicaoSomaTotal(): number { + const fromTotal = this.toNumber(this.resumo?.gbDistribuicaoTotal?.somaTotal); + if (fromTotal !== null) return fromTotal; + return this.gbDistribuicaoRows.reduce((acc, row) => acc + (this.toNumber(row.soma) ?? 0), 0); + } + get reservaTotals() { return this.resumo?.reservaTotal; } +} diff --git a/src/app/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html index 0430e7a..6e31ffb 100644 --- a/src/app/pages/troca-numero/troca-numero.html +++ b/src/app/pages/troca-numero/troca-numero.html @@ -76,7 +76,7 @@ - + diff --git a/src/app/pages/troca-numero/troca-numero.scss b/src/app/pages/troca-numero/troca-numero.scss index a0a3175..8c6fa2e 100644 --- a/src/app/pages/troca-numero/troca-numero.scss +++ b/src/app/pages/troca-numero/troca-numero.scss @@ -250,7 +250,7 @@ .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .search-group { - max-width: 380px; + max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 357cd5b..1ad0ba4 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -23,7 +23,11 @@
GESTÃO DE VIGÊNCIA
Controle de contratos e fidelização -
+
+ +
@@ -39,19 +43,25 @@ Total Vencidos {{ kpiTotalVencidos }}
-
- Valor Total - {{ kpiValorTotal | currency:'BRL' }} -
-
-
- - - -
+
+ + + + +
@@ -109,7 +119,7 @@ EFETIVAÇÃO VENCIMENTO TOTAL - AÇÕES + AÇÕES @@ -135,6 +145,8 @@
+ +
@@ -164,52 +176,258 @@
-
+
+
-
- + + +
+ +
+ + +
+ +
+ + +
+
diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss index 1a9a7f4..e382393 100644 --- a/src/app/pages/vigencia/vigencia.scss +++ b/src/app/pages/vigencia/vigencia.scss @@ -6,6 +6,9 @@ --blue: #030FAA; --text: #111214; --muted: rgba(17, 18, 20, 0.65); + --surface-soft: rgba(255, 255, 255, 0.7); + --surface-hover: rgba(255, 255, 255, 0.94); + --focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16); --success-bg: rgba(25, 135, 84, 0.1); --success-text: #198754; @@ -75,42 +78,107 @@ .title-badge { display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px; - border-radius: 999px; background: rgba(255, 255, 255, 0.78); + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.7)); border: 1px solid rgba(227, 61, 207, 0.22); font-size: 13px; font-weight: 800; + box-shadow: 0 8px 20px rgba(17, 18, 20, 0.06); i { color: var(--brand); } } .header-title { text-align: center; } .title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; } .subtitle { color: var(--muted); font-weight: 700; } +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + white-space: nowrap; + min-height: 38px; + border-width: 1px; + } +} /* KPIs */ .mureg-kpis { - display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + display: grid; + grid-template-columns: repeat(3, minmax(158px, 205px)); + justify-content: center; + gap: 8px; .kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); - border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; + border-radius: 14px; + padding: 8px 10px; + min-height: 58px; + display: flex; + justify-content: space-between; + align-items: center; transition: transform 0.2s; &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } - .lbl { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; color: var(--muted); } - .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } + .lbl { font-size: 0.64rem; font-weight: 900; text-transform: uppercase; color: var(--muted); } + .val { font-size: 1.02rem; font-weight: 950; color: var(--text); } .text-brand { color: var(--brand) !important; } } - - .kpi.kpi-stack { - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - text-align: center; - } } /* Controls */ .search-group { - border-radius: 12px; background: #fff; border: 1px solid rgba(17,18,20,0.15); display: flex; align-items: center; - &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); } - .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; &:focus { outline: none; } } + max-width: 270px; + border-radius: 12px; + overflow: hidden; + display: flex; + align-items: stretch; + background: #fff; + border: 1px solid rgba(17,18,20,0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + + &:focus-within { + border-color: var(--brand); + box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); + transform: translateY(-1px); + } + + .input-group-text { + background: transparent; + border: none; + color: var(--muted); + padding-left: 14px; + padding-right: 8px; + display: flex; + align-items: center; + } + + .form-control { + border: none; + background: transparent; + padding: 10px 0; + font-size: 0.9rem; + color: var(--text); + box-shadow: none; + + &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } + &:focus { outline: none; } + } + + .btn-clear { + background: transparent; + border: none; + color: var(--muted); + padding: 0 12px; + display: flex; + align-items: center; + cursor: pointer; + transition: color 0.2s; + + &:hover { color: #dc3545; } + } } .select-glass { @@ -118,6 +186,75 @@ color: var(--blue); font-weight: 800; } +.btn-brand, +.btn-glass, +.btn-primary, +.btn-danger { + border-radius: 12px; + font-weight: 900; + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s, filter 0.2s; + + &:hover { + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + } + + &:disabled { + opacity: 0.72; + cursor: not-allowed; + transform: none; + box-shadow: none; + } +} + +.btn-brand { + background-color: var(--brand); + border-color: var(--brand); + color: #fff; + box-shadow: 0 10px 22px rgba(227, 61, 207, 0.22); + + &:hover { + box-shadow: 0 12px 24px rgba(227, 61, 207, 0.28); + filter: brightness(1.05); + } +} + +.btn-glass { + background: var(--surface-soft); + border: 1px solid rgba(3, 15, 170, 0.24); + color: var(--blue); + box-shadow: 0 6px 16px rgba(3, 15, 170, 0.1); + + &:hover { + background: var(--surface-hover); + border-color: var(--brand); + color: var(--brand); + box-shadow: 0 8px 18px rgba(227, 61, 207, 0.16); + } +} + +.btn-primary { + background: linear-gradient(135deg, #1543ff, #030faa); + border-color: #030faa; + color: #fff; + box-shadow: 0 10px 22px rgba(3, 15, 170, 0.28); + + &:hover { + box-shadow: 0 12px 24px rgba(3, 15, 170, 0.3); + filter: brightness(1.05); + } +} + +.btn-danger { + background: linear-gradient(135deg, #ef4444, #dc2626); + border-color: #dc2626; + box-shadow: 0 10px 22px rgba(220, 38, 38, 0.26); +} + /* BODY E GRUPOS */ .geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .groups-container { padding: 16px; overflow-y: auto; height: 100%; } @@ -166,10 +303,23 @@ .text-blue { color: var(--blue) !important; } .td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.actions-col { min-width: 152px; } + +.action-group { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-wrap: nowrap; + white-space: nowrap; +} + .btn-icon { - width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; color: rgba(17,18,20,0.5); + width: 32px; height: 32px; border: none; background: rgba(17,18,20,0.04); border-radius: 8px; color: rgba(17,18,20,0.6); display: flex; align-items: center; justify-content: center; transition: all 0.2s; - &:hover { background: rgba(3,15,170,0.1); color: var(--blue); } + &:hover { background: rgba(17,18,20,0.08); color: var(--text); transform: translateY(-1px); } + &.primary:hover { background: rgba(3,15,170,0.1); color: var(--blue); } + &.danger:hover { background: rgba(220, 53, 69, 0.12); color: #dc3545; } } /* FOOTER */ @@ -177,10 +327,309 @@ padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; } .pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: #fff; margin: 0 2px; } +.pagination-modern .page-link:hover { border-color: var(--brand); color: var(--brand); background: rgba(255, 255, 255, 0.98); } .pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; } /* MODAL */ -.lg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } +.lg-backdrop { + position: fixed; + inset: 0; + background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%); + z-index: 9990; + backdrop-filter: blur(5px); +} .lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } -.lg-modal-card { background: #ffffff; border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); width: 600px; overflow: hidden; animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } +.lg-modal-card { + background: #ffffff; + border: 1px solid rgba(255,255,255,0.86); + border-radius: 20px; + box-shadow: 0 30px 60px -18px rgba(0, 0, 0, 0.4); + width: min(860px, 96vw); + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; + animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.lg-modal-card.modal-lg { width: min(760px, 94vw); } +.lg-modal-card.modal-xl { width: min(1040px, 95vw); max-height: 86vh; } + +.lg-modal-card .modal-header { + padding: 16px 24px; + border-bottom: 1px solid rgba(0,0,0,0.06); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.09), rgba(255, 255, 255, 0.95) 70%); + display: flex; + justify-content: space-between; + align-items: center; +} + +.lg-modal-card .modal-title { + font-size: 1.08rem; + font-weight: 900; + color: var(--text); + display: flex; + align-items: center; + gap: 12px; +} + +.lg-modal-card .icon-bg { + width: 32px; + height: 32px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: rgba(3, 15, 170, 0.1); + color: var(--blue); +} + +.lg-modal-card .icon-bg.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } +.lg-modal-card .icon-bg.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } + +.lg-modal-card .modal-body { flex: 1; min-height: 0; overflow-y: auto; } +.lg-modal-card .modal-footer { + flex-shrink: 0; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96)); +} +.lg-modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; } +.lg-modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); } +.lg-modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); } +.lg-modal-card.create-modal .edit-sections { gap: 14px; } +.lg-modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); } +.lg-modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); } +.lg-modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); } +.lg-modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); } +.lg-modal-card.create-modal .form-control, +.lg-modal-card.create-modal .form-select { min-height: 40px; } +.lg-modal-card.create-modal .modal-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 14px 20px !important; + background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95)); +} +.lg-modal-card.create-modal .modal-footer .btn { + border-radius: 12px; + font-weight: 900; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; +} +.lg-modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; } +.bg-light-gray { background-color: #f8f9fa; } + +.lg-modal-card .btn-icon { + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: rgba(17, 18, 20, 0.04); + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(17, 18, 20, 0.08); + color: var(--brand); + transform: translateY(-1px); + } +} + @keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } + +.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 16px; } +.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.06); box-shadow: 0 2px 10px rgba(0,0,0,0.03); overflow: hidden; } + +.box-header { + padding: 10px 16px; + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid rgba(0,0,0,0.05); + background: #fdfdfd; + display: flex; + align-items: center; +} + +.box-header.justify-content-center { + justify-content: center; + text-align: center; + color: var(--brand); + background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08)); +} + +.box-body { padding: 16px; } + +.info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.info-item { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 8px; + background: rgba(245, 245, 247, 0.55); + border-radius: 12px; + border: 1px solid rgba(0,0,0,0.04); + + &.span-2 { grid-column: span 2; } + + .lbl { + font-size: 0.64rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + color: var(--muted); + margin-bottom: 2px; + } + + .val { + font-size: 0.92rem; + font-weight: 700; + color: var(--text); + word-break: break-word; + line-height: 1.25; + } +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 84px; + padding: 5px 12px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.03em; + background: rgba(25, 135, 84, 0.12); + color: #157347; + border: 1px solid rgba(25, 135, 84, 0.24); +} + +.status-pill.is-danger { + background: rgba(220, 53, 69, 0.12); + color: #b02a37; + border-color: rgba(220, 53, 69, 0.24); +} + +.edit-sections { display: grid; gap: 12px; } +.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); } + +summary.box-header { + cursor: pointer; + list-style: none; + user-select: none; + + i:not(.transition-icon) { color: var(--brand); margin-right: 6px; } + &::-webkit-details-marker { display: none; } +} + +.transition-icon { color: var(--muted); transition: transform 0.25s ease, color 0.25s ease; } +details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); } + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + + &.span-2 { grid-column: span 2; } + + label { + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.64); + } +} + +.form-control, +.form-select { + border-radius: 10px; + border: 1px solid rgba(17,18,20,0.15); + background: #fff; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease; + + &:hover { border-color: rgba(17, 18, 20, 0.36); } + &:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227,61,207,0.15); + outline: none; + transform: translateY(-1px); + } +} + +.confirm-delete { + border: 1px solid rgba(220, 53, 69, 0.16); + background: #fff; + border-radius: 14px; + padding: 18px 16px; + display: flex; + align-items: center; + gap: 12px; + + p { font-weight: 700; color: rgba(17, 18, 20, 0.85); } +} + +.confirm-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(220, 53, 69, 0.12); + color: #dc3545; + flex-shrink: 0; +} + +@media (max-width: 992px) { + .mureg-kpis { + grid-template-columns: repeat(2, minmax(150px, 198px)); + } +} + +@media (max-width: 700px) { + .mureg-kpis { + grid-template-columns: minmax(0, 1fr); + justify-content: stretch; + } + .lg-modal-card { border-radius: 16px; } + .lg-modal-card .modal-header { padding: 12px 16px; } + .lg-modal-card .modal-body { padding: 16px !important; } + .lg-modal-card.create-modal .modal-footer { flex-direction: column-reverse; } + .lg-modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; } + .form-grid, + .info-grid { grid-template-columns: 1fr; } + .info-item.span-2, + .form-field.span-2 { grid-column: span 1; } +} diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index d5dd528..bda08c7 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -1,14 +1,25 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; -import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service'; +import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { AuthService } from '../../services/auth.service'; +import { LinesService, MobileLineDetail } from '../../services/lines.service'; +import { PlanAutoFillService } from '../../services/plan-autofill.service'; type SortDir = 'asc' | 'desc'; type ToastType = 'success' | 'danger'; type ViewMode = 'lines' | 'groups'; +interface LineOptionDto { + id: string; + item: number; + linha: string | null; + usuario: string | null; + label?: string; +} + @Component({ selector: 'app-vigencia', standalone: true, @@ -16,7 +27,7 @@ type ViewMode = 'lines' | 'groups'; templateUrl: './vigencia.html', styleUrls: ['./vigencia.scss'], }) -export class VigenciaComponent implements OnInit { +export class VigenciaComponent implements OnInit, OnDestroy { loading = false; errorMsg = ''; @@ -46,7 +57,6 @@ export class VigenciaComponent implements OnInit { kpiTotalClientes = 0; kpiTotalLinhas = 0; kpiTotalVencidos = 0; - kpiValorTotal = 0; // === ACORDEÃO === expandedGroup: string | null = null; @@ -56,18 +66,63 @@ export class VigenciaComponent implements OnInit { // UI detailsOpen = false; selectedRow: VigenciaRow | null = null; + editOpen = false; + editSaving = false; + editModel: VigenciaRow | null = null; + editEfetivacao = ''; + editTermino = ''; + editingId: string | null = null; + deleteOpen = false; + deleteTarget: VigenciaRow | null = null; + + createOpen = false; + createSaving = false; + createModel: any = { + selectedClient: '', + mobileLineId: '', + item: '', + conta: '', + linha: '', + cliente: '', + usuario: '', + planoContrato: '', + total: null + }; + createEfetivacao = ''; + createTermino = ''; + + lineOptionsCreate: LineOptionDto[] = []; + createClientsLoading = false; + createLinesLoading = false; + clientsFromGeral: string[] = []; + planOptions: string[] = []; + + isAdmin = false; toastOpen = false; toastMessage = ''; toastType: ToastType = 'success'; private toastTimer: any = null; + private searchTimer: any = null; - constructor(private vigenciaService: VigenciaService) {} + constructor( + private vigenciaService: VigenciaService, + private authService: AuthService, + private linesService: LinesService, + private planAutoFill: PlanAutoFillService + ) {} ngOnInit(): void { + this.isAdmin = this.authService.hasRole('admin'); this.loadClients(); + this.loadPlanRules(); this.fetch(1); } + ngOnDestroy(): void { + if (this.searchTimer) clearTimeout(this.searchTimer); + if (this.toastTimer) clearTimeout(this.toastTimer); + } + setView(mode: ViewMode): void { if (this.viewMode === mode) return; this.viewMode = mode; @@ -85,6 +140,15 @@ export class VigenciaComponent implements OnInit { }); } + private async loadPlanRules() { + try { + await this.planAutoFill.load(); + this.planOptions = this.planAutoFill.getPlanOptions(); + } catch { + this.planOptions = []; + } + } + get totalPages(): number { return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); } @@ -120,7 +184,6 @@ export class VigenciaComponent implements OnInit { this.kpiTotalClientes = res.kpis.totalClientes; this.kpiTotalLinhas = res.kpis.totalLinhas; this.kpiTotalVencidos = res.kpis.totalVencidos; - this.kpiValorTotal = res.kpis.valorTotal; this.loading = false; }, @@ -199,10 +262,298 @@ export class VigenciaComponent implements OnInit { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } - clearFilters() { this.search = ''; this.fetch(1); } + onSearchChange() { + if (this.searchTimer) clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => this.fetch(1), 300); + } + + clearFilters() { + this.search = ''; + if (this.searchTimer) clearTimeout(this.searchTimer); + this.fetch(1); + } openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; } closeDetails() { this.detailsOpen = false; } + openEdit(r: VigenciaRow) { + if (!this.isAdmin) return; + this.editingId = r.id; + this.editModel = { ...r }; + this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico); + this.editTermino = this.toDateInput(r.dtTerminoFidelizacao); + this.editOpen = true; + } + + closeEdit() { + this.editOpen = false; + this.editSaving = false; + this.editModel = null; + this.editEfetivacao = ''; + this.editTermino = ''; + this.editingId = null; + } + + saveEdit() { + if (!this.editModel || !this.editingId) return; + this.editSaving = true; + + const payload: UpdateVigenciaRequest = { + item: this.toNullableNumber(this.editModel.item), + conta: this.editModel.conta, + linha: this.editModel.linha, + cliente: this.editModel.cliente, + usuario: this.editModel.usuario, + planoContrato: this.editModel.planoContrato, + dtEfetivacaoServico: this.dateInputToIso(this.editEfetivacao), + dtTerminoFidelizacao: this.dateInputToIso(this.editTermino), + total: this.toNullableNumber(this.editModel.total) + }; + + this.vigenciaService.update(this.editingId, payload).subscribe({ + next: () => { + this.editSaving = false; + this.closeEdit(); + this.fetch(); + this.showToast('Registro atualizado!', 'success'); + }, + error: () => { + this.editSaving = false; + this.showToast('Erro ao salvar.', 'danger'); + } + }); + } + + // ========================== + // CREATE + // ========================== + openCreate() { + if (!this.isAdmin) return; + this.resetCreateModel(); + this.createOpen = true; + this.preloadGeralClients(); + } + + closeCreate() { + this.createOpen = false; + this.createSaving = false; + this.createModel = null; + } + + private resetCreateModel() { + this.createModel = { + selectedClient: '', + mobileLineId: '', + item: '', + conta: '', + linha: '', + cliente: '', + usuario: '', + planoContrato: '', + total: null + }; + this.createEfetivacao = ''; + this.createTermino = ''; + this.lineOptionsCreate = []; + this.createLinesLoading = false; + this.createClientsLoading = false; + } + + private preloadGeralClients() { + this.createClientsLoading = true; + this.linesService.getClients().subscribe({ + next: (list) => { + this.clientsFromGeral = list ?? []; + this.createClientsLoading = false; + }, + error: () => { + this.clientsFromGeral = []; + this.createClientsLoading = false; + } + }); + } + + onCreateClientChange() { + const c = (this.createModel.selectedClient ?? '').trim(); + this.createModel.mobileLineId = ''; + this.createModel.linha = ''; + this.createModel.conta = ''; + this.createModel.usuario = ''; + this.createModel.planoContrato = ''; + this.createModel.total = null; + this.createModel.cliente = c; + this.lineOptionsCreate = []; + + if (c) this.loadLinesForClient(c); + } + + private loadLinesForClient(cliente: string) { + const c = (cliente ?? '').trim(); + if (!c) return; + + this.createLinesLoading = true; + this.linesService.getLinesByClient(c).subscribe({ + next: (items: any[]) => { + const mapped: LineOptionDto[] = (items ?? []) + .filter(x => !!String(x?.id ?? '').trim()) + .map(x => ({ + id: String(x.id), + item: Number(x.item ?? 0), + linha: x.linha ?? null, + usuario: x.usuario ?? null, + label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}` + })) + .filter(x => !!String(x.linha ?? '').trim()); + + this.lineOptionsCreate = mapped; + this.createLinesLoading = false; + }, + error: () => { + this.lineOptionsCreate = []; + this.createLinesLoading = false; + this.showToast('Erro ao carregar linhas da GERAL.', 'danger'); + } + }); + } + + onCreateLineChange() { + const id = String(this.createModel.mobileLineId ?? '').trim(); + if (!id) return; + + this.linesService.getById(id).subscribe({ + next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d), + error: () => this.showToast('Erro ao carregar dados da linha.', 'danger') + }); + } + + private applyLineDetailToCreate(d: MobileLineDetail) { + this.createModel.linha = d.linha ?? ''; + this.createModel.conta = d.conta ?? ''; + this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? ''; + this.createModel.usuario = d.usuario ?? ''; + this.createModel.planoContrato = d.planoContrato ?? ''; + this.createEfetivacao = this.toDateInput(d.dtEfetivacaoServico ?? null); + this.createTermino = this.toDateInput(d.dtTerminoFidelizacao ?? null); + + this.ensurePlanOption(this.createModel.planoContrato); + + if (!String(this.createModel.item ?? '').trim() && d.item) { + this.createModel.item = String(d.item); + } + + this.onCreatePlanChange(); + } + + onCreatePlanChange() { + this.ensurePlanOption(this.createModel?.planoContrato); + this.applyPlanSuggestion(this.createModel); + } + + onEditPlanChange() { + if (!this.editModel) return; + this.ensurePlanOption(this.editModel?.planoContrato); + this.applyPlanSuggestion(this.editModel); + } + + private applyPlanSuggestion(model: any) { + const plan = (model?.planoContrato ?? '').toString().trim(); + if (!plan) return; + + const suggestion = this.planAutoFill.suggest(plan); + if (!suggestion) return; + + if (suggestion.valorPlano != null) { + model.total = suggestion.valorPlano; + } + } + + private ensurePlanOption(plan: any) { + const p = (plan ?? '').toString().trim(); + if (!p) return; + if (!this.planOptions.includes(p)) { + this.planOptions = [p, ...this.planOptions]; + } + } + + saveCreate() { + if (!this.createModel) return; + this.applyPlanSuggestion(this.createModel); + + const payload = { + item: this.toNullableNumber(this.createModel.item), + conta: this.createModel.conta, + linha: this.createModel.linha, + cliente: this.createModel.cliente, + usuario: this.createModel.usuario, + planoContrato: this.createModel.planoContrato, + dtEfetivacaoServico: this.dateInputToIso(this.createEfetivacao), + dtTerminoFidelizacao: this.dateInputToIso(this.createTermino), + total: this.toNullableNumber(this.createModel.total) + }; + + this.createSaving = true; + this.vigenciaService.create(payload).subscribe({ + next: () => { + this.createSaving = false; + this.closeCreate(); + this.fetch(); + this.showToast('Vigência criada com sucesso!', 'success'); + }, + error: () => { + this.createSaving = false; + this.showToast('Erro ao criar vigência.', 'danger'); + } + }); + } + + openDelete(r: VigenciaRow) { + if (!this.isAdmin) return; + this.deleteTarget = r; + this.deleteOpen = true; + } + + cancelDelete() { + this.deleteOpen = false; + this.deleteTarget = null; + } + + confirmDelete() { + if (!this.deleteTarget) return; + const id = this.deleteTarget.id; + this.vigenciaService.remove(id).subscribe({ + next: () => { + this.deleteOpen = false; + this.deleteTarget = null; + this.fetch(); + this.showToast('Registro removido.', 'success'); + }, + error: () => { + this.deleteOpen = false; + this.deleteTarget = null; + this.showToast('Erro ao remover.', 'danger'); + } + }); + } + + private toDateInput(value: string | null): string { + if (!value) return ''; + const d = new Date(value); + if (isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 10); + } + + private dateInputToIso(value: string): string | null { + if (!value) return null; + const d = new Date(`${value}T00:00:00`); + if (isNaN(d.getTime())) return null; + return d.toISOString(); + } + + private toNullableNumber(value: any): number | null { + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + return Number.isNaN(n) ? null : n; + } + handleError(err: HttpErrorResponse, msg: string) { this.loading = false; this.expandedLoading = false; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index dbeda6a..89a9b5a 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../environments/environment'; +import { BehaviorSubject } from 'rxjs'; import { tap } from 'rxjs/operators'; export interface RegisterPayload { @@ -16,35 +17,112 @@ export interface LoginPayload { password: string; } +export interface LoginOptions { + rememberMe?: boolean; +} + +export interface LoginResponse { + token?: string; + accessToken?: string; +} + +export interface AuthUserProfile { + id: string; + nome: string; + email: string; + tenantId: string; + roles: string[]; +} + @Injectable({ providedIn: 'root' }) export class AuthService { private baseUrl = `${environment.apiUrl}/auth`; + private userProfileSubject = new BehaviorSubject(null); + readonly userProfile$ = this.userProfileSubject.asObservable(); + private readonly tokenStorageKey = 'token'; + private readonly tokenExpiresAtKey = 'tokenExpiresAt'; + private readonly rememberMeHours = 6; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { + this.syncUserProfileFromToken(); + } register(payload: RegisterPayload) { return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload) - .pipe(tap(r => localStorage.setItem('token', r.token))); + .pipe(tap(r => this.setToken(r.token))); } - login(payload: LoginPayload) { - return this.http.post<{ token: string }>(`${this.baseUrl}/login`, payload) - .pipe(tap(r => localStorage.setItem('token', r.token))); + login(payload: LoginPayload, options?: LoginOptions) { + return this.http.post(`${this.baseUrl}/login`, payload) + .pipe( + tap((r) => { + const token = this.resolveLoginToken(r); + if (!token) return; + this.setToken(token, options?.rememberMe ?? false); + }) + ); } logout() { - localStorage.removeItem('token'); + if (typeof window === 'undefined') { + this.userProfileSubject.next(null); + return; + } + + this.clearTokenStorage(localStorage); + this.clearTokenStorage(sessionStorage); + this.userProfileSubject.next(null); + } + + setToken(token: string, rememberMe = false) { + if (typeof window === 'undefined') return; + this.clearTokenStorage(localStorage); + this.clearTokenStorage(sessionStorage); + + if (rememberMe) { + const expiresAt = Date.now() + this.rememberMeHours * 60 * 60 * 1000; + localStorage.setItem(this.tokenStorageKey, token); + localStorage.setItem(this.tokenExpiresAtKey, String(expiresAt)); + } else { + sessionStorage.setItem(this.tokenStorageKey, token); + } + + this.syncUserProfileFromToken(); } get token(): string | null { if (typeof window === 'undefined') return null; - return localStorage.getItem('token'); + this.cleanupExpiredRememberSession(); + + const sessionToken = sessionStorage.getItem(this.tokenStorageKey); + if (sessionToken) return sessionToken; + + return localStorage.getItem(this.tokenStorageKey); } isLoggedIn(): boolean { return !!this.token; } + get currentUserProfile(): AuthUserProfile | null { + return this.userProfileSubject.value; + } + + syncUserProfileFromToken() { + this.userProfileSubject.next(this.buildProfileFromToken()); + } + + updateUserProfile(profile: Pick) { + const current = this.userProfileSubject.value; + if (!current) return; + + this.userProfileSubject.next({ + ...current, + nome: profile.nome.trim(), + email: profile.email.trim().toLowerCase(), + }); + } + getTokenPayload(): Record | null { const token = this.token; if (!token) return null; @@ -66,6 +144,10 @@ export class AuthService { getRoles(): string[] { const payload = this.getTokenPayload(); if (!payload) return []; + return this.extractRoles(payload); + } + + private extractRoles(payload: Record): string[] { const possibleKeys = [ 'role', 'roles', @@ -81,9 +163,74 @@ export class AuthService { return roles.map(r => r.toLowerCase()); } + private buildProfileFromToken(): AuthUserProfile | null { + const payload = this.getTokenPayload(); + if (!payload) return null; + + const id = String( + payload['sub'] ?? + payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] ?? + '' + ).trim(); + const nome = String(payload['name'] ?? '').trim(); + const email = String( + payload['email'] ?? + payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] ?? + '' + ).trim().toLowerCase(); + const tenantId = String( + payload['tenantId'] ?? + payload['tenant'] ?? + payload['TenantId'] ?? + '' + ).trim(); + + if (!id || !tenantId) return null; + + return { + id, + nome, + email, + tenantId, + roles: this.extractRoles(payload), + }; + } + hasRole(role: string): boolean { const target = (role || '').toLowerCase(); if (!target) return false; return this.getRoles().includes(target); } + + private cleanupExpiredRememberSession() { + const token = localStorage.getItem(this.tokenStorageKey); + if (!token) return; + + const expiresAtRaw = localStorage.getItem(this.tokenExpiresAtKey); + if (!expiresAtRaw) { + this.clearTokenStorage(localStorage); + return; + } + + const expiresAt = Number(expiresAtRaw); + if (!Number.isFinite(expiresAt)) { + this.clearTokenStorage(localStorage); + return; + } + + if (Date.now() > expiresAt) { + this.clearTokenStorage(localStorage); + } + } + + private clearTokenStorage(storage: Storage) { + storage.removeItem(this.tokenStorageKey); + storage.removeItem(this.tokenExpiresAtKey); + } + + private resolveLoginToken(response: LoginResponse | null | undefined): string | null { + const raw = response?.token ?? response?.accessToken ?? null; + const token = (raw ?? '').toString().trim(); + return token || null; + } } diff --git a/src/app/services/billing.ts b/src/app/services/billing.ts index 1638710..adcd886 100644 --- a/src/app/services/billing.ts +++ b/src/app/services/billing.ts @@ -34,6 +34,22 @@ export interface BillingItem { aparelho?: string | null; formaPagamento?: string | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface BillingUpdateRequest { + tipo?: string; + item?: number | null; + cliente?: string | null; + qtdLinhas?: number | null; + franquiaVivo?: number | null; + valorContratoVivo?: number | null; + franquiaLine?: number | null; + valorContratoLine?: number | null; + lucro?: number | null; + aparelho?: string | null; + formaPagamento?: string | null; } export interface BillingQuery { @@ -84,4 +100,16 @@ export class BillingService { return this.getPaged(q).pipe(map((res) => res.items ?? [])); } + + getById(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + update(id: string, payload: BillingUpdateRequest): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + remove(id: string): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } } diff --git a/src/app/services/chips-controle.service.ts b/src/app/services/chips-controle.service.ts index 87374b7..d21be34 100644 --- a/src/app/services/chips-controle.service.ts +++ b/src/app/services/chips-controle.service.ts @@ -17,8 +17,18 @@ export interface ChipVirgemListDto { item: number; numeroDoChip: string | null; observacoes: string | null; + createdAt?: string | null; + updatedAt?: string | null; } +export interface UpdateChipVirgemRequest { + item?: number | null; + numeroDoChip?: string | null; + observacoes?: string | null; +} + +export interface CreateChipVirgemRequest extends UpdateChipVirgemRequest {} + export interface ControleRecebidoListDto { id: string; ano: number | null; @@ -34,8 +44,28 @@ export interface ControleRecebidoListDto { dataDoRecebimento: string | null; quantidade: number | null; isResumo: boolean | null; + createdAt?: string | null; + updatedAt?: string | null; } +export interface UpdateControleRecebidoRequest { + ano?: number | null; + item?: number | null; + notaFiscal?: string | null; + chip?: string | null; + serial?: string | null; + conteudoDaNf?: string | null; + numeroDaLinha?: string | null; + valorUnit?: number | null; + valorDaNf?: number | null; + dataDaNf?: string | null; + dataDoRecebimento?: string | null; + quantidade?: number | null; + isResumo?: boolean | null; +} + +export interface CreateControleRecebidoRequest extends UpdateControleRecebidoRequest {} + @Injectable({ providedIn: 'root' }) export class ChipsControleService { private readonly baseApi: string; @@ -67,6 +97,18 @@ export class ChipsControleService { return this.http.get(`${this.baseApi}/chips-virgens/${id}`); } + updateChipVirgem(id: string, payload: UpdateChipVirgemRequest): Observable { + return this.http.put(`${this.baseApi}/chips-virgens/${id}`, payload); + } + + createChipVirgem(payload: CreateChipVirgemRequest): Observable { + return this.http.post(`${this.baseApi}/chips-virgens`, payload); + } + + removeChipVirgem(id: string): Observable { + return this.http.delete(`${this.baseApi}/chips-virgens/${id}`); + } + getControleRecebidos(opts: { ano?: number | string | null; isResumo?: boolean | string | null; @@ -95,4 +137,16 @@ export class ChipsControleService { getControleRecebidoById(id: string): Observable { return this.http.get(`${this.baseApi}/controle-recebidos/${id}`); } + + updateControleRecebido(id: string, payload: UpdateControleRecebidoRequest): Observable { + return this.http.put(`${this.baseApi}/controle-recebidos/${id}`, payload); + } + + createControleRecebido(payload: CreateControleRecebidoRequest): Observable { + return this.http.post(`${this.baseApi}/controle-recebidos`, payload); + } + + removeControleRecebido(id: string): Observable { + return this.http.delete(`${this.baseApi}/controle-recebidos/${id}`); + } } diff --git a/src/app/services/dados-usuarios.service.ts b/src/app/services/dados-usuarios.service.ts index 88e69d6..d39a627 100644 --- a/src/app/services/dados-usuarios.service.ts +++ b/src/app/services/dados-usuarios.service.ts @@ -17,6 +17,10 @@ export interface UserDataRow { item: number; linha: string | null; cliente: string | null; + tipoPessoa?: string | null; + nome?: string | null; + razaoSocial?: string | null; + cnpj?: string | null; cpf: string | null; email: string | null; celular: string | null; @@ -26,10 +30,30 @@ export interface UserDataRow { dataNascimento: string | null; } +export interface UpdateUserDataRequest { + item?: number | null; + linha?: string | null; + cliente?: string | null; + tipoPessoa?: string | null; + nome?: string | null; + razaoSocial?: string | null; + cnpj?: string | null; + cpf?: string | null; + rg?: string | null; + dataNascimento?: string | null; + email?: string | null; + endereco?: string | null; + celular?: string | null; + telefoneFixo?: string | null; +} + +export interface CreateUserDataRequest extends UpdateUserDataRequest {} + export interface UserDataClientGroup { cliente: string; totalRegistros: number; comCpf: number; + comCnpj: number; comEmail: number; } @@ -37,6 +61,7 @@ export interface UserDataKpis { totalRegistros: number; clientesUnicos: number; comCpf: number; + comCnpj: number; comEmail: number; } @@ -56,6 +81,7 @@ export class DadosUsuariosService { getGroups(opts: { search?: string; + tipo?: string; page?: number; pageSize?: number; sortBy?: string; @@ -63,6 +89,7 @@ export class DadosUsuariosService { }): Observable { let params = new HttpParams(); if (opts.search) params = params.set('search', opts.search); + if (opts.tipo) params = params.set('tipo', opts.tipo); params = params.set('page', String(opts.page || 1)); params = params.set('pageSize', String(opts.pageSize || 10)); @@ -75,6 +102,7 @@ export class DadosUsuariosService { getRows(opts: { search?: string; client?: string; + tipo?: string; page?: number; pageSize?: number; sortBy?: string; @@ -83,6 +111,7 @@ export class DadosUsuariosService { let params = new HttpParams(); if (opts.search) params = params.set('search', opts.search); if (opts.client) params = params.set('client', opts.client); + if (opts.tipo) params = params.set('tipo', opts.tipo); params = params.set('page', String(opts.page || 1)); params = params.set('pageSize', String(opts.pageSize || 20)); @@ -92,11 +121,25 @@ export class DadosUsuariosService { return this.http.get>(`${this.baseApi}/user-data`, { params }); } - getClients(): Observable { - return this.http.get(`${this.baseApi}/user-data/clients`); + getClients(tipo?: string): Observable { + let params = new HttpParams(); + if (tipo) params = params.set('tipo', tipo); + return this.http.get(`${this.baseApi}/user-data/clients`, { params }); } getById(id: string): Observable { return this.http.get(`${this.baseApi}/user-data/${id}`); } -} \ No newline at end of file + + update(id: string, payload: UpdateUserDataRequest): Observable { + return this.http.put(`${this.baseApi}/user-data/${id}`, payload); + } + + create(payload: CreateUserDataRequest): Observable { + return this.http.post(`${this.baseApi}/user-data`, payload); + } + + remove(id: string): Observable { + return this.http.delete(`${this.baseApi}/user-data/${id}`); + } +} diff --git a/src/app/services/historico.service.ts b/src/app/services/historico.service.ts new file mode 100644 index 0000000..69e9636 --- /dev/null +++ b/src/app/services/historico.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE'; +export type AuditChangeType = 'added' | 'modified' | 'removed'; + +export interface AuditFieldChangeDto { + field: string; + changeType: AuditChangeType; + oldValue?: string | null; + newValue?: string | null; +} + +export interface AuditLogDto { + id: string; + occurredAtUtc: string; + action: AuditAction | string; + page: string; + entityName: string; + entityId?: string | null; + entityLabel?: string | null; + userId?: string | null; + userName?: string | null; + userEmail?: string | null; + requestPath?: string | null; + requestMethod?: string | null; + ipAddress?: string | null; + changes: AuditFieldChangeDto[]; +} + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface HistoricoQuery { + pageName?: string; + action?: AuditAction | string; + entity?: string; + userId?: string; + search?: string; + dateFrom?: string; + dateTo?: string; + page?: number; + pageSize?: number; +} + +@Injectable({ providedIn: 'root' }) +export class HistoricoService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + list(params: HistoricoQuery): Observable> { + let httpParams = new HttpParams(); + if (params.pageName) httpParams = httpParams.set('pageName', params.pageName); + if (params.action) httpParams = httpParams.set('action', params.action); + if (params.entity) httpParams = httpParams.set('entity', params.entity); + if (params.userId) httpParams = httpParams.set('userId', params.userId); + if (params.search) httpParams = httpParams.set('search', params.search); + if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); + if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); + + httpParams = httpParams.set('page', String(params.page || 1)); + httpParams = httpParams.set('pageSize', String(params.pageSize || 10)); + + return this.http.get>(`${this.baseApi}/historico`, { params: httpParams }); + } +} diff --git a/src/app/services/lines.service.ts b/src/app/services/lines.service.ts index 3483a2e..d65f616 100644 --- a/src/app/services/lines.service.ts +++ b/src/app/services/lines.service.ts @@ -47,6 +47,8 @@ export interface MobileLineDetail extends MobileLineList { solicitante?: string | null; dataEntregaOpera?: string | null; dataEntregaCliente?: string | null; + dtEfetivacaoServico?: string | null; + dtTerminoFidelizacao?: string | null; } export interface LineOption { diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index 07e16e8..174fbc9 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; @@ -20,6 +20,10 @@ export type NotificationDto = { cliente?: string | null; linha?: string | null; usuario?: string | null; + conta?: string | null; + planoContrato?: string | null; + dtEfetivacaoServico?: string | null; + dtTerminoFidelizacao?: string | null; }; @Injectable({ providedIn: 'root' }) @@ -38,4 +42,29 @@ export class NotificationsService { markAsRead(id: string): Observable { return this.http.patch(`${this.baseApi}/notifications/${id}/read`, {}); } + + 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 }); + } + + export(filter?: string, notificationIds?: string[]): Observable> { + let params = new HttpParams(); + if (filter) params = params.set('filter', filter); + if (notificationIds && notificationIds.length) { + return this.http.post(`${this.baseApi}/notifications/export`, { notificationIds }, { + params, + observe: 'response', + responseType: 'blob' + }); + } + + return this.http.get(`${this.baseApi}/notifications/export`, { + params, + observe: 'response', + responseType: 'blob' + }); + } } diff --git a/src/app/services/parcelamentos.service.ts b/src/app/services/parcelamentos.service.ts new file mode 100644 index 0000000..1a3a9b2 --- /dev/null +++ b/src/app/services/parcelamentos.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface ParcelamentoListItem { + id: string; + anoRef?: number | null; + item?: number | null; + linha?: string | null; + cliente?: string | null; + qtParcelas?: string | null; + parcelaAtual?: number | null; + totalParcelas?: number | null; + valorCheio?: number | string | null; + desconto?: number | string | null; + valorComDesconto?: number | string | null; +} + +export interface ParcelamentoParcela { + competencia: string; + valor?: number | string | null; +} + +export interface ParcelamentoAnnualMonth { + month: number; + valor?: number | string | null; +} + +export interface ParcelamentoAnnualRow { + year: number; + total?: number | string | null; + months?: ParcelamentoAnnualMonth[]; +} + +export interface ParcelamentoMonthInput { + competencia: string; + valor?: number | string | null; +} + +export interface ParcelamentoUpsertRequest { + anoRef?: number | null; + item?: number | null; + linha?: string | null; + cliente?: string | null; + qtParcelas?: string | null; + parcelaAtual?: number | null; + totalParcelas?: number | null; + valorCheio?: number | string | null; + desconto?: number | string | null; + valorComDesconto?: number | string | null; + monthValues?: ParcelamentoMonthInput[] | null; +} + +export interface ParcelamentoDetail extends ParcelamentoListItem { + parcelasMensais?: ParcelamentoParcela[]; + annualRows?: ParcelamentoAnnualRow[]; +} + +export interface ParcelamentoDetailResponse extends ParcelamentoListItem { + parcelasMensais?: ParcelamentoParcela[]; + parcelas?: ParcelamentoParcela[]; + monthValues?: ParcelamentoParcela[]; + annualRows?: ParcelamentoAnnualRow[]; +} + +@Injectable({ providedIn: 'root' }) +export class ParcelamentosService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + list(filters: { + anoRef?: number; + linha?: string; + cliente?: string; + competenciaAno?: number; + competenciaMes?: number; + page?: number; + pageSize?: number; + }): Observable> { + let params = new HttpParams(); + if (filters.anoRef !== undefined) params = params.set('anoRef', String(filters.anoRef)); + if (filters.linha && filters.linha.trim()) params = params.set('linha', filters.linha.trim()); + if (filters.cliente && filters.cliente.trim()) params = params.set('cliente', filters.cliente.trim()); + if (filters.competenciaAno !== undefined) params = params.set('competenciaAno', String(filters.competenciaAno)); + if (filters.competenciaMes !== undefined) params = params.set('competenciaMes', String(filters.competenciaMes)); + params = params.set('page', String(filters.page ?? 1)); + params = params.set('pageSize', String(filters.pageSize ?? 10)); + + return this.http.get>(`${this.baseApi}/parcelamentos`, { params }); + } + + getById(id: string): Observable { + return this.http.get(`${this.baseApi}/parcelamentos/${id}`); + } + + create(payload: ParcelamentoUpsertRequest): Observable { + return this.http.post(`${this.baseApi}/parcelamentos`, payload); + } + + update(id: string, payload: ParcelamentoUpsertRequest): Observable { + return this.http.put(`${this.baseApi}/parcelamentos/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.baseApi}/parcelamentos/${id}`); + } +} diff --git a/src/app/services/plan-autofill.service.ts b/src/app/services/plan-autofill.service.ts new file mode 100644 index 0000000..2955b32 --- /dev/null +++ b/src/app/services/plan-autofill.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ResumoService, PlanoContratoResumo, MacrophonyPlan } from './resumo.service'; + +export type PlanSuggestion = { + franquiaGb?: number | null; + valorPlano?: number | null; +}; + +@Injectable({ providedIn: 'root' }) +export class PlanAutoFillService { + private loaded = false; + private loadingPromise: Promise | null = null; + private planMap = new Map(); + private planOptions: string[] = []; + + constructor(private resumoService: ResumoService) {} + + async load(): Promise { + if (this.loaded) return; + if (this.loadingPromise) return this.loadingPromise; + + this.loadingPromise = firstValueFrom(this.resumoService.getResumo()) + .then((res) => { + const items: Array = [ + ...(res?.planoContratoResumos ?? []), + ...(res?.macrophonyPlans ?? []) + ]; + + items.forEach((row) => this.addPlanRule(row)); + this.planOptions = Array.from(new Set(this.planOptions)).sort((a, b) => + a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }) + ); + + this.loaded = true; + }) + .catch(() => { + this.loaded = true; + }) + .finally(() => { + this.loadingPromise = null; + }); + + return this.loadingPromise; + } + + getPlanOptions(): string[] { + return [...this.planOptions]; + } + + suggest(planName: string | null | undefined): PlanSuggestion | null { + const plan = (planName ?? '').trim(); + if (!plan) return null; + + const key = this.normalizePlan(plan); + const fromMap = this.planMap.get(key); + + const franquia = fromMap?.franquiaGb ?? this.parseFranquiaFromPlan(plan); + const valorPlano = fromMap?.valorPlano ?? null; + + if (franquia == null && valorPlano == null) return null; + return { franquiaGb: franquia ?? null, valorPlano }; + } + + private addPlanRule(row: PlanoContratoResumo | MacrophonyPlan) { + const plano = (row?.planoContrato ?? '').toString().trim(); + if (!plano) return; + + const key = this.normalizePlan(plano); + const current = this.planMap.get(key) || {}; + + const franquia = this.toNumber((row as any).franquiaGb ?? (row as any).gb); + const valorPlano = this.toNumber((row as any).valorIndividualComSvas); + + const next: PlanSuggestion = { + franquiaGb: current.franquiaGb ?? franquia ?? null, + valorPlano: current.valorPlano ?? valorPlano ?? null + }; + + this.planMap.set(key, next); + this.planOptions.push(plano); + } + + private normalizePlan(plan: string): string { + return plan.trim().replace(/\s+/g, ' ').toUpperCase(); + } + + private toNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + + const raw = String(value).trim(); + if (!raw) return null; + + const cleaned = raw.replace(/[^0-9,.-]/g, '').replace(',', '.'); + const n = parseFloat(cleaned); + return Number.isFinite(n) ? n : null; + } + + private parseFranquiaFromPlan(plan: string): number | null { + const match = plan.match(/(\d+(?:[.,]\d+)?)\s*(GB|MB)/i); + if (!match) return null; + + const raw = match[1].replace(',', '.'); + const unit = match[2].toUpperCase(); + + const value = parseFloat(raw); + if (!Number.isFinite(value)) return null; + + if (unit === 'MB') { + return Number((value / 1000).toFixed(4)); + } + + return value; + } +} diff --git a/src/app/services/profile.service.ts b/src/app/services/profile.service.ts new file mode 100644 index 0000000..3879844 --- /dev/null +++ b/src/app/services/profile.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +export type ProfileMeDto = { + id: string; + nome: string; + email: string; +}; + +export type UpdateProfilePayload = { + nome: string; + email: string; +}; + +export type ChangePasswordPayload = { + credencialAtual: string; + novaCredencial: string; + confirmarNovaCredencial: string; +}; + +@Injectable({ providedIn: 'root' }) +export class ProfileService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + getMe(): Observable { + return this.http.get(`${this.baseApi}/profile/me`); + } + + updateProfile(payload: UpdateProfilePayload): Observable { + return this.http.patch(`${this.baseApi}/profile`, payload); + } + + changePassword(payload: ChangePasswordPayload): Observable { + return this.http.post(`${this.baseApi}/profile/change-password`, payload); + } +} diff --git a/src/app/services/resumo.service.ts b/src/app/services/resumo.service.ts new file mode 100644 index 0000000..16be4b3 --- /dev/null +++ b/src/app/services/resumo.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../environments/environment'; + +export interface MacrophonyPlan { + planoContrato?: string | null; + gb?: string | number | null; + valorIndividualComSvas?: string | number | null; + franquiaGb?: string | number | null; + totalLinhas?: number | string | null; + valorTotal?: string | number | null; + vivoTravel?: boolean | string | number | null; +} + +export interface MacrophonyTotals { + franquiaGbTotal?: string | number | null; + totalLinhasTotal?: number | string | null; + valorTotal?: string | number | null; +} + +export interface VivoLineResumo { + skil?: string | null; + cliente?: string | null; + qtdLinhas?: number | string | null; + franquiaTotal?: string | number | null; + valorContratoVivo?: string | number | null; + franquiaLine?: string | number | null; + valorContratoLine?: string | number | null; + lucro?: string | number | null; +} + +export interface VivoLineTotals { + qtdLinhasTotal?: number | string | null; + franquiaTotal?: string | number | null; + valorContratoVivo?: string | number | null; + franquiaLine?: string | number | null; + valorContratoLine?: string | number | null; + lucro?: string | number | null; +} + +export interface ClienteEspecial { + nome?: string | null; + valor?: string | number | null; +} + +export interface PlanoContratoResumo { + planoContrato?: string | null; + gb?: string | number | null; + valorIndividualComSvas?: string | number | null; + franquiaGb?: string | number | null; + totalLinhas?: number | string | null; + valorTotal?: string | number | null; +} + +export interface PlanoContratoTotal { + valorTotal?: string | number | null; +} + +export interface LineTotal { + tipo?: string | null; + valorTotalLine?: string | number | null; + lucroTotalLine?: string | number | null; + qtdLinhas?: number | string | null; +} + +export interface GbDistribuicao { + gb?: string | number | null; + qtd?: number | string | null; + soma?: string | number | null; +} + +export interface GbDistribuicaoTotal { + totalLinhas?: number | string | null; + somaTotal?: string | number | null; +} + +export interface ReservaLine { + ddd?: string | number | null; + franquiaGb?: string | number | null; + qtdLinhas?: number | string | null; + total?: string | number | null; +} + +export interface ReservaPorFranquia { + franquiaGb?: string | number | null; + totalLinhas?: number | string | null; +} + +export interface ReservaPorDdd { + ddd?: string | number | null; + totalLinhas?: number | string | null; + porFranquia?: ReservaPorFranquia[]; +} + +export interface ReservaTotal { + qtdLinhasTotal?: number | string | null; + total?: string | number | null; +} + +export interface ResumoResponse { + macrophonyPlans?: MacrophonyPlan[]; + macrophonyTotals?: MacrophonyTotals; + vivoLineResumos?: VivoLineResumo[]; + vivoLineTotals?: VivoLineTotals; + clienteEspeciais?: ClienteEspecial[]; + planoContratoResumos?: PlanoContratoResumo[]; + planoContratoTotal?: PlanoContratoTotal; + lineTotais?: LineTotal[]; + gbDistribuicao?: GbDistribuicao[]; + gbDistribuicaoTotal?: GbDistribuicaoTotal; + reservaLines?: ReservaLine[]; + reservaPorDdd?: ReservaPorDdd[]; + totalGeralLinhasReserva?: number | string | null; + reservaTotal?: ReservaTotal; +} + +@Injectable({ providedIn: 'root' }) +export class ResumoService { + private readonly apiBase: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + getResumo() { + return this.http.get(`${this.apiBase}/resumo`); + } +} diff --git a/src/app/services/session-notice.service.ts b/src/app/services/session-notice.service.ts new file mode 100644 index 0000000..5f9f9df --- /dev/null +++ b/src/app/services/session-notice.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; + +@Injectable({ providedIn: 'root' }) +export class SessionNoticeService { + private toastEl: HTMLElement | null = null; + private toastBodyEl: HTMLElement | null = null; + private toastHeaderEl: HTMLElement | null = null; + private handling401 = false; + private last401At = 0; + + constructor( + private authService: AuthService, + private router: Router, + @Inject(PLATFORM_ID) private platformId: object + ) {} + + async handleUnauthorized(): Promise { + if (!isPlatformBrowser(this.platformId)) return; + + const now = Date.now(); + if (this.handling401 && now - this.last401At < 3000) return; + this.handling401 = true; + this.last401At = now; + + await this.showToast('Sua sessão expirou. Faça login novamente.', 'danger'); + this.authService.logout(); + this.router.navigateByUrl('/login'); + + setTimeout(() => { + this.handling401 = false; + }, 3000); + } + + async handleForbidden(): Promise { + if (!isPlatformBrowser(this.platformId)) return; + await this.showToast('Acesso restrito.', 'warning'); + } + + private ensureToast(): void { + if (!isPlatformBrowser(this.platformId)) return; + if (this.toastEl && this.toastBodyEl && this.toastHeaderEl) return; + + const doc = document; + let container = doc.getElementById('lg-global-toast-container'); + if (!container) { + container = doc.createElement('div'); + container.id = 'lg-global-toast-container'; + container.className = 'toast-container position-fixed top-0 end-0 p-3'; + container.style.zIndex = '10000'; + doc.body.appendChild(container); + } + + const toast = doc.createElement('div'); + toast.className = 'toast text-bg-danger border-0 shadow'; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + + const header = doc.createElement('div'); + header.className = 'toast-header border-bottom-0'; + + const title = doc.createElement('strong'); + title.className = 'me-auto text-primary'; + title.textContent = 'LineGestão'; + + const closeBtn = doc.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn-close'; + closeBtn.setAttribute('data-bs-dismiss', 'toast'); + closeBtn.setAttribute('aria-label', 'Fechar'); + + header.appendChild(title); + header.appendChild(closeBtn); + + const body = doc.createElement('div'); + body.className = 'toast-body bg-white rounded-bottom text-dark'; + + toast.appendChild(header); + toast.appendChild(body); + container.appendChild(toast); + + this.toastEl = toast; + this.toastBodyEl = body; + this.toastHeaderEl = header; + } + + private async showToast(message: string, variant: 'danger' | 'warning'): Promise { + if (!isPlatformBrowser(this.platformId)) return; + this.ensureToast(); + if (!this.toastEl || !this.toastBodyEl || !this.toastHeaderEl) return; + + this.toastBodyEl.textContent = message; + this.toastEl.classList.remove('text-bg-danger', 'text-bg-warning'); + this.toastEl.classList.add(variant === 'warning' ? 'text-bg-warning' : 'text-bg-danger'); + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.toastEl, { + autohide: true, + delay: 3000 + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/services/vigencia.service.ts b/src/app/services/vigencia.service.ts index b062c74..8840fcf 100644 --- a/src/app/services/vigencia.service.ts +++ b/src/app/services/vigencia.service.ts @@ -23,8 +23,24 @@ export interface VigenciaRow { dtEfetivacaoServico: string | null; dtTerminoFidelizacao: string | null; total: number | null; + createdAt?: string | null; + updatedAt?: string | null; } +export interface UpdateVigenciaRequest { + item?: number | null; + conta?: string | null; + linha?: string | null; + cliente?: string | null; + usuario?: string | null; + planoContrato?: string | null; + dtEfetivacaoServico?: string | null; + dtTerminoFidelizacao?: string | null; + total?: number | null; +} + +export interface CreateVigenciaRequest extends UpdateVigenciaRequest {} + export interface VigenciaClientGroup { cliente: string; linhas: number; @@ -86,4 +102,20 @@ export class VigenciaService { getClients(): Observable { return this.http.get(`${this.baseApi}/lines/vigencia/clients`); } -} \ No newline at end of file + + getById(id: string): Observable { + return this.http.get(`${this.baseApi}/lines/vigencia/${id}`); + } + + update(id: string, payload: UpdateVigenciaRequest): Observable { + return this.http.put(`${this.baseApi}/lines/vigencia/${id}`, payload); + } + + create(payload: CreateVigenciaRequest): Observable { + return this.http.post(`${this.baseApi}/lines/vigencia`, payload); + } + + remove(id: string): Observable { + return this.http.delete(`${this.baseApi}/lines/vigencia/${id}`); + } +} diff --git a/src/index.html b/src/index.html index f2bb223..cc6e397 100644 --- a/src/index.html +++ b/src/index.html @@ -1,11 +1,11 @@ - + - LineGestaoFrontend + LineGestão - + diff --git a/src/main.server.ts b/src/main.server.ts index 723e001..4a25c51 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -1,7 +1,11 @@ import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser'; +import { registerLocaleData } from '@angular/common'; +import localePt from '@angular/common/locales/pt'; import { App } from './app/app'; import { config } from './app/app.config.server'; +registerLocaleData(localePt, 'pt-BR'); + const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context); diff --git a/src/main.ts b/src/main.ts index f4c0acf..2a8b0e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,11 @@ import { bootstrapApplication } from '@angular/platform-browser'; +import { registerLocaleData } from '@angular/common'; +import localePt from '@angular/common/locales/pt'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app'; import 'bootstrap/dist/js/bootstrap.bundle.min.js'; +registerLocaleData(localePt, 'pt-BR'); + bootstrapApplication(AppComponent, appConfig) .catch((err) => console.error(err)); diff --git a/src/styles.scss b/src/styles.scss index 8cc0a47..f4c8f3e 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -83,7 +83,12 @@ select.form-control-sm { /* Empurra o conteúdo pra baixo do header fixo */ .app-main.has-header { + position: relative; padding-top: 84px; /* altura segura p/ header (mobile/desktop) */ + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.1), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.06), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); } @media (max-width: 600px) { @@ -92,6 +97,21 @@ select.form-control-sm { } } +/* Ajuste para monitores grandes: elimina o "vão" visual entre header e corpo. */ +@media (min-width: 1400px) { + .app-main.has-header { + padding-top: 72px; + } + + .container-geral, + .container-geral-responsive, + .container-fat, + .container-mureg, + .container-troca { + margin-top: 14px !important; + } +} + /* ========================================================== */ /* 🚀 GLOBAL FIX: Proporção Horizontal e Vertical */ /* ========================================================== */ @@ -143,7 +163,14 @@ select.form-control-sm { .users-page, .fat-page, .mureg-page, -.troca-page { +.troca-page, +.historico-page, +.perfil-page, +.dashboard-page, +.chips-page, +.parcelamentos-page, +.resumo-page, +.create-user-page { overflow-y: auto !important; height: auto !important; display: block !important; @@ -280,3 +307,23 @@ app-header .modal-card .btn-secondary:hover { } } +/* Remove separators inside search inputs (icon / text / clear button). */ +.input-group.search-group { + > .input-group-text, + > .form-control, + > .btn, + > .btn-clear { + border: 0 !important; + box-shadow: none !important; + background: transparent !important; + } + + > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.invalid-tooltip) { + margin-left: 0 !important; + } + + > .form-control:focus { + box-shadow: none !important; + } +} + From 99807d78f71c29ab5d886ddbc0213eb85c8b36e5 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Mon, 9 Feb 2026 18:32:07 -0300 Subject: [PATCH 38/46] Docker aplicado --- .dockerignore | 10 + Dockerfile | 25 + angular.json | 8 +- package-lock.json | 1794 ++++++----------- package.json | 25 +- src/app/app.routes.server.ts | 2 +- src/app/app.routes.ts | 2 - src/app/app.spec.ts | 12 +- src/app/components/header/header.spec.ts | 10 +- src/app/components/header/header.ts | 1 - .../dados-usuarios/dados-usuarios.spec.ts | 8 + src/app/pages/dashboard/dashboard.ts | 2 +- src/app/pages/geral/geral.spec.ts | 10 +- src/app/pages/geral/geral.ts | 7 +- src/app/pages/mureg/mureg.ts | 7 +- src/app/pages/novo-usuario/novo-usuario.html | 176 -- src/app/pages/novo-usuario/novo-usuario.scss | 372 ---- src/app/pages/novo-usuario/novo-usuario.ts | 309 --- src/app/pages/register/register.spec.ts | 10 +- src/app/pages/troca-numero/troca-numero.ts | 13 +- src/app/services/billing.ts | 9 +- src/app/services/chips-controle.service.ts | 2 +- src/app/services/dados-usuarios.service.ts | 2 +- src/app/services/lines.service.ts | 10 +- src/app/services/resumo.service.ts | 2 +- src/environments/environment.ts | 2 +- src/server.ts | 55 +- 27 files changed, 808 insertions(+), 2077 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 src/app/pages/novo-usuario/novo-usuario.html delete mode 100644 src/app/pages/novo-usuario/novo-usuario.scss delete mode 100644 src/app/pages/novo-usuario/novo-usuario.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17c2c5d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.vscode +.angular +node_modules +dist +out-tsc +coverage +*.log +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7375ad5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-alpine AS deps +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +FROM deps AS build +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=4000 +ENV API_BASE_URL=http://backend:8080 + +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=build /app/dist ./dist + +EXPOSE 4000 + +CMD ["node", "dist/line-gestao-frontend/server/server.mjs"] diff --git a/angular.json b/angular.json index 25e3369..242303a 100644 --- a/angular.json +++ b/angular.json @@ -44,13 +44,13 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "2MB", + "maximumError": "3MB" }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "20kB", + "maximumError": "40kB" } ], "outputHashing": "all" diff --git a/package-lock.json b/package-lock.json index 5df8186..f32b36a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,14 @@ "name": "line-gestao-frontend", "version": "0.0.0", "dependencies": { - "@angular/common": "^20.3.0", - "@angular/compiler": "^20.3.0", - "@angular/core": "^20.3.0", - "@angular/forms": "^20.3.0", - "@angular/platform-browser": "^20.3.0", - "@angular/platform-server": "^20.3.0", - "@angular/router": "^20.3.0", - "@angular/ssr": "^20.3.10", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-server": "20.3.16", + "@angular/router": "20.3.16", + "@angular/ssr": "20.3.16", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", @@ -26,9 +26,9 @@ "zone.js": "~0.15.0" }, "devDependencies": { - "@angular/build": "^20.3.10", - "@angular/cli": "^20.3.10", - "@angular/compiler-cli": "^20.3.0", + "@angular/build": "20.3.16", + "@angular/cli": "20.3.16", + "@angular/compiler-cli": "20.3.16", "@types/bootstrap": "^5.2.10", "@types/express": "^5.0.1", "@types/jasmine": "~5.1.0", @@ -266,13 +266,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2003.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.12.tgz", - "integrity": "sha512-5H40lAFF4CKY32C4HOp6bTlOF1f4WsGCwe7FjFQp9A+T7yoCBiHpIWt2JKTwV4sBoTKVDZOnuf0GG+UVKjQT4A==", + "version": "0.2003.16", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.16.tgz", + "integrity": "sha512-W7FPVhZzIeHVP/duuKepfZU66LpQ0k9YMHFhrGpzaUuHPOwKmza6+pjVvvti3g6jzT8b1uVlb+XlYgNPZ5jrPQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.12", + "@angular-devkit/core": "20.3.16", "rxjs": "7.8.2" }, "engines": { @@ -282,9 +282,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.12.tgz", - "integrity": "sha512-ReFxd/UOoVDr3+kIUjmYILQZF89qg62POdY7a7OqBH7plmInFlYVSEDouJvGqj3LVCPiqTk2ZOSChbhS/eLxXA==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.16.tgz", + "integrity": "sha512-6L9Lpe3lbkyz32gzqxZGVC8MhXxXht+yV+4LUsb4+6T/mG/V9lW6UTW0dhwVOS3vpWMEwpy75XHT298t7HcKEg==", "dev": true, "license": "MIT", "dependencies": { @@ -310,13 +310,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.12.tgz", - "integrity": "sha512-JqJ1u59y+Ud51k/8MHYzSP+aQOeC2PJBaDmMnvqfWVaIt6n3x4gc/VtuhqhpJ0SKulbFuOWgAfI6QbPFrgUYQQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.16.tgz", + "integrity": "sha512-3K8QwTpKjnLo3hIvNzB9sTjrlkeRyMK0TxdwgTbwJseewGhXLl98oBoTCWM2ygtpskiWNpYqXJNIhoslNN65WQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.12", + "@angular-devkit/core": "20.3.16", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "8.2.0", @@ -329,14 +329,14 @@ } }, "node_modules/@angular/build": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.12.tgz", - "integrity": "sha512-iAZve4VPviC8y6RFctyh3qFXSlP5mth9K46/0zasB4LV4pcmu8BrzIHERxIn/jCDNdVdPh973kxo1ksO4WpyuA==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.16.tgz", + "integrity": "sha512-p1W3wwMG1Bs4tkPW7ceXO4woO1KCP28sjfpBJg32dIMW3dYSC+iWNmUkYS/wb4YEkqCV0wd6Apnd98mZjL6rNg==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2003.12", + "@angular-devkit/architect": "0.2003.16", "@babel/core": "7.28.3", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -378,7 +378,7 @@ "@angular/platform-browser": "^20.0.0", "@angular/platform-server": "^20.0.0", "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.3.12", + "@angular/ssr": "^20.3.16", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^20.0.0", @@ -428,30 +428,30 @@ } }, "node_modules/@angular/cli": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.12.tgz", - "integrity": "sha512-vqVyVjbFPCRMjA5evL7tV2JeR6Anuzb9WcXTMB17fr7uzKNNAvo7KyRaOJjp+TU4JDARTNyGPy0aywfPx7R60A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.16.tgz", + "integrity": "sha512-kjGp0ywIWebWrH6U5eCRkS4Tx1D/yMe2iT7DXMfEcLc8iMSrBozEriMJppbot9ou8O2LeEH5d1Nw0efNNo78Kw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2003.12", - "@angular-devkit/core": "20.3.12", - "@angular-devkit/schematics": "20.3.12", + "@angular-devkit/architect": "0.2003.16", + "@angular-devkit/core": "20.3.16", + "@angular-devkit/schematics": "20.3.16", "@inquirer/prompts": "7.8.2", "@listr2/prompt-adapter-inquirer": "3.0.1", - "@modelcontextprotocol/sdk": "1.17.3", - "@schematics/angular": "20.3.12", + "@modelcontextprotocol/sdk": "1.26.0", + "@schematics/angular": "20.3.16", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.35.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", "listr2": "9.0.1", "npm-package-arg": "13.0.0", - "pacote": "21.0.0", + "pacote": "21.0.4", "resolve": "1.22.10", "semver": "7.7.2", "yargs": "18.0.0", - "zod": "3.25.76" + "zod": "4.1.13" }, "bin": { "ng": "bin/ng.js" @@ -463,9 +463,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", - "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", + "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", "license": "MIT", "peer": true, "dependencies": { @@ -475,14 +475,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15", + "@angular/core": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", - "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", + "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", "license": "MIT", "peer": true, "dependencies": { @@ -493,9 +493,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz", - "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", + "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", "dev": true, "license": "MIT", "peer": true, @@ -517,7 +517,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -527,9 +527,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", - "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", + "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", "license": "MIT", "peer": true, "dependencies": { @@ -539,7 +539,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -553,9 +553,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz", - "integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", + "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -564,16 +564,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", - "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", + "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", "license": "MIT", "peer": true, "dependencies": { @@ -583,9 +583,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.15", - "@angular/common": "20.3.15", - "@angular/core": "20.3.15" + "@angular/animations": "20.3.16", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -594,9 +594,9 @@ } }, "node_modules/@angular/platform-server": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-20.3.15.tgz", - "integrity": "sha512-OB3/ztCREeZ0pe2P+43Nah9Xq2Y79fN6mbsOY1JwwYxkM8ZN1WkSP11xlHHwAcoquHP7uFPhXwJqgTHBqGqkcw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-20.3.16.tgz", + "integrity": "sha512-LxQscYd3UCWV8H3sdlnM05UB60MZVuVsdsHvXdkJ9+WOQjVDN1l1rYhj2aDL/5KkaRd/nqo0yFRnVjwceXDJhQ==", "license": "MIT", "peer": true, "dependencies": { @@ -607,17 +607,17 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/router": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz", - "integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", + "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "license": "MIT", "peer": true, "dependencies": { @@ -627,16 +627,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/ssr": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-20.3.12.tgz", - "integrity": "sha512-liIxrlozOkcx+6qkMb0rFcKjo32aRtUI/U7TQ+1KA1XZrIk97aTjHEpq0KhBMjNlOt/xwrlUARDXjS5au5kP5g==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-20.3.16.tgz", + "integrity": "sha512-EpPSc9kUiLbe3Lpj0GUplt0JNPFmyuTnOv/h4bJqfj07xvSbn5vH3W0wl78RQrcOh9hfXua4xVCvCF/6nV6zPg==", "license": "MIT", "peer": true, "dependencies": { @@ -1400,6 +1400,19 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -1762,9 +1775,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1774,67 +1787,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2019,53 +1971,46 @@ ] }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz", - "integrity": "sha512-JPwUKWSsbzx+DLFznf/QZ32Qa+ptfbUlHhRLrBQBAFu9iI1iYvizM4p+zhhRDceSsPutXp4z+R/HPVphlIiclg==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -2474,83 +2419,109 @@ } }, "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", "dev": true, "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", + "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "dev": true, "license": "ISC", "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/git": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", - "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.1.tgz", + "integrity": "sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/git/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "node_modules/@npmcli/git/node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": ">=16" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/@npmcli/git/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "dev": true, "license": "ISC", "dependencies": { @@ -2560,149 +2531,126 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", - "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", "dev": true, "license": "ISC", "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", - "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/package-json": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", - "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.4.tgz", + "integrity": "sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.5.3", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.2", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@npmcli/package-json/node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@npmcli/package-json/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@npmcli/promise-spawn": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.3.tgz", - "integrity": "sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", "dev": true, "license": "ISC", "dependencies": { - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "dev": true, "license": "ISC", "dependencies": { @@ -2712,51 +2660,61 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/redact": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/run-script": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", - "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.3.tgz", + "integrity": "sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0", + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@npmcli/run-script/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": ">=16" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/run-script/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "dev": true, "license": "ISC", "dependencies": { @@ -2766,7 +2724,7 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@parcel/watcher": { @@ -3101,17 +3059,6 @@ "license": "MIT", "optional": true }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3432,14 +3379,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.12.tgz", - "integrity": "sha512-ikl+nkWUab/Z4eSkBHgq9FLIUH8qh4OcYKeBQ0fyWqIUFHyjjK0JOfwmH1g/3zAmuUMtkthHCehAtyKzCTQjVA==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.16.tgz", + "integrity": "sha512-KeOcsM5piwv/6tUKBmLD1zXTwtJlZBnR2WM/4T9ImaQbmFGe1MMHUABT5SQ3Bifv1YKCw58ImxiaQUY9sdNqEQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.12", - "@angular-devkit/schematics": "20.3.12", + "@angular-devkit/core": "20.3.16", + "@angular-devkit/schematics": "20.3.16", "jsonc-parser": "3.3.1" }, "engines": { @@ -3449,32 +3396,32 @@ } }, "node_modules/@sigstore/bundle": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", - "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@sigstore/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", - "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.1.0.tgz", + "integrity": "sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.3.tgz", - "integrity": "sha512-fk2zjD9117RL9BjqEwF7fwv7Q/P9yGsMV4MUJZ/DocaQJ6+3pKr+syBq1owU5Q5qGw5CUbXzm+4yJ2JVRDQeSA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", + "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3482,50 +3429,60 @@ } }, "node_modules/@sigstore/sign": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", - "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.0.tgz", + "integrity": "sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.3", + "proc-log": "^6.1.0", "promise-retry": "^2.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/sign/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@sigstore/tuf": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", - "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.1.tgz", + "integrity": "sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@sigstore/verify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", - "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", + "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@socket.io/component-emitter": { @@ -3546,40 +3503,30 @@ } }, "node_modules/@tufjs/models": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", - "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", "dev": true, "license": "MIT", "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" + "minimatch": "^10.1.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3758,13 +3705,13 @@ "license": "BSD-2-Clause" }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/accepts": { @@ -4122,120 +4069,72 @@ } }, "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/fs": "^4.0.0", + "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.2", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/cacache/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4335,13 +4234,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/cli-cursor": { @@ -4816,13 +4715,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5167,19 +5059,20 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "peer": true, "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -5210,11 +5103,14 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "dev": true, "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -5239,13 +5135,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5339,23 +5228,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5614,6 +5486,17 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", @@ -5777,13 +5660,13 @@ } }, "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { "node": "20 || >=22" @@ -5838,9 +5721,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "dev": true, "license": "MIT", "engines": { @@ -6083,22 +5966,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jasmine-core": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz", @@ -6107,6 +5974,16 @@ "license": "MIT", "peer": true }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6128,13 +6005,13 @@ } }, "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/json-schema-traverse": { @@ -6144,6 +6021,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6354,24 +6238,24 @@ } }, "node_modules/karma/node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -6432,23 +6316,6 @@ "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/karma/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6525,33 +6392,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/karma/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/karma/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -6580,16 +6431,6 @@ "node": ">=0.10.0" } }, - "node_modules/karma/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/karma/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6752,9 +6593,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -6913,26 +6754,36 @@ } }, "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", + "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/make-fetch-happen/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/math-intrinsics": { @@ -7092,18 +6943,18 @@ } }, "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.1.tgz", + "integrity": "sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==", "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", + "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -7176,38 +7027,18 @@ "license": "ISC" }, "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^3.0.0" + "minipass": "^7.1.2" }, "engines": { "node": ">=8" } }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -7331,28 +7162,28 @@ "optional": true }, "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp-build-optional-packages": { @@ -7371,47 +7202,30 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "node_modules/node-gyp/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": ">=16" - } - }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "dev": true, "license": "ISC", "dependencies": { @@ -7421,17 +7235,7 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-releases": { @@ -7442,19 +7246,19 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -7468,39 +7272,39 @@ } }, "node_modules/npm-bundled": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", - "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", "dev": true, "license": "ISC", "dependencies": { - "npm-normalize-package-bin": "^4.0.0" + "npm-normalize-package-bin": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-install-checks": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.2.tgz", - "integrity": "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-package-arg": { @@ -7544,111 +7348,49 @@ } }, "node_modules/npm-pick-manifest": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", - "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", "dev": true, "license": "ISC", "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-pick-manifest/node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-pick-manifest/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/npm-pick-manifest/node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-registry-fetch": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/redact": "^3.0.0", + "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", + "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm-registry-fetch/node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "node_modules/npm-registry-fetch/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-registry-fetch/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/npm-registry-fetch/node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/nth-check": { @@ -7768,37 +7510,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/pacote": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.0.tgz", - "integrity": "sha512-lcqexq73AMv6QNLo7SOpz0JJoaGdS3rBFgF122NZVl1bApo2mfu+XzUBU/X/XsiJu+iUmKpekRayqQYAs+PhkA==", + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz", + "integrity": "sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^10.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" @@ -7807,40 +7542,14 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pacote/node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "node_modules/pacote/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/pacote/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pacote/node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/parse5": { @@ -7947,28 +7656,31 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "8.3.0", @@ -8114,9 +7826,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -8551,21 +8263,21 @@ } }, "node_modules/sigstore": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", - "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", + "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/slice-ansi": { @@ -8849,16 +8561,16 @@ "license": "CC0-1.0" }, "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", + "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/statuses": { @@ -8916,62 +8628,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -8988,30 +8644,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9039,105 +8671,31 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.14", @@ -9196,18 +8754,18 @@ "peer": true }, "node_modules/tuf-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", - "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "dev": true, "license": "MIT", "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.4.1", - "make-fetch-happen": "^14.0.3" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/type-is": { @@ -9274,29 +8832,29 @@ "license": "MIT" }, "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", "dev": true, "license": "ISC", "dependencies": { - "unique-slug": "^5.0.0" + "unique-slug": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/universalify": { @@ -9349,26 +8907,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -9565,96 +9103,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -9822,9 +9270,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", "peer": true, @@ -9833,9 +9281,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/package.json b/package.json index 1484e44..5f32111 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,14 @@ }, "private": true, "dependencies": { - "@angular/common": "^20.3.0", - "@angular/compiler": "^20.3.0", - "@angular/core": "^20.3.0", - "@angular/forms": "^20.3.0", - "@angular/platform-browser": "^20.3.0", - "@angular/platform-server": "^20.3.0", - "@angular/router": "^20.3.0", - "@angular/ssr": "^20.3.10", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-server": "20.3.16", + "@angular/router": "20.3.16", + "@angular/ssr": "20.3.16", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", @@ -41,9 +41,9 @@ "zone.js": "~0.15.0" }, "devDependencies": { - "@angular/build": "^20.3.10", - "@angular/cli": "^20.3.10", - "@angular/compiler-cli": "^20.3.0", + "@angular/build": "20.3.16", + "@angular/cli": "20.3.16", + "@angular/compiler-cli": "20.3.16", "@types/bootstrap": "^5.2.10", "@types/express": "^5.0.1", "@types/jasmine": "~5.1.0", @@ -55,5 +55,8 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.2" + }, + "overrides": { + "qs": "^6.14.1" } } diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index ffd37b1..2c5a11d 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ { path: '**', - renderMode: RenderMode.Prerender + renderMode: RenderMode.Server } ]; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0333c55..d541fc3 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -14,7 +14,6 @@ import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; import { Dashboard } from './pages/dashboard/dashboard'; import { Notificacoes } from './pages/notificacoes/notificacoes'; -import { NovoUsuario } from './pages/novo-usuario/novo-usuario'; import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos'; import { Resumo } from './pages/resumo/resumo'; import { Parcelamentos } from './pages/parcelamentos/parcelamentos'; @@ -33,7 +32,6 @@ export const routes: Routes = [ { 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: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard], title: 'Novo Usuário' }, { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' }, { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' }, diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index bcf4fa0..3f40ce7 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,10 +1,18 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }).compileComponents(); }); @@ -14,10 +22,10 @@ describe('App', () => { expect(app).toBeTruthy(); }); - it('should render title', () => { + it('should render app layout', () => { const fixture = TestBed.createComponent(App); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, line-gestao-frontend'); + expect(compiled.querySelector('main.app-main')).toBeTruthy(); }); }); diff --git a/src/app/components/header/header.spec.ts b/src/app/components/header/header.spec.ts index 7da9515..5b4be04 100644 --- a/src/app/components/header/header.spec.ts +++ b/src/app/components/header/header.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { Header } from './header'; @@ -8,7 +11,12 @@ describe('Header', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Header] + imports: [Header], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }) .compileComponents(); diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 3b766ff..1518cbe 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -68,7 +68,6 @@ export class Header { '/trocanumero', '/dashboard', '/notificacoes', - '/novo-usuario', '/chips-controle-recebidos', '/resumo', '/parcelamentos', diff --git a/src/app/pages/dados-usuarios/dados-usuarios.spec.ts b/src/app/pages/dados-usuarios/dados-usuarios.spec.ts index a079053..8425268 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.spec.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { DadosUsuarios } from './dados-usuarios'; describe('DadosUsuarios', () => { @@ -8,6 +11,11 @@ describe('DadosUsuarios', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [DadosUsuarios], // standalone component + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }).compileComponents(); fixture = TestBed.createComponent(DadosUsuarios); diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index 052047d..9f16739 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -325,7 +325,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private resumoService: ResumoService, @Inject(PLATFORM_ID) private platformId: object ) { - const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; } diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts index 8cbf6c0..491ce60 100644 --- a/src/app/pages/geral/geral.spec.ts +++ b/src/app/pages/geral/geral.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { Geral } from './geral'; @@ -8,7 +11,12 @@ describe('Geral', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Geral] + imports: [Geral], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }) .compileComponents(); diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 8fc761c..1c3ece0 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -21,6 +21,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { AuthService } from '../../services/auth.service'; import { firstValueFrom, Subscription, filter } from 'rxjs'; +import { environment } from '../../../environments/environment'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; @@ -146,7 +147,11 @@ export class Geral implements AfterViewInit, OnDestroy { private router: Router ) {} - private readonly apiBase = 'https://localhost:7205/api/lines'; + private readonly apiBase = (() => { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + return `${apiBase}/lines`; + })(); loading = false; isAdmin = false; diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index ce66b51..48e0e6c 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; import { LinesService } from '../../services/lines.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { environment } from '../../../environments/environment'; type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente'; @@ -92,7 +93,11 @@ export class Mureg implements AfterViewInit { private linesService: LinesService ) {} - private readonly apiBase = 'https://localhost:7205/api/mureg'; + private readonly apiBase = (() => { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + return `${apiBase}/mureg`; + })(); // ====== DATA ====== clientGroups: ClientGroup[] = []; diff --git a/src/app/pages/novo-usuario/novo-usuario.html b/src/app/pages/novo-usuario/novo-usuario.html deleted file mode 100644 index 6436379..0000000 --- a/src/app/pages/novo-usuario/novo-usuario.html +++ /dev/null @@ -1,176 +0,0 @@ -
-
-
-
-
-

Novo Usuário LineGestão

-

Preencha os dados para criar um novo usuário.

-
- -
- Confira os campos: -
    -
  • {{ err.message }}
  • -
-
-
{{ createSuccess }}
- -
-
- - - Nome obrigatório. -
- -
- - - Email inválido. -
- -
- - - Senha inválida. -
- -
- - - As senhas não conferem. -
- -
- - - Selecione uma permissão. -
- -
- - -
-
-
- -
-
-
-

Usuários

-

Gerencie permissões e status.

-
-
- - - -
-
- -
-
Carregando...
- - - - - - - - - - - - - - - - - - - -
NomeEmailPermissãoStatusAções
{{ u.nome }}{{ u.email }}{{ u.permissao }} - - {{ u.ativo === false ? 'Inativo' : 'Ativo' }} - - - -
-
- Nenhum usuario encontrado. -
-
- - -
-
-
-
- - - - - diff --git a/src/app/pages/novo-usuario/novo-usuario.scss b/src/app/pages/novo-usuario/novo-usuario.scss deleted file mode 100644 index bd77823..0000000 --- a/src/app/pages/novo-usuario/novo-usuario.scss +++ /dev/null @@ -1,372 +0,0 @@ -/* Página Criar Novo Usuário */ -.create-user-page { - min-height: calc(100vh - 69.2px); - padding: 32px 16px 80px; - background: radial-gradient(circle at 10% 15%, #e8f0ff 0%, #f6f7fb 45%, #ffffff 100%); -} - -.page-shell { - max-width: 980px; - margin: 0 auto; - display: grid; - place-items: center; -} - -.grid-shell { - width: 100%; - display: grid; - gap: 24px; - grid-template-columns: minmax(0, 1fr); -} - -.form-card { - width: min(720px, 100%); - background: #ffffff; - border-radius: 18px; - border: 1px solid rgba(15, 23, 42, 0.08); - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); - padding: 28px 28px 24px; -} - -.form-header { - margin-bottom: 18px; - - h1 { - font-size: 22px; - font-weight: 700; - color: #0f172a; - margin: 0 0 6px; - } - - p { - margin: 0; - color: #64748b; - font-size: 13px; - } -} - -.user-form { - display: grid; - gap: 14px; -} - -.form-field { - display: grid; - gap: 6px; - - label { - font-size: 13px; - font-weight: 600; - color: #0f172a; - } - - input, - select { - height: 42px; - border-radius: 10px; - border: 1.5px solid #d7dbe6; - padding: 0 12px; - font-size: 14px; - color: #0f172a; - background: #ffffff; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - } - - input:focus, - select:focus { - outline: none; - border-color: #3b5bff; - box-shadow: 0 0 0 3px rgba(59, 91, 255, 0.15); - } -} - -.form-field.inline { - grid-template-columns: 1fr; -} - -.form-alert { - border-radius: 12px; - padding: 12px 14px; - font-size: 13px; - margin-bottom: 12px; -} - -.form-alert.error { - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.2); - color: #b91c1c; -} - -.form-alert.success { - background: rgba(16, 185, 129, 0.08); - border: 1px solid rgba(16, 185, 129, 0.2); - color: #047857; -} - -.field-error { - color: #b91c1c; - font-size: 12px; -} - -.list-card { - width: min(900px, 100%); - background: #ffffff; - border-radius: 18px; - border: 1px solid rgba(15, 23, 42, 0.08); - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); - padding: 22px 22px 18px; -} - -.list-header { - display: flex; - flex-wrap: wrap; - gap: 16px; - align-items: center; - justify-content: space-between; - - h2 { - margin: 0; - font-size: 18px; - color: #0f172a; - } - - p { - margin: 4px 0 0; - font-size: 12px; - color: #64748b; - } -} - -.list-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - - input { - height: 38px; - border-radius: 10px; - border: 1.5px solid #d7dbe6; - padding: 0 12px; - font-size: 13px; - min-width: 220px; - } -} - -.list-body { - margin-top: 16px; -} - -.users-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - - th, - td { - padding: 10px 8px; - border-bottom: 1px solid #edf0f6; - text-align: left; - } - - th { - font-weight: 600; - color: #475569; - } -} - -.status-pill { - display: inline-flex; - padding: 4px 10px; - border-radius: 999px; - background: rgba(16, 185, 129, 0.12); - color: #047857; - font-weight: 600; - font-size: 12px; -} - -.status-pill.off { - background: rgba(239, 68, 68, 0.12); - color: #b91c1c; -} - -.list-footer { - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: center; - justify-content: space-between; - margin-top: 12px; - font-size: 12px; - color: #64748b; -} - -.pagination { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.btn-ghost { - height: 34px; - border-radius: 10px; - border: 1px solid #d7dbe6; - background: #fff; - padding: 0 10px; - font-size: 12px; - cursor: pointer; -} - -.btn-ghost.active { - background: #2f6bff; - border-color: #2f6bff; - color: #ffffff; -} - -.btn-link { - border: none; - background: transparent; - color: #2f6bff; - cursor: pointer; - font-weight: 600; -} - -.loading, -.empty { - padding: 18px 0; - text-align: center; - color: #64748b; -} - -.cap { - text-transform: capitalize; -} - -.toggle { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.form-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 6px; -} - -.btn-primary, -.btn-secondary { - height: 40px; - min-width: 110px; - border-radius: 10px; - border: none; - font-weight: 600; - font-size: 14px; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; -} - -.btn-primary { - background: #2f6bff; - color: #ffffff; - box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2); -} - -.btn-secondary { - background: #e2e8f0; - color: #0f172a; -} - -.btn-secondary, -.btn-ghost { - transition: transform 0.15s ease, box-shadow 0.15s ease; -} - -.btn-primary:hover, -.btn-secondary:hover, -.btn-ghost:hover { - transform: translateY(-1px); -} - -/* Modal */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.4); - z-index: 999; -} - -.modal-card { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(520px, 92vw); - background: #ffffff; - border-radius: 16px; - box-shadow: 0 20px 45px rgba(15, 23, 42, 0.2); - z-index: 1000; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 18px 10px; - border-bottom: 1px solid #edf0f6; -} - -.modal-body { - padding: 16px 18px; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - padding: 0 18px 16px; -} - -.btn-icon { - background: transparent; - border: none; - cursor: pointer; -} - -@media (max-width: 768px) { - .grid-shell { - gap: 18px; - } - - .form-card { - padding: 22px 20px 20px; - } - - .list-card { - padding: 20px 18px 16px; - } - - .form-actions { - flex-direction: column; - align-items: stretch; - } - - .btn-primary, - .btn-secondary { - width: 100%; - } -} - -@media (min-width: 980px) { - .grid-shell { - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); - align-items: start; - } - - .form-card, - .list-card { - width: 100%; - } -} diff --git a/src/app/pages/novo-usuario/novo-usuario.ts b/src/app/pages/novo-usuario/novo-usuario.ts deleted file mode 100644 index 3f6065c..0000000 --- a/src/app/pages/novo-usuario/novo-usuario.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms'; -import { UsersService, CreateUserPayload, UpdateUserPayload, UserDto, ApiFieldError } from '../../services/users.service'; -import { FormsModule } from '@angular/forms'; -import { HttpErrorResponse } from '@angular/common/http'; -import { CustomSelectComponent } from '../../components/custom-select/custom-select'; - -@Component({ - selector: 'app-novo-usuario', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent], - templateUrl: './novo-usuario.html', - styleUrls: ['./novo-usuario.scss'], -}) -export class NovoUsuario implements OnInit { - createForm: FormGroup; - editForm: FormGroup; - - permissionOptions = [ - { value: 'admin', label: 'Administrador' }, - { value: 'gestor', label: 'Gestor' }, - ]; - - - createSubmitting = false; - editSubmitting = false; - - createErrors: ApiFieldError[] = []; - editErrors: ApiFieldError[] = []; - - createSuccess = ''; - editSuccess = ''; - - users: UserDto[] = []; - loading = false; - - search = ''; - page = 1; - pageSize = 10; - total = 0; - - editOpen = false; - private editBase: UserDto | null = null; - - constructor(private usersService: UsersService, private fb: FormBuilder) { - this.createForm = this.fb.group( - { - nome: ['', [Validators.required, Validators.minLength(2)]], - email: ['', [Validators.required, Validators.email]], - senha: ['', [Validators.required, Validators.minLength(6)]], - confirmarSenha: ['', [Validators.required, Validators.minLength(6)]], - permissao: ['', [Validators.required]], - }, - { validators: this.passwordsMatchValidator } - ); - - this.editForm = this.fb.group( - { - nome: [''], - email: [''], - senha: [''], - confirmarSenha: [''], - permissao: [''], - ativo: [true], - } - ); - } - - ngOnInit(): void { - this.fetchUsers(1); - } - - get totalPages(): number { - return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); - } - - get pageNumbers(): number[] { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; - } - - fetchUsers(goToPage?: number) { - if (goToPage) this.page = goToPage; - this.loading = true; - this.usersService.list({ - search: this.search?.trim() || undefined, - page: this.page, - pageSize: this.pageSize, - }).subscribe({ - next: (res) => { - this.users = res.items || []; - this.total = res.total || 0; - this.loading = false; - }, - error: () => { - this.users = []; - this.total = 0; - this.loading = false; - } - }); - } - - onSearch() { - this.page = 1; - this.fetchUsers(); - } - - clearSearch() { - this.search = ''; - this.page = 1; - this.fetchUsers(); - } - - onPageSizeChange() { - this.page = 1; - this.fetchUsers(); - } - - goToPage(p: number) { - this.page = p; - this.fetchUsers(); - } - - submitCreate() { - if (this.createSubmitting) return; - if (this.createForm.invalid) { - this.createForm.markAllAsTouched(); - return; - } - - this.createSubmitting = true; - this.setCreateFormDisabled(true); - this.createErrors = []; - this.createSuccess = ''; - - const payload = this.createForm.value as CreateUserPayload; - this.usersService.create(payload).subscribe({ - next: (created) => { - this.createSubmitting = false; - this.setCreateFormDisabled(false); - this.createSuccess = `Usuario ${created.nome} criado com sucesso.`; - this.createForm.reset({ permissao: '' }); - this.fetchUsers(1); - }, - error: (err: HttpErrorResponse) => { - this.createSubmitting = false; - this.setCreateFormDisabled(false); - const apiErrors = err?.error?.errors; - if (Array.isArray(apiErrors)) { - this.createErrors = apiErrors.map((e: any) => ({ - field: e?.field, - message: e?.message || 'Erro ao criar usuario.', - })); - } else { - this.createErrors = [{ message: err?.error?.message || 'Erro ao criar usuario.' }]; - } - }, - }); - } - - openEdit(user: UserDto) { - this.editOpen = true; - this.editErrors = []; - this.editSuccess = ''; - this.editSubmitting = false; - this.setEditFormDisabled(false); - this.editBase = null; - this.editForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true }); - - this.usersService.getById(user.id).subscribe({ - next: (full) => { - this.editBase = full; - this.editForm.reset({ - nome: full.nome ?? '', - email: full.email ?? '', - senha: '', - confirmarSenha: '', - permissao: full.permissao ?? '', - ativo: full.ativo ?? true, - }); - }, - error: () => { - this.editErrors = [{ message: 'Erro ao carregar usuario.' }]; - }, - }); - } - - closeEdit() { - this.editOpen = false; - this.editErrors = []; - this.editSuccess = ''; - this.editSubmitting = false; - this.editBase = null; - this.setEditFormDisabled(false); - } - - submitEdit() { - if (this.editSubmitting || !this.editBase) return; - this.editErrors = []; - this.editSuccess = ''; - - const payload: UpdateUserPayload = {}; - const nome = (this.editForm.get('nome')?.value || '').toString().trim(); - const email = (this.editForm.get('email')?.value || '').toString().trim(); - const permissao = (this.editForm.get('permissao')?.value || '').toString().trim(); - const ativo = !!this.editForm.get('ativo')?.value; - - if (nome && nome !== (this.editBase.nome || '').trim()) payload.nome = nome; - if (email && email !== (this.editBase.email || '').trim()) payload.email = email; - if (permissao && permissao !== (this.editBase.permissao || '').trim()) payload.permissao = permissao as any; - if ((this.editBase.ativo ?? true) !== ativo) payload.ativo = ativo; - - const senha = (this.editForm.get('senha')?.value || '').toString(); - const confirmar = (this.editForm.get('confirmarSenha')?.value || '').toString(); - if (senha || confirmar) { - if (!senha || !confirmar) { - this.editErrors = [{ message: 'Para alterar a senha, preencha senha e confirmaçao.' }]; - return; - } - if (senha.length < 6) { - this.editErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }]; - return; - } - if (senha !== confirmar) { - this.editErrors = [{ message: 'As senhas nao conferem.' }]; - return; - } - payload.senha = senha; - payload.confirmarSenha = confirmar; - } - - if (Object.keys(payload).length === 0) { - this.editErrors = [{ message: 'Nenhuma alteraçao detectada.' }]; - return; - } - - this.editSubmitting = true; - this.setEditFormDisabled(true); - this.usersService.update(this.editBase.id, payload).subscribe({ - next: (updated) => { - this.editSubmitting = false; - this.setEditFormDisabled(false); - this.editSuccess = `Usuario ${updated.nome} atualizado.`; - this.fetchUsers(); - }, - error: (err: HttpErrorResponse) => { - this.editSubmitting = false; - this.setEditFormDisabled(false); - const apiErrors = err?.error?.errors; - if (Array.isArray(apiErrors)) { - this.editErrors = apiErrors.map((e: any) => ({ - field: e?.field, - message: e?.message || 'Erro ao atualizar usuario.', - })); - } else { - this.editErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }]; - } - } - }); - } - - hasCreateFieldError(field: string): boolean { - return this.getFieldErrors(this.createErrors, field).length > 0; - } - - hasEditFieldError(field: string): boolean { - return this.getFieldErrors(this.editErrors, field).length > 0; - } - - getFieldErrors(source: ApiFieldError[], field: string): string[] { - const key = this.normalizeField(field); - return source - .filter((e) => this.normalizeField(e.field) === key) - .map((e) => e.message || 'Erro'); - } - - get createPasswordMismatch(): boolean { - return !!this.createForm.errors?.['passwordsMismatch']; - } - - private normalizeField(field?: string | null): string { - return (field || '').trim().toLowerCase(); - } - - private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { - const senha = group.get('senha')?.value; - const confirmar = group.get('confirmarSenha')?.value; - if (!senha || !confirmar) return null; - return senha === confirmar ? null : { passwordsMismatch: true }; - } - - private setCreateFormDisabled(disabled: boolean) { - if (disabled) this.createForm.disable({ emitEvent: false }); - else this.createForm.enable({ emitEvent: false }); - } - - private setEditFormDisabled(disabled: boolean) { - if (disabled) this.editForm.disable({ emitEvent: false }); - else this.editForm.enable({ emitEvent: false }); - } -} diff --git a/src/app/pages/register/register.spec.ts b/src/app/pages/register/register.spec.ts index eac286c..77f13b9 100644 --- a/src/app/pages/register/register.spec.ts +++ b/src/app/pages/register/register.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { Register } from './register'; @@ -8,7 +11,12 @@ describe('Register', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Register] + imports: [Register], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }) .compileComponents(); diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts index 5c954c3..b27b637 100644 --- a/src/app/pages/troca-numero/troca-numero.ts +++ b/src/app/pages/troca-numero/troca-numero.ts @@ -11,6 +11,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { environment } from '../../../environments/environment'; type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao'; @@ -71,10 +72,18 @@ export class TrocaNumero implements AfterViewInit { private cdr: ChangeDetectorRef ) {} - private readonly apiBase = 'https://localhost:7205/api/trocanumero'; + private readonly apiBase = (() => { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + return `${apiBase}/trocanumero`; + })(); /** ✅ base do GERAL (para buscar clientes/linhas no modal) */ - private readonly linesApiBase = 'https://localhost:7205/api/lines'; + private readonly linesApiBase = (() => { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + return `${apiBase}/lines`; + })(); // ====== DATA ====== groups: GroupItem[] = []; diff --git a/src/app/services/billing.ts b/src/app/services/billing.ts index adcd886..a03c960 100644 --- a/src/app/services/billing.ts +++ b/src/app/services/billing.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, map } from 'rxjs'; +import { environment } from '../../environments/environment'; export type SortDir = 'asc' | 'desc'; export type TipoCliente = 'PF' | 'PJ'; @@ -71,9 +72,13 @@ export interface ApiPagedResult { @Injectable({ providedIn: 'root' }) export class BillingService { - private readonly baseUrl = 'https://localhost:7205/api/billing'; + private readonly baseUrl: string; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseUrl = `${apiBase}/billing`; + } getPaged(q: BillingQuery): Observable> { let params = new HttpParams() diff --git a/src/app/services/chips-controle.service.ts b/src/app/services/chips-controle.service.ts index d21be34..16423a7 100644 --- a/src/app/services/chips-controle.service.ts +++ b/src/app/services/chips-controle.service.ts @@ -71,7 +71,7 @@ export class ChipsControleService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; } diff --git a/src/app/services/dados-usuarios.service.ts b/src/app/services/dados-usuarios.service.ts index d39a627..0e78ddc 100644 --- a/src/app/services/dados-usuarios.service.ts +++ b/src/app/services/dados-usuarios.service.ts @@ -75,7 +75,7 @@ export class DadosUsuariosService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; } diff --git a/src/app/services/lines.service.ts b/src/app/services/lines.service.ts index d65f616..5ce8ae2 100644 --- a/src/app/services/lines.service.ts +++ b/src/app/services/lines.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; export interface PagedResult { page: number; @@ -58,10 +59,13 @@ export interface LineOption { @Injectable({ providedIn: 'root' }) export class LinesService { - // ✅ Mesma base do Swagger (evita redirect no preflight/CORS) - private baseUrl = 'https://localhost:7205/api/lines'; + private readonly baseUrl: string; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseUrl = `${apiBase}/lines`; + } getLines(page: number, pageSize: number, search: string): Observable> { let params = new HttpParams() diff --git a/src/app/services/resumo.service.ts b/src/app/services/resumo.service.ts index 16be4b3..507872f 100644 --- a/src/app/services/resumo.service.ts +++ b/src/app/services/resumo.service.ts @@ -119,7 +119,7 @@ export class ResumoService { private readonly apiBase: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 76c4506..c46aeaa 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiUrl: 'https://localhost:7205' + apiUrl: '' }; diff --git a/src/server.ts b/src/server.ts index 4bf0631..7896ef8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,23 +6,60 @@ import { } from '@angular/ssr/node'; import express from 'express'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; const browserDistFolder = join(import.meta.dirname, '../browser'); +const apiBaseUrl = (process.env['API_BASE_URL'] || 'http://backend:8080').replace(/\/+$/, ''); const app = express(); const angularApp = new AngularNodeAppEngine(); /** - * Example Express Rest API endpoints can be defined here. - * Uncomment and define endpoints as necessary. - * - * Example: - * ```ts - * app.get('/api/{*splat}', (req, res) => { - * // Handle API request - * }); - * ``` + * Proxy API calls from the browser-facing frontend server to the backend API. */ +app.use(['/api', '/auth'], async (req, res, next) => { + try { + const targetUrl = new URL(req.originalUrl, `${apiBaseUrl}/`); + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + const lower = key.toLowerCase(); + if (lower === 'host' || lower === 'content-length') continue; + + headers.set(key, Array.isArray(value) ? value.join(',') : value); + } + + const requestInit: RequestInit & { duplex?: 'half' } = { + method: req.method, + headers, + redirect: 'manual', + }; + + if (req.method !== 'GET' && req.method !== 'HEAD') { + requestInit.body = req as unknown as BodyInit; + requestInit.duplex = 'half'; + } + + const upstream = await fetch(targetUrl, requestInit); + + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'connection' || lower === 'transfer-encoding') return; + res.setHeader(key, value); + }); + + if (!upstream.body) { + res.end(); + return; + } + + Readable.fromWeb(upstream.body as any).pipe(res); + } catch (error) { + next(error); + } +}); /** * Serve static files from /browser From 5f7b4e19e8af9fbfcd55e4248ff1a999858e0234 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Mon, 9 Feb 2026 21:50:03 -0300 Subject: [PATCH 39/46] =?UTF-8?q?Aplica=C3=A7=C3=A3o=20Funcionando?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/cta-button/cta-button.ts | 2 +- src/app/pages/resumo/resumo.html | 14 +-- src/app/pages/resumo/resumo.ts | 99 +++++++++++++++++---- src/server.ts | 2 +- src/styles.scss | 28 +++--- 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/src/app/components/cta-button/cta-button.ts b/src/app/components/cta-button/cta-button.ts index de6ea5a..8b54119 100644 --- a/src/app/components/cta-button/cta-button.ts +++ b/src/app/components/cta-button/cta-button.ts @@ -2,11 +2,11 @@ import { Component, EventEmitter, Output, Input } from '@angular/core'; @Component({ selector: 'app-cta-button', + standalone: true, templateUrl: './cta-button.html', styleUrls: ['./cta-button.scss'] }) export class CtaButtonComponent { - @Input() label: string = 'COMEÇAR AGORA'; @Input() width: string = '250px'; diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index 7464d04..cb43f63 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -102,14 +102,14 @@ + [value]="macrophonySearch" + (input)="onMacrophonySearch($any($event.target).value)" />
@@ -120,7 +120,7 @@
@@ -428,8 +428,8 @@ + [value]="group.search" + (input)="onGroupedSearch(group, $any($event.target).value)" />
@@ -439,7 +439,7 @@
diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts index 6901ad5..81dbf0b 100644 --- a/src/app/pages/resumo/resumo.ts +++ b/src/app/pages/resumo/resumo.ts @@ -11,7 +11,6 @@ import { HostBinding } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import Chart from 'chart.js/auto'; import type { ChartConfiguration, ChartData, ScriptableContext, TooltipItem } from 'chart.js'; @@ -70,7 +69,7 @@ type GroupedTableState = { key: string; label: string; table: TableState; @Component({ standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule], templateUrl: './resumo.html', styleUrls: ['./resumo.scss'] }) @@ -500,15 +499,32 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { // Métodos obrigatórios para o template funcionar toggleMacrophonyCompact() { this.macrophonyCompact = !this.macrophonyCompact; } - onMacrophonySearch() { this.macrophonyPage = 1; this.updateMacrophonyView(); } - onMacrophonyPageSizeChange() { this.macrophonyPage = 1; this.updateMacrophonyView(); } + onMacrophonySearch(value?: string) { + if (typeof value === 'string') this.macrophonySearch = value; + this.macrophonyPage = 1; + this.updateMacrophonyView(); + } + onMacrophonyPageSizeChange(value?: number | string) { + if (value !== undefined && value !== null) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + this.macrophonyPageSize = parsed; + } + } + this.macrophonyPage = 1; + this.updateMacrophonyView(); + } isMacrophonyOpen(key: string) { return this.macrophonyOpen.has(key); } toggleMacrophonyGroup(key: string) { if (this.macrophonyOpen.has(key)) this.macrophonyOpen.delete(key); else this.macrophonyOpen.add(key); } openMacrophonyDetail(g: MacrophonyGroup) { this.macrophonyDetailGroup = g; this.macrophonyDetailOpen = true; } closeMacrophonyDetail() { this.macrophonyDetailOpen = false; this.macrophonyDetailGroup = null; } goToMacrophonyPage(p: number) { this.macrophonyPage = p; this.updateMacrophonyView(); } - onGroupedSearch(g: GroupedTableState) { g.page = 1; this.updateGroupView(g); } + onGroupedSearch(g: GroupedTableState, value?: string) { + if (typeof value === 'string') g.search = value; + g.page = 1; + this.updateGroupView(g); + } toggleGroupedCompact(g: GroupedTableState) { g.compact = !g.compact; } exportGroupedCsv(g: GroupedTableState, file: string) { this.exportCsv(g.table, file); } isGroupedOpen(g: GroupedTableState, key: string) { return g.open.has(key); } @@ -1037,21 +1053,72 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { if (!isPlatformBrowser(this.platformId)) return; const rows = table.data ?? []; const columns = table.columns ?? []; - const header = columns.map((c) => c.label); - const body = rows.map((row) => - columns.map((column) => { - const value = this.formatCell(column, row); - const escaped = String(value).replace(/"/g, '""'); - return `"${escaped}"`; - }) - ); + const generatedAt = new Date().toLocaleString('pt-BR'); + const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); - const csv = [header.join(';'), ...body.map((line) => line.join(';'))].join('\n'); - const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' }); + const headerHtml = columns + .map((column) => `${escapeHtml(column.label)}`) + .join(''); + + const bodyHtml = rows + .map((row, index) => { + const cells = columns + .map((column) => { + const value = this.formatCell(column, row); + const toneClass = column.tone ? this.getToneClass(column.value(row)) : ''; + const alignClass = column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : ''; + const classes = [alignClass, toneClass].filter(Boolean).join(' '); + return `${escapeHtml(String(value))}`; + }) + .join(''); + return `${cells}`; + }) + .join(''); + + const html = ` + + + + + + +
${escapeHtml(table.label || 'Resumo')}
+
Exportado em ${escapeHtml(generatedAt)} | Total de linhas: ${rows.length}
+ + + ${headerHtml} + + + ${bodyHtml} + +
+ +`; + + const blob = new Blob([`\uFEFF${html}`], { type: 'application/vnd.ms-excel;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${filename}.csv`; + a.download = `${filename}.xls`; a.click(); URL.revokeObjectURL(url); } diff --git a/src/server.ts b/src/server.ts index 7896ef8..2582640 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,7 @@ import { join } from 'node:path'; import { Readable } from 'node:stream'; const browserDistFolder = join(import.meta.dirname, '../browser'); -const apiBaseUrl = (process.env['API_BASE_URL'] || 'http://backend:8080').replace(/\/+$/, ''); +const apiBaseUrl = (process.env['API_BASE_URL'] || 'http://localhost:5298').replace(/\/+$/, ''); const app = express(); const angularApp = new AngularNodeAppEngine(); diff --git a/src/styles.scss b/src/styles.scss index f4c8f3e..55b2fe5 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -17,7 +17,13 @@ } body { - background-color: var(--bg-body); + min-height: 100vh; + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.1), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.06), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); + background-attachment: fixed; + background-repeat: no-repeat; color: var(--text-main); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; @@ -84,31 +90,20 @@ select.form-control-sm { /* Empurra o conteúdo pra baixo do header fixo */ .app-main.has-header { position: relative; - padding-top: 84px; /* altura segura p/ header (mobile/desktop) */ - background: - radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.1), transparent 60%), - radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.06), transparent 60%), - linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); + padding-top: 76px; /* evita vão visual entre o header fixo e o conteúdo */ + background: transparent; } @media (max-width: 600px) { .app-main.has-header { - padding-top: 96px; + padding-top: 88px; } } /* Ajuste para monitores grandes: elimina o "vão" visual entre header e corpo. */ @media (min-width: 1400px) { .app-main.has-header { - padding-top: 72px; - } - - .container-geral, - .container-geral-responsive, - .container-fat, - .container-mureg, - .container-troca { - margin-top: 14px !important; + padding-top: 76px; } } @@ -326,4 +321,3 @@ app-header .modal-card .btn-secondary:hover { box-shadow: none !important; } } - From 751d965e8f361438d2ff4c1452187600ed5d725b Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 10 Feb 2026 16:23:46 -0300 Subject: [PATCH 40/46] Sem docker --- .dockerignore | 10 - Dockerfile | 25 --- angular.json | 20 +- package-lock.json | 220 ++++++-------------- package.json | 7 +- src/app/app.config.server.ts | 12 -- src/app/app.config.ts | 2 - src/app/app.routes.server.ts | 8 - src/app/app.ts | 1 - src/environments/environment.development.ts | 4 + src/environments/environment.production.ts | 4 + src/environments/environment.ts | 2 +- src/main.server.ts | 12 -- src/server.ts | 105 ---------- 14 files changed, 91 insertions(+), 341 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 src/app/app.config.server.ts delete mode 100644 src/app/app.routes.server.ts create mode 100644 src/environments/environment.development.ts create mode 100644 src/environments/environment.production.ts delete mode 100644 src/main.server.ts delete mode 100644 src/server.ts diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 17c2c5d..0000000 --- a/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -.git -.github -.vscode -.angular -node_modules -dist -out-tsc -coverage -*.log -README.md diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7375ad5..0000000 --- a/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM node:22-alpine AS deps -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -FROM deps AS build -COPY . . -RUN npm run build - -FROM node:22-alpine AS runtime -WORKDIR /app - -ENV NODE_ENV=production -ENV PORT=4000 -ENV API_BASE_URL=http://backend:8080 - -COPY package*.json ./ -RUN npm ci --omit=dev && npm cache clean --force - -COPY --from=build /app/dist ./dist - -EXPOSE 4000 - -CMD ["node", "dist/line-gestao-frontend/server/server.mjs"] diff --git a/angular.json b/angular.json index 242303a..6e8f7df 100644 --- a/angular.json +++ b/angular.json @@ -18,6 +18,7 @@ "builder": "@angular/build:application", "options": { "browser": "src/main.ts", + "outputPath": "dist/line-gestao-frontend", "polyfills": [ "zone.js" ], @@ -32,15 +33,16 @@ "styles": [ "src/styles.scss", "node_modules/bootstrap/dist/css/bootstrap.min.css" - ], - "server": "src/main.server.ts", - "outputMode": "server", - "ssr": { - "entry": "src/server.ts" - } + ] }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ], "budgets": [ { "type": "initial", @@ -56,6 +58,12 @@ "outputHashing": "all" }, "development": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ], "optimization": false, "extractLicenses": false, "sourceMap": true diff --git a/package-lock.json b/package-lock.json index f32b36a..be57a92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,14 +13,11 @@ "@angular/core": "20.3.16", "@angular/forms": "20.3.16", "@angular/platform-browser": "20.3.16", - "@angular/platform-server": "20.3.16", "@angular/router": "20.3.16", - "@angular/ssr": "20.3.16", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "chart.js": "^4.5.1", - "express": "^5.1.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -30,7 +27,6 @@ "@angular/cli": "20.3.16", "@angular/compiler-cli": "20.3.16", "@types/bootstrap": "^5.2.10", - "@types/express": "^5.0.1", "@types/jasmine": "~5.1.0", "@types/node": "^20.17.19", "jasmine-core": "~5.9.0", @@ -593,27 +589,6 @@ } } }, - "node_modules/@angular/platform-server": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-20.3.16.tgz", - "integrity": "sha512-LxQscYd3UCWV8H3sdlnM05UB60MZVuVsdsHvXdkJ9+WOQjVDN1l1rYhj2aDL/5KkaRd/nqo0yFRnVjwceXDJhQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.3.0", - "xhr2": "^0.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "20.3.16", - "@angular/compiler": "20.3.16", - "@angular/core": "20.3.16", - "@angular/platform-browser": "20.3.16", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, "node_modules/@angular/router": { "version": "20.3.16", "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", @@ -633,27 +608,6 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@angular/ssr": { - "version": "20.3.16", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-20.3.16.tgz", - "integrity": "sha512-EpPSc9kUiLbe3Lpj0GUplt0JNPFmyuTnOv/h4bJqfj07xvSbn5vH3W0wl78RQrcOh9hfXua4xVCvCF/6nV6zPg==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/platform-server": "^20.0.0", - "@angular/router": "^20.0.0" - }, - "peerDependenciesMeta": { - "@angular/platform-server": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -3532,17 +3486,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, "node_modules/@types/bootstrap": { "version": "5.2.10", "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz", @@ -3553,16 +3496,6 @@ "@popperjs/core": "^2.9.2" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3580,38 +3513,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/jasmine": { "version": "5.1.13", "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.13.tgz", @@ -3619,13 +3520,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -3637,53 +3531,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -3718,6 +3565,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -3931,6 +3779,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -4063,6 +3912,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4139,6 +3989,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4152,6 +4003,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4455,6 +4307,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4468,6 +4321,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4484,6 +4338,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4493,6 +4348,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -4578,6 +4434,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4595,6 +4452,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4705,6 +4563,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4719,6 +4578,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -4739,6 +4599,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4928,6 +4789,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4937,6 +4799,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4946,6 +4809,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5010,12 +4874,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5062,6 +4928,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5187,6 +5054,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -5232,6 +5100,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5241,6 +5110,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -5300,6 +5170,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5342,6 +5213,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5366,6 +5238,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5421,6 +5294,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5450,6 +5324,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5478,6 +5353,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5571,6 +5447,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -5634,6 +5511,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5708,6 +5586,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -5734,6 +5613,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -5831,6 +5711,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6790,6 +6671,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6799,6 +6681,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -6808,6 +6691,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6862,6 +6746,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6871,6 +6756,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -7079,6 +6965,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -7148,6 +7035,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7420,6 +7308,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7432,6 +7321,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -7444,6 +7334,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7623,6 +7514,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -7686,6 +7578,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -7799,6 +7692,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -7829,6 +7723,7 @@ "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7844,6 +7739,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7853,6 +7749,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -8030,6 +7927,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -8074,6 +7972,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -8115,6 +8014,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -8137,6 +8037,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -8152,6 +8053,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, "node_modules/shebang-command": { @@ -8181,6 +8083,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8200,6 +8103,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8216,6 +8120,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8234,6 +8139,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8577,6 +8483,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8741,6 +8648,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -8772,6 +8680,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -8871,6 +8780,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8942,6 +8852,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9178,6 +9089,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -9206,7 +9118,9 @@ "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" } diff --git a/package.json b/package.json index 5f32111..f3f70f1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test", - "serve:ssr:line-gestao-frontend": "node dist/line-gestao-frontend/server/server.mjs" + "test": "ng test" }, "prettier": { "printWidth": 100, @@ -28,14 +27,11 @@ "@angular/core": "20.3.16", "@angular/forms": "20.3.16", "@angular/platform-browser": "20.3.16", - "@angular/platform-server": "20.3.16", "@angular/router": "20.3.16", - "@angular/ssr": "20.3.16", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "chart.js": "^4.5.1", - "express": "^5.1.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -45,7 +41,6 @@ "@angular/cli": "20.3.16", "@angular/compiler-cli": "20.3.16", "@types/bootstrap": "^5.2.10", - "@types/express": "^5.0.1", "@types/jasmine": "~5.1.0", "@types/node": "^20.17.19", "jasmine-core": "~5.9.0", diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts deleted file mode 100644 index 41031f1..0000000 --- a/src/app/app.config.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; -import { provideServerRendering, withRoutes } from '@angular/ssr'; -import { appConfig } from './app.config'; -import { serverRoutes } from './app.routes.server'; - -const serverConfig: ApplicationConfig = { - providers: [ - provideServerRendering(withRoutes(serverRoutes)) - ] -}; - -export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a91a2a6..961d642 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -5,7 +5,6 @@ import { provideZoneChangeDetection } from '@angular/core'; import { provideRouter, TitleStrategy } from '@angular/router'; -import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; @@ -20,7 +19,6 @@ export const appConfig: ApplicationConfig = { { provide: LOCALE_ID, useValue: 'pt-BR' }, provideRouter(routes), { provide: TitleStrategy, useClass: AppTitleStrategy }, - provideClientHydration(withEventReplay()), // ✅ HttpClient com fetch + interceptor provideHttpClient( diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts deleted file mode 100644 index 2c5a11d..0000000 --- a/src/app/app.routes.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RenderMode, ServerRoute } from '@angular/ssr'; - -export const serverRoutes: ServerRoute[] = [ - { - path: '**', - renderMode: RenderMode.Server - } -]; diff --git a/src/app/app.ts b/src/app/app.ts index 3ad4aab..7bafa32 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -90,5 +90,4 @@ export class AppComponent { } } -// ✅ SSR espera importar { App } de './app/app' export { AppComponent as App }; diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts new file mode 100644 index 0000000..f7d93cd --- /dev/null +++ b/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'http://localhost:5298' +}; diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts new file mode 100644 index 0000000..c0788e9 --- /dev/null +++ b/src/environments/environment.production.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: 'https://api.linegestao.inglinesystems.com.br' +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c46aeaa..f7d93cd 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiUrl: '' + apiUrl: 'http://localhost:5298' }; diff --git a/src/main.server.ts b/src/main.server.ts deleted file mode 100644 index 4a25c51..0000000 --- a/src/main.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser'; -import { registerLocaleData } from '@angular/common'; -import localePt from '@angular/common/locales/pt'; -import { App } from './app/app'; -import { config } from './app/app.config.server'; - -registerLocaleData(localePt, 'pt-BR'); - -const bootstrap = (context: BootstrapContext) => - bootstrapApplication(App, config, context); - -export default bootstrap; diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 2582640..0000000 --- a/src/server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - AngularNodeAppEngine, - createNodeRequestHandler, - isMainModule, - writeResponseToNodeResponse, -} from '@angular/ssr/node'; -import express from 'express'; -import { join } from 'node:path'; -import { Readable } from 'node:stream'; - -const browserDistFolder = join(import.meta.dirname, '../browser'); -const apiBaseUrl = (process.env['API_BASE_URL'] || 'http://localhost:5298').replace(/\/+$/, ''); - -const app = express(); -const angularApp = new AngularNodeAppEngine(); - -/** - * Proxy API calls from the browser-facing frontend server to the backend API. - */ -app.use(['/api', '/auth'], async (req, res, next) => { - try { - const targetUrl = new URL(req.originalUrl, `${apiBaseUrl}/`); - const headers = new Headers(); - - for (const [key, value] of Object.entries(req.headers)) { - if (value === undefined) continue; - const lower = key.toLowerCase(); - if (lower === 'host' || lower === 'content-length') continue; - - headers.set(key, Array.isArray(value) ? value.join(',') : value); - } - - const requestInit: RequestInit & { duplex?: 'half' } = { - method: req.method, - headers, - redirect: 'manual', - }; - - if (req.method !== 'GET' && req.method !== 'HEAD') { - requestInit.body = req as unknown as BodyInit; - requestInit.duplex = 'half'; - } - - const upstream = await fetch(targetUrl, requestInit); - - res.status(upstream.status); - upstream.headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'connection' || lower === 'transfer-encoding') return; - res.setHeader(key, value); - }); - - if (!upstream.body) { - res.end(); - return; - } - - Readable.fromWeb(upstream.body as any).pipe(res); - } catch (error) { - next(error); - } -}); - -/** - * Serve static files from /browser - */ -app.use( - express.static(browserDistFolder, { - maxAge: '1y', - index: false, - redirect: false, - }), -); - -/** - * Handle all other requests by rendering the Angular application. - */ -app.use((req, res, next) => { - angularApp - .handle(req) - .then((response) => - response ? writeResponseToNodeResponse(response, res) : next(), - ) - .catch(next); -}); - -/** - * Start the server if this module is the main entry point, or it is ran via PM2. - * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. - */ -if (isMainModule(import.meta.url) || process.env['pm_id']) { - const port = process.env['PORT'] || 4000; - app.listen(port, (error) => { - if (error) { - throw error; - } - - console.log(`Node Express server listening on http://localhost:${port}`); - }); -} - -/** - * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions. - */ -export const reqHandler = createNodeRequestHandler(app); From 8729ffddbb3e2c7a7c92733cb97b3436ff585532 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 11 Feb 2026 16:18:51 -0300 Subject: [PATCH 41/46] =?UTF-8?q?Branch=20Produ=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.html | 25 ++-- src/app/components/header/header.scss | 66 ++++++++++ src/app/components/header/header.ts | 120 +++++++++++++++++-- src/app/pages/dashboard/dashboard.ts | 39 +++++- src/app/pages/notificacoes/notificacoes.html | 10 +- src/app/pages/notificacoes/notificacoes.ts | 65 ++++++++-- src/app/pages/resumo/resumo.html | 2 +- src/app/pages/resumo/resumo.ts | 100 +++++++++++++++- 8 files changed, 381 insertions(+), 46 deletions(-) diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 44eadb5..0b3a0b1 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -42,7 +42,7 @@
-
+
Carregando...
@@ -57,20 +57,27 @@

Tudo limpo por aqui!

+
+ + + Mostrando {{ notificationsPreviewLimit }} de {{ notifications.length }} notificações + +
+
+ [class.danger]="getNotificationTipo(n) === 'Vencido'" + [class.warn]="getNotificationTipo(n) === 'AVencer'"> + [class.bi-x-lg]="getNotificationTipo(n) === 'Vencido'" + [class.bi-clock-history]="getNotificationTipo(n) === 'AVencer'" + [class.bi-info-circle]="getNotificationTipo(n) !== 'Vencido' && getNotificationTipo(n) !== 'AVencer'">
@@ -84,7 +91,7 @@

{{ getVigenciaLabel(n) }}: - + {{ getVigenciaDate(n) }}

@@ -399,7 +406,7 @@
Atenção à Vigência - +
A linha {{ toastItem.linha }} vence em breve. diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 704ab16..b30b58f 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -108,6 +108,72 @@ $border-color: #e5e7eb; .notifications-head { padding: 16px; border-bottom: 1px solid $border-color; display: flex; justify-content: space-between; align-items: center; .head-title { font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 6px; } .see-all { font-size: 12px; color: $primary; text-decoration: none; font-weight: 600; } } .notifications-body { max-height: 360px; overflow-y: auto; } .notifications-empty { padding: 32px; text-align: center; color: $text-muted; .empty-icon { font-size: 24px; margin-bottom: 8px; } } +.notifications-state { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 16px 8px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(28, 56, 201, 0.14); + background: rgba(28, 56, 201, 0.06); + color: #1e3a8a; + font-size: 12px; + font-weight: 600; + line-height: 1.35; + + i { + font-size: 14px; + color: $primary; + } + + .spinner-border { + width: 14px; + height: 14px; + border-width: 2px; + } + + &.loading { + background: #f8fafc; + border-color: #e2e8f0; + color: #475569; + } + + &.info { + background: #eff6ff; + border-color: #bfdbfe; + color: #1e3a8a; + + .notifications-truncate-copy { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px; + font-weight: 600; + color: #1e3a8a; + + strong { + padding: 1px 6px; + border-radius: 999px; + border: 1px solid #93c5fd; + background: #dbeafe; + color: #1d4ed8; + font-weight: 800; + line-height: 1.2; + } + } + } + + &.warn { + background: #fff7ed; + border-color: #fed7aa; + color: #9a3412; + + i { + color: #c2410c; + } + } +} .notification-item { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid $border-color; cursor: pointer; diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 1518cbe..dea9b10 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, Inject, ElementRef, ViewChild } from '@angular/core'; +import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; import { RouterLink, Router, NavigationEnd } from '@angular/router'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; @@ -17,7 +17,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select'; templateUrl: './header.html', styleUrls: ['./header.scss'], }) -export class Header { +export class Header implements AfterViewInit { isScrolled = false; menuOpen = false; @@ -32,6 +32,10 @@ export class Header { notificationsLoading = false; notificationsError = false; private notificationsLoaded = false; + private pendingToastCheck = false; + private lastNotificationsLoadAt = 0; + private readonly notificationsRefreshMs = 60_000; + readonly notificationsPreviewLimit = 40; @ViewChild('notifToast') notifToast?: ElementRef; createUserForm: FormGroup; @@ -195,7 +199,15 @@ export class Header { this.notificationsOpen = !this.notificationsOpen; if (this.notificationsOpen) { this.optionsOpen = false; - this.loadNotifications(); + if (!this.notificationsLoaded) { + this.loadNotifications(false); + return; + } + + if (Date.now() - this.lastNotificationsLoadAt > this.notificationsRefreshMs) { + // Atualiza em background para não bloquear a abertura visual do dropdown. + setTimeout(() => this.loadNotifications(false), 0); + } } } @@ -214,7 +226,7 @@ export class Header { } getVigenciaLabel(notification: NotificationDto): string { - return notification.tipo === 'Vencido' ? 'Venceu em' : 'Vence em'; + return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em'; } getVigenciaDate(notification: NotificationDto): string { @@ -223,7 +235,30 @@ export class Header { notification.referenciaData ?? notification.data; if (!raw) return '-'; - return new Date(raw).toLocaleDateString('pt-BR'); + const parsed = this.parseDateOnly(raw); + if (!parsed) return '-'; + return parsed.toLocaleDateString('pt-BR'); + } + + getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) return notification.tipo; + + const today = this.startOfDay(new Date()); + return parsed < today ? 'Vencido' : 'AVencer'; + } + + getNotificationDaysToExpire(notification: NotificationDto): number | null { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) { + return notification.diasParaVencer ?? null; + } + + const today = this.startOfDay(new Date()); + const msPerDay = 24 * 60 * 60 * 1000; + return Math.round((parsed.getTime() - today.getTime()) / msPerDay); } abbreviateName(value?: string | null): string { @@ -251,6 +286,18 @@ export class Header { return this.notifications.filter(n => !n.lida).length; } + get notificationsPreview() { + return this.notifications.slice(0, this.notificationsPreviewLimit); + } + + get hasNotificationsTruncated() { + return this.notifications.length > this.notificationsPreviewLimit; + } + + trackByNotificationId(_: number, notification: NotificationDto) { + return notification.id; + } + logout() { this.authService.logout(); this.optionsOpen = false; @@ -288,13 +335,27 @@ export class Header { localStorage.setItem('vigenciaAcknowledgedIds', JSON.stringify(Array.from(acknowledged))); } - private ensureNotificationsLoaded() { - if (this.notificationsLoaded || this.notificationsLoading) return; - this.loadNotifications(); + acknowledgeCurrentToast() { + const current = this.toastNotification; + if (!current) return; + this.acknowledgeNotification(current); } - private loadNotifications() { + ngAfterViewInit() { + if (this.pendingToastCheck) { + this.pendingToastCheck = false; + void this.maybeShowVigenciaToast(); + } + } + + private ensureNotificationsLoaded() { + if (this.notificationsLoaded || this.notificationsLoading) return; + this.loadNotifications(true); + } + + private loadNotifications(showToast = true) { if (!isPlatformBrowser(this.platformId)) return; + if (this.notificationsLoading) return; this.notificationsLoading = true; this.notificationsError = false; this.notificationsService.list().subscribe({ @@ -302,7 +363,10 @@ export class Header { this.notifications = data || []; this.notificationsLoaded = true; this.notificationsLoading = false; - this.maybeShowVigenciaToast(); + this.lastNotificationsLoadAt = Date.now(); + if (showToast) { + void this.maybeShowVigenciaToast(); + } }, error: () => { this.notificationsError = true; @@ -312,12 +376,17 @@ export class Header { } private async maybeShowVigenciaToast() { - if (!this.notifToast || !isPlatformBrowser(this.platformId)) return; + if (!isPlatformBrowser(this.platformId)) return; + if (!this.notifToast) { + this.pendingToastCheck = true; + return; + } + const pending = this.getPendingVigenciaToast(); if (!pending) return; const bs = await import('bootstrap'); - const toast = new bs.Toast(this.notifToast.nativeElement, { autohide: false }); + const toast = bs.Toast.getOrCreateInstance(this.notifToast.nativeElement, { autohide: false }); toast.show(); } @@ -328,10 +397,35 @@ export class Header { private getPendingVigenciaToast() { const acknowledged = this.getAcknowledgedIds(); return this.notifications.find( - n => n.tipo === 'AVencer' && n.diasParaVencer === 5 && !acknowledged.has(n.id) + n => + this.getNotificationTipo(n) === 'AVencer' && + this.getNotificationDaysToExpire(n) === 5 && + !acknowledged.has(n.id) ); } + private parseDateOnly(raw?: string | null): Date | null { + if (!raw) return null; + const datePart = raw.split('T')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) { + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) { + return new Date(year, month - 1, day); + } + } + + const fallback = new Date(raw); + if (Number.isNaN(fallback.getTime())) return null; + return this.startOfDay(fallback); + } + + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + private getAcknowledgedIds() { if (!isPlatformBrowser(this.platformId)) return new Set(); try { diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index 9f16739..fb8fc8f 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -129,6 +129,13 @@ type InsightsKpisAdicionais = { totalLinesWithNoPaidAdditional?: number | null; }; +type InsightsLineTotal = { + tipo?: string | null; + qtdLinhas?: number | null; + valorTotalLine?: number | null; + lucroTotalLine?: number | null; +}; + type DashboardGeralInsightsDto = { kpis?: { totalLinhas?: number | null; @@ -136,6 +143,7 @@ type DashboardGeralInsightsDto = { vivo?: InsightsKpisVivo | null; travelMundo?: InsightsKpisTravel | null; adicionais?: InsightsKpisAdicionais | null; + totaisLine?: InsightsLineTotal[] | null; } | null; charts?: { linhasPorFranquia?: InsightsChartSeries | null; @@ -507,6 +515,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const vivoRaw = this.readNode(kpisRaw, 'vivo', 'Vivo') ?? {}; const travelRaw = this.readNode(kpisRaw, 'travelMundo', 'TravelMundo') ?? {}; const adicionaisRaw = this.readNode(kpisRaw, 'adicionais', 'Adicionais') ?? {}; + const totaisLineRaw = this.readNode(kpisRaw, 'totaisLine', 'TotaisLine'); const chartsRaw = this.readNode(raw, 'charts', 'Charts') ?? {}; return { @@ -532,6 +541,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { totalLinesWithAnyPaidAdditional: this.toNumberOrNull(this.readNode(adicionaisRaw, 'totalLinesWithAnyPaidAdditional', 'TotalLinesWithAnyPaidAdditional')), totalLinesWithNoPaidAdditional: this.toNumberOrNull(this.readNode(adicionaisRaw, 'totalLinesWithNoPaidAdditional', 'TotalLinesWithNoPaidAdditional')), }, + totaisLine: this.normalizeLineTotals(totaisLineRaw), }, charts: { linhasPorFranquia: this.normalizeChartSeries(this.readNode(chartsRaw, 'linhasPorFranquia', 'LinhasPorFranquia')), @@ -559,6 +569,19 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }; } + private normalizeLineTotals(rawTotals: any): InsightsLineTotal[] { + if (!Array.isArray(rawTotals)) return []; + + return rawTotals + .map((row: any) => ({ + tipo: String(this.readNode(row, 'tipo', 'Tipo') ?? '').trim() || null, + qtdLinhas: this.toNumberOrNull(this.readNode(row, 'qtdLinhas', 'QtdLinhas')), + valorTotalLine: this.toNumberOrNull(this.readNode(row, 'valorTotalLine', 'ValorTotalLine')), + lucroTotalLine: this.toNumberOrNull(this.readNode(row, 'lucroTotalLine', 'LucroTotalLine')), + })) + .filter((row) => !!row.tipo); + } + private readNode(source: any, ...keys: string[]): any { if (!source || typeof source !== 'object') return undefined; @@ -585,7 +608,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return; } - const lineTotals = Array.isArray(this.resumo.lineTotais) ? this.resumo.lineTotais : []; + const lineTotals = this.getEffectiveLineTotals(); const pf = this.findLineTotal(lineTotals, ['PF', 'PESSOA FISICA']); const pj = this.findLineTotal(lineTotals, ['PJ', 'PESSOA JURIDICA']); const diferenca = this.findLineTotal(lineTotals, ['DIFERENCA PJ X PF', 'DIFERENÇA PJ X PF', 'DIFERENCA']); @@ -686,6 +709,20 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return null; } + private getEffectiveLineTotals(): LineTotal[] { + const fromInsights = (this.insights?.kpis?.totaisLine ?? []) + .map((row) => ({ + tipo: row.tipo ?? null, + qtdLinhas: row.qtdLinhas ?? null, + valorTotalLine: row.valorTotalLine ?? null, + lucroTotalLine: row.lucroTotalLine ?? null, + })) + .filter((row) => !!(row.tipo ?? '').toString().trim()); + + if (fromInsights.length) return fromInsights; + return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : []; + } + private clearInsightsData() { this.insights = null; this.franquiaLabels = []; diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 8c78c82..f1442db 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -83,8 +83,8 @@ class="list-item" *ngFor="let n of filteredNotifications" [class.is-read]="n.lida" - [class.is-danger]="n.tipo === 'Vencido'" - [class.is-warning]="n.tipo === 'AVencer'" + [class.is-danger]="getNotificationTipo(n) === 'Vencido'" + [class.is-warning]="getNotificationTipo(n) === 'AVencer'" >
@@ -94,7 +94,7 @@
- +
@@ -124,8 +124,8 @@ {{ n.planoContrato || '-' }}
- - {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} + + {{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index 930bf7d..7b149da 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -42,10 +42,10 @@ export class Notificacoes implements OnInit { get filteredNotifications() { if (this.filter === 'vencidas') { - return this.notifications.filter(n => n.tipo === 'Vencido'); + return this.notifications.filter(n => this.getNotificationTipo(n) === 'Vencido'); } if (this.filter === 'aVencer') { - return this.notifications.filter(n => n.tipo === 'AVencer'); + return this.notifications.filter(n => this.getNotificationTipo(n) === 'AVencer'); } if (this.filter === 'lidas') { return this.notifications.filter(n => n.lida); @@ -55,7 +55,18 @@ export class Notificacoes implements OnInit { formatDateLabel(date?: string | null): string { if (!date) return '-'; - return new Date(date).toLocaleDateString('pt-BR'); + const parsed = this.parseDateOnly(date); + if (!parsed) return '-'; + return parsed.toLocaleDateString('pt-BR'); + } + + getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; + const parsed = this.parseDateOnly(reference); + if (!parsed) return notification.tipo; + + const today = this.startOfDay(new Date()); + return parsed < today ? 'Vencido' : 'AVencer'; } private loadNotifications() { @@ -74,20 +85,23 @@ export class Notificacoes implements OnInit { } countByType(tipo: 'Vencido' | 'AVencer'): number { - return this.notifications.filter(n => n.tipo === tipo && !n.lida).length; + return this.notifications.filter(n => this.getNotificationTipo(n) === tipo && !n.lida).length; } markAllAsRead() { if (this.filter === 'lidas' || this.bulkLoading) return; this.bulkLoading = true; - const filterParam = this.getFilterParam(); - const ids = Array.from(this.selectedIds); - this.notificationsService.markAllAsRead(filterParam, ids.length ? ids : undefined).subscribe({ + const selectedIds = Array.from(this.selectedIds); + const scopedIds = selectedIds.length + ? selectedIds + : (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []); + const filterParam = scopedIds.length ? undefined : this.getFilterParam(); + this.notificationsService.markAllAsRead(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({ next: () => { const now = new Date().toISOString(); this.notifications = this.notifications.map((n) => { - if (ids.length ? ids.includes(n.id) : this.shouldMarkRead(n)) { + if (scopedIds.length ? scopedIds.includes(n.id) : this.shouldMarkRead(n)) { return { ...n, lida: true, lidaEm: now }; } return n; @@ -105,9 +119,12 @@ export class Notificacoes implements OnInit { if (this.filter === 'lidas' || this.exportLoading) return; this.exportLoading = true; - const filterParam = this.getFilterParam(); - const ids = Array.from(this.selectedIds); - this.notificationsService.export(filterParam, ids.length ? ids : undefined).subscribe({ + const selectedIds = Array.from(this.selectedIds); + const scopedIds = selectedIds.length + ? selectedIds + : (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []); + const filterParam = scopedIds.length ? undefined : this.getFilterParam(); + this.notificationsService.export(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({ next: (res) => { const blob = res.body; if (!blob) { @@ -172,11 +189,33 @@ export class Notificacoes implements OnInit { private shouldMarkRead(n: NotificationDto): boolean { if (this.filter === 'todas') return true; - if (this.filter === 'aVencer') return n.tipo === 'AVencer'; - if (this.filter === 'vencidas') return n.tipo === 'Vencido'; + if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer'; + if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido'; return false; } + private parseDateOnly(raw?: string | null): Date | null { + if (!raw) return null; + const datePart = raw.split('T')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) { + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) { + return new Date(year, month - 1, day); + } + } + + const fallback = new Date(raw); + if (Number.isNaN(fallback.getTime())) return null; + return this.startOfDay(fallback); + } + + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + private extractFilename(contentDisposition: string | null): string | null { if (!contentDisposition) return null; const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index cb43f63..68539bb 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -308,7 +308,7 @@
Lucro Consolidado - {{ formatMoney(clientesTotals?.lucro) }} + {{ formatMoney(totaisLineLucroConsolidado) }}
diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts index 81dbf0b..2e3bd79 100644 --- a/src/app/pages/resumo/resumo.ts +++ b/src/app/pages/resumo/resumo.ts @@ -11,6 +11,7 @@ import { HostBinding } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; import { ActivatedRoute, Router } from '@angular/router'; import Chart from 'chart.js/auto'; import type { ChartConfiguration, ChartData, ScriptableContext, TooltipItem } from 'chart.js'; @@ -30,6 +31,7 @@ import { ReservaPorDdd, ReservaTotal } from '../../services/resumo.service'; +import { environment } from '../../../environments/environment'; type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva'; @@ -66,6 +68,7 @@ type MacrophonyGroup = { key: string; plano: string; gbLabel: string; totalLinha type GroupMetric = { label: string; value: string; tone?: string }; type GroupItem = { key: string; title: string; subtitle?: string; rows: T[]; metrics: GroupMetric[]; }; type GroupedTableState = { key: string; label: string; table: TableState; groupBy: (row: T) => string; groupTitle: (rows: T[]) => string; groupSubtitle?: (rows: T[]) => string | undefined; groupMetrics: (rows: T[]) => GroupMetric[]; groupSort?: (a: GroupItem, b: GroupItem) => number; search: string; page: number; pageSize: number; pageSizeOptions: number[]; compact: boolean; open: Set; groups: GroupItem[]; filtered: GroupItem[]; view: GroupItem[]; detailOpen: boolean; detailGroup: GroupItem | null; }; +type GeralLineTotalPayload = { tipo?: unknown; qtdLinhas?: unknown; valorTotalLine?: unknown; lucroTotalLine?: unknown; }; @Component({ standalone: true, @@ -97,6 +100,8 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { private charts: { [key: string]: Chart | undefined } = {}; private viewReady = false; private dataReady = false; + private readonly baseApi: string; + private totaisLineFromGeral: LineTotal[] = []; // Estados de Tabela e Grupo macrophonyGroups: MacrophonyGroup[] = []; @@ -126,11 +131,14 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { constructor( @Inject(PLATFORM_ID) private platformId: object, + private http: HttpClient, private resumoService: ResumoService, private route: ActivatedRoute, private router: Router, private cdr: ChangeDetectorRef ) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; this.initTables(); this.initGroupTables(); // Default chart configuration for Enterprise look @@ -269,6 +277,16 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { .sort((a, b) => b.lucro - a.lucro) .slice(0, 10); + // Garante altura suficiente para exibir todos os nomes no eixo Y sem auto-skip. + const parent = canvas.parentElement as HTMLElement | null; + if (parent) { + const minHeight = 300; + const rowHeight = 34; + parent.style.height = `${Math.max(minHeight, data.length * rowHeight)}px`; + } + + const common = this.getCommonChartOptions('currency') as any; + this.charts['clientes'] = new Chart(canvas, { type: 'bar', data: { @@ -282,8 +300,25 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { }] }, options: { - ...this.getCommonChartOptions('currency'), - indexAxis: 'y' + ...common, + indexAxis: 'y', + scales: { + x: { + ...(common.scales?.x ?? {}), + ticks: { + ...((common.scales?.x as any)?.ticks ?? {}), + maxTicksLimit: 8 + } + }, + y: { + ...(common.scales?.y ?? {}), + ticks: { + ...((common.scales?.y as any)?.ticks ?? {}), + autoSkip: false, + maxTicksLimit: data.length || 10 + } + } + } } }); } @@ -407,12 +442,15 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { this.loading = true; this.errorMessage = ''; this.dataReady = false; + this.totaisLineFromGeral = []; + this.resumoService.getResumo().subscribe({ next: (data) => { this.resumo = data ? this.normalizeResumo(data) : null; this.loading = false; this.dataReady = true; this.bindTables(); + this.loadTotaisLineFromGeral(); this.cdr.detectChanges(); this.tryBuildCharts(); }, @@ -424,6 +462,46 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { }); } + private loadTotaisLineFromGeral(): void { + if (!isPlatformBrowser(this.platformId)) return; + + this.http.get(`${this.baseApi}/dashboard/geral/insights`).subscribe({ + next: (dto) => { + const rows = this.extractTotaisLineFromInsights(dto); + if (!rows.length) return; + + this.totaisLineFromGeral = rows; + this.bindTables(); + this.cdr.detectChanges(); + this.tryBuildCharts(); + }, + error: () => { + // Mantem fallback para a tabela de Resumo quando insights falhar. + }, + }); + } + + private extractTotaisLineFromInsights(dto: any): LineTotal[] { + const kpis = dto?.kpis ?? dto?.Kpis; + const rawRows = kpis?.totaisLine ?? kpis?.TotaisLine; + if (!Array.isArray(rawRows)) return []; + + return rawRows + .map((row: GeralLineTotalPayload) => ({ + tipo: this.toText(row?.tipo), + qtdLinhas: this.toNumber(row?.qtdLinhas), + valorTotalLine: this.toNumber(row?.valorTotalLine), + lucroTotalLine: this.toNumber(row?.lucroTotalLine), + })) + .filter((row) => !!(row.tipo ?? '').toString().trim()); + } + + private toText(value: unknown): string | null { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text ? text : null; + } + // Animação de entrada private animateIn(): void { if (!isPlatformBrowser(this.platformId)) return; @@ -840,7 +918,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { this.tablePlanoContrato.data = this.buildPlanoContratoResumoConsolidado(resumo.planoContratoResumos ?? []); this.tableClientes.data = resumo.vivoLineResumos ?? []; this.tableClientesEspeciais.data = resumo.clienteEspeciais ?? []; - this.tableTotaisLine.data = resumo.lineTotais ?? []; + this.tableTotaisLine.data = this.getEffectiveLineTotais(); this.tableReserva.data = resumo.reservaLines ?? []; this.updateMacrophonyView(); @@ -1148,13 +1226,27 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); } findLineTotal(k: string[]): LineTotal | null { const keys = k.map((item) => item.toUpperCase()); - const list = Array.isArray(this.resumo?.lineTotais) ? this.resumo?.lineTotais : []; + const list = this.getEffectiveLineTotais(); for (const item of list) { const tipo = (item?.tipo ?? '').toString().toUpperCase(); if (keys.some((key) => tipo.includes(key))) return item; } return null; } + + get totaisLineLucroConsolidado(): number { + const pf = this.toNumber(this.findLineTotal(['PF', 'PESSOA FISICA'])?.lucroTotalLine) ?? 0; + const pj = this.toNumber(this.findLineTotal(['PJ', 'PESSOA JURIDICA'])?.lucroTotalLine) ?? 0; + return pf + pj; + } + + private getEffectiveLineTotais(): LineTotal[] { + if (this.totaisLineFromGeral.length > 0) { + return this.totaisLineFromGeral; + } + + return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : []; + } get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; } get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); } From 70fc642286f30f254e05d32ca42ae7adf699a364 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Thu, 12 Feb 2026 10:01:28 -0300 Subject: [PATCH 42/46] =?UTF-8?q?Minha=20altera=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/interceptors/session.interceptor.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/interceptors/session.interceptor.ts b/src/app/interceptors/session.interceptor.ts index 9d58e1b..7e3a493 100644 --- a/src/app/interceptors/session.interceptor.ts +++ b/src/app/interceptors/session.interceptor.ts @@ -3,6 +3,7 @@ import { inject, PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { catchError, throwError } from 'rxjs'; import { SessionNoticeService } from '../services/session-notice.service'; +import { AuthService } from '../services/auth.service'; export const sessionInterceptor: HttpInterceptorFn = (req, next) => { const platformId = inject(PLATFORM_ID); @@ -11,11 +12,18 @@ export const sessionInterceptor: HttpInterceptorFn = (req, next) => { } const sessionNotice = inject(SessionNoticeService); + const authService = inject(AuthService); + + const url = (req.url || '').toLowerCase(); + const isAuthRequest = url.includes('/auth/login') || url.includes('/auth/register'); return next(req).pipe( catchError((err: HttpErrorResponse) => { if (err?.status === 401) { - sessionNotice.handleUnauthorized(); + // 401 durante /auth/login = credenciais inválidas, não "sessão expirada". + if (!isAuthRequest && !!authService.token) { + sessionNotice.handleUnauthorized(); + } } else if (err?.status === 403) { sessionNotice.handleForbidden(); } From 1f277b8c8a48a5c14807b448cd708d245bc4a961 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Thu, 12 Feb 2026 16:49:42 -0300 Subject: [PATCH 43/46] =?UTF-8?q?Subindo=20altera=C3=A7=C3=B5es=20para=20p?= =?UTF-8?q?rodu=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> { From 96d1b28c19ab3ea832ca6abcb5c72c4e72223a67 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 24 Feb 2026 11:05:51 -0300 Subject: [PATCH 44/46] Responsividade Mobile --- src/app/components/header/header.html | 23 +- src/app/components/header/header.scss | 395 +++++++++++++ src/app/components/header/header.ts | 217 +++++++- .../chips-controle-recebidos.scss | 125 +++++ .../chips-controle-recebidos.ts | 7 +- .../pages/dados-usuarios/dados-usuarios.html | 78 ++- .../pages/dados-usuarios/dados-usuarios.scss | 239 ++++++++ .../pages/dados-usuarios/dados-usuarios.ts | 13 +- src/app/pages/faturamento/faturamento.ts | 4 +- src/app/pages/geral/geral.html | 2 +- src/app/pages/geral/geral.scss | 49 ++ src/app/pages/geral/geral.ts | 13 +- src/app/pages/historico/historico.scss | 394 ++++++++++++- src/app/pages/mureg/mureg.ts | 4 +- src/app/pages/parcelamentos/parcelamentos.ts | 4 +- src/app/pages/resumo/resumo.scss | 31 ++ src/app/pages/vigencia/vigencia.html | 16 +- src/app/pages/vigencia/vigencia.scss | 519 +++++++++++++++++- src/app/pages/vigencia/vigencia.ts | 4 +- src/app/services/users.service.ts | 8 +- src/app/utils/destructive-confirmation.ts | 333 +++++++++++ src/styles.scss | 408 +++++++++++++- 22 files changed, 2821 insertions(+), 65 deletions(-) create mode 100644 src/app/utils/destructive-confirmation.ts diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 9aead02..fa1e907 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -340,7 +340,20 @@ - +
@@ -430,6 +443,14 @@
-
- -
@@ -270,13 +270,18 @@
-
+
-
- +
+ +
@@ -286,15 +291,19 @@
-
-
-
-
-
+
+
+ + + Gerado automaticamente pelo sistema +
+
+
+
-
-
+
+
@@ -327,13 +336,18 @@
-
+
-
- +
+ +
@@ -343,17 +357,21 @@
-
-
-
+
+
+ + + Gerado automaticamente pelo sistema +
+
-
+
-
+
@@ -364,10 +382,10 @@
-
+
-
-
+
+
diff --git a/src/app/pages/dados-usuarios/dados-usuarios.scss b/src/app/pages/dados-usuarios/dados-usuarios.scss index 6723c90..5ac4967 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.scss +++ b/src/app/pages/dados-usuarios/dados-usuarios.scss @@ -192,6 +192,74 @@ /* Controls */ .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } + +.filter-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 12px; + backdrop-filter: blur(8px); + box-shadow: 0 2px 8px rgba(17, 18, 20, 0.04); +} + +.filter-tab { + border: 1px solid transparent; + background: transparent; + padding: 8px 14px; + border-radius: 8px; + font-size: 0.84rem; + font-weight: 800; + color: rgba(17, 18, 20, 0.62); + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + min-height: 34px; + + &:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.72); + border-color: rgba(17, 18, 20, 0.08); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14); + } + + &.active { + background: #fff; + color: var(--brand); + border-color: rgba(227, 61, 207, 0.16); + box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); + } +} + +.tipo-filter-tabs { + .filter-tab { + min-width: 122px; + } +} + +@media (max-width: 700px) { + .tipo-filter-tabs { + width: 100%; + justify-content: stretch; + } + + .tipo-filter-tabs .filter-tab { + flex: 1 1 0; + min-width: 0; + padding: 8px 10px; + font-size: 0.8rem; + } +} + .search-group { max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease; &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); } @@ -326,6 +394,122 @@ .modal-body { padding: 16px; } .modal-card.create-modal .modal-footer { flex-direction: column-reverse; } .modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; } + + .modal-card.create-modal summary.box-header { + padding: 10px 12px; + + span { + font-size: 0.72rem; + line-height: 1.2; + gap: 7px; + } + + i:not(.transition-icon) { + width: 20px; + height: 20px; + border-radius: 6px; + font-size: 0.8rem; + } + } + + .form-field.field-line { + order: initial; + grid-column: span 2; + } + + .form-field.field-line .form-control { + min-height: 42px; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; + } + + .form-field.field-item { + align-items: flex-start; + } + + .form-field.field-item .form-control { + width: 100%; + max-width: none; + min-height: 38px; + text-align: left; + font-size: 0.82rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + + .user-modal-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 10px; + } + + .user-modal-grid .span-2, + .user-modal-grid .field-tipo, + .user-modal-grid .field-line, + .user-modal-grid .field-rg { + grid-column: span 2 !important; + } + + .user-modal-grid .field-item, + .user-modal-grid .field-cpf-cnpj, + .user-modal-grid .field-celular, + .user-modal-grid .field-telefone { + grid-column: span 1 !important; + min-width: 0; + } + + .user-modal-grid .field-item .field-hint { + display: none; + } + + .user-modal-grid .field-tipo .form-control { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + min-height: 42px; + border-radius: 12px; + border-color: rgba(3, 15, 170, 0.18); + background: + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,248,251,0.98)); + box-shadow: 0 2px 8px rgba(3, 15, 170, 0.06); + padding-right: 34px; + font-weight: 800; + color: var(--blue); + background-image: + linear-gradient(45deg, transparent 50%, rgba(17,18,20,0.55) 50%), + linear-gradient(135deg, rgba(17,18,20,0.55) 50%, transparent 50%); + background-position: + calc(100% - 16px) calc(50% - 2px), + calc(100% - 11px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + } + + .user-modal-grid .field-tipo .form-control:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227,61,207,0.14); + } + + .user-modal-grid .field-cpf-cnpj .form-control, + .user-modal-grid .field-item .form-control { + min-height: 40px; + font-size: 0.84rem; + font-variant-numeric: tabular-nums; + } + + .user-modal-grid .field-rg .form-control { + min-height: 40px; + font-size: 0.88rem; + max-width: 100%; + } + + .contact-modal-grid .field-celular .form-control, + .contact-modal-grid .field-telefone .form-control { + width: 100%; + min-width: 0; + min-height: 40px; + } } /* FORM & DETAILS */ @@ -353,6 +537,8 @@ summary.box-header { cursor: pointer; user-select: none; list-style: none; + border-radius: 14px 14px 0 0; + transition: background 0.2s ease, color 0.2s ease; i:not(.transition-icon) { color: var(--brand); @@ -362,6 +548,41 @@ summary.box-header { &::-webkit-details-marker { display: none; } } +.modal-card.create-modal summary.box-header { + padding: 11px 14px; + border-bottom-color: rgba(227, 61, 207, 0.08); + background: linear-gradient(135deg, rgba(227, 61, 207, 0.10), rgba(3, 15, 170, 0.06)); + + span { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 0.76rem; + line-height: 1.2; + color: rgba(17, 18, 20, 0.84); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 900; + white-space: normal; + overflow-wrap: anywhere; + } + + i:not(.transition-icon) { + width: 22px; + height: 22px; + margin-right: 0; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(227, 61, 207, 0.12); + color: var(--brand); + flex-shrink: 0; + } +} + .transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); } details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); } @@ -391,6 +612,24 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); &.span-2 { grid-column: span 2; } } +.field-hint { + display: block; + font-size: 0.66rem; + line-height: 1.2; + color: rgba(17, 18, 20, 0.52); + font-weight: 700; +} + +.form-field.field-auto .form-control { + background: rgba(245, 245, 247, 0.9); + border-color: rgba(17, 18, 20, 0.1); + color: rgba(17, 18, 20, 0.72); +} + +.form-field.field-auto .form-control[readonly] { + cursor: default; +} + .details-dashboard .form-field > div { border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index 58c4b50..aa917b9 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -15,6 +15,7 @@ import { } from '../../services/dados-usuarios.service'; import { AuthService } from '../../services/auth.service'; import { LinesService, MobileLineDetail } from '../../services/lines.service'; +import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; type ViewMode = 'lines' | 'groups'; @@ -26,6 +27,11 @@ interface LineOptionDto { label?: string; } +interface SimpleOption { + label: string; + value: string; +} + @Component({ selector: 'app-dados-usuarios', standalone: true, @@ -90,6 +96,10 @@ export class DadosUsuarios implements OnInit { createDateNascimento = ''; clientsFromGeral: string[] = []; lineOptionsCreate: LineOptionDto[] = []; + readonly tipoPessoaOptions: SimpleOption[] = [ + { label: 'Pessoa Física', value: 'PF' }, + { label: 'Pessoa Jurídica', value: 'PJ' }, + ]; createClientsLoading = false; createLinesLoading = false; @@ -532,8 +542,9 @@ export class DadosUsuarios implements OnInit { this.deleteTarget = null; } - confirmDelete() { + async confirmDelete() { if (!this.deleteTarget) return; + if (!(await confirmDeletionWithTyping('este registro de dados do usuário'))) return; const id = this.deleteTarget.id; this.service.remove(id).subscribe({ next: () => { diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index 4764397..889a440 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -24,6 +24,7 @@ import { BillingUpdateRequest } from '../../services/billing'; import { AuthService } from '../../services/auth.service'; +import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; interface BillingClientGroup { cliente: string; @@ -695,8 +696,9 @@ export class Faturamento implements AfterViewInit, OnDestroy { this.cdr.detectChanges(); } - confirmDelete() { + async confirmDelete() { if (!this.deleteTarget) return; + if (!(await confirmDeletionWithTyping('este registro de faturamento'))) return; const id = this.deleteTarget.id; this.billing.remove(id).subscribe({ next: () => { diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 80a145b..063ec89 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -302,7 +302,7 @@
Gerenciar Grupo -
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 4151b81..3cec0d6 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -287,6 +287,55 @@ .group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; } .client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); } .group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); } +.btn-add-line-group { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + border: 1px solid transparent; + border-radius: 12px; + padding: 0.42rem 0.85rem; + line-height: 1; + white-space: nowrap; + font-weight: 800; + letter-spacing: 0.01em; + color: #fff; + background: + linear-gradient(135deg, rgba(227, 61, 207, 0.95), rgba(3, 15, 170, 0.95)); + background-clip: padding-box; + box-shadow: + 0 10px 22px rgba(3, 15, 170, 0.16), + inset 0 1px 0 rgba(255,255,255,0.2); + transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; + + i { + font-weight: 900; + } + + &:hover { + color: #fff; + transform: translateY(-1px); + filter: saturate(1.05) brightness(1.02); + box-shadow: + 0 14px 26px rgba(227, 61, 207, 0.18), + 0 8px 18px rgba(3, 15, 170, 0.14); + } + + &:focus-visible { + outline: none; + box-shadow: + 0 0 0 3px rgba(255, 255, 255, 0.95), + 0 0 0 6px rgba(227, 61, 207, 0.28), + 0 12px 24px rgba(3, 15, 170, 0.16); + } + + &:active { + transform: translateY(0); + box-shadow: + 0 8px 16px rgba(3, 15, 170, 0.14), + inset 0 1px 0 rgba(255,255,255,0.16); + } +} @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } /* Inner Table Destravada */ diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 1c3ece0..3bbc7b2 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -4,6 +4,7 @@ import { ViewChild, Inject, PLATFORM_ID, + OnInit, AfterViewInit, ChangeDetectorRef, OnDestroy, @@ -22,6 +23,7 @@ import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { AuthService } from '../../services/auth.service'; import { firstValueFrom, Subscription, filter } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; @@ -127,7 +129,7 @@ interface AccountCompanyOption { templateUrl: './geral.html', styleUrls: ['./geral.scss'] }) -export class Geral implements AfterViewInit, OnDestroy { +export class Geral implements OnInit, AfterViewInit, OnDestroy { toastMessage = ''; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @@ -413,10 +415,13 @@ export class Geral implements AfterViewInit, OnDestroy { this.navigationSub?.unsubscribe(); } + ngOnInit(): void { + if (!isPlatformBrowser(this.platformId)) return; + this.isAdmin = this.authService.hasRole('admin'); + } + async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; - - this.isAdmin = this.authService.hasRole('admin'); this.initAnimations(); setTimeout(() => { @@ -1646,7 +1651,7 @@ export class Geral implements AfterViewInit, OnDestroy { return; } - if (!confirm(`Remover linha ${r.linha}?`)) return; + if (!(await confirmDeletionWithTyping(`a linha ${r.linha}`))) return; this.loading = true; diff --git a/src/app/pages/historico/historico.scss b/src/app/pages/historico/historico.scss index 3dbbf74..3cc55ae 100644 --- a/src/app/pages/historico/historico.scss +++ b/src/app/pages/historico/historico.scss @@ -674,6 +674,396 @@ @media (max-width: 700px) { .filters-grid { grid-template-columns: 1fr; } .search-group { width: 100%; max-width: 100%; } - .entity-cell { flex-direction: column; align-items: flex-start; } - .expand-btn { align-self: flex-end; } + .entity-cell { + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 6px; + } + .entity-label { flex: 1 1 auto; min-width: 0; } + .expand-btn { align-self: center; flex-shrink: 0; } +} + +@media (max-width: 768px) { + .historico-page { + padding: 0 8px; + } + + .container-geral-responsive { + width: calc(100vw - 16px) !important; + margin-top: 18px; + margin-bottom: 28px; + } + + .page-blob { + opacity: 0.28; + filter: blur(40px); + + &.blob-1, + &.blob-2, + &.blob-3, + &.blob-4 { + width: 240px; + height: 240px; + } + } + + .geral-card { + min-height: auto; + border-radius: 16px; + } + + .geral-header { + padding: 12px; + } + + .title { + font-size: 22px; + margin-top: 0; + line-height: 1.15; + } + + .subtitle { + font-size: 12px; + text-align: center; + line-height: 1.35; + } + + .header-actions { + width: 100%; + justify-self: stretch; + display: flex; + } + + .header-actions .btn { + width: 100%; + justify-content: center; + } + + .filters-card { + padding: 12px; + gap: 12px; + border-radius: 14px; + } + + .filters-head { + align-items: stretch; + } + + .filters-title { + width: 100%; + justify-content: center; + text-align: center; + } + + .filters-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .filters-actions .btn-primary, + .filters-actions .btn-ghost { + width: 100%; + justify-content: center; + min-width: 0; + padding: 0 10px; + } + + .filters-grid { + grid-template-columns: 1fr !important; + gap: 10px; + } + + .filter-search { + grid-column: span 1; + } + + .filter-field { + min-width: 0; + } + + .filter-field input { + width: 100%; + min-width: 0; + font-size: 13px; + } + + .filter-field app-select, + .filter-field .select-glass { + width: 100%; + min-width: 0; + display: block; + } + + .search-group { + max-width: 100%; + min-width: 0; + min-height: 40px; + align-items: center; + } + + .search-group .form-control { + min-width: 0; + height: 40px; + padding: 0; + font-size: 16px; + line-height: 1.2; + } + + .search-group .input-group-text, + .search-group .btn-clear { + height: 40px; + min-height: 40px; + padding-top: 0; + padding-bottom: 0; + display: inline-flex; + align-items: center; + line-height: 1; + } + + .search-group .input-group-text { + padding-left: 10px; + padding-right: 6px; + } + + .search-group .btn-clear { + padding-left: 8px; + padding-right: 10px; + } + + .geral-body { + min-width: 0; + } + + .table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + } + + .table-modern { + min-width: 860px !important; + } + + .table-modern thead th, + .table-modern td { + padding: 10px 8px; + font-size: 0.78rem; + } + + .table-modern th:nth-child(2), + .table-modern td:nth-child(2) { + min-width: 190px; + } + + .table-modern th:nth-child(5), + .table-modern td:nth-child(5) { + min-width: 210px; + } + + .td-clip { + max-width: 180px; + } + + .entity-cell { + justify-content: flex-start; + gap: 6px; + } + + .entity-label { + min-width: 0; + flex: 1 1 auto; + } + + .entity-id { + margin-top: 2px; + line-height: 1.2; + } + + .details-row td { + padding: 0 8px 12px; + } + + .details-panel { + padding: 10px; + gap: 10px; + border-radius: 14px; + max-width: 640px; + margin: 0 auto; + } + + .section-title { + font-size: 0.82rem; + flex-wrap: wrap; + line-height: 1.25; + } + + .change-item { + padding: 8px 10px; + } + + .change-head { + align-items: flex-start; + flex-wrap: wrap; + } + + .change-field { + min-width: 0; + overflow-wrap: break-word; + } + + .change-values { + flex-direction: row; + align-items: center; + gap: 6px; + font-size: 0.75rem; + line-height: 1.2; + } + + .change-values i { + transform: none; + font-size: 0.7rem; + flex-shrink: 0; + } + + .change-values .old, + .change-values .new { + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } + + .tech-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + .tech-value { + font-size: 13px; + } + + .alert.alert-danger { + margin: 12px !important; + display: grid; + gap: 8px; + } + + .alert.alert-danger .btn { + margin-left: 0 !important; + width: 100%; + } + + .geral-footer { + padding: 12px; + gap: 10px; + } + + .footer-meta { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .footer-meta > .small { + text-align: center; + } + + .page-size { + width: 100%; + justify-content: space-between; + } + + .select-wrapper { + min-width: 110px; + } + + .geral-footer nav { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .pagination-modern { + flex-wrap: nowrap; + width: max-content; + min-width: 100%; + justify-content: center; + padding-bottom: 2px; + } + + .pagination-modern .page-link { + white-space: nowrap; + font-size: 12px; + padding: 0.35rem 0.6rem; + } +} + +@media (max-width: 480px) { + .title-badge { + font-size: 12px; + padding: 6px 10px; + gap: 8px; + } + + .title { + font-size: 20px; + } + + .filters-actions { + grid-template-columns: 1fr; + } + + .btn-primary, + .btn-ghost { + width: 100%; + justify-content: center; + } + + .table-modern { + min-width: 760px !important; + } + + .td-clip { + max-width: 140px; + } + + .table-modern th:nth-child(2), + .table-modern td:nth-child(2) { + min-width: 170px; + } + + .table-modern th:nth-child(5), + .table-modern td:nth-child(5) { + min-width: 180px; + } + + .details-panel { + max-width: 520px; + padding: 8px; + gap: 8px; + } + + .change-item { + padding: 7px 8px; + } + + .change-values { + font-size: 0.72rem; + gap: 5px; + } + + .entity-cell { + gap: 6px; + } + + .expand-btn { + width: 30px; + height: 30px; + border-radius: 8px; + } } diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index 48e0e6c..a09f8f8 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -13,6 +13,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { LinesService } from '../../services/lines.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { environment } from '../../../environments/environment'; +import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente'; @@ -698,8 +699,9 @@ export class Mureg implements AfterViewInit { this.deleteSaving = false; } - confirmDelete() { + async confirmDelete() { if (!this.deleteTarget?.id) return; + if (!(await confirmDeletionWithTyping('esta Mureg'))) return; this.deleteSaving = true; const targetId = this.deleteTarget.id; diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts index d601b91..278e75b 100644 --- a/src/app/pages/parcelamentos/parcelamentos.ts +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -33,6 +33,7 @@ import { ParcelamentoCreateModalComponent, ParcelamentoCreateModel, } from './components/parcelamento-create-modal/parcelamento-create-modal'; +import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; type MonthOption = { value: number; label: string }; type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados'; @@ -452,8 +453,9 @@ export class Parcelamentos implements OnInit, OnDestroy { this.deleteTarget = null; } - confirmDelete(): void { + async confirmDelete(): Promise { if (!this.deleteTarget || this.deleteLoading) return; + if (!(await confirmDeletionWithTyping('este parcelamento'))) return; const id = this.getItemId(this.deleteTarget); if (!id) return; this.deleteLoading = true; diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss index d2bce06..80a7539 100644 --- a/src/app/pages/resumo/resumo.scss +++ b/src/app/pages/resumo/resumo.scss @@ -69,6 +69,16 @@ transform: translateY(0); } +@media (max-width: 768px) { + .wrap { + padding-top: 12px; + } + + :host(.animate-ready) [data-animate] { + transform: translateY(8px); + } +} + /* Header */ .page-head { display: flex; @@ -253,6 +263,27 @@ } } +@media (max-width: 768px) { + .tab-bar { + width: 100%; + max-width: 100%; + min-width: 0; + flex-wrap: nowrap; + justify-content: flex-start; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: thin; + padding-bottom: 6px; + } + + .tab-btn { + flex: 0 0 auto; + white-space: nowrap; + } +} + /* Hero Section */ .section-hero { background: white; diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 1ad0ba4..31d5fbf 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -51,8 +51,12 @@ ` + : '' + } + +
+ `; + + overlay.appendChild(card); + document.body.appendChild(overlay); + activeOverlay = overlay; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const input = hasInput ? (card.querySelector('input') as HTMLInputElement | null) : null; + const confirmBtn = card.querySelector('.js-confirm') as HTMLButtonElement | null; + const cancelBtn = card.querySelector('.js-cancel') as HTMLButtonElement | null; + const requiredText = (options.requiredText || '').trim().toUpperCase(); + + const cleanup = (result: boolean) => { + document.removeEventListener('keydown', onKeyDown); + overlay.remove(); + if (activeOverlay === overlay) activeOverlay = null; + document.body.style.overflow = prevOverflow; + resolve(result); + }; + + const canConfirm = () => { + if (!hasInput) return true; + return (input?.value || '').trim().toUpperCase() === requiredText; + }; + + const updateConfirmState = () => { + if (!confirmBtn) return; + confirmBtn.disabled = !canConfirm(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + cleanup(false); + } + if (event.key === 'Enter') { + if (document.activeElement === cancelBtn) return; + if (canConfirm()) { + event.preventDefault(); + cleanup(true); + } + } + }; + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) cleanup(false); + }); + + cancelBtn?.addEventListener('click', () => cleanup(false)); + confirmBtn?.addEventListener('click', () => cleanup(canConfirm())); + input?.addEventListener('input', updateConfirmState); + input?.addEventListener('paste', () => setTimeout(updateConfirmState)); + document.addEventListener('keydown', onKeyDown); + + const focusTarget = input ?? cancelBtn ?? confirmBtn; + setTimeout(() => focusTarget?.focus(), 0); + }); +} + +function ensureStyles() { + if (stylesInjected || typeof document === 'undefined') return; + + const style = document.createElement('style'); + style.id = 'lgx-confirm-modal-styles'; + style.textContent = ` + .lgx-confirm-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + backdrop-filter: blur(2px); + display: grid; + place-items: center; + z-index: 5000; + padding: 16px; + animation: lgxFadeIn .12s ease-out; + } + .lgx-confirm-card { + width: min(460px, calc(100vw - 32px)); + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 16px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18); + padding: 18px; + animation: lgxScaleIn .14s ease-out; + font-family: inherit; + color: #111827; + } + .lgx-confirm-head { + display: grid; + grid-template-columns: 40px 1fr; + gap: 12px; + align-items: start; + } + .lgx-confirm-icon { + width: 40px; + height: 40px; + border-radius: 999px; + display: grid; + place-items: center; + font-size: 18px; + font-weight: 700; + background: #f3f4f6; + color: #374151; + } + .lgx-confirm-card h3 { + margin: 2px 0 6px; + font-size: 16px; + font-weight: 700; + line-height: 1.2; + } + .lgx-confirm-card p { + margin: 0; + font-size: 13px; + color: #6b7280; + line-height: 1.4; + } + .lgx-confirm-field { + margin-top: 14px; + display: grid; + gap: 6px; + } + .lgx-confirm-field label { + font-size: 12px; + font-weight: 600; + color: #111827; + } + .lgx-confirm-field input { + height: 40px; + border: 1px solid #d1d5db; + border-radius: 10px; + padding: 0 12px; + font-size: 14px; + outline: none; + transition: border-color .15s ease, box-shadow .15s ease; + } + .lgx-confirm-field input:focus { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); + } + .lgx-confirm-field small { + font-size: 11px; + color: #6b7280; + } + .lgx-confirm-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + gap: 10px; + } + .lgx-btn { + height: 38px; + border-radius: 10px; + border: 1px solid transparent; + padding: 0 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .lgx-btn.ghost { + background: #fff; + color: #374151; + border-color: #d1d5db; + } + .lgx-btn.ghost:hover { background: #f9fafb; } + .lgx-btn.primary { + background: #1d4ed8; + color: #fff; + border-color: #1d4ed8; + } + .lgx-btn.primary:hover:not(:disabled) { background: #1e40af; border-color: #1e40af; } + .lgx-btn.danger { + background: #dc2626; + color: #fff; + border-color: #dc2626; + } + .lgx-btn.danger:hover:not(:disabled) { background: #b91c1c; border-color: #b91c1c; } + .lgx-btn:disabled { + opacity: .6; + cursor: not-allowed; + } + .lgx-confirm-card.tone-danger .lgx-confirm-icon { + background: #fee2e2; + color: #b91c1c; + } + .lgx-confirm-card.tone-warning .lgx-confirm-icon { + background: #fef3c7; + color: #b45309; + } + .lgx-confirm-card.tone-warning .lgx-btn.primary { + background: #d97706; + border-color: #d97706; + } + .lgx-confirm-card.tone-warning .lgx-btn.primary:hover:not(:disabled) { + background: #b45309; + border-color: #b45309; + } + @keyframes lgxFadeIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes lgxScaleIn { from { opacity: 0; transform: translateY(6px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } + `; + + document.head.appendChild(style); + stylesInjected = true; +} + +function getToneIcon(tone: BaseDialogOptions['tone']): string { + if (tone === 'danger') return '!'; + if (tone === 'warning') return '!'; + return 'i'; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/styles.scss b/src/styles.scss index 55b2fe5..ac5ae4d 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -90,20 +90,35 @@ select.form-control-sm { /* Empurra o conteúdo pra baixo do header fixo */ .app-main.has-header { position: relative; - padding-top: 76px; /* evita vão visual entre o header fixo e o conteúdo */ + padding-top: var(--app-header-offset, 76px); /* sincroniza com a altura real do header */ background: transparent; } @media (max-width: 600px) { .app-main.has-header { - padding-top: 88px; + padding-top: var(--app-header-offset, 88px); } } +html, +body { + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +img, +svg, +canvas, +video { + max-width: 100%; + height: auto; +} + /* Ajuste para monitores grandes: elimina o "vão" visual entre header e corpo. */ @media (min-width: 1400px) { .app-main.has-header { - padding-top: 76px; + padding-top: var(--app-header-offset, 76px); } } @@ -116,7 +131,9 @@ select.form-control-sm { .container-fat, .container-mureg, .container-troca, -.container-geral-responsive { +.container-geral-responsive, +.container-dashboard, +.container-chips { max-width: 1100px !important; /* Largura controlada */ width: 96% !important; /* Margem segura em telas menores */ margin-left: auto !important; @@ -321,3 +338,386 @@ app-header .modal-card .btn-secondary:hover { box-shadow: none !important; } } + +/* Títulos de cliente no agrupamento: não herdar o "td-clip" das tabelas */ +.client-group-card .group-header { + gap: 12px; +} + +.client-group-card .group-header .group-info { + flex: 1 1 auto; + min-width: 0; +} + +.client-group-card .group-header .group-info > h6, +.client-group-card .group-header .group-info > h6.td-clip { + min-width: 0; + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; + word-break: normal !important; + overflow-wrap: break-word; + line-height: 1.25; +} + +.client-group-card .group-header .group-toggle-icon { + flex: 0 0 auto; +} + +/* ========================================================== */ +/* Ajustes globais de responsividade (mobile-first) */ +/* ========================================================== */ + +@media (min-width: 1600px) { + .container-geral, + .container-fat, + .container-mureg, + .container-troca, + .container-geral-responsive, + .container-dashboard, + .container-chips { + max-width: 1240px !important; + } +} + +@media (min-width: 2200px) { + .container-geral, + .container-fat, + .container-mureg, + .container-troca, + .container-geral-responsive, + .container-dashboard, + .container-chips { + max-width: 1360px !important; + } +} + +@media (max-width: 1024px) { + .container-geral, + .container-fat, + .container-mureg, + .container-troca, + .container-geral-responsive, + .container-dashboard, + .container-chips { + width: min(100%, calc(100vw - 20px)) !important; + } + + .header-actions, + .filters-actions { + flex-wrap: wrap; + gap: 8px !important; + } + + .controls, + .filters-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: stretch; + } + + .search-group, + .input-group.search-group { + min-width: 0; + flex: 1 1 280px; + } + + .page-size { + margin-left: 0 !important; + flex: 0 1 auto; + } +} + +@media (max-width: 768px) { + .app-main.has-header { + padding-top: var(--app-header-offset, 84px); + } + + .geral-card, + .fat-card, + .mureg-card, + .troca-card, + .vigencia-page .geral-card { + min-height: auto !important; + margin-bottom: 20px !important; + } + + .header-row-top { + gap: 12px !important; + } + + .header-actions { + width: 100%; + justify-content: stretch !important; + } + + .header-actions .btn, + .header-actions button.btn { + flex: 1 1 220px; + } + + .filters-head, + .filters-meta { + align-items: stretch !important; + } + + .filters-actions { + width: 100%; + justify-content: stretch !important; + } + + .filters-actions .btn-primary, + .filters-actions .btn-ghost, + .filters-actions .btn, + .filters-actions button { + flex: 1 1 160px; + justify-content: center; + } + + .filters-grid { + grid-template-columns: 1fr !important; + gap: 10px !important; + } + + .filter-tabs { + width: 100%; + display: flex; + flex-wrap: nowrap; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + + .filter-tabs .filter-tab { + flex: 0 0 auto; + white-space: nowrap; + } + + .client-filter-wrap, + .additional-filter-wrap, + .search-group, + .input-group.search-group, + .search-box, + .page-size { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + } + + .chips-container { + max-width: 100% !important; + } + + .btn-client-filter { + width: 100%; + min-height: 40px; + justify-content: space-between; + gap: 8px; + } + + .client-dropdown, + .additional-dropdown { + width: min(100%, calc(100vw - 24px)) !important; + max-width: calc(100vw - 24px) !important; + right: auto !important; + left: 0 !important; + } + + .controls .page-size { + justify-content: space-between !important; + } + + .table-wrap, + .table-wrap-tall, + .inner-table-wrap, + .parcelamentos-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto !important; + overflow-y: visible !important; + -webkit-overflow-scrolling: touch; + } + + .table-wrap table.table-modern, + .inner-table-wrap table.table-modern, + .table-wrap .table-modern, + .parcelamentos-table-wrap .table-modern, + table.table-modern { + width: max-content !important; + min-width: max(100%, 920px) !important; + } + + .table-modern thead th, + .table-modern td { + padding: 10px 8px !important; + } + + .table-modern thead th { + font-size: 0.7rem !important; + } + + .table-modern td { + font-size: 0.8rem !important; + } + + .td-clip { + max-width: 180px !important; + } + + .actions-col { + min-width: 120px !important; + } + + .modal-custom, + .lg-modal, + .macrophony-modal, + .grouped-modal { + padding: 8px !important; + align-items: center !important; + justify-content: center !important; + } + + .modal-card, + .lg-modal-card, + .macrophony-card, + .grouped-card { + width: min(100%, calc(100vw - 16px)) !important; + max-width: calc(100vw - 16px) !important; + max-height: min(92dvh, calc(100vh - 16px)) !important; + border-radius: 14px !important; + min-height: 0; + } + + .modal-header, + .lg-modal-card .modal-header, + .detail-head { + padding: 12px 14px !important; + gap: 10px; + flex-wrap: wrap; + } + + .modal-title, + .lg-modal-card .modal-title { + min-width: 0; + flex: 1 1 auto; + } + + .modal-header h3, + .detail-head h4 { + font-size: 15px !important; + line-height: 1.3; + } + + .modal-body, + .modern-body, + .lg-modal-card .modal-body, + .macrophony-modal-body, + .grouped-modal-body { + padding: 14px !important; + overflow-y: auto; + } + + .modal-footer, + .modal-actions, + .lg-modal-card .modal-footer, + .manage-actions-footer { + display: flex !important; + flex-wrap: wrap !important; + gap: 8px !important; + justify-content: stretch !important; + align-items: stretch !important; + } + + .modal-footer .btn, + .modal-footer button, + .modal-actions .btn, + .modal-actions button, + .lg-modal-card .modal-footer .btn, + .manage-actions-footer .btn, + .manage-actions-footer button { + flex: 1 1 180px; + min-width: 0 !important; + justify-content: center; + } + + .manage-actions-footer .btn-delete-permanent-left { + margin-right: 0 !important; + } + + .form-grid, + .edit-grid, + .info-grid, + .details-2col, + .finance-dashboard { + grid-template-columns: 1fr !important; + } + + .details-dashboard { + grid-template-columns: 1fr !important; + gap: 12px !important; + } + + .macrophony-summary, + .grouped-summary-bar { + padding: 12px 14px !important; + gap: 12px !important; + } + + .toast-container { + left: 8px !important; + right: 8px !important; + width: auto !important; + padding: 8px !important; + } + + .toast-container .toast { + width: 100% !important; + } +} + +@media (max-width: 480px) { + .container-geral, + .container-fat, + .container-mureg, + .container-troca, + .container-geral-responsive, + .container-dashboard, + .container-chips { + width: calc(100vw - 14px) !important; + } + + .title-badge, + .header-title .title { + word-break: break-word; + } + + .header-actions .btn, + .header-actions button.btn, + .filters-actions .btn-primary, + .filters-actions .btn-ghost, + .filters-actions .btn, + .filters-actions button, + .modal-footer .btn, + .modal-footer button, + .modal-actions .btn, + .modal-actions button { + width: 100%; + flex-basis: 100%; + } + + .table-wrap table.table-modern, + .inner-table-wrap table.table-modern, + .table-wrap .table-modern, + .parcelamentos-table-wrap .table-modern, + table.table-modern { + min-width: max(100%, 820px) !important; + } + + .td-clip { + max-width: 140px !important; + } +} From ec3abc056fc537a0c4cc37fd55436448b989b0d5 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 25 Feb 2026 11:34:51 -0300 Subject: [PATCH 45/46] =?UTF-8?q?Adi=C3=A7=C3=A3o=20Lote=20de=20Linhas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/header/header.scss | 203 +++- .../pages/geral/batch-mass-input.util.spec.ts | 156 +++ src/app/pages/geral/batch-mass-input.util.ts | 438 ++++++++ src/app/pages/geral/geral.html | 767 +++++++++++++- src/app/pages/geral/geral.scss | 79 ++ src/app/pages/geral/geral.spec.ts | 45 + src/app/pages/geral/geral.ts | 967 ++++++++++++++++-- src/app/pages/notificacoes/notificacoes.scss | 388 +++++++ 8 files changed, 2944 insertions(+), 99 deletions(-) create mode 100644 src/app/pages/geral/batch-mass-input.util.spec.ts create mode 100644 src/app/pages/geral/batch-mass-input.util.ts diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 264ec7f..313a733 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -55,10 +55,11 @@ $border-color: #e5e7eb; display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s; &:hover { color: $primary; } } -.header-actions { display: flex; align-items: center; } +.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; } .btn-login-header { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px; border: 1px solid rgba(28, 56, 201, 0.18); background: #fff; color: $primary; font-weight: 700; font-size: 13px; text-decoration: none; transition: all 0.2s; + white-space: nowrap; &:hover { transform: translateY(-1px); background: rgba(28, 56, 201, 0.04); box-shadow: 0 4px 12px rgba(28, 56, 201, 0.15); } } @@ -739,6 +740,7 @@ $border-color: #e5e7eb; .header-inner { gap: 8px; + flex-wrap: nowrap; } .logged-header { @@ -780,6 +782,36 @@ $border-color: #e5e7eb; } } + /* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */ + .header-inner > .logo-area { + flex: 1 1 auto; + min-width: 0; + } + + .header-inner > .logo-area .logo-text { + display: block; + font-size: 14px; + line-height: 1; + white-space: nowrap; + } + + .header-inner > .header-actions { + margin-left: auto; + flex: 0 0 auto; + justify-content: flex-end; + } + + .header-inner > .header-actions .btn-login-header { + padding: 7px 10px; + gap: 4px; + font-size: 12px; + } + + /* Header logado: mantém nome visível, porém menor para smartphone */ + .left-logged .logo-area .logo-text { + font-size: 13px; + } + .logged-actions { gap: 6px; } @@ -809,10 +841,23 @@ $border-color: #e5e7eb; position: fixed; top: calc(var(--app-header-offset, 76px) + 8px); right: 8px; - width: min(260px, calc(100vw - 16px)); + width: min(228px, calc(100vw - 16px)); + padding: 4px; + border-radius: 12px; z-index: 1250; } + .options-dropdown .options-item { + padding: 8px 10px; + font-size: 12px; + gap: 8px; + border-radius: 7px; + } + + .options-dropdown .divider { + margin: 3px 0; + } + .notifications-head { padding: 12px; flex-wrap: wrap; @@ -912,6 +957,20 @@ $border-color: #e5e7eb; border-radius: 14px; } + .modal-actions { + padding: 12px 14px; + gap: 8px; + } + + .modal-actions .btn-primary, + .modal-actions .btn-secondary { + min-height: 40px; + height: 40px; + padding: 0 12px; + font-size: 13px; + border-radius: 10px; + } + .modal-card.manage-users-modal { width: calc(100vw - 12px); height: min(calc(100dvh - 12px), 680px); @@ -928,6 +987,20 @@ $border-color: #e5e7eb; max-height: 38vh; } + .manage-search { + padding: 10px 12px; + } + + .manage-search .search-input-wrapper input { + height: 34px; + font-size: 12px; + } + + .manage-search .search-input-wrapper i { + left: 10px; + font-size: 13px; + } + .manage-right-wrapper { height: auto; min-height: 0; @@ -937,6 +1010,58 @@ $border-color: #e5e7eb; padding: 14px; } + .manage-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .manage-table { + min-width: 560px; + } + + .manage-table thead th { + padding: 8px 10px; + font-size: 10px; + letter-spacing: 0.35px; + } + + .manage-table tbody tr td { + padding: 9px 10px; + } + + .user-cell { + gap: 8px; + } + + .user-cell .avatar-mini { + width: 28px; + height: 28px; + min-width: 28px; + font-size: 11px; + } + + .user-cell .user-info .u-name { + font-size: 12px; + } + + .user-cell .user-info .u-email { + font-size: 10px; + } + + .badge-role { + padding: 2px 6px; + font-size: 9px; + } + + .actions-group { + gap: 3px; + } + + .actions-group .btn-action { + width: 26px; + height: 26px; + } + .edit-header-info { gap: 10px; margin-bottom: 14px; @@ -983,8 +1108,36 @@ $border-color: #e5e7eb; } @media (max-width: 420px) { - .logo-area .logo-text { - display: none; + .header-inner > .logo-area { + gap: 6px; + } + + .header-inner > .logo-area .logo-icon { + width: 30px; + height: 30px; + font-size: 15px; + } + + .header-inner > .logo-area .logo-text { + display: block; + font-size: 13px; + letter-spacing: -0.25px; + } + + .header-inner > .header-actions .btn-login-header { + padding: 6px 8px; + font-size: 11px; + gap: 3px; + } + + .left-logged .logo-area { + gap: 6px; + } + + .left-logged .logo-area .logo-text { + display: block; + font-size: 12px; + letter-spacing: -0.2px; } .logged-actions { @@ -1006,6 +1159,48 @@ $border-color: #e5e7eb; .options-dropdown { right: 6px; top: calc(var(--app-header-offset, 76px) + 6px); + width: min(216px, calc(100vw - 12px)); + } + + .options-dropdown .options-item { + padding: 7px 9px; + font-size: 11px; + gap: 7px; + } + + .modal-actions { + padding: 10px 12px; + } + + .modal-actions .btn-primary, + .modal-actions .btn-secondary { + min-height: 40px; + height: 40px; + font-size: 12px; + } + + .manage-table { + min-width: 520px; + } + + .manage-table thead th { + padding: 7px 8px; + font-size: 9px; + } + + .manage-table tbody tr td { + padding: 8px; + } + + .user-cell .avatar-mini { + width: 26px; + height: 26px; + min-width: 26px; + } + + .actions-group .btn-action { + width: 24px; + height: 24px; } .notifications-head { diff --git a/src/app/pages/geral/batch-mass-input.util.spec.ts b/src/app/pages/geral/batch-mass-input.util.spec.ts new file mode 100644 index 0000000..fd7e789 --- /dev/null +++ b/src/app/pages/geral/batch-mass-input.util.spec.ts @@ -0,0 +1,156 @@ +import { buildBatchMassExampleText, buildBatchMassHeaderLine, buildBatchMassPreview, mergeMassRows } from './batch-mass-input.util'; + +describe('batch-mass-input.util', () => { + it('parses rows separated by semicolon', () => { + const preview = buildBatchMassPreview( + '11999999999;8955000000000000001;Usuario 1;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01' + ); + + expect(preview.separator).toBe('SEMICOLON'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['linha']).toBe('11999999999'); + expect(preview.rows[0].data['chip']).toBe('8955000000000000001'); + expect(preview.rows[0].data['planoContrato']).toBe('PLANO A'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('parses rows separated by TAB', () => { + const preview = buildBatchMassPreview( + '11999999999\t8955000000000000001\tUsuario 1\teSIM\tPLANO A\tATIVO\tEMPRESA A\tCONTA A\t2026-01-01\t2027-01-01' + ); + + expect(preview.separator).toBe('TAB'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['usuario']).toBe('Usuario 1'); + }); + + it('parses rows separated by pipe', () => { + const preview = buildBatchMassPreview( + '11999999999|8955000000000000001|Usuario 1|eSIM|PLANO A|ATIVO|EMPRESA A|CONTA A|2026-01-01|2027-01-01' + ); + + expect(preview.separator).toBe('PIPE'); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['tipoDeChip']).toBe('eSIM'); + }); + + it('ignores empty lines', () => { + const preview = buildBatchMassPreview( + '\n\n11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01\n\n' + ); + + expect(preview.recognizedRows).toBe(1); + }); + + it('detects and uses header row when present', () => { + const preview = buildBatchMassPreview( + [ + 'Linha;ICCID;Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt Efetivacao Servico;Dt Termino Fidelizacao', + '11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;01/02/2026;01/02/2027' + ].join('\n') + ); + + expect(preview.hasHeader).toBeTrue(); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['dtEfetivacaoServico']).toBe('2026-02-01'); + expect(preview.rows[0].data['dtTerminoFidelizacao']).toBe('2027-02-01'); + }); + + it('maps official header labels with parentheses (Chip (ICCID), Empresa (Conta))', () => { + const preview = buildBatchMassPreview( + [ + 'Linha;Chip (ICCID);Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt. Efetivação Serviço;Dt. Término Fidelização', + '11999999999;8955000000000000001;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01' + ].join('\n') + ); + + expect(preview.hasHeader).toBeTrue(); + expect(preview.recognizedRows).toBe(1); + expect(preview.rows[0].data['chip']).toBe('8955000000000000001'); + expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('fills missing columns with defaults when available', () => { + const preview = buildBatchMassPreview('11999999999;8955', { + defaults: { + planoContrato: 'PLANO PADRAO', + status: 'ATIVO', + contaEmpresa: 'EMPRESA A', + conta: 'CONTA A', + dtEfetivacaoServico: '2026-01-01', + dtTerminoFidelizacao: '2027-01-01' + } + }); + + expect(preview.rows[0].data['planoContrato']).toBe('PLANO PADRAO'); + expect(preview.rows[0].data['status']).toBe('ATIVO'); + expect(preview.rows[0].errors).toEqual([]); + }); + + it('uses row value instead of default when both exist', () => { + const preview = buildBatchMassPreview( + '11999999999;8955;U1;eSIM;PLANO LINHA;SUSPENSO;EMPRESA LINHA;CONTA LINHA;2026-05-01;2027-05-01', + { + defaults: { + planoContrato: 'PLANO PADRAO', + status: 'ATIVO', + contaEmpresa: 'EMPRESA PADRAO', + conta: 'CONTA PADRAO', + dtEfetivacaoServico: '2026-01-01', + dtTerminoFidelizacao: '2027-01-01' + } + } + ); + + expect(preview.rows[0].data['planoContrato']).toBe('PLANO LINHA'); + expect(preview.rows[0].data['status']).toBe('SUSPENSO'); + expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA LINHA'); + }); + + it('marks row invalid when required fields are missing', () => { + const preview = buildBatchMassPreview('11999999999;8955'); + + expect(preview.invalidRows).toBe(1); + expect(preview.rows[0].errors).toContain('Plano Contrato obrigatorio.'); + expect(preview.rows[0].errors).toContain('Status obrigatorio.'); + expect(preview.rows[0].errors).toContain('Empresa (Conta) obrigatoria.'); + expect(preview.rows[0].errors).toContain('Conta obrigatoria.'); + expect(preview.rows[0].errors).toContain('Dt. Efetivacao Servico obrigatoria.'); + expect(preview.rows[0].errors).toContain('Dt. Termino Fidelizacao obrigatoria.'); + }); + + it('detects duplicate line numbers inside the batch', () => { + const text = [ + '11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01', + '11 99999-9999;9999;U2;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01' + ].join('\n'); + + const preview = buildBatchMassPreview(text); + + expect(preview.duplicateRows).toBe(2); + expect(preview.rows[0].errors).toContain('Linha duplicada no lote.'); + expect(preview.rows[1].errors).toContain('Linha duplicada no lote.'); + }); + + it('mergeMassRows keeps existing rows when mode is ADD', () => { + const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'ADD'); + + expect(merged.map((x) => x.linha)).toEqual(['1', '2', '3']); + }); + + it('mergeMassRows replaces existing rows when mode is REPLACE', () => { + const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'REPLACE'); + + expect(merged.map((x) => x.linha)).toEqual(['2', '3']); + }); + + it('builds header and example using selected separator', () => { + const header = buildBatchMassHeaderLine('TAB'); + const example = buildBatchMassExampleText('PIPE', true); + + expect(header).toContain('\t'); + expect(example.split('\n')[0]).toContain('|'); + expect(example.split('\n').length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/pages/geral/batch-mass-input.util.ts b/src/app/pages/geral/batch-mass-input.util.ts new file mode 100644 index 0000000..9ba3c6b --- /dev/null +++ b/src/app/pages/geral/batch-mass-input.util.ts @@ -0,0 +1,438 @@ +export type BatchMassSeparatorMode = 'AUTO' | 'SEMICOLON' | 'TAB' | 'PIPE'; +export type BatchMassApplyMode = 'ADD' | 'REPLACE'; + +export interface BatchMassColumnGuideItem { + key: string; + label: string; + required: boolean; + canUseDefault: boolean; + note?: string; +} + +export interface BatchMassDefaults { + usuario?: string; + tipoDeChip?: string; + planoContrato?: string; + status?: string; + contaEmpresa?: string; + conta?: string; + dtEfetivacaoServico?: string; + dtTerminoFidelizacao?: string; +} + +export interface BatchMassPreviewRow { + sourceLineNumber: number; + rawLine: string; + values: string[]; + data: Record; + errors: string[]; +} + +export interface BatchMassPreviewResult { + separator: BatchMassSeparatorMode; + recognizedRows: number; + validRows: number; + invalidRows: number; + duplicateRows: number; + hasHeader: boolean; + rows: BatchMassPreviewRow[]; + parseErrors: string[]; +} + +const SEQUENCE_KEYS = [ + 'linha', + 'chip', + 'usuario', + 'tipoDeChip', + 'planoContrato', + 'status', + 'contaEmpresa', + 'conta', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' +] as const; + +const SEQUENCE_LABELS: Record<(typeof SEQUENCE_KEYS)[number], string> = { + linha: 'Linha', + chip: 'Chip (ICCID)', + usuario: 'Usuário', + tipoDeChip: 'Tipo de Chip', + planoContrato: 'Plano Contrato', + status: 'Status', + contaEmpresa: 'Empresa (Conta)', + conta: 'Conta', + dtEfetivacaoServico: 'Dt. Efetivação Serviço', + dtTerminoFidelizacao: 'Dt. Término Fidelização' +}; + +export const BATCH_MASS_COLUMN_GUIDE: BatchMassColumnGuideItem[] = [ + { key: 'linha', label: 'Linha', required: true, canUseDefault: false, note: 'Número da linha (telefone).' }, + { key: 'chip', label: 'Chip (ICCID)', required: true, canUseDefault: false, note: 'ICCID da linha.' }, + { key: 'usuario', label: 'Usuário', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' }, + { key: 'tipoDeChip', label: 'Tipo de Chip', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' }, + { key: 'planoContrato', label: 'Plano Contrato', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'status', label: 'Status', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'contaEmpresa', label: 'Empresa (Conta)', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'conta', label: 'Conta', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' }, + { key: 'dtEfetivacaoServico', label: 'Dt. Efetivação Serviço', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' }, + { key: 'dtTerminoFidelizacao', label: 'Dt. Término Fidelização', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' } +]; + +const REQUIRED_KEYS = [ + 'linha', + 'chip', + 'planoContrato', + 'status', + 'contaEmpresa', + 'conta', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' +] as const; + +const HEADER_ALIAS_TO_KEY: Array<[string, (typeof SEQUENCE_KEYS)[number]]> = [ + ['linha', 'linha'], + ['numero linha', 'linha'], + ['n linha', 'linha'], + ['telefone', 'linha'], + ['chip', 'chip'], + ['iccid', 'chip'], + ['chip iccid', 'chip'], + ['usuario', 'usuario'], + ['usuario da linha', 'usuario'], + ['tipo de chip', 'tipoDeChip'], + ['tipodechip', 'tipoDeChip'], + ['plano contrato', 'planoContrato'], + ['plano', 'planoContrato'], + ['status', 'status'], + ['empresa conta', 'contaEmpresa'], + ['empresa', 'contaEmpresa'], + ['conta empresa', 'contaEmpresa'], + ['conta', 'conta'], + ['dt efetivacao servico', 'dtEfetivacaoServico'], + ['data efetivacao servico', 'dtEfetivacaoServico'], + ['dt efetivacao', 'dtEfetivacaoServico'], + ['efetivacao', 'dtEfetivacaoServico'], + ['dt termino fidelizacao', 'dtTerminoFidelizacao'], + ['data termino fidelizacao', 'dtTerminoFidelizacao'], + ['termino fidelizacao', 'dtTerminoFidelizacao'], + ['fidelizacao', 'dtTerminoFidelizacao'] +]; + +function stripAccents(value: string): string { + return value.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +function normalizeHeaderCell(value: string): string { + return stripAccents((value ?? '').toString()) + .toLowerCase() + // Normalize punctuation like parentheses so headers such as + // "Chip (ICCID)" and "Empresa (Conta)" match aliases. + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function getSeparatorChar(mode: Exclude): string { + if (mode === 'SEMICOLON') return ';'; + if (mode === 'TAB') return '\t'; + return '|'; +} + +function getEffectiveSeparatorForTemplate(mode: BatchMassSeparatorMode): Exclude { + return mode === 'AUTO' ? 'SEMICOLON' : mode; +} + +function detectSeparator(text: string): Exclude { + const lines = text.split(/\r?\n/).map((x) => x.trim()).filter(Boolean); + const first = lines[0] ?? ''; + const counts = { + TAB: (first.match(/\t/g) ?? []).length, + SEMICOLON: (first.match(/;/g) ?? []).length, + PIPE: (first.match(/\|/g) ?? []).length + } as const; + + const entries = Object.entries(counts) as Array<[Exclude, number]>; + entries.sort((a, b) => b[1] - a[1]); + return entries[0]?.[1] ? entries[0][0] : 'SEMICOLON'; +} + +function splitBySeparator(line: string, mode: BatchMassSeparatorMode): string[] { + const effective: Exclude = mode === 'AUTO' ? detectSeparator(line) : mode; + const sepChar = getSeparatorChar(effective); + return line + .split(sepChar) + .map((x) => x.trim()) + .map((x) => (x === '""' ? '' : x)); +} + +function resolveHeaderKey(cell: string): (typeof SEQUENCE_KEYS)[number] | null { + const normalized = normalizeHeaderCell(cell); + if (!normalized) return null; + + const alias = HEADER_ALIAS_TO_KEY.find(([name]) => name === normalized); + return alias?.[1] ?? null; +} + +function maybeNormalizeDate(value: string): string { + const raw = (value ?? '').toString().trim(); + if (!raw) return ''; + + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + if (/^\d{4}\/\d{2}\/\d{2}$/.test(raw)) return raw.replace(/\//g, '-'); + + const m = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/); + if (m) { + const day = m[1].padStart(2, '0'); + const month = m[2].padStart(2, '0'); + const year = m[3]; + return `${year}-${month}-${day}`; + } + + return raw; +} + +function normalizeRowData(data: Record): Record { + const next = { ...data }; + next['linha'] = (next['linha'] ?? '').toString().trim(); + next['chip'] = (next['chip'] ?? '').toString().trim(); + next['usuario'] = (next['usuario'] ?? '').toString().trim(); + next['tipoDeChip'] = (next['tipoDeChip'] ?? '').toString().trim(); + next['planoContrato'] = (next['planoContrato'] ?? '').toString().trim(); + next['status'] = (next['status'] ?? '').toString().trim(); + next['contaEmpresa'] = (next['contaEmpresa'] ?? '').toString().trim(); + next['conta'] = (next['conta'] ?? '').toString().trim(); + next['dtEfetivacaoServico'] = maybeNormalizeDate(next['dtEfetivacaoServico'] ?? ''); + next['dtTerminoFidelizacao'] = maybeNormalizeDate(next['dtTerminoFidelizacao'] ?? ''); + return next; +} + +function parseHeaderMap(values: string[]): Map { + const map = new Map(); + values.forEach((cell, idx) => { + const key = resolveHeaderKey(cell); + if (key) map.set(idx, key); + }); + return map; +} + +function looksLikeDataRow(values: string[]): boolean { + const first = (values[0] ?? '').trim(); + const second = (values[1] ?? '').trim(); + const firstDigits = first.replace(/\D/g, ''); + const secondDigits = second.replace(/\D/g, ''); + + return firstDigits.length >= 8 || secondDigits.length >= 8; +} + +function buildDataFromValues( + values: string[], + defaults: BatchMassDefaults, + headerMap?: Map +): Record { + const base: Record = { + linha: '', + chip: '', + usuario: defaults.usuario?.toString().trim() ?? '', + tipoDeChip: defaults.tipoDeChip?.toString().trim() ?? '', + planoContrato: defaults.planoContrato?.toString().trim() ?? '', + status: defaults.status?.toString().trim() ?? '', + contaEmpresa: defaults.contaEmpresa?.toString().trim() ?? '', + conta: defaults.conta?.toString().trim() ?? '', + dtEfetivacaoServico: defaults.dtEfetivacaoServico?.toString().trim() ?? '', + dtTerminoFidelizacao: defaults.dtTerminoFidelizacao?.toString().trim() ?? '' + }; + + if (headerMap && headerMap.size > 0) { + values.forEach((value, idx) => { + const key = headerMap.get(idx); + if (!key) return; + base[key] = value; + }); + return normalizeRowData(base); + } + + values.forEach((value, idx) => { + const key = SEQUENCE_KEYS[idx]; + if (!key) return; + base[key] = value; + }); + + return normalizeRowData(base); +} + +function validatePreviewRows(rows: BatchMassPreviewRow[]): void { + const linhaCounts = new Map(); + + rows.forEach((row) => { + const digits = (row.data['linha'] ?? '').replace(/\D/g, ''); + if (!digits) return; + linhaCounts.set(digits, (linhaCounts.get(digits) ?? 0) + 1); + }); + + rows.forEach((row) => { + const errors: string[] = []; + const linha = (row.data['linha'] ?? '').trim(); + const chip = (row.data['chip'] ?? '').trim(); + const linhaDigits = linha.replace(/\D/g, ''); + + if (!linha) errors.push('Linha obrigatoria.'); + else if (!linhaDigits) errors.push('Numero de linha invalido.'); + if (!chip) errors.push('Chip (ICCID) obrigatorio.'); + + REQUIRED_KEYS.forEach((key) => { + if (key === 'linha' || key === 'chip') return; + if (!(row.data[key] ?? '').toString().trim()) { + if (key === 'contaEmpresa') errors.push('Empresa (Conta) obrigatoria.'); + else if (key === 'planoContrato') errors.push('Plano Contrato obrigatorio.'); + else if (key === 'dtEfetivacaoServico') errors.push('Dt. Efetivacao Servico obrigatoria.'); + else if (key === 'dtTerminoFidelizacao') errors.push('Dt. Termino Fidelizacao obrigatoria.'); + else if (key === 'conta') errors.push('Conta obrigatoria.'); + else if (key === 'status') errors.push('Status obrigatorio.'); + } + }); + + if (linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1) { + errors.push('Linha duplicada no lote.'); + } + + row.errors = errors; + }); +} + +export function buildBatchMassPreview( + text: string, + opts?: { + separatorMode?: BatchMassSeparatorMode; + defaults?: BatchMassDefaults; + detectHeader?: boolean; + } +): BatchMassPreviewResult { + const rawText = (text ?? '').toString(); + const separatorMode = opts?.separatorMode ?? 'AUTO'; + const defaults = opts?.defaults ?? {}; + const detectHeaderEnabled = opts?.detectHeader ?? true; + const parseErrors: string[] = []; + + const nonEmptyLines = rawText + .split(/\r?\n/) + .map((line, idx) => ({ line: line.trim(), sourceLineNumber: idx + 1 })) + .filter((x) => x.line.length > 0); + + if (nonEmptyLines.length === 0) { + return { + separator: separatorMode === 'AUTO' ? 'SEMICOLON' : separatorMode, + recognizedRows: 0, + validRows: 0, + invalidRows: 0, + duplicateRows: 0, + hasHeader: false, + rows: [], + parseErrors: [] + }; + } + + const effectiveSeparator = separatorMode === 'AUTO' ? detectSeparator(nonEmptyLines[0].line) : separatorMode; + const splitLines = nonEmptyLines.map((entry) => ({ + ...entry, + values: splitBySeparator(entry.line, effectiveSeparator) + })); + + let headerMap: Map | undefined; + let hasHeader = false; + if (detectHeaderEnabled && splitLines.length > 0) { + const first = splitLines[0]; + const candidate = parseHeaderMap(first.values); + const minAliases = looksLikeDataRow(first.values) ? 4 : 2; + if (candidate.size >= minAliases) { + headerMap = candidate; + hasHeader = true; + } + } + + const rows: BatchMassPreviewRow[] = []; + const startIndex = hasHeader ? 1 : 0; + for (let i = startIndex; i < splitLines.length; i++) { + const entry = splitLines[i]; + const allEmpty = entry.values.every((v) => !v.trim()); + if (allEmpty) continue; + + const data = buildDataFromValues(entry.values, defaults, headerMap); + rows.push({ + sourceLineNumber: entry.sourceLineNumber, + rawLine: entry.line, + values: entry.values, + data, + errors: [] + }); + } + + if (rows.length === 0 && hasHeader) { + parseErrors.push('Nenhuma linha de dados encontrada abaixo do cabecalho.'); + } + + validatePreviewRows(rows); + + const duplicateRows = rows.filter((r) => r.errors.some((e) => e.includes('duplicada'))).length; + const invalidRows = rows.filter((r) => r.errors.length > 0).length; + + return { + separator: effectiveSeparator, + recognizedRows: rows.length, + validRows: rows.length - invalidRows, + invalidRows, + duplicateRows, + hasHeader, + rows, + parseErrors + }; +} + +export function mergeMassRows(existing: T[], incoming: T[], mode: BatchMassApplyMode): T[] { + return mode === 'REPLACE' ? [...incoming] : [...existing, ...incoming]; +} + +export function buildBatchMassHeaderLine(mode: BatchMassSeparatorMode = 'SEMICOLON'): string { + const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode)); + return SEQUENCE_KEYS.map((key) => SEQUENCE_LABELS[key]).join(sep); +} + +export function buildBatchMassExampleText(mode: BatchMassSeparatorMode = 'SEMICOLON', withHeader = true): string { + const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode)); + const lines: string[] = []; + + if (withHeader) { + lines.push(buildBatchMassHeaderLine(mode)); + } + + lines.push( + [ + '11999999999', + '8955000000000000001', + 'João', + 'eSIM', + 'SMART EMPRESAS 6GB', + 'ATIVO', + 'VIVO MACROPHONY', + '0430237019', + '2026-01-01', + '2027-01-01' + ].join(sep) + ); + + lines.push( + [ + '11999999998', + '8955000000000000002', + 'Maria', + 'Físico', + 'SMART EMPRESAS 10GB', + 'ATIVO', + 'VIVO MACROPHONY', + '0430237019', + '2026-01-02', + '2027-01-02' + ].join(sep) + ); + + return lines.join('\n'); +} diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 063ec89..baf4fc8 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -515,12 +515,17 @@ *ngIf="createOpen" #createModal class="modal-card modal-lg modal-create" + [class.batch-mode]="isCreateBatchMode" (click)="$event.stopPropagation()" > diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 3cec0d6..d155bfc 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -377,6 +377,7 @@ .modal-body .box-body { overflow: visible; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; } +.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); } /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ @@ -429,3 +430,81 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } } .form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } } .form-control, .form-select { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); background-color: #fff; font-size: 0.9rem; font-weight: 500; color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; &:hover { border-color: rgba(17, 18, 20, 0.7); } &:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); outline: none; } &:disabled, &[readonly] { background-color: rgba(17, 18, 20, 0.04); border-color: rgba(17, 18, 20, 0.2); color: var(--muted); } } + +/* === CREATE MODES / LOTE === */ +.create-entry-mode { display: grid; gap: 10px; } +.mode-pill-group { display: inline-flex; flex-wrap: wrap; gap: 8px; background: rgba(255,255,255,0.75); padding: 6px; border-radius: 999px; border: 1px solid rgba(17,18,20,0.08); width: fit-content; max-width: 100%; } +.mode-pill { border: 1px solid transparent; background: transparent; color: rgba(17,18,20,0.7); border-radius: 999px; padding: 8px 14px; font-size: 0.85rem; font-weight: 800; line-height: 1; transition: all 0.2s ease; white-space: nowrap; &:hover:not(:disabled) { background: rgba(227, 61, 207, 0.06); color: var(--brand); } &:disabled { opacity: 0.6; cursor: not-allowed; } &.active { background: linear-gradient(180deg, rgba(227,61,207,0.12), rgba(227,61,207,0.06)); color: var(--brand); border-color: rgba(227,61,207,0.18); box-shadow: 0 4px 12px rgba(227,61,207,0.08); } } +.mode-helper { font-size: 0.83rem; color: rgba(17,18,20,0.65); background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; padding: 10px 12px; } + +.batch-lines-panel { .detail-box { border-color: rgba(3,15,170,0.08); box-shadow: 0 10px 20px rgba(3,15,170,0.03); } } +.batch-client-setup { .detail-box { border-color: rgba(17,18,20,0.08); } } +.batch-count-badge { font-size: 0.72rem; font-weight: 900; color: var(--blue); background: rgba(3,15,170,0.08); border: 1px solid rgba(3,15,170,0.12); border-radius: 999px; padding: 3px 8px; } +.batch-summary-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } +.summary-pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 5px 10px; font-size: 0.75rem; font-weight: 900; border: 1px solid rgba(17,18,20,0.08); background: #fff; color: rgba(17,18,20,0.72); &.total { color: var(--blue); background: rgba(3,15,170,0.04); border-color: rgba(3,15,170,0.12); } &.ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } &.warn { color: #b58105; background: rgba(255,193,7,0.14); border-color: rgba(255,193,7,0.18); } &.dup { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } } +.batch-validation-banner { display: flex; align-items: center; gap: 8px; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px; font-size: 0.84rem; font-weight: 700; border: 1px solid rgba(17,18,20,0.08); background: rgba(255,255,255,0.7); color: rgba(17,18,20,0.72); i { font-size: 1rem; } &.is-danger { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } &.is-ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } } +.batch-inheritance-note { border-radius: 12px; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.72); padding: 10px 12px; color: rgba(17,18,20,0.65); font-size: 0.82rem; line-height: 1.35; margin-bottom: 12px; } +.batch-mass-input-box { border: 1px solid rgba(17,18,20,0.07); background: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0.72)); border-radius: 14px; padding: 12px; margin-bottom: 12px; display: grid; gap: 10px; } +.batch-mass-input-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } +.batch-mass-title { font-size: 0.9rem; font-weight: 900; color: var(--text); } +.batch-mass-sub { font-size: 0.76rem; color: rgba(17,18,20,0.62); line-height: 1.35; margin-top: 3px; code { font-size: 0.72rem; color: var(--blue); background: rgba(3,15,170,0.05); border: 1px solid rgba(3,15,170,0.08); border-radius: 6px; padding: 1px 4px; } } +.batch-mass-controls { display: grid; gap: 4px; min-width: 150px; } +.batch-mass-guide { border: 1px solid rgba(3,15,170,0.08); border-radius: 12px; background: rgba(3,15,170,0.02); overflow: hidden; + summary { cursor: pointer; list-style: none; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: left; white-space: normal; } } +} +.batch-mass-guide-body { padding: 10px 12px 12px; border-top: 1px solid rgba(3,15,170,0.06); display: grid; gap: 10px; } +.batch-mass-guide-list { display: grid; grid-template-columns: 1fr; gap: 8px; } +.batch-mass-guide-item { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.88); border-radius: 10px; padding: 8px; .pos { width: 28px; height: 28px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(3,15,170,0.08); color: var(--blue); font-weight: 900; font-size: 0.78rem; } .meta { min-width: 0; display: grid; gap: 2px; } .name { display: block; font-size: 0.79rem; font-weight: 800; color: var(--text); line-height: 1.25; } .hint { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.62); font-weight: 800; line-height: 1.2; } .note { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.56); line-height: 1.25; } } +.batch-mass-guide-note { border-radius: 8px; background: rgba(255,255,255,0.85); border: 1px solid rgba(17,18,20,0.05); padding: 8px 10px; font-size: 0.76rem; color: rgba(17,18,20,0.62); } +.batch-mass-defaults { border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; background: rgba(255,255,255,0.8); overflow: hidden; + summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); background: rgba(3,15,170,0.02); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: right; } } +} +.batch-mass-defaults-body { padding: 12px; border-top: 1px solid rgba(17,18,20,0.05); } +.batch-mass-textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; line-height: 1.35; resize: vertical; min-height: 120px; } +.batch-mass-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.batch-mass-preview { border-top: 1px solid rgba(17,18,20,0.06); padding-top: 10px; display: grid; gap: 8px; } +.batch-mass-preview-pills { display: flex; flex-wrap: wrap; gap: 6px; } +.batch-mass-preview-errors { border-radius: 10px; border: 1px solid rgba(220,53,69,0.16); background: rgba(220,53,69,0.05); color: #842029; padding: 8px 10px; font-size: 0.78rem; ul { margin: 0; padding-left: 16px; } } +.batch-mass-preview-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.06); border-radius: 10px; background: #fff; } +.batch-mass-preview-table { width: 100%; min-width: 780px; border-collapse: separate; border-spacing: 0; th, td { padding: 8px 10px; border-bottom: 1px solid rgba(17,18,20,0.05); font-size: 0.76rem; vertical-align: top; } thead th { background: rgba(248,249,250,0.95); font-weight: 900; text-transform: uppercase; letter-spacing: 0.03em; color: rgba(17,18,20,0.62); white-space: nowrap; } tbody tr:last-child td { border-bottom: 0; } } +.batch-mass-preview-foot { font-size: 0.74rem; color: rgba(17,18,20,0.58); padding: 8px 10px 0; } +.batch-actions-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } +.batch-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 12px; align-items: start; } +.batch-grid-pane { min-width: 0; display: grid; gap: 10px; } +.batch-drawer-col { min-width: 0; } +.batch-lines-empty { border: 1px dashed rgba(17,18,20,0.12); background: rgba(255,255,255,0.65); color: rgba(17,18,20,0.6); border-radius: 12px; padding: 14px; text-align: center; font-weight: 600; } +.batch-lines-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.08); border-radius: 14px; background: #fff; } +.batch-lines-table { width: 100%; min-width: 1010px; border-collapse: separate; border-spacing: 0; th, td { padding: 10px; border-bottom: 1px solid rgba(17,18,20,0.06); vertical-align: middle; } thead th { position: sticky; top: 0; z-index: 1; background: rgba(248, 249, 250, 0.96); font-size: 0.73rem; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(17,18,20,0.65); font-weight: 900; white-space: nowrap; } thead th:last-child { padding-left: 4px; padding-right: 4px; } tbody tr { transition: background-color 0.15s ease; &.is-selected { background: rgba(3,15,170,0.03); } &.is-invalid-row { background: rgba(220,53,69,0.025); } &:hover { background: rgba(227,61,207,0.03); } } tbody tr:last-child td { border-bottom: 0; } .index-cell { width: 64px; text-align: center; font-weight: 900; color: var(--blue); } .validation-cell { min-width: 160px; } .actions-cell { width: 76px; min-width: 76px; text-align: left; white-space: nowrap; padding-left: 0; padding-right: 2px; display: flex; align-items: center; justify-content: flex-start; gap: 4px; } .form-control { min-width: 140px; } } +.batch-input-invalid { border-color: rgba(220,53,69,0.45) !important; background: rgba(220,53,69,0.03) !important; box-shadow: inset 0 0 0 1px rgba(220,53,69,0.08); } +.batch-row-valid { display: inline-flex; align-items: center; gap: 6px; font-size: 0.76rem; font-weight: 900; color: #157347; background: rgba(25,135,84,0.07); border: 1px solid rgba(25,135,84,0.12); border-radius: 999px; padding: 5px 9px; } +.batch-row-errors { margin: 0; padding-left: 14px; color: #842029; font-size: 0.74rem; line-height: 1.2; li + li { margin-top: 3px; } } +.batch-row-errors-compact { display: grid; gap: 2px; color: #842029; } +.batch-row-error-main { font-size: 0.76rem; font-weight: 800; line-height: 1.15; } +.batch-row-more { font-size: 0.7rem; color: rgba(132,32,41,0.8); font-weight: 700; } +.batch-detail-attention { border-color: rgba(220,53,69,0.25) !important; color: #842029 !important; background: rgba(220,53,69,0.06) !important; } +.batch-selected-hint { margin-top: 10px; font-size: 0.8rem; color: rgba(17,18,20,0.6); display: flex; align-items: center; gap: 8px; i { color: var(--blue); } } +.batch-detail-drawer { background: #fff; border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; box-shadow: 0 14px 28px rgba(17,18,20,0.08); overflow: hidden; display: flex; flex-direction: column; max-height: 72vh; position: sticky; top: 0; } +.batch-detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(17,18,20,0.06); background: linear-gradient(180deg, rgba(3,15,170,0.03), rgba(255,255,255,0.9)); } +.batch-detail-title { font-size: 0.95rem; font-weight: 900; color: var(--text); } +.batch-detail-sub { margin-top: 2px; font-size: 0.76rem; color: rgba(17,18,20,0.6); } +.batch-detail-body { padding: 12px; overflow: auto; } +.batch-detail-body .detail-box { border-radius: 12px; } +.batch-detail-placeholder { border: 1px dashed rgba(17,18,20,0.12); border-radius: 16px; background: rgba(255,255,255,0.72); padding: 18px; color: rgba(17,18,20,0.62); display: grid; gap: 8px; align-content: start; min-height: 180px; i { font-size: 1.4rem; color: var(--blue); } p { margin: 0; font-weight: 700; } small { color: rgba(17,18,20,0.58); } } + +@media (max-width: 768px) { + .mode-pill-group { width: 100%; border-radius: 14px; } + .mode-pill { flex: 1 1 180px; justify-content: center; } + .batch-actions-row .btn { flex: 1 1 200px; } + .batch-mass-input-head { flex-direction: column; } + .batch-mass-controls { min-width: 0; width: 100%; } + .batch-mass-guide summary { flex-direction: column; align-items: flex-start; } + .batch-mass-defaults summary { flex-direction: column; align-items: flex-start; } + .batch-mass-actions .btn { flex: 1 1 180px; } + .batch-editor-layout { grid-template-columns: 1fr; } + .batch-detail-drawer { position: static; max-height: none; } + .batch-detail-header { flex-direction: column; align-items: stretch; } + .batch-detail-header > .d-flex { flex-wrap: wrap; } + .batch-summary-strip { gap: 6px; } + .summary-pill { font-size: 0.72rem; } + .batch-validation-banner { align-items: flex-start; } +} diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts index 491ce60..05f32d1 100644 --- a/src/app/pages/geral/geral.spec.ts +++ b/src/app/pages/geral/geral.spec.ts @@ -28,4 +28,49 @@ describe('Geral', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not create manual batch row automatically when switching to batch mode', () => { + component.createBatchLines = []; + component.setCreateEntryMode('BATCH'); + + expect(component.createEntryMode).toBe('BATCH'); + expect(component.createBatchLines.length).toBe(0); + expect(component.batchDetailOpen).toBeFalse(); + }); + + it('should add parsed mass-input rows to existing batch and keep rows editable', async () => { + spyOn(component, 'showToast').and.resolveTo(); + component.createEntryMode = 'BATCH'; + component.batchMassInputText = + '11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01'; + + await component.applyBatchMassInput('ADD'); + + expect(component.createBatchLines.length).toBe(1); + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO A'); + + component.createBatchLines[0]['planoContrato'] = 'PLANO EDITADO'; + component.onBatchLineDetailsChange(); + + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO EDITADO'); + }); + + it('should replace current batch when applying mass input in replace mode', async () => { + spyOn(component, 'showToast').and.resolveTo(); + component.createEntryMode = 'BATCH'; + component.batchMassInputText = + '11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01'; + + await component.applyBatchMassInput('ADD'); + expect(component.createBatchLines.length).toBe(1); + + component.batchMassInputText = + '11888888888;8955000000000000002;Maria;FISICO;PLANO B;ATIVO;EMPRESA B;CONTA B;2026-02-01;2027-02-01'; + + await component.applyBatchMassInput('REPLACE'); + + expect(component.createBatchLines.length).toBe(1); + expect(component.createBatchLines[0].linha).toBe('11888888888'); + expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B'); + }); }); diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 3bbc7b2..12adc05 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -24,9 +24,20 @@ import { AuthService } from '../../services/auth.service'; import { firstValueFrom, Subscription, filter } from 'rxjs'; import { environment } from '../../../environments/environment'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + BATCH_MASS_COLUMN_GUIDE, + type BatchMassApplyMode, + buildBatchMassExampleText, + buildBatchMassHeaderLine, + type BatchMassPreviewResult, + type BatchMassSeparatorMode, + buildBatchMassPreview, + mergeMassRows +} from './batch-mass-input.util'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; +type CreateEntryMode = 'SINGLE' | 'BATCH'; type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; @@ -122,6 +133,43 @@ interface AccountCompanyOption { contas: string[]; } +interface CreateBatchLineDraft extends Partial { + uid: number; + linha: string; + chip: string; + tipoDeChip: string; + usuario: string; + contaEmpresa?: string; +} + +interface BatchLineValidation { + uid: number; + index: number; + linhaDigits: string; + errors: string[]; +} + +interface BatchValidationSummary { + total: number; + valid: number; + invalid: number; + duplicates: number; +} + +interface CreateMobileLinesBatchRequest { + lines: CreateMobileLineRequest[]; +} + +interface CreateMobileLinesBatchResponse { + created?: number; + items?: Array<{ + id: string; + item: number; + linha?: string | null; + cliente?: string | null; + }>; +} + @Component({ standalone: true, @@ -130,6 +178,7 @@ interface AccountCompanyOption { styleUrls: ['./geral.scss'] }) export class Geral implements OnInit, AfterViewInit, OnDestroy { + readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE; toastMessage = ''; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @@ -201,6 +250,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { createOpen = false; createSaving = false; createMode: CreateMode = 'NEW_CLIENT'; + createEntryMode: CreateEntryMode = 'SINGLE'; + createBatchLines: CreateBatchLineDraft[] = []; + selectedBatchLineUid: number | null = null; + batchDetailOpen = false; + batchMassInputText = ''; + batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO'; + batchMassPreview: BatchMassPreviewResult | null = null; + createBatchValidationByUid: Record = {}; + createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; detailData: any = null; financeData: any = null; @@ -216,6 +274,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private groupsRequestVersion = 0; private linesRequestVersion = 0; private clientsRequestVersion = 0; + private createBatchUidSeed = 0; loadingKpis = false; kpiTotalClientes = 0; @@ -323,6 +382,74 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { lucro: null }; + get isCreateBatchMode(): boolean { + return this.createEntryMode === 'BATCH'; + } + + get createBatchCount(): number { + return this.createBatchLines.length; + } + + get createSubmitText(): string { + if (this.createSaving) return ''; + if (this.isCreateBatchMode) { + const count = this.createBatchCount; + return count > 0 ? `Cadastrar Lote (${count})` : 'Cadastrar Lote'; + } + return 'Cadastrar'; + } + + get isCreateSaveDisabled(): boolean { + if (this.createSaving) return true; + if (!this.isCreateBatchMode) return false; + if (this.createBatchCount === 0) return true; + return this.createBatchValidationSummary.invalid > 0; + } + + get hasBatchSelection(): boolean { + if (this.selectedBatchLineUid == null) return false; + return this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid); + } + + get selectedBatchLine(): CreateBatchLineDraft | null { + if (this.selectedBatchLineUid == null) return null; + return this.createBatchLines.find((x) => x.uid === this.selectedBatchLineUid) ?? null; + } + + get selectedBatchLineIndex(): number { + if (this.selectedBatchLineUid == null) return -1; + return this.createBatchLines.findIndex((x) => x.uid === this.selectedBatchLineUid); + } + + get batchActiveDetailLine(): CreateBatchLineDraft | null { + if (!this.batchDetailOpen) return null; + return this.selectedBatchLine; + } + + get batchValidationMessage(): string { + const s = this.createBatchValidationSummary; + if (!this.isCreateBatchMode) return ''; + if (s.total === 0) return 'Adicione linhas ao lote para começar o preenchimento.'; + if (s.invalid > 0) return `Corrija ${s.invalid} linha(s) inválida(s) antes de salvar.`; + return `Lote pronto para envio: ${s.valid} linha(s) válida(s).`; + } + + get batchMassHasPreview(): boolean { + return !!this.batchMassPreview && (this.batchMassPreview.recognizedRows > 0 || this.batchMassPreview.parseErrors.length > 0); + } + + get batchMassSeparatorLabel(): string { + if (!this.batchMassPreview) return ''; + if (this.batchMassPreview.separator === 'TAB') return 'TAB'; + if (this.batchMassPreview.separator === 'PIPE') return '|'; + return ';'; + } + + get batchMassPreviewRowsPreview(): Array<{ line: number; data: Record; errors: string[] }> { + const rows = this.batchMassPreview?.rows ?? []; + return rows.slice(0, 5).map((row) => ({ line: row.sourceLineNumber, data: row.data, errors: row.errors })); + } + get isGroupMode(): boolean { return this.viewMode === 'GROUPS'; } @@ -536,6 +663,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.editModel = null; this.editingId = null; + this.batchDetailOpen = false; + this.batchMassPreview = null; // Limpa overlays/locks residuais this.cleanupModalArtifacts(); @@ -1740,9 +1869,705 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { desconto: null, lucro: null }; + this.createEntryMode = 'SINGLE'; + this.createBatchLines = []; + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + this.batchMassInputText = ''; + this.batchMassSeparatorMode = 'AUTO'; + this.batchMassPreview = null; + this.createBatchValidationByUid = {}; + this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; this.createSaving = false; } + setCreateEntryMode(mode: CreateEntryMode) { + this.createEntryMode = mode; + if (mode === 'BATCH') { + if (this.createBatchLines.length > 0) { + if (this.selectedBatchLineUid == null) { + this.selectedBatchLineUid = this.createBatchLines[0]?.uid ?? null; + } + this.batchDetailOpen = true; + this.ensureBatchLineDefaults(this.selectedBatchLine); + } else { + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + } + this.recomputeBatchValidation(); + return; + } + + this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + addBatchLine(seed?: Partial) { + const templateSource = this.selectedBatchLine ?? this.createBatchLines[this.createBatchLines.length - 1] ?? this.createModel; + const row = this.createBatchDraftFromSource(templateSource, seed, { + keepLinha: false, + keepChip: false, + copyDetails: false + }); + + this.createBatchLines = [...this.createBatchLines, row]; + this.selectedBatchLineUid = row.uid; + this.recomputeBatchValidation(); + } + + addBatchLines(count: number) { + const total = Math.max(1, Math.min(200, Math.floor(Number(count) || 1))); + for (let i = 0; i < total; i++) this.addBatchLine(); + } + + removeBatchLine(uid: number) { + this.createBatchLines = this.createBatchLines.filter((x) => x.uid !== uid); + if (this.selectedBatchLineUid === uid) { + this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null; + } + if (this.createBatchLines.length === 0) this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + clearBatchLines() { + this.createBatchLines = []; + this.selectedBatchLineUid = null; + this.batchDetailOpen = false; + this.recomputeBatchValidation(); + } + + onBatchMassInputChange() { + this.batchMassPreview = null; + } + + previewBatchMassInput() { + this.batchMassPreview = buildBatchMassPreview(this.batchMassInputText, { + separatorMode: this.batchMassSeparatorMode, + defaults: this.getBatchMassDefaults(), + detectHeader: true + }); + } + + clearBatchMassInput() { + this.batchMassInputText = ''; + this.batchMassPreview = null; + } + + useBatchMassExample() { + this.batchMassInputText = buildBatchMassExampleText(this.batchMassSeparatorMode, true); + this.batchMassPreview = null; + } + + useBatchMassHeaderTemplate() { + this.batchMassInputText = buildBatchMassHeaderLine(this.batchMassSeparatorMode); + this.batchMassPreview = null; + } + + async applyBatchMassInput(mode: BatchMassApplyMode) { + if (!this.batchMassInputText.trim()) { + await this.showToast('Cole ou digite as linhas no campo de entrada em massa.'); + return; + } + + this.previewBatchMassInput(); + const preview = this.batchMassPreview; + if (!preview || preview.recognizedRows <= 0) { + await this.showToast(preview?.parseErrors[0] ?? 'Nenhuma linha reconhecida na entrada em massa.'); + return; + } + + const parsedRows = preview.rows.map((previewRow) => + this.createBatchDraftFromSource( + this.createModel, + { + ...previewRow.data + } as Partial, + { keepLinha: true, keepChip: true, copyDetails: false } + ) + ); + + this.createBatchLines = mergeMassRows(this.createBatchLines, parsedRows, mode); + this.selectedBatchLineUid = parsedRows[parsedRows.length - 1]?.uid ?? this.selectedBatchLineUid; + this.batchDetailOpen = this.createBatchLines.length > 0; + this.recomputeBatchValidation(); + + await this.showToast( + mode === 'REPLACE' + ? `${parsedRows.length} linha(s) carregada(s) (substituindo o lote atual).` + : `${parsedRows.length} linha(s) adicionada(s) ao lote.` + ); + } + + selectBatchLine(uid: number) { + this.selectedBatchLineUid = uid; + } + + openBatchLineDetails(uid: number) { + this.selectBatchLine(uid); + this.batchDetailOpen = true; + this.ensureBatchLineDefaults(this.selectedBatchLine); + this.recomputeBatchValidation(); + } + + closeBatchLineDetails() { + this.batchDetailOpen = false; + } + + selectPreviousBatchLine() { + const idx = this.selectedBatchLineIndex; + if (idx <= 0) return; + const prev = this.createBatchLines[idx - 1]; + if (!prev) return; + this.openBatchLineDetails(prev.uid); + } + + selectNextBatchLine() { + const idx = this.selectedBatchLineIndex; + if (idx < 0 || idx >= this.createBatchLines.length - 1) return; + const next = this.createBatchLines[idx + 1]; + if (!next) return; + this.openBatchLineDetails(next.uid); + } + + duplicateLastBatchLine() { + const source = this.createBatchLines[this.createBatchLines.length - 1]; + if (!source) return; + this.duplicateBatchLine(source.uid); + } + + duplicateSelectedBatchLine() { + if (this.selectedBatchLineUid == null) return; + this.duplicateBatchLine(this.selectedBatchLineUid); + } + + duplicateBatchLine(uid: number) { + const idx = this.createBatchLines.findIndex((x) => x.uid === uid); + if (idx < 0) return; + + const source = this.createBatchLines[idx]; + const clone = this.createBatchDraftFromSource(source, undefined, { + keepLinha: true, + keepChip: true, + copyDetails: true + }); + + const next = [...this.createBatchLines]; + next.splice(idx + 1, 0, clone); + this.createBatchLines = next; + this.selectedBatchLineUid = clone.uid; + this.recomputeBatchValidation(); + } + + async removeInvalidBatchLines() { + if (this.createBatchLines.length === 0) return; + const before = this.createBatchLines.length; + this.createBatchLines = this.createBatchLines.filter((row) => (this.getBatchValidation(row.uid)?.errors.length ?? 0) === 0); + const removed = before - this.createBatchLines.length; + if (removed <= 0) return; + + if (this.selectedBatchLineUid != null && !this.createBatchLines.some((x) => x.uid === this.selectedBatchLineUid)) { + this.selectedBatchLineUid = this.createBatchLines[this.createBatchLines.length - 1]?.uid ?? null; + } + + this.recomputeBatchValidation(); + await this.showToast(`${removed} linha(s) inválida(s) removida(s) do lote.`); + } + + onBatchLineDraftChange() { + this.recomputeBatchValidation(); + } + + onBatchLineFieldChange(uid: number) { + this.selectBatchLine(uid); + this.onBatchLineDraftChange(); + } + + onBatchLineDetailsChange() { + this.onBatchLineDraftChange(); + } + + applySelectedBatchLineDetailsToAll() { + const source = this.selectedBatchLine; + if (!source) return; + + const sourceData = this.getBatchLineDataWithoutInternal(source); + this.createBatchLines = this.createBatchLines.map((row) => { + if (row.uid === source.uid) return row; + return { + ...row, + ...sourceData, + uid: row.uid, + linha: row.linha, + chip: row.chip + }; + }); + + this.recomputeBatchValidation(); + } + + trackBatchLine(_index: number, row: CreateBatchLineDraft): number { + return row.uid; + } + + isBatchLineSelected(uid: number): boolean { + return this.selectedBatchLineUid === uid; + } + + getBatchValidation(uid: number): BatchLineValidation | null { + return this.createBatchValidationByUid[uid] ?? null; + } + + getBatchLineErrors(uid: number): string[] { + return this.createBatchValidationByUid[uid]?.errors ?? []; + } + + hasBatchLineError(uid: number): boolean { + return (this.createBatchValidationByUid[uid]?.errors.length ?? 0) > 0; + } + + hasBatchFieldError(uid: number, field: 'linha' | 'chip'): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + if (field === 'linha') { + return errors.some((e) => e.toLowerCase().includes('linha')); + } + return errors.some((e) => e.toLowerCase().includes('chip')); + } + + hasBatchDetailError(uid: number): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + const detailKeywords = ['empresa', 'conta', 'plano', 'status', 'efetivação', 'fidelização', 'detalhes']; + return errors.some((e) => detailKeywords.some((k) => e.toLowerCase().includes(k))); + } + + hasBatchRequiredFieldError(uid: number, fieldKey: string): boolean { + const errors = this.createBatchValidationByUid[uid]?.errors ?? []; + const key = fieldKey.toLowerCase(); + if (key === 'conta') { + return errors.some((e) => e.toLowerCase().startsWith('conta obrigatória')); + } + if (key === 'empresa') { + return errors.some((e) => e.toLowerCase().startsWith('empresa (conta) obrigatória')); + } + return errors.some((e) => e.toLowerCase().includes(key)); + } + + getContaEmpresaOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.contaEmpresa, this.contaEmpresaOptions); + } + + getContaOptionsForBatchLine(row: any): string[] { + const empresaSelecionada = (row?.contaEmpresa ?? '').toString().trim(); + const baseOptions = empresaSelecionada ? this.getContasByEmpresa(empresaSelecionada) : this.getAllContas(); + return this.mergeOption(row?.conta, baseOptions); + } + + getPlanOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.planoContrato, this.planOptions); + } + + getStatusOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.status, this.statusOptions); + } + + getSkilOptionsForBatchLine(row: any): string[] { + return this.mergeOption(row?.skil, this.skilOptions); + } + + onBatchContaEmpresaChange(row: any) { + if (!row) return; + const contas = this.getContasByEmpresa(row.contaEmpresa); + const selectedConta = (row.conta ?? '').toString().trim(); + if (selectedConta) { + const hasMatch = contas.some((c) => this.sameConta(c, selectedConta)); + if (!hasMatch) row.conta = ''; + } + this.onBatchLineDetailsChange(); + } + + onBatchPlanoChange(row: any) { + if (!row) return; + const plan = (row.planoContrato ?? '').toString().trim(); + if (!plan) { + this.onBatchLineDetailsChange(); + return; + } + + const suggestion = this.planAutoFill.suggest(plan); + if (suggestion) { + if (suggestion.franquiaGb != null) { + row.franquiaVivo = suggestion.franquiaGb; + if (row.franquiaLine === null || row.franquiaLine === undefined || row.franquiaLine === '') { + row.franquiaLine = suggestion.franquiaGb; + } + } + if (suggestion.valorPlano != null) row.valorPlanoVivo = suggestion.valorPlano; + } + + this.calculateFinancials(row); + this.onBatchLineDetailsChange(); + } + + onBatchFinancialChange(row: any) { + if (!row) return; + this.calculateFinancials(row); + this.onBatchLineDetailsChange(); + } + + private getBatchMassDefaults() { + return { + usuario: (this.createModel?.usuario ?? '').toString().trim(), + tipoDeChip: (this.createModel?.tipoDeChip ?? '').toString().trim(), + planoContrato: (this.createModel?.planoContrato ?? '').toString().trim(), + status: (this.createModel?.status ?? '').toString().trim(), + contaEmpresa: (this.createModel?.contaEmpresa ?? '').toString().trim(), + conta: (this.createModel?.conta ?? '').toString().trim(), + dtEfetivacaoServico: (this.createModel?.dtEfetivacaoServico ?? '').toString().trim(), + dtTerminoFidelizacao: (this.createModel?.dtTerminoFidelizacao ?? '').toString().trim() + }; + } + + private createBatchDraftFromSource( + source: any, + seed?: Partial, + opts?: { keepLinha?: boolean; keepChip?: boolean; copyDetails?: boolean } + ): CreateBatchLineDraft { + const keepLinha = !!opts?.keepLinha; + const keepChip = !!opts?.keepChip; + const copyDetails = opts?.copyDetails ?? true; + const baseSource = source ?? this.createModel ?? {}; + const raw = this.getBatchLineDataWithoutInternal(baseSource); + + this.createBatchUidSeed += 1; + + const row: CreateBatchLineDraft = { + ...raw, + ...(seed ? this.getBatchLineDataWithoutInternal(seed) : {}), + uid: this.createBatchUidSeed, + item: 0, + linha: keepLinha ? (seed?.linha ?? raw.linha ?? '') : (seed?.linha ?? ''), + chip: keepChip ? (seed?.chip ?? raw.chip ?? '') : (seed?.chip ?? ''), + usuario: (seed?.usuario ?? raw.usuario ?? '').toString(), + tipoDeChip: (seed?.tipoDeChip ?? raw.tipoDeChip ?? '').toString() + }; + + if (!copyDetails) { + const clearScalarFields = [ + 'contaEmpresa', + 'conta', + 'status', + 'planoContrato', + 'vencConta', + 'modalidade', + 'cedente', + 'solicitante', + 'dataBloqueio', + 'dataEntregaOpera', + 'dataEntregaCliente', + 'dtEfetivacaoServico', + 'dtTerminoFidelizacao' + ]; + const clearNumericFields = [ + 'franquiaVivo', + 'valorPlanoVivo', + 'gestaoVozDados', + 'skeelo', + 'vivoNewsPlus', + 'vivoTravelMundo', + 'vivoGestaoDispositivo', + 'vivoSync', + 'valorContratoVivo', + 'franquiaLine', + 'franquiaGestao', + 'locacaoAp', + 'valorContratoLine', + 'desconto', + 'lucro' + ]; + + const mutableRow = row as Record; + clearScalarFields.forEach((key) => (mutableRow[key] = '')); + clearNumericFields.forEach((key) => (mutableRow[key] = null)); + + if (seed) { + Object.assign(row, this.getBatchLineDataWithoutInternal(seed)); + } + } + + this.ensureBatchLineDefaults(row); + return row; + } + + private getBatchLineDataWithoutInternal(source: any): any { + if (!source) return {}; + const { uid, ...rest } = source; + return { ...rest }; + } + + private ensureBatchLineDefaults(row: any) { + if (!row) return; + + if (!('item' in row)) row.item = 0; + if (!('skil' in row) || row.skil === null || row.skil === undefined || row.skil === '') { + row.skil = this.createMode === 'NEW_LINE_IN_GROUP' ? (this.createModel?.skil ?? 'PESSOA FÍSICA') : (this.createModel?.skil ?? 'PESSOA FÍSICA'); + } + + const scalarDefaults: Array<[string, any]> = [ + ['linha', ''], + ['chip', ''], + ['tipoDeChip', ''], + ['usuario', ''], + ['contaEmpresa', ''], + ['conta', ''], + ['status', ''], + ['planoContrato', ''], + ['vencConta', ''], + ['modalidade', ''], + ['cedente', ''], + ['solicitante', ''], + ['dataBloqueio', ''], + ['dataEntregaOpera', ''], + ['dataEntregaCliente', ''], + ['dtEfetivacaoServico', ''], + ['dtTerminoFidelizacao', ''] + ]; + + const numericDefaults: Array<[string, any]> = [ + ['franquiaVivo', null], + ['valorPlanoVivo', null], + ['gestaoVozDados', null], + ['skeelo', null], + ['vivoNewsPlus', null], + ['vivoTravelMundo', null], + ['vivoGestaoDispositivo', null], + ['vivoSync', null], + ['valorContratoVivo', null], + ['franquiaLine', null], + ['franquiaGestao', null], + ['locacaoAp', null], + ['valorContratoLine', null], + ['desconto', null], + ['lucro', null] + ]; + + scalarDefaults.forEach(([k, v]) => { + if (!(k in row) || row[k] === null || row[k] === undefined) row[k] = v; + }); + numericDefaults.forEach(([k, v]) => { + if (!(k in row)) row[k] = v; + }); + + if (!(row.contaEmpresa ?? '').toString().trim() && (row.conta ?? '').toString().trim()) { + row.contaEmpresa = this.findEmpresaByConta(row.conta); + } + + row.cliente = (this.createModel?.cliente ?? row.cliente ?? '').toString(); + } + + private recomputeBatchValidation() { + const byUid: Record = {}; + const counts = new Map(); + + this.createBatchLines.forEach((row) => { + const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, ''); + if (!linhaDigits) return; + counts.set(linhaDigits, (counts.get(linhaDigits) ?? 0) + 1); + }); + + let valid = 0; + let invalid = 0; + let duplicates = 0; + + this.createBatchLines.forEach((row, index) => { + const linhaRaw = (row?.linha ?? '').toString().trim(); + const chipRaw = (row?.chip ?? '').toString().trim(); + const linhaDigits = linhaRaw.replace(/\D/g, ''); + const errors: string[] = []; + + if (!linhaRaw) errors.push('Linha obrigatória.'); + else if (!linhaDigits) errors.push('Número de linha inválido.'); + + if (!chipRaw) errors.push('Chip (ICCID) obrigatório.'); + + const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim(); + const conta = (row?.['conta'] ?? '').toString().trim(); + const status = (row?.['status'] ?? '').toString().trim(); + const plano = (row?.['planoContrato'] ?? '').toString().trim(); + const dtEfet = (row?.['dtEfetivacaoServico'] ?? '').toString().trim(); + const dtFidel = (row?.['dtTerminoFidelizacao'] ?? '').toString().trim(); + + if (!contaEmpresa) errors.push('Empresa (Conta) obrigatória.'); + if (!conta) errors.push('Conta obrigatória.'); + if (!status) errors.push('Status obrigatório.'); + if (!plano) errors.push('Plano Contrato obrigatório.'); + if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.'); + if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.'); + + const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1; + if (isDuplicate) { + errors.push('Linha duplicada no lote.'); + duplicates++; + } + + const hasDetailPending = errors.some((e) => + ['empresa', 'conta', 'status', 'plano', 'efetivação', 'fidelização'].some((k) => + e.toLowerCase().includes(k) + ) + ); + if (hasDetailPending && !errors.some((e) => e.toLowerCase().includes('pendências nos detalhes'))) { + errors.unshift('Pendências nos detalhes da linha.'); + } + + if (errors.length > 0) invalid++; + else valid++; + + byUid[row.uid] = { + uid: row.uid, + index, + linhaDigits, + errors + }; + }); + + this.createBatchValidationByUid = byUid; + this.createBatchValidationSummary = { + total: this.createBatchLines.length, + valid, + invalid, + duplicates + }; + } + + private validateCreateClientFields(): string | null { + if (this.createMode !== 'NEW_CLIENT') return null; + + if (!this.createModel.cliente) { + return this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.'; + } + if (!this.createModel.docNumber) { + return `O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`; + } + return null; + } + + private validateCreateCommonFields(opts?: { requireLinha?: boolean; requireChip?: boolean }): string | null { + const requireLinha = opts?.requireLinha ?? true; + const requireChip = opts?.requireChip ?? true; + + if (!this.createModel.contaEmpresa) return 'Selecione a Empresa (Conta).'; + if (!this.createModel.conta) return 'Selecione uma Conta.'; + if (requireLinha && !this.createModel.linha) return 'O número da Linha é obrigatório.'; + if (requireChip && !this.createModel.chip) return 'O Chip (ICCID) é obrigatório.'; + if (!this.createModel.status) return 'Selecione um Status.'; + if (!this.createModel.planoContrato) return 'Selecione um Plano.'; + if (!this.createModel.dtEfetivacaoServico) return 'A Dt. Efetivação Serviço é obrigatória.'; + if (!this.createModel.dtTerminoFidelizacao) return 'A Dt. Término Fidelização é obrigatória.'; + return null; + } + + private validateBatchLines(): string | null { + this.recomputeBatchValidation(); + + if (this.createBatchLines.length === 0) { + return 'Adicione ao menos uma linha no lote.'; + } + + if (this.createBatchValidationSummary.invalid <= 0) { + return null; + } + + for (let i = 0; i < this.createBatchLines.length; i++) { + const row = this.createBatchLines[i]; + const errors = this.getBatchLineErrors(row.uid); + if (errors.length <= 0) continue; + return `Linha ${i + 1}: ${errors[0]}`; + } + + return 'Existem linhas inválidas no lote.'; + } + + private buildCreatePayload(model: any): CreateMobileLineRequest { + this.calculateFinancials(model); + + const { contaEmpresa: _contaEmpresa, uid: _uid, ...createModelPayload } = model; + + return { + ...createModelPayload, + item: Number(model.item), + dataBloqueio: this.dateInputToIso(model.dataBloqueio), + dataEntregaOpera: this.dateInputToIso(model.dataEntregaOpera), + dataEntregaCliente: this.dateInputToIso(model.dataEntregaCliente), + dtEfetivacaoServico: this.dateInputToIso(model.dtEfetivacaoServico), + dtTerminoFidelizacao: this.dateInputToIso(model.dtTerminoFidelizacao), + franquiaVivo: this.toNullableNumber(model.franquiaVivo), + valorPlanoVivo: this.toNullableNumber(model.valorPlanoVivo), + gestaoVozDados: this.toNullableNumber(model.gestaoVozDados), + skeelo: this.toNullableNumber(model.skeelo), + vivoNewsPlus: this.toNullableNumber(model.vivoNewsPlus), + vivoTravelMundo: this.toNullableNumber(model.vivoTravelMundo), + vivoGestaoDispositivo: this.toNullableNumber(model.vivoGestaoDispositivo), + vivoSync: this.toNullableNumber(model.vivoSync), + valorContratoVivo: this.toNullableNumber(model.valorContratoVivo), + franquiaLine: this.toNullableNumber(model.franquiaLine), + franquiaGestao: this.toNullableNumber(model.franquiaGestao), + locacaoAp: this.toNullableNumber(model.locacaoAp), + valorContratoLine: this.toNullableNumber(model.valorContratoLine), + desconto: this.toNullableNumber(model.desconto), + lucro: this.toNullableNumber(model.lucro), + tipoDeChip: (model.tipoDeChip ?? '').toString() + }; + } + + private buildBatchPayloads(): CreateMobileLineRequest[] { + const clientName = (this.createModel?.cliente ?? '').toString().trim(); + + return this.createBatchLines.map((row) => { + const lineModel = { + ...row, + cliente: clientName || (row?.['cliente'] ?? '').toString(), + linha: (row.linha ?? '').toString(), + chip: (row.chip ?? '').toString(), + usuario: (row.usuario ?? '').toString(), + tipoDeChip: (row.tipoDeChip ?? '').toString() + }; + + return this.buildCreatePayload(lineModel); + }); + } + + private getCreateSuccessMessage(createdCount: number): string { + if (createdCount <= 1) { + return this.createMode === 'NEW_CLIENT' ? 'Sucesso! Cliente cadastrado.' : 'Linha cadastrada com sucesso.'; + } + + return `Sucesso! ${createdCount} linhas cadastradas no lote.`; + } + + private async finalizeCreateSuccess(createdCount: number) { + const targetClient = (this.createModel?.cliente ?? '').toString().trim(); + + this.createSaving = false; + this.closeAllModals(); + + await this.showToast(this.getCreateSuccessMessage(createdCount)); + + if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === targetClient) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + + this.fetchGroupLines(this.expandedGroup!, useTerm); + this.loadGroups(); + this.loadKpis(); + } else { + this.refreshData(); + } + } + + private async handleCreateError(err: HttpErrorResponse, fallbackMessage = 'Erro ao criar registro.') { + this.createSaving = false; + const msg = (err.error as any)?.message || fallbackMessage; + await this.showToast(msg); + } + onContaEmpresaChange(isEdit: boolean) { const model = isEdit ? this.editModel : this.createModel; if (!model) return; @@ -1779,106 +2604,74 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async saveCreate() { - if (this.createMode === 'NEW_CLIENT') { - if (!this.createModel.cliente) { - this.showToast(this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.'); - return; - } - if (!this.createModel.docNumber) { - this.showToast(`O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`); - return; - } + if (this.isCreateBatchMode) { + await this.saveCreateBatch(); + return; } - if (!this.createModel.contaEmpresa) { - this.showToast('Selecione a Empresa (Conta).'); + await this.saveCreateSingle(); + } + + private async saveCreateSingle() { + const clientError = this.validateCreateClientFields(); + if (clientError) { + await this.showToast(clientError); return; } - if (!this.createModel.conta) { - this.showToast('Selecione uma Conta.'); - return; - } - if (!this.createModel.linha) { - this.showToast('O número da Linha é obrigatório.'); - return; - } - if (!this.createModel.chip) { - this.showToast('O Chip (ICCID) é obrigatório.'); - return; - } - if (!this.createModel.status) { - this.showToast('Selecione um Status.'); - return; - } - if (!this.createModel.planoContrato) { - this.showToast('Selecione um Plano.'); - return; - } - if (!this.createModel.dtEfetivacaoServico) { - this.showToast('A Dt. Efetivação Serviço é obrigatória.'); - return; - } - if (!this.createModel.dtTerminoFidelizacao) { - this.showToast('A Dt. Término Fidelização é obrigatória.'); + + const commonError = this.validateCreateCommonFields({ requireLinha: true, requireChip: true }); + if (commonError) { + await this.showToast(commonError); return; } this.createSaving = true; - this.calculateFinancials(this.createModel); - - const { contaEmpresa: _contaEmpresa, ...createModelPayload } = this.createModel; - - const payload: CreateMobileLineRequest = { - ...createModelPayload, - item: Number(this.createModel.item), - dataBloqueio: this.dateInputToIso(this.createModel.dataBloqueio), - dataEntregaOpera: this.dateInputToIso(this.createModel.dataEntregaOpera), - dataEntregaCliente: this.dateInputToIso(this.createModel.dataEntregaCliente), - dtEfetivacaoServico: this.dateInputToIso(this.createModel.dtEfetivacaoServico), - dtTerminoFidelizacao: this.dateInputToIso(this.createModel.dtTerminoFidelizacao), - franquiaVivo: this.toNullableNumber(this.createModel.franquiaVivo), - valorPlanoVivo: this.toNullableNumber(this.createModel.valorPlanoVivo), - gestaoVozDados: this.toNullableNumber(this.createModel.gestaoVozDados), - skeelo: this.toNullableNumber(this.createModel.skeelo), - vivoNewsPlus: this.toNullableNumber(this.createModel.vivoNewsPlus), - vivoTravelMundo: this.toNullableNumber(this.createModel.vivoTravelMundo), - vivoGestaoDispositivo: this.toNullableNumber(this.createModel.vivoGestaoDispositivo), - vivoSync: this.toNullableNumber(this.createModel.vivoSync), - valorContratoVivo: this.toNullableNumber(this.createModel.valorContratoVivo), - franquiaLine: this.toNullableNumber(this.createModel.franquiaLine), - franquiaGestao: this.toNullableNumber(this.createModel.franquiaGestao), - locacaoAp: this.toNullableNumber(this.createModel.locacaoAp), - valorContratoLine: this.toNullableNumber(this.createModel.valorContratoLine), - desconto: this.toNullableNumber(this.createModel.desconto), - lucro: this.toNullableNumber(this.createModel.lucro), - tipoDeChip: (this.createModel.tipoDeChip ?? '').toString() - }; - + const payload = this.buildCreatePayload(this.createModel); this.http.post(this.apiBase, payload).subscribe({ next: async () => { - this.createSaving = false; - - // fecha e limpa overlay SEMPRE - this.closeAllModals(); - - await this.showToast('Sucesso! Cliente cadastrado.'); - - if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === this.createModel.cliente) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(this.expandedGroup!, useTerm); - this.loadGroups(); - this.loadKpis(); - } else { - this.refreshData(); - } + await this.finalizeCreateSuccess(1); }, error: async (err: HttpErrorResponse) => { - this.createSaving = false; - const msg = (err.error as any)?.message || 'Erro ao criar registro.'; - await this.showToast(msg); + await this.handleCreateError(err); + } + }); + } + + private async saveCreateBatch() { + const clientError = this.validateCreateClientFields(); + if (clientError) { + await this.showToast(clientError); + return; + } + + const batchError = this.validateBatchLines(); + if (batchError) { + await this.showToast(batchError); + return; + } + + this.createSaving = true; + + const payload: CreateMobileLinesBatchRequest = { + lines: this.buildBatchPayloads() + }; + + this.http.post(`${this.apiBase}/batch`, payload).subscribe({ + next: async (res) => { + const createdCount = Number(res?.created ?? payload.lines.length) || payload.lines.length; + await this.finalizeCreateSuccess(createdCount); + }, + error: async (err: HttpErrorResponse) => { + if (err.status === 405) { + await this.showToast( + 'A API em execução não aceita POST em /api/lines/batch (405). Reinicie/atualize o backend para a versão com o endpoint de lote.' + ); + this.createSaving = false; + return; + } + + await this.handleCreateError(err, 'Erro ao criar lote de linhas.'); } }); } diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index 7d4a357..ae15b80 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -343,3 +343,391 @@ $border: #e5e7eb; /* Mobile optimization: show button usually only on hover desktop, always mobile */ @media(min-width: 768px) { opacity: 0.6; } } + +/* ========================================================================== + RESPONSIVIDADE MOBILE (Central de Notificações) + ========================================================================== */ +@media (max-width: 768px) { + .wrap { + padding: calc(var(--app-header-offset, 72px) - 8px) 0 24px; + } + + .main-container { + padding: 0 12px; + } + + .page-header { + margin-bottom: 20px; + text-align: center; + + .header-text { + text-align: center; + margin-bottom: 10px; + } + + h2 { + font-size: 22px; + line-height: 1.12; + margin-bottom: 6px; + letter-spacing: -0.35px; + word-break: break-word; + } + + p { + font-size: 13px; + line-height: 1.35; + margin-bottom: 14px; + } + } + + .filters-bar { + display: flex; + width: 100%; + max-width: 100%; + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding: 4px; + border-radius: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + } + + .pill { + flex: 0 0 auto; + white-space: nowrap; + padding: 7px 10px; + font-size: 12px; + gap: 5px; + + .count-badge { + font-size: 9px; + padding: 1px 5px; + } + } + + .search-row { + margin-top: 8px; + } + + .search-box { + width: 100%; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + min-height: 40px; + + i { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + font-size: 14px; + line-height: 1; + flex: 0 0 auto; + } + + input { + min-width: 0; + font-size: 16px; /* evita zoom no iPhone */ + line-height: 20px; + height: 20px; + padding: 0; + + &::placeholder { + font-size: 12px; + line-height: 20px; + color: rgba($text-secondary, 0.95); + } + } + } + + .clear-btn { + flex: 0 0 auto; + } + + .bulk-actions-bar { + margin-top: 12px; + gap: 10px; + align-items: stretch; + } + + .bulk-left { + width: 100%; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + } + + .select-all { + flex: 0 0 auto; + font-size: 11px; + letter-spacing: 0.3px; + + input { + width: 18px; + height: 18px; + } + } + + .bulk-count { + flex: 1 1 auto; + min-width: 0; + font-size: 10px; + line-height: 1.35; + letter-spacing: 0.35px; + margin-left: auto; + text-align: right; + white-space: normal; + } + + .bulk-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 8px; + } + + .bulk-btn { + width: 100%; + justify-content: center; + padding: 8px 10px; + font-size: 11px; + line-height: 1.2; + border-radius: 10px; + text-align: center; + white-space: normal; + } + + .state-container { + padding: 24px 14px; + } + + .empty-state-large { + padding: 32px 14px; + + .illustration { + font-size: 46px; + margin-bottom: 10px; + } + + h3 { + font-size: 17px; + margin-bottom: 6px; + } + + p { + font-size: 13px; + line-height: 1.35; + margin-bottom: 0; + } + } + + .notif-list { + gap: 10px; + } + + .list-item { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: start; + gap: 10px; + padding: 12px 12px 12px 14px; + border-radius: 14px; + + &:hover { + transform: none; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.04); + } + } + + .item-select { + margin: 0; + min-width: 20px; + align-self: center; + + input { + width: 18px; + height: 18px; + } + } + + .item-icon { + width: 30px; + height: 30px; + margin: 0; + font-size: 18px; + align-self: start; + } + + .item-content { + min-width: 0; + } + + .content-top { + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 8px; + } + + .item-title { + font-size: 14px; + line-height: 1.25; + gap: 4px; + } + + .separator { + display: none; + } + + .item-client { + display: block; + width: 100%; + overflow-wrap: anywhere; + } + + .date-stack { + width: 100%; + min-width: 0; + align-items: flex-start; + text-align: left; + gap: 4px; + } + + .date-pill { + font-size: 10px; + padding: 4px 7px; + letter-spacing: 0.25px; + line-height: 1.2; + white-space: normal; + } + + .item-meta-grid { + grid-template-columns: 1fr; + gap: 6px; + } + + .meta-row { + gap: 3px; + } + + .meta-label { + font-size: 10px; + letter-spacing: 0.35px; + } + + .meta-value { + font-size: 12px; + line-height: 1.25; + overflow-wrap: anywhere; + } + + .badge-tag { + font-size: 9px; + letter-spacing: 0.35px; + padding: 4px 7px; + } + + .item-actions { + grid-column: 1 / -1; + margin: 2px 0 0 0; + width: 100%; + } + + .btn-action { + width: 100%; + justify-content: center; + padding: 8px 10px; + border-radius: 10px; + font-size: 12px; + gap: 6px; + } + + .btn-action .d-none.d-md-inline { + display: inline !important; + } +} + +@media (max-width: 420px) { + .wrap { + padding: calc(var(--app-header-offset, 72px) - 10px) 0 24px; + } + + .main-container { + padding: 0 10px; + } + + .page-header { + .header-text { + margin-bottom: 8px; + } + + h2 { + font-size: 20px; + } + + p { + font-size: 12px; + margin-bottom: 12px; + } + } + + .filters-bar { + border-radius: 12px; + } + + .pill { + padding: 6px 9px; + font-size: 11px; + gap: 4px; + } + + .search-box { + padding: 8px 9px; + gap: 7px; + + input::placeholder { + font-size: 11px; + letter-spacing: -0.1px; + } + } + + .bulk-btn { + font-size: 10px; + padding: 8px 8px; + } + + .bulk-left { + gap: 6px; + } + + .bulk-count { + font-size: 9px; + letter-spacing: 0.25px; + } + + .list-item { + gap: 8px; + padding: 11px 10px 11px 12px; + } + + .item-title { + font-size: 13px; + } + + .date-pill { + font-size: 9px; + letter-spacing: 0.15px; + } + + .meta-value { + font-size: 11px; + } + + .btn-action { + font-size: 11px; + padding: 7px 8px; + } +} From e88762c6da71a968c26d3ec529af0191eb7fe176 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 26 Feb 2026 17:17:45 -0300 Subject: [PATCH 46/46] =?UTF-8?q?feat:=20tela=20e=20fluxo=20de=20cria?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20usu=C3=A1rio=20do=20cliente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.routes.ts | 8 + src/app/app.ts | 1 + src/app/components/header/header.html | 24 +- src/app/components/header/header.ts | 22 +- src/app/guards/admin.guard.ts | 4 +- src/app/guards/system-admin.guard.ts | 27 + .../chips-controle-recebidos.ts | 2 +- .../pages/dados-usuarios/dados-usuarios.ts | 2 +- src/app/pages/dashboard/dashboard.html | 501 ++++++++++-------- src/app/pages/dashboard/dashboard.ts | 121 ++++- src/app/pages/faturamento/faturamento.ts | 2 +- src/app/pages/geral/geral.html | 63 ++- src/app/pages/geral/geral.ts | 47 +- src/app/pages/parcelamentos/parcelamentos.ts | 2 +- .../system-provision-user.html | 146 +++++ .../system-provision-user.scss | 298 +++++++++++ .../system-provision-user.ts | 255 +++++++++ src/app/pages/vigencia/vigencia.ts | 2 +- src/app/services/system-admin.service.ts | 63 +++ src/app/services/users.service.ts | 2 +- 20 files changed, 1282 insertions(+), 310 deletions(-) create mode 100644 src/app/guards/system-admin.guard.ts create mode 100644 src/app/pages/system-provision-user/system-provision-user.html create mode 100644 src/app/pages/system-provision-user/system-provision-user.scss create mode 100644 src/app/pages/system-provision-user/system-provision-user.ts create mode 100644 src/app/services/system-admin.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 53c721b..ff379d1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento'; import { authGuard } from './guards/auth.guard'; import { adminGuard } from './guards/admin.guard'; +import { systemAdminGuard } from './guards/system-admin.guard'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; @@ -19,6 +20,7 @@ import { Resumo } from './pages/resumo/resumo'; import { Parcelamentos } from './pages/parcelamentos/parcelamentos'; import { Historico } from './pages/historico/historico'; import { Perfil } from './pages/perfil/perfil'; +import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user'; export const routes: Routes = [ { path: '', component: Home }, @@ -37,6 +39,12 @@ export const routes: Routes = [ { 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' }, + { + path: 'system/fornecer-usuario', + component: SystemProvisionUserPage, + canActivate: [authGuard, systemAdminGuard], + title: 'Fornecer Usuário', + }, // ✅ rota correta { path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' }, diff --git a/src/app/app.ts b/src/app/app.ts index 7bafa32..11b8f61 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -41,6 +41,7 @@ export class AppComponent { '/parcelamentos', '/historico', '/perfil', + '/system', ]; constructor( diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index fa1e907..24a3875 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -191,6 +191,9 @@ +
diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 380c435..8bcd080 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -32,6 +32,8 @@ export class Header implements AfterViewInit, OnDestroy { isLoggedHeader = false; isHome = false; isAdmin = false; + canViewAll = false; + isSystemAdmin = false; notifications: NotificationDto[] = []; notificationsLoading = false; notificationsError = false; @@ -52,8 +54,9 @@ export class Header implements AfterViewInit, OnDestroy { createUserForbidden = false; createUserSuccess = ''; readonly permissionOptions = [ - { value: 'admin', label: 'Administrador' }, + { value: 'sysadmin', label: 'SysAdmin' }, { value: 'gestor', label: 'Gestor' }, + { value: 'cliente', label: 'Cliente' }, ]; manageUsersLoading = false; @@ -86,6 +89,7 @@ export class Header implements AfterViewInit, OnDestroy { '/parcelamentos', '/historico', '/perfil', + '/system', ]; constructor( @@ -200,9 +204,15 @@ export class Header implements AfterViewInit, OnDestroy { private syncPermissions() { if (!isPlatformBrowser(this.platformId)) { this.isAdmin = false; + this.canViewAll = false; + this.isSystemAdmin = false; return; } - this.isAdmin = this.authService.hasRole('admin'); + const isSysAdmin = this.authService.hasRole('sysadmin'); + const isGestor = this.authService.hasRole('gestor'); + this.isAdmin = isSysAdmin; + this.canViewAll = isSysAdmin || isGestor; + this.isSystemAdmin = this.authService.hasRole('sysadmin'); } toggleMenu() { @@ -227,6 +237,12 @@ export class Header implements AfterViewInit, OnDestroy { this.router.navigate(['/perfil']); } + goToSystemProvisionUser() { + if (!this.isSystemAdmin) return; + this.closeOptions(); + this.router.navigate(['/system/fornecer-usuario']); + } + openCreateUserModal() { if (!this.isAdmin) return; this.createUserOpen = true; @@ -430,6 +446,8 @@ export class Header implements AfterViewInit, OnDestroy { this.optionsOpen = false; this.notificationsOpen = false; this.isAdmin = false; + this.canViewAll = false; + this.isSystemAdmin = false; this.router.navigate(['/']); } diff --git a/src/app/guards/admin.guard.ts b/src/app/guards/admin.guard.ts index 19118f4..276b26a 100644 --- a/src/app/guards/admin.guard.ts +++ b/src/app/guards/admin.guard.ts @@ -18,8 +18,8 @@ export const adminGuard: CanActivateFn = () => { return router.parseUrl('/login'); } - const isAdmin = authService.hasRole('admin'); - if (!isAdmin) { + const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor'); + if (!hasAccess) { return router.parseUrl('/dashboard'); } diff --git a/src/app/guards/system-admin.guard.ts b/src/app/guards/system-admin.guard.ts new file mode 100644 index 0000000..b5fb227 --- /dev/null +++ b/src/app/guards/system-admin.guard.ts @@ -0,0 +1,27 @@ +import { inject, PLATFORM_ID } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; + +import { AuthService } from '../services/auth.service'; + +export const systemAdminGuard: CanActivateFn = () => { + const router = inject(Router); + const platformId = inject(PLATFORM_ID); + const authService = inject(AuthService); + + if (!isPlatformBrowser(platformId)) { + return true; + } + + const token = authService.token; + if (!token) { + return router.parseUrl('/login'); + } + + const isSystemAdmin = authService.hasRole('sysadmin'); + if (!isSystemAdmin) { + return router.parseUrl('/dashboard'); + } + + return true; +}; diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts index 9ec45ba..455911e 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts @@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); this.fetchChips(); this.fetchControle(); } diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index aa917b9..ca2a5c5 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit { ) {} ngOnInit(): void { - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); this.fetch(1); } diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index 5c2f891..85638dd 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -40,288 +40,331 @@
-
-

Página Geral

-

Distribuição e saúde atual da base de linhas.

-
+ +
+

Página Geral

+

Distribuição e saúde atual da base de linhas.

+
-
-
-
-
-
-
-

Status da Base

-

Distribuição atual das linhas

+
+
+
+
+
+
+

Status da Base

+

Distribuição atual das linhas

+
+
+
+
+ +
+
+
+ + Ativas + {{ statusResumo.ativos | number:'1.0-0' }} +
+
+ + Reservas + {{ statusResumo.reservas | number:'1.0-0' }} +
+
+ + Bloq. (Perda/Roubo) + {{ statusResumo.perdaRoubo | number:'1.0-0' }} +
+
+ + Bloq. (120 dias) + {{ statusResumo.bloq120 | number:'1.0-0' }} +
+
+ Total Geral + {{ statusResumo.total | number:'1.0-0' }} +
+
-
+ +
+
+
+
+

Serviços Adicionais

+

Comparativo de linhas com e sem adicionais (Geral)

+
+
+
+
+ +
+
+
+ + Com adicionais + {{ adicionaisComparativo.com | number:'1.0-0' }} + {{ adicionaisComparativo.pctCom }} +
+
+ + Sem adicionais + {{ adicionaisComparativo.sem | number:'1.0-0' }} + {{ adicionaisComparativo.pctSem }} +
+
+ Total analisado + {{ adicionaisComparativo.total | number:'1.0-0' }} +
+
+
+
+
+
+ +
+
+
+
+
+
+

Vigência (Buckets)

+

Status de vencimento atual

+
+
- +
-
-
- - Ativas - {{ statusResumo.ativos | number:'1.0-0' }} -
-
- - Reservas - {{ statusResumo.reservas | number:'1.0-0' }} -
-
- - Bloq. (Perda/Roubo) - {{ statusResumo.perdaRoubo | number:'1.0-0' }} -
-
- - Bloq. (120 dias) - {{ statusResumo.bloq120 | number:'1.0-0' }} -
-
- Total Geral - {{ statusResumo.total | number:'1.0-0' }} +
+ +
+
+
+
+

Vivo Travel

+

Linhas com e sem serviço ativo

+
+ +
-
-
-
-
-

Serviços Adicionais

-

Comparativo de linhas com e sem adicionais (Geral)

+
+
+
+
+

Linhas por Franquia

+

Distribuição da base por faixa de franquia

+
+
+
+
-
-
- + +
+
+
+

Adicionais Pagos (Serviços)

+

Quantidade de linhas por serviço adicional ativo

+
-
-
- - Com adicionais - {{ adicionaisComparativo.com | number:'1.0-0' }} - {{ adicionaisComparativo.pctCom }} -
-
- - Sem adicionais - {{ adicionaisComparativo.sem | number:'1.0-0' }} - {{ adicionaisComparativo.pctSem }} -
-
- Total analisado - {{ adicionaisComparativo.total | number:'1.0-0' }} +
+ +
+
+ +
+
+
+

Tipo de Chip

+

Quantidade de linhas e-SIM e SIMCARD

+
+ +
-
-
-
-
-
-
-
-

Vigência (Buckets)

-

Status de vencimento atual

-
-
-
- -
-
- -
-
-
-
-

Vivo Travel

-

Linhas com e sem serviço ativo

-
-
-
- -
-
+
+

Página Resumo

+

Indicadores do Resumo focados em quantidade e distribuição de linhas.

-
-
-
-
-

Linhas por Franquia

-

Distribuição da base por faixa de franquia

+
+
+
+
+ +
+

Resumo Operacional de Linhas

+

Dados consolidados da página Resumo sem foco financeiro

+
-
-
- -
-
-
-
-
-

Adicionais Pagos (Serviços)

-

Quantidade de linhas por serviço adicional ativo

-
-
-
- -
-
+
+
+ + +
-
-
-
-

Tipo de Chip

-

Quantidade de linhas e-SIM e SIMCARD

-
-
-
- -
-
-
-
+
-
-

Página Resumo

-

Indicadores do Resumo focados em quantidade e distribuição de linhas.

-
- -
-
-
-
- -
-

Resumo Operacional de Linhas

-

Dados consolidados da página Resumo sem foco financeiro

+ Ver Página Resumo
-
-
- - +
+
+
-
- - Ver Página Resumo -
-
- -
-
-
-
- -
- - {{ resumoError }} -
- -
-
-
Top Clientes (Qtd. Linhas)
-
+
+ + {{ resumoError }}
-
-
Top Planos (Qtd. Linhas)
-
-
+
+
+
Top Clientes (Qtd. Linhas)
+
+
-
-
PF vs PJ (Qtd. Linhas)
-
-
+
+
Top Planos (Qtd. Linhas)
+
+
-
-
Reserva por DDD
-
-
+
+
PF vs PJ (Qtd. Linhas)
+
+
-
-
DIFERENÇA PJ X PF
-
-
- PF (Linhas) - {{ formatInt(resumoDiferencaPjPf.pfLinhas) }} -
-
- PJ (Linhas) - {{ formatInt(resumoDiferencaPjPf.pjLinhas) }} -
-
- Total Linhas - {{ formatInt(resumoDiferencaPjPf.totalLinhas) }} +
+
Reserva por DDD
+
+
+ +
+
DIFERENÇA PJ X PF
+
+
+ PF (Linhas) + {{ formatInt(resumoDiferencaPjPf.pfLinhas) }} +
+
+ PJ (Linhas) + {{ formatInt(resumoDiferencaPjPf.pjLinhas) }} +
+
+ Total Linhas + {{ formatInt(resumoDiferencaPjPf.totalLinhas) }} +
-
-
-

Histórico

-

Séries mensais para acompanhamento contínuo de movimentações e vigência.

-
+
+

Histórico

+

Séries mensais para acompanhamento contínuo de movimentações e vigência.

+
-
-
-
-
-
-

MUREG (12 Meses)

-

Histórico mensal de mudanças de plano/aparelho

+
+
+
+
+
+

MUREG (12 Meses)

+

Histórico mensal de mudanças de plano/aparelho

+
+
+
+
-
- -
-
-
-
-
-

Troca de Número (12 Meses)

-

Histórico mensal de trocas realizadas

+
+
+
+

Troca de Número (12 Meses)

+

Histórico mensal de trocas realizadas

+
+
+
+
-
- -
-
-
-
-
-
-

Vigência (Próx. 12 Meses)

-

Contratos a encerrar por mês

+
+
+
+
+

Vigência (Próx. 12 Meses)

+

Contratos a encerrar por mês

+
+
+
+
-
-
-
-
+ + +
+
+
+
+
+
+

Status da Base

+

Distribuição atual das linhas

+
+
+
+
+ +
+
+
+ + Ativas + {{ statusResumo.ativos | number:'1.0-0' }} +
+
+ + Bloqueadas + {{ statusResumo.bloqueadas | number:'1.0-0' }} +
+
+ + Reserva + {{ statusResumo.reservas | number:'1.0-0' }} +
+
+ Total + {{ statusResumo.total | number:'1.0-0' }} +
+
+
+
+
+
+
diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index 8171b8c..bb82fe8 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -20,6 +20,7 @@ import { ResumoResponse, LineTotal, } from '../../services/resumo.service'; +import { AuthService } from '../../services/auth.service'; // --- Interfaces (Mantidas intactas para não quebrar contrato) --- type KpiCard = { @@ -218,6 +219,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { loading = true; errorMsg: string | null = null; + isCliente = false; kpis: KpiCard[] = []; @@ -240,6 +242,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { statusResumo = { total: 0, ativos: 0, + bloqueadas: 0, perdaRoubo: 0, bloq120: 0, reservas: 0, @@ -331,6 +334,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { constructor( private http: HttpClient, private resumoService: ResumoService, + private authService: AuthService, @Inject(PLATFORM_ID) private platformId: object ) { const raw = (environment.apiUrl || '').replace(/\/+$/, ''); @@ -340,9 +344,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; + const isSysAdmin = this.authService.hasRole('sysadmin'); + const isGestor = this.authService.hasRole('gestor'); + this.isCliente = !(isSysAdmin || isGestor); + this.loadDashboard(); - this.loadInsights(); - this.loadResumoExecutive(); + if (!this.isCliente) { + this.loadInsights(); + this.loadResumoExecutive(); + } } ngAfterViewInit(): void { @@ -466,6 +476,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.statusResumo = { total: k.totalLinhas ?? 0, ativos: k.ativos ?? 0, + bloqueadas: k.bloqueados ?? 0, perdaRoubo: k.bloqueadosPerdaRoubo ?? 0, bloq120: k.bloqueados120Dias ?? 0, reservas: k.reservas ?? 0, @@ -912,6 +923,40 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } private rebuildPrimaryKpis() { + if (this.isCliente) { + this.kpis = [ + { + key: 'linhas_total', + title: 'Total de Linhas', + value: this.formatInt(this.dashboardRaw?.totalLinhas ?? this.statusResumo.total), + icon: 'bi bi-sim-fill', + hint: 'Base geral', + }, + { + key: 'linhas_ativas', + title: 'Linhas Ativas', + value: this.formatInt(this.dashboardRaw?.ativos ?? this.statusResumo.ativos), + icon: 'bi bi-check2-circle', + hint: 'Status ativo', + }, + { + key: 'linhas_bloqueadas', + title: 'Linhas Bloqueadas', + value: this.formatInt(this.dashboardRaw?.bloqueados ?? this.statusResumo.bloqueadas), + icon: 'bi bi-slash-circle', + hint: 'Todos os bloqueios', + }, + { + key: 'linhas_reserva', + title: 'Linhas em Reserva', + value: this.formatInt(this.dashboardRaw?.reservas ?? this.statusResumo.reservas), + icon: 'bi bi-inboxes-fill', + hint: 'Base de reserva', + }, + ]; + return; + } + const cards: KpiCard[] = []; const used = new Set(); const add = (key: string, title: string, value: string, icon: string, hint?: string) => { @@ -969,18 +1014,22 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { if (!this.viewReady || !this.dataReady) return; requestAnimationFrame(() => { - const canvases = [ - this.chartStatusPie?.nativeElement, - this.chartAdicionaisComparativo?.nativeElement, - this.chartVigenciaMesAno?.nativeElement, - this.chartVigenciaSupervisao?.nativeElement, - this.chartMureg12?.nativeElement, - this.chartTroca12?.nativeElement, - this.chartLinhasPorFranquia?.nativeElement, - this.chartAdicionaisPagos?.nativeElement, - this.chartTipoChip?.nativeElement, - this.chartTravelMundo?.nativeElement, - ].filter(Boolean) as HTMLCanvasElement[]; + const canvases = ( + this.isCliente + ? [this.chartStatusPie?.nativeElement] + : [ + this.chartStatusPie?.nativeElement, + this.chartAdicionaisComparativo?.nativeElement, + this.chartVigenciaMesAno?.nativeElement, + this.chartVigenciaSupervisao?.nativeElement, + this.chartMureg12?.nativeElement, + this.chartTroca12?.nativeElement, + this.chartLinhasPorFranquia?.nativeElement, + this.chartAdicionaisPagos?.nativeElement, + this.chartTipoChip?.nativeElement, + this.chartTravelMundo?.nativeElement, + ] + ).filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length) return; if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { @@ -1033,26 +1082,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { // 1. Status Pie if (this.chartStatusPie?.nativeElement) { + const chartLabels = this.isCliente + ? ['Ativas', 'Bloqueadas', 'Reservas'] + : ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros']; + const chartData = this.isCliente + ? [this.statusResumo.ativos, this.statusResumo.bloqueadas, this.statusResumo.reservas] + : [ + this.statusResumo.ativos, + this.statusResumo.perdaRoubo, + this.statusResumo.bloq120, + this.statusResumo.reservas, + this.statusResumo.outras, + ]; + const chartColors = this.isCliente + ? [palette.status.ativos, palette.status.blocked, palette.status.reserve] + : [ + palette.status.ativos, + palette.status.blocked, + palette.status.purple, + palette.status.reserve, + '#cbd5e1', + ]; + this.chartPie = new Chart(this.chartStatusPie.nativeElement, { type: 'doughnut', data: { - labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'], + labels: chartLabels, datasets: [{ - data: [ - this.statusResumo.ativos, - this.statusResumo.perdaRoubo, - this.statusResumo.bloq120, - this.statusResumo.reservas, - this.statusResumo.outras - ], + data: chartData, borderWidth: 0, - backgroundColor: [ - palette.status.ativos, - palette.status.blocked, - palette.status.purple, - palette.status.reserve, - '#cbd5e1' - ], + backgroundColor: chartColors, hoverOffset: 4 }] }, @@ -1065,6 +1124,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }); } + if (this.isCliente) { + return; + } + if (this.chartAdicionaisComparativo?.nativeElement) { this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, { type: 'doughnut', diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index 889a440..b9175d0 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -160,7 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); setTimeout(() => { this.refreshData(true); diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index baf4fc8..cd82780 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -46,6 +46,7 @@ - - - + + + + +
-
+
-
+
@@ -320,7 +323,7 @@ LINHA USUÁRIO STATUS - VENCIMENTO + VENCIMENTO AÇÕES @@ -333,13 +336,15 @@ {{ statusLabel(r.status) }} - {{ r.contrato }} + {{ r.contrato }}
- - - + + + + +
@@ -365,7 +370,7 @@ - + @@ -416,7 +421,7 @@
- +
VENCIMENTO @@ -431,13 +436,13 @@ - + Carregando... - + Nenhum registro encontrado. @@ -450,13 +455,15 @@ {{ statusLabel(r.status) }} {{ r.skil }} - {{ r.contrato }} + {{ r.contrato }}
- - - + + + + +
@@ -1578,7 +1585,7 @@
-
+
Gestão @@ -1632,7 +1639,7 @@
-
+
Contrato & Status diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 12adc05..eabf0de 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -205,6 +205,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { })(); loading = false; isAdmin = false; + isGestor = false; + isClientRestricted = false; rows: LineRow[] = []; clientGroups: ClientGroupDto[] = []; @@ -544,7 +546,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); + this.isGestor = this.authService.hasRole('gestor'); + this.isClientRestricted = !(this.isAdmin || this.isGestor); + + if (this.isClientRestricted) { + this.filterSkil = 'ALL'; + this.additionalMode = 'ALL'; + this.selectedAdditionalServices = []; + this.selectedClients = []; + } } async ngAfterViewInit() { @@ -553,7 +564,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { setTimeout(() => { this.refreshData(); - this.loadClients(); + if (!this.isClientRestricted) { + this.loadClients(); + } this.loadPlanRules(); this.loadAccountCompanies(); @@ -574,7 +587,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (!url.includes('/geral')) return; this.searchResolvedClient = null; - this.loadClients(); + if (!this.isClientRestricted) { + this.loadClients(); + } this.refreshData(); }); } @@ -915,6 +930,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') { + if (this.isClientRestricted && type !== 'ALL') return; + const isSameFilter = this.filterSkil === type; this.expandedGroup = null; @@ -927,13 +944,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.clientSearchTerm = ''; this.searchResolvedClient = null; - this.loadClients(); + if (!this.isClientRestricted) { + this.loadClients(); + } this.page = 1; this.refreshData(); } setAdditionalMode(mode: AdditionalMode) { + if (this.isClientRestricted) return; if (this.additionalMode === mode) return; this.additionalMode = mode; @@ -947,6 +967,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } toggleAdditionalService(key: AdditionalServiceKey) { + if (this.isClientRestricted) return; const idx = this.selectedAdditionalServices.indexOf(key); if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1); else this.selectedAdditionalServices.push(key); @@ -965,6 +986,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } clearAdditionalFilters() { + if (this.isClientRestricted) return; this.additionalMode = 'ALL'; this.selectedAdditionalServices = []; this.expandedGroup = null; @@ -1427,11 +1449,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } toggleClientMenu() { + if (this.isClientRestricted) return; if (!this.showClientMenu) this.showAdditionalMenu = false; this.showClientMenu = !this.showClientMenu; } toggleAdditionalMenu() { + if (this.isClientRestricted) return; if (!this.showAdditionalMenu) this.showClientMenu = false; this.showAdditionalMenu = !this.showAdditionalMenu; } @@ -1450,6 +1474,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } selectClient(client: string | null) { + if (this.isClientRestricted) return; if (client === null) { this.selectedClients = []; } else { @@ -1465,6 +1490,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } removeClient(client: string, event: Event) { + if (this.isClientRestricted) return; event.stopPropagation(); const idx = this.selectedClients.indexOf(client); if (idx >= 0) { @@ -1477,6 +1503,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } clearClientSelection(event?: Event) { + if (this.isClientRestricted) return; if (event) event.stopPropagation(); this.selectedClients = []; this.clientSearchTerm = ''; @@ -1776,7 +1803,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { async onRemover(r: LineRow, fromGroup = false) { if (!this.isAdmin) { - await this.showToast('Apenas administradores podem remover linhas.'); + await this.showToast('Apenas sysadmin pode remover linhas.'); return; } @@ -1808,6 +1835,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onCadastrarLinha() { + if (this.isClientRestricted) { + await this.showToast('Você não tem permissão para cadastrar novos clientes.'); + return; + } + this.createMode = 'NEW_CLIENT'; this.resetCreateModel(); this.createOpen = true; @@ -1815,6 +1847,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onAddLineToGroup(clientName: string) { + if (this.isClientRestricted) { + await this.showToast('Você não tem permissão para adicionar linhas.'); + return; + } + this.createMode = 'NEW_LINE_IN_GROUP'; this.resetCreateModel(); diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts index 278e75b..c879fe2 100644 --- a/src/app/pages/parcelamentos/parcelamentos.ts +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy { } private syncPermissions(): void { - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); } get totalPages(): number { diff --git a/src/app/pages/system-provision-user/system-provision-user.html b/src/app/pages/system-provision-user/system-provision-user.html new file mode 100644 index 0000000..983d7d6 --- /dev/null +++ b/src/app/pages/system-provision-user/system-provision-user.html @@ -0,0 +1,146 @@ +
+ + + + +
+
+
+
+ SYSTEM ADMIN +
+

Fornecer Usuário para Cliente

+

Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.

+
+ +
+
+ {{ tenantsError }} +
+ +
+ {{ successMessage }} +
+ + UserId: {{ createdUser.userId }} | TenantId: + {{ createdUser.tenantId }} + +
+
+ +
+ Falha ao criar usuário: +
    +
  • {{ err }}
  • +
+
+ +
+
+
+ +
+ + +
+ Origem: {{ sourceType }} (apenas tenants ativos). + + Selecione um tenant-cliente. + +
+ +
+ + +
+ +
+ + + Email é obrigatório. + Email inválido. +
+ +
+ + + Senha é obrigatória. + Mínimo de 6 caracteres. +
+ +
+ + + + Confirmação é obrigatória. + + As senhas não conferem. +
+ +
+ +
+ +
+ + Selecione ao menos uma role. + +
+
+ +
+ +
+
+
+
+
+
diff --git a/src/app/pages/system-provision-user/system-provision-user.scss b/src/app/pages/system-provision-user/system-provision-user.scss new file mode 100644 index 0000000..07de5f7 --- /dev/null +++ b/src/app/pages/system-provision-user/system-provision-user.scss @@ -0,0 +1,298 @@ +:host { + --brand: #e33dcf; + --brand-dark: #8b2d7f; + --text: #111214; + --muted: rgba(17, 18, 20, 0.66); + --danger: #dc3545; + --success: #198754; + --border: rgba(17, 18, 20, 0.12); + --card-bg: rgba(255, 255, 255, 0.84); + --surface: #ffffff; + display: block; +} + +.system-provision-page { + min-height: 100vh; + padding: 24px 12px 110px; + position: relative; + background: + radial-gradient(780px 360px at 10% 0%, rgba(227, 61, 207, 0.16), transparent 60%), + radial-gradient(900px 380px at 90% 16%, rgba(3, 15, 170, 0.12), transparent 62%), + linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%); +} + +.page-blob { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(36px); + opacity: 0.5; + z-index: 0; + background: radial-gradient(circle at 40% 40%, rgba(227, 61, 207, 0.4), rgba(227, 61, 207, 0.08)); + + &.blob-1 { + width: 420px; + height: 420px; + top: -150px; + left: -160px; + } + + &.blob-2 { + width: 560px; + height: 560px; + top: -230px; + right: -260px; + } + + &.blob-3 { + width: 360px; + height: 360px; + bottom: -160px; + left: 30%; + } +} + +.container-shell { + width: 100%; + max-width: 1080px; + margin: 0 auto; + position: relative; + z-index: 1; +} + +.card-shell { + background: var(--card-bg); + border: 1px solid rgba(227, 61, 207, 0.2); + backdrop-filter: blur(10px); + border-radius: 24px; + box-shadow: 0 24px 50px rgba(17, 18, 20, 0.12); + overflow: hidden; +} + +.card-header { + padding: 24px 28px 18px; + border-bottom: 1px solid rgba(17, 18, 20, 0.08); + + h1 { + margin: 10px 0 6px; + font-size: clamp(1.4rem, 2.2vw, 2rem); + font-weight: 900; + letter-spacing: -0.02em; + color: var(--text); + } + + p { + margin: 0; + color: var(--muted); + font-size: 0.95rem; + font-weight: 600; + } +} + +.title-badge { + display: inline-flex; + align-items: center; + gap: 10px; + border-radius: 999px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(227, 61, 207, 0.24); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.05em; + color: var(--brand-dark); + + i { + color: var(--brand); + } +} + +.card-body { + padding: 20px 28px 28px; +} + +.alert-box { + border-radius: 12px; + padding: 10px 12px; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 12px; + + ul { + margin: 8px 0 0 18px; + } + + &.error { + color: var(--danger); + background: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.22); + } + + &.success { + color: var(--success); + background: rgba(25, 135, 84, 0.1); + border: 1px solid rgba(25, 135, 84, 0.24); + } +} + +.provision-form { + display: grid; + gap: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.form-field { + display: grid; + gap: 6px; + + &.span-2 { + grid-column: span 2; + } + + label { + margin: 0; + font-size: 0.8rem; + font-weight: 800; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.03em; + } +} + +.select-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.form-control { + width: 100%; + height: 42px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 0 12px; + font-size: 0.92rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14); + } +} + +.field-help { + font-size: 0.78rem; + color: var(--muted); + font-weight: 600; +} + +.field-error { + font-size: 0.78rem; + color: var(--danger); + font-weight: 700; +} + +.roles-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.role-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + border: 1px solid rgba(17, 18, 20, 0.1); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.86); + cursor: pointer; + + input { + margin-top: 4px; + } +} + +.role-content { + display: grid; + gap: 2px; + + strong { + font-size: 0.88rem; + color: var(--text); + } + + span { + font-size: 0.78rem; + color: var(--muted); + } +} + +.form-actions { + display: flex; + justify-content: flex-end; +} + +.btn { + border: 1px solid transparent; + border-radius: 10px; + padding: 0 14px; + height: 40px; + font-size: 0.88rem; + font-weight: 700; + cursor: pointer; +} + +.btn-primary { + background: linear-gradient(120deg, #e33dcf, #b131a0); + color: #fff; + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } +} + +.btn-ghost { + border-color: rgba(17, 18, 20, 0.16); + background: #fff; + color: var(--text); +} + +@media (max-width: 992px) { + .card-header, + .card-body { + padding-left: 18px; + padding-right: 18px; + } + + .roles-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .system-provision-page { + padding-top: 16px; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-field.span-2 { + grid-column: span 1; + } + + .select-row { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/system-provision-user/system-provision-user.ts b/src/app/pages/system-provision-user/system-provision-user.ts new file mode 100644 index 0000000..90ea561 --- /dev/null +++ b/src/app/pages/system-provision-user/system-provision-user.ts @@ -0,0 +1,255 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators, +} from '@angular/forms'; + +import { + SystemAdminService, + SystemTenantDto, + CreateSystemTenantUserResponse, +} from '../../services/system-admin.service'; + +type RoleOption = { + value: string; + label: string; + description: string; +}; + +@Component({ + selector: 'app-system-provision-user', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './system-provision-user.html', + styleUrls: ['./system-provision-user.scss'], +}) +export class SystemProvisionUserPage implements OnInit { + readonly roleOptions: RoleOption[] = [ + { value: 'sysadmin', label: 'SysAdmin', description: 'Acesso administrativo global do sistema (apenas SystemTenant).' }, + { value: 'gestor', label: 'Gestor', description: 'Acesso global de gestão, sem permissões administrativas.' }, + { value: 'cliente', label: 'Cliente', description: 'Acesso restrito ao tenant do cliente.' }, + ]; + + readonly sourceType = 'MobileLines.Cliente'; + + provisionForm: FormGroup; + tenants: SystemTenantDto[] = []; + tenantsLoading = false; + tenantsError = ''; + + submitting = false; + submitErrors: string[] = []; + successMessage = ''; + createdUser: CreateSystemTenantUserResponse | null = null; + + constructor( + private fb: FormBuilder, + private systemAdminService: SystemAdminService + ) { + this.provisionForm = this.fb.group( + { + tenantId: ['', [Validators.required]], + name: [''], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(6)]], + confirmPassword: ['', [Validators.required, Validators.minLength(6)]], + roles: this.fb.control(['cliente'], [Validators.required]), + }, + { validators: this.passwordsMatchValidator } + ); + } + + ngOnInit(): void { + this.loadTenants(); + } + + loadTenants(): void { + if (this.tenantsLoading) return; + + this.tenantsLoading = true; + this.tenantsError = ''; + + this.systemAdminService + .listTenants({ source: this.sourceType, active: true }) + .subscribe({ + next: (tenants) => { + this.tenants = (tenants || []).slice().sort((a, b) => + (a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' }) + ); + this.tenantsLoading = false; + }, + error: (err: HttpErrorResponse) => { + this.tenantsLoading = false; + this.tenantsError = this.extractErrorMessage( + err, + 'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.' + ); + }, + }); + } + + isRoleSelected(role: string): boolean { + const selected = this.selectedRoles; + return selected.includes(role); + } + + toggleRole(role: string, checked: boolean): void { + const current = this.selectedRoles; + const next = checked + ? Array.from(new Set([...current, role])) + : current.filter((value) => value !== role); + + this.rolesControl.setValue(next); + this.rolesControl.markAsDirty(); + this.rolesControl.markAsTouched(); + } + + onSubmit(): void { + if (this.submitting) return; + + this.successMessage = ''; + this.submitErrors = []; + this.createdUser = null; + + if (this.provisionForm.invalid || this.selectedRoles.length === 0) { + this.provisionForm.markAllAsTouched(); + if (this.selectedRoles.length === 0) { + this.submitErrors = ['Selecione ao menos uma role para o usuário.']; + } + return; + } + + const tenantId = String(this.provisionForm.get('tenantId')?.value ?? '').trim(); + const email = String(this.provisionForm.get('email')?.value ?? '').trim().toLowerCase(); + const nameRaw = String(this.provisionForm.get('name')?.value ?? '').trim(); + const password = String(this.provisionForm.get('password')?.value ?? ''); + + this.submitting = true; + this.setFormDisabled(true); + + this.systemAdminService + .createTenantUser(tenantId, { + name: nameRaw, + email, + password, + roles: this.selectedRoles, + }) + .subscribe({ + next: (created) => { + this.submitting = false; + this.setFormDisabled(false); + + this.createdUser = created; + const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId); + const tenantName = tenant?.nomeOficial || 'cliente selecionado'; + this.successMessage = `Usuário ${created.email} criado com sucesso para ${tenantName}.`; + + this.provisionForm.patchValue({ + name: '', + email: '', + password: '', + confirmPassword: '', + roles: ['cliente'], + }); + this.provisionForm.markAsPristine(); + this.provisionForm.markAsUntouched(); + }, + error: (err: HttpErrorResponse) => { + this.submitting = false; + this.setFormDisabled(false); + this.submitErrors = this.extractErrors(err); + }, + }); + } + + trackByTenantId(_: number, tenant: SystemTenantDto): string { + return tenant.tenantId; + } + + trackByRoleValue(_: number, role: RoleOption): string { + return role.value; + } + + hasFieldError(field: string, error?: string): boolean { + const control = this.provisionForm.get(field); + if (!control) return false; + if (error) return !!(control.touched && control.hasError(error)); + return !!(control.touched && control.invalid); + } + + get passwordMismatch(): boolean { + const confirmTouched = this.provisionForm.get('confirmPassword')?.touched; + return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']); + } + + get selectedRoles(): string[] { + const roles = this.rolesControl.value; + return Array.isArray(roles) ? roles : []; + } + + get rolesControl(): AbstractControl { + return this.provisionForm.get('roles') as AbstractControl; + } + + private findTenantById(tenantId: string): SystemTenantDto | undefined { + return this.tenants.find((tenant) => tenant.tenantId === tenantId); + } + + private setFormDisabled(disabled: boolean): void { + if (disabled) { + this.provisionForm.disable({ emitEvent: false }); + return; + } + + this.provisionForm.enable({ emitEvent: false }); + } + + private extractErrors(err: HttpErrorResponse): string[] { + const apiError = err?.error; + + if (Array.isArray(apiError)) { + const list = apiError.map((entry) => String(entry ?? '').trim()).filter(Boolean); + if (list.length) return list; + } + + if (Array.isArray(apiError?.errors)) { + const list = apiError.errors + .map((entry: unknown) => String((entry as { message?: string })?.message ?? entry ?? '').trim()) + .filter(Boolean); + if (list.length) return list; + } + + if (typeof apiError?.message === 'string' && apiError.message.trim()) { + return [apiError.message.trim()]; + } + + if (typeof apiError === 'string' && apiError.trim()) { + return [apiError.trim()]; + } + + if (err.status === 403) { + return ['Acesso negado. Este recurso é exclusivo para sysadmin.']; + } + + return ['Não foi possível criar o usuário para o cliente selecionado.']; + } + + private extractErrorMessage(err: HttpErrorResponse, fallback: string): string { + const messages = this.extractErrors(err); + if (messages.length) return messages[0]; + return fallback; + } + + private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { + const password = group.get('password')?.value; + const confirm = group.get('confirmPassword')?.value; + if (!password || !confirm) return null; + return password === confirm ? null : { passwordMismatch: true }; + } +} diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index 0160e1e..ba9e1bb 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -113,7 +113,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - this.isAdmin = this.authService.hasRole('admin'); + this.isAdmin = this.authService.hasRole('sysadmin'); this.loadClients(); this.loadPlanRules(); this.fetch(1); diff --git a/src/app/services/system-admin.service.ts b/src/app/services/system-admin.service.ts new file mode 100644 index 0000000..044183d --- /dev/null +++ b/src/app/services/system-admin.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; + +export type SystemTenantDto = { + tenantId: string; + nomeOficial: string; +}; + +export type ListSystemTenantsParams = { + source?: string; + active?: boolean; +}; + +export type CreateSystemTenantUserPayload = { + name: string; + email: string; + password: string; + roles: string[]; +}; + +export type CreateSystemTenantUserResponse = { + userId: string; + tenantId: string; + email: string; + roles: string[]; +}; + +@Injectable({ providedIn: 'root' }) +export class SystemAdminService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + listTenants(params?: ListSystemTenantsParams): Observable { + let httpParams = new HttpParams(); + if (params?.source) { + httpParams = httpParams.set('source', params.source); + } + if (typeof params?.active === 'boolean') { + httpParams = httpParams.set('active', String(params.active)); + } + + return this.http.get(`${this.baseApi}/system/tenants`, { + params: httpParams, + }); + } + + createTenantUser( + tenantId: string, + payload: CreateSystemTenantUserPayload + ): Observable { + return this.http.post( + `${this.baseApi}/system/tenants/${tenantId}/users`, + payload + ); + } +} diff --git a/src/app/services/users.service.ts b/src/app/services/users.service.ts index f6141e1..115f3ce 100644 --- a/src/app/services/users.service.ts +++ b/src/app/services/users.service.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; -export type UserPermission = 'admin' | 'gestor'; +export type UserPermission = 'sysadmin' | 'gestor' | 'cliente'; export type UserDto = { id: string;