diff --git a/public/logo.png b/public/logo.png index 54ca585..dc9fdf6 100644 Binary files a/public/logo.png and b/public/logo.png differ 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; + } +} +