From 3f5c55162ef7c55e6252ad9f8924cb4b98fa4e67 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Mon, 2 Mar 2026 13:26:48 -0300 Subject: [PATCH] =?UTF-8?q?Feat:=20Aplicando=20Altera=C3=A7=C3=B5es/Ajuste?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.routes.ts | 2 +- src/app/components/header/header.html | 33 +- src/app/components/header/header.ts | 117 ++++- src/app/pages/dashboard/dashboard.html | 173 +++++-- src/app/pages/dashboard/dashboard.scss | 4 + src/app/pages/dashboard/dashboard.ts | 423 ++++++++++++++++-- src/app/pages/geral/geral.html | 5 +- src/app/pages/geral/geral.scss | 21 + src/app/pages/geral/geral.ts | 2 +- .../system-provision-user.html | 46 +- .../system-provision-user.ts | 71 +-- src/app/services/sysadmin.service.ts | 1 + 12 files changed, 699 insertions(+), 199 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 51c630c..3c39d3d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -43,7 +43,7 @@ export const routes: Routes = [ path: 'system/fornecer-usuario', component: SystemProvisionUserPage, canActivate: [authGuard, sysadminOnlyGuard], - title: 'Fornecer Usuário', + title: 'Criar Credenciais do Cliente', }, // ✅ rota correta diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 4e0f288..359699f 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -191,8 +191,11 @@ +
@@ -303,7 +306,7 @@ @@ -316,7 +319,7 @@ Usuário - Permissão + Perfil Status Ações @@ -391,7 +394,7 @@
{{ target.nome.charAt(0).toUpperCase() }}

{{ target.nome }}

- Editando perfil + {{ isManageClientsMode ? 'Editando credencial do cliente' : 'Editando perfil' }}
@@ -403,13 +406,13 @@
- +
- +
@@ -427,7 +430,13 @@
- +
@@ -452,7 +461,7 @@ (click)="confirmPermanentDeleteUser(target)" [disabled]="editUserSubmitting" [title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'"> - Excluir Permanentemente + {{ isManageClientsMode ? 'Excluir Credencial' : 'Excluir Permanentemente' }}
@@ -538,7 +547,7 @@ Troca de número - Fornecer usuário + Criar credenciais do cliente diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 1e0ad93..bfb9051 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -62,6 +62,7 @@ export class Header implements AfterViewInit, OnDestroy { manageUsersErrors: ApiFieldError[] = []; manageUsersSuccess = ''; private manageUsersFeedbackTimer: ReturnType | null = null; + manageMode: 'users' | 'clients' = 'users'; manageUsers: any[] = []; manageSearch = ''; managePage = 1; @@ -254,6 +255,16 @@ export class Header implements AfterViewInit, OnDestroy { openManageUsersModal() { if (!this.isSysAdmin) return; + this.manageMode = 'users'; + this.manageUsersOpen = true; + this.closeOptions(); + this.resetManageUsersState(); + this.fetchManageUsers(1); + } + + openManageClientCredentialsModal() { + if (!this.isSysAdmin) return; + this.manageMode = 'clients'; this.manageUsersOpen = true; this.closeOptions(); this.resetManageUsersState(); @@ -263,6 +274,7 @@ export class Header implements AfterViewInit, OnDestroy { closeManageUsersModal() { this.manageUsersOpen = false; this.resetManageUsersState(); + this.manageMode = 'users'; } toggleNotifications() { @@ -660,6 +672,7 @@ export class Header implements AfterViewInit, OnDestroy { this.usersService .list({ search: this.manageSearch?.trim() || undefined, + permissao: this.isManageClientsMode ? 'cliente' : undefined, page: this.managePage, pageSize: this.managePageSize, }) @@ -720,17 +733,23 @@ export class Header implements AfterViewInit, OnDestroy { this.usersService.getById(user.id).subscribe({ next: (full) => { this.editUserTarget = full; + const permissao = this.isManageClientsMode ? 'cliente' : (full.permissao ?? ''); this.editUserForm.reset({ nome: full.nome ?? '', email: full.email ?? '', senha: '', confirmarSenha: '', - permissao: full.permissao ?? '', + permissao, ativo: full.ativo ?? true, }); + if (this.isManageClientsMode) { + this.editUserForm.get('permissao')?.disable({ emitEvent: false }); + } else { + this.editUserForm.get('permissao')?.enable({ emitEvent: false }); + } }, error: () => { - this.editUserErrors = [{ message: 'Erro ao carregar usuario.' }]; + this.editUserErrors = [{ message: this.isManageClientsMode ? 'Erro ao carregar credencial do cliente.' : 'Erro ao carregar usuário.' }]; }, }); } @@ -750,12 +769,21 @@ export class Header implements AfterViewInit, OnDestroy { const payload: any = {}; const nome = (this.editUserForm.get('nome')?.value || '').toString().trim(); const email = (this.editUserForm.get('email')?.value || '').toString().trim(); - const permissao = (this.editUserForm.get('permissao')?.value || '').toString().trim(); + const permissao = this.isManageClientsMode + ? 'cliente' + : (this.editUserForm.get('permissao')?.value || '').toString().trim(); const ativo = !!this.editUserForm.get('ativo')?.value; if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome; if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email; - if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) payload.permissao = permissao; + if (this.isManageClientsMode) { + const targetPermissao = String(this.editUserTarget.permissao || '').trim().toLowerCase(); + if (targetPermissao !== 'cliente') { + payload.permissao = 'cliente'; + } + } else if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) { + payload.permissao = permissao; + } if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo; const senha = (this.editUserForm.get('senha')?.value || '').toString(); @@ -794,18 +822,25 @@ export class Header implements AfterViewInit, OnDestroy { const merged = this.mergeUserUpdate(currentTarget, payload); this.editUserSubmitting = false; this.setEditFormDisabled(false); - this.editUserSuccess = `Usuario ${merged.nome} atualizado com sucesso.`; + this.editUserSuccess = this.isManageClientsMode + ? `Credencial de ${merged.nome} atualizada com sucesso.` + : `Usuario ${merged.nome} atualizado com sucesso.`; this.editUserTarget = merged; this.editUserForm.patchValue({ nome: merged.nome ?? '', email: merged.email ?? '', - permissao: merged.permissao ?? '', + permissao: this.isManageClientsMode ? 'cliente' : (merged.permissao ?? ''), ativo: merged.ativo ?? true, senha: '', confirmarSenha: '', }); this.upsertManageUser(merged); - this.showManageUsersFeedback(`Usuario ${merged.nome} atualizado com sucesso.`, 'success'); + this.showManageUsersFeedback( + this.isManageClientsMode + ? `Credencial de ${merged.nome} atualizada com sucesso.` + : `Usuario ${merged.nome} atualizado com sucesso.`, + 'success' + ); }, error: (err: HttpErrorResponse) => { this.editUserSubmitting = false; @@ -814,12 +849,17 @@ export class Header implements AfterViewInit, OnDestroy { if (Array.isArray(apiErrors)) { this.editUserErrors = apiErrors.map((e: any) => ({ field: e?.field, - message: e?.message || 'Erro ao atualizar usuario.', + message: e?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'), })); } else { - this.editUserErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }]; + this.editUserErrors = [{ + message: err?.error?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.') + }]; } - this.showManageUsersFeedback(this.editUserErrors[0]?.message || 'Erro ao atualizar usuario.', 'error'); + this.showManageUsersFeedback( + this.editUserErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'), + 'error' + ); }, }); } @@ -827,11 +867,13 @@ export class Header implements AfterViewInit, OnDestroy { async confirmToggleUserStatus(user: any) { const nextActive = user.ativo === false; const actionLabel = nextActive ? 'reativar' : 'inativar'; + const entity = this.isManageClientsMode ? 'Credencial do Cliente' : 'Usuário'; + const entityLower = this.isManageClientsMode ? 'credencial do cliente' : 'usuário'; const confirmed = await confirmActionModal({ - title: nextActive ? 'Reativar Usuário' : 'Inativar Usuário', + title: nextActive ? `Reativar ${entity}` : `Inativar ${entity}`, message: nextActive - ? `Deseja reativar o usuário ${user.nome}? Ele voltará a ter acesso ao sistema.` - : `Deseja inativar o usuário ${user.nome}? A conta ficará sem acesso até ser reativada.`, + ? `Deseja reativar ${entityLower} ${user.nome}? O acesso ao sistema será liberado novamente.` + : `Deseja inativar ${entityLower} ${user.nome}? O acesso ao sistema ficará bloqueado até reativação.`, confirmLabel: nextActive ? 'Reativar' : 'Inativar', tone: nextActive ? 'neutral' : 'warning', }); @@ -845,11 +887,13 @@ export class Header implements AfterViewInit, OnDestroy { this.editUserTarget = { ...this.editUserTarget, ativo: nextActive }; this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' }); this.editUserErrors = []; - this.editUserSuccess = `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`; + this.editUserSuccess = this.isManageClientsMode + ? `Credencial de ${user.nome} ${nextActive ? 'reativada' : 'inativada'} com sucesso.` + : `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`; } }, error: (err: HttpErrorResponse) => { - const message = err?.error?.message || `Erro ao ${actionLabel} usuario.`; + const message = err?.error?.message || `Erro ao ${actionLabel} ${this.isManageClientsMode ? 'credencial do cliente' : 'usuario'}.`; if (this.editUserTarget?.id === user.id) { this.editUserSuccess = ''; this.editUserErrors = [{ message }]; @@ -860,7 +904,9 @@ export class Header implements AfterViewInit, OnDestroy { async confirmPermanentDeleteUser(user: any) { if (user?.ativo !== false) { - const message = 'Inative a conta antes de excluir permanentemente.'; + const message = this.isManageClientsMode + ? 'Inative a credencial antes de excluir permanentemente.' + : 'Inative a conta antes de excluir permanentemente.'; if (this.editUserTarget?.id === user?.id) { this.editUserSuccess = ''; this.editUserErrors = [{ message }]; @@ -870,7 +916,9 @@ export class Header implements AfterViewInit, OnDestroy { return; } - const confirmed = await confirmDeletionWithTyping(`o usuário ${user.nome}`); + const confirmed = await confirmDeletionWithTyping( + this.isManageClientsMode ? `a credencial do cliente ${user.nome}` : `o usuário ${user.nome}` + ); if (!confirmed) return; this.usersService.delete(user.id).subscribe({ @@ -883,8 +931,8 @@ export class Header implements AfterViewInit, OnDestroy { error: (err: HttpErrorResponse) => { const apiErrors = err?.error?.errors; const message = Array.isArray(apiErrors) - ? (apiErrors[0]?.message || 'Erro ao excluir usuario.') - : (err?.error?.message || 'Erro ao excluir usuario.'); + ? (apiErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.')) + : (err?.error?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.')); if (this.editUserTarget?.id === user.id) { this.editUserSuccess = ''; @@ -936,6 +984,30 @@ export class Header implements AfterViewInit, OnDestroy { this.cancelEditUser(); } + get isManageClientsMode(): boolean { + return this.manageMode === 'clients'; + } + + get manageModalTitle(): string { + return this.isManageClientsMode ? 'Credenciais de Clientes' : 'Gestão de Usuários'; + } + + get manageListTitle(): string { + return this.isManageClientsMode ? 'Credenciais de Cliente' : 'Usuários'; + } + + get manageSearchPlaceholder(): string { + return this.isManageClientsMode + ? 'Buscar por cliente, nome ou email...' + : 'Buscar por nome ou email...'; + } + + get editPermissionOptions() { + return this.isManageClientsMode + ? [{ value: 'cliente', label: 'Cliente' }] + : this.permissionOptions; + } + private normalizeField(field?: string | null): string { return (field || '').trim().toLowerCase(); } @@ -947,7 +1019,12 @@ export class Header implements AfterViewInit, OnDestroy { private setEditFormDisabled(disabled: boolean) { if (disabled) this.editUserForm.disable({ emitEvent: false }); - else this.editUserForm.enable({ emitEvent: false }); + else { + this.editUserForm.enable({ emitEvent: false }); + if (this.isManageClientsMode) { + this.editUserForm.get('permissao')?.disable({ emitEvent: false }); + } + } } private upsertManageUser(user: any) { diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index 85638dd..7ae6145 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -7,10 +7,12 @@
- Visão Geral + {{ isCliente ? 'Visão Cliente' : 'Visão Geral' }}
-

Dashboard de Gestão de Linhas

-

Painel operacional com foco em status, cobertura e histórico da base.

+

{{ isCliente ? 'Dashboard do Cliente' : 'Dashboard de Gestão de Linhas' }}

+

+ {{ isCliente ? 'Acompanhe suas linhas em tempo real com foco em operação e disponibilidade.' : 'Painel operacional com foco em status, cobertura e histórico da base.' }} +

@@ -27,7 +29,7 @@
-
+
@@ -326,45 +328,144 @@ -
-
-
-
-
-
-

Status da Base

-

Distribuição atual das linhas

+ +
+

Monitoramento da Sua Base

+

Visão operacional das suas linhas para acompanhar uso, status e disponibilidade.

+
+ +
+
+
+
+
+
+

Status das Linhas

+

Distribuição atual da sua base

+
+
+
+
+ +
+
+
+ + Ativas + {{ statusResumo.ativos | number:'1.0-0' }} +
+
+ + Bloqueadas + {{ statusResumo.bloqueadas | number:'1.0-0' }} +
+
+ + Reserva + {{ statusResumo.reservas | number:'1.0-0' }} +
+
+ + Outros Status + {{ clientOverview.outrosStatus | number:'1.0-0' }} +
+
+ Total + {{ statusResumo.total | number:'1.0-0' }} +
+
-
-
- + +
+
+
+
+

Faixa de Franquia Line

+

Quantidade de linhas por faixa de franquia contratada

+
-
-
- - Ativas - {{ statusResumo.ativos | number:'1.0-0' }} -
-
- - Bloqueadas - {{ statusResumo.bloqueadas | number:'1.0-0' }} -
-
- - Reserva - {{ statusResumo.reservas | number:'1.0-0' }} -
-
- Total - {{ statusResumo.total | number:'1.0-0' }} -
+
+
-
+ +
+
+
+
+
+

Top Planos (Qtd. Linhas)

+

Planos com maior volume na sua operação

+
+
+
+ +
+
+ +
+
+
+

Top Usuários (Qtd. Linhas)

+

Usuários com maior concentração de linhas

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

Reserva por DDD

+

Linhas disponíveis em reserva por região

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

Tipo de Chip

+

Distribuição entre e-SIM, SIMCARD e outros

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

Sem dados para exibição

+

Não encontramos linhas vinculadas ao seu acesso no momento.

+
+
+
+

+ Assim que a base deste cliente estiver disponível na página Geral, os KPIs e gráficos serão atualizados automaticamente. +

+
+
+
+
diff --git a/src/app/pages/dashboard/dashboard.scss b/src/app/pages/dashboard/dashboard.scss index e9b23bd..f3a2dd7 100644 --- a/src/app/pages/dashboard/dashboard.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -163,6 +163,10 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; + + @media (min-width: 1500px) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } } .hero-card { diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index bb82fe8..d010e61 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -155,6 +155,13 @@ type DashboardGeralInsightsDto = { }; type DashboardLineListItemDto = { + linha?: string | null; + cliente?: string | null; + usuario?: string | null; + skil?: string | null; + planoContrato?: string | null; + status?: string | null; + franquiaLine?: number | null; gestaoVozDados?: number | null; skeelo?: number | null; vivoNewsPlus?: number | null; @@ -164,13 +171,6 @@ type DashboardLineListItemDto = { tipoDeChip?: string | null; }; -type DashboardLinesPageDto = { - page: number; - pageSize: number; - total: number; - items: DashboardLineListItemDto[]; -}; - type ResumoTopCliente = { cliente: string; linhas: number; @@ -193,6 +193,18 @@ type ResumoDiferencaPjPf = { totalLinhas: number | null; }; +type ClientDashboardOverview = { + hasData: boolean; + totalLinhas: number; + ativas: number; + bloqueadas: number; + reservas: number; + franquiaLineTotalGb: number; + planosContratados: number; + usuariosComLinha: number; + outrosStatus: number; +}; + @Component({ selector: 'app-dashboard', standalone: true, @@ -286,6 +298,17 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { resumo: ResumoResponse | null = null; resumoTopN = 5; resumoTopOptions = [5, 10, 15]; + clientOverview: ClientDashboardOverview = { + hasData: false, + totalLinhas: 0, + ativas: 0, + bloqueadas: 0, + reservas: 0, + franquiaLineTotalGb: 0, + planosContratados: 0, + usuariosComLinha: 0, + outrosStatus: 0, + }; // Resumo Derived Data resumoTopClientes: ResumoTopCliente[] = []; @@ -348,11 +371,14 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const isGestor = this.authService.hasRole('gestor'); this.isCliente = !(isSysAdmin || isGestor); - this.loadDashboard(); - if (!this.isCliente) { - this.loadInsights(); - this.loadResumoExecutive(); + if (this.isCliente) { + this.loadClientDashboardData(); + return; } + + this.loadDashboard(); + this.loadInsights(); + this.loadResumoExecutive(); } ngAfterViewInit(): void { @@ -389,6 +415,255 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } } + private async loadClientDashboardData() { + this.loading = true; + this.errorMsg = null; + this.dataReady = false; + this.resumoLoading = true; + this.resumoError = null; + this.resumoReady = false; + + try { + const [operacionais, reservas] = await Promise.all([ + this.fetchAllDashboardLines(false), + this.fetchAllDashboardLines(true), + ]); + const allLines = [...operacionais, ...reservas]; + this.applyClientLineAggregates(allLines, reservas); + + this.loading = false; + this.resumoLoading = false; + this.dataReady = true; + this.resumoReady = true; + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + } catch (error) { + this.loading = false; + this.resumoLoading = false; + this.resumoReady = false; + this.dataReady = false; + this.errorMsg = this.isNetworkError(error) + ? 'Falha ao carregar o Dashboard. Verifique a conexão.' + : 'Falha ao carregar os dados do cliente.'; + this.clearClientDashboardState(); + } + } + + private async fetchAllDashboardLines(onlyReserva: boolean): Promise { + const pageSize = 500; + let page = 1; + const all: DashboardLineListItemDto[] = []; + + while (true) { + let params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + if (onlyReserva) { + params = params.set('skil', 'RESERVA'); + } + + const response = await firstValueFrom(this.http.get(`${this.baseApi}/lines`, { params })); + const itemsRaw = this.readNode(response, 'items', 'Items'); + const items = Array.isArray(itemsRaw) ? (itemsRaw as DashboardLineListItemDto[]) : []; + all.push(...items); + + const total = this.toNumberOrNull(this.readNode(response, 'total', 'Total')); + if (!items.length) break; + if (total !== null && all.length >= total) break; + if (items.length < pageSize) break; + + page += 1; + } + + return all; + } + + private applyClientLineAggregates( + allLines: DashboardLineListItemDto[], + reservaLines: DashboardLineListItemDto[] + ): void { + const planMap = new Map(); + const userMap = new Map(); + const reservaDddMap = new Map(); + const franquiaBandMap = new Map(); + + let totalLinhas = 0; + let ativas = 0; + let bloqueadas = 0; + let reservas = 0; + let outrosStatus = 0; + let franquiaLineTotalGb = 0; + let eSim = 0; + let simCard = 0; + let outrosChip = 0; + + for (const line of allLines) { + totalLinhas += 1; + const isReserva = this.isReservaLine(line); + const status = this.normalizeSeriesKey(this.readLineString(line, 'status', 'Status')); + const planoContrato = this.readLineString(line, 'planoContrato', 'PlanoContrato').trim(); + const usuario = this.readLineString(line, 'usuario', 'Usuario').trim(); + const franquiaLine = this.readLineNumber(line, 'franquiaLine', 'FranquiaLine'); + franquiaLineTotalGb += franquiaLine > 0 ? franquiaLine : 0; + + if (isReserva) { + reservas += 1; + } else if (status.includes('ATIV')) { + ativas += 1; + } else if ( + status.includes('BLOQUE') || + status.includes('PERDA') || + status.includes('ROUBO') || + status.includes('SUSPEN') || + status.includes('CANCEL') + ) { + bloqueadas += 1; + } else { + outrosStatus += 1; + } + + if (!isReserva) { + const planoKey = planoContrato || 'Sem plano'; + planMap.set(planoKey, (planMap.get(planoKey) ?? 0) + 1); + + const userKey = usuario || 'Sem usuário'; + userMap.set(userKey, (userMap.get(userKey) ?? 0) + 1); + + const faixa = this.resolveFranquiaLineBand(franquiaLine); + franquiaBandMap.set(faixa, (franquiaBandMap.get(faixa) ?? 0) + 1); + } + + const chipType = this.normalizeChipType(this.readLineString(line, 'tipoDeChip', 'TipoDeChip')); + if (chipType === 'ESIM') { + eSim += 1; + } else if (chipType === 'SIMCARD') { + simCard += 1; + } else { + outrosChip += 1; + } + } + + for (const line of reservaLines) { + const ddd = this.extractDddFromLine(this.readLineString(line, 'linha', 'Linha')) ?? 'Sem DDD'; + reservaDddMap.set(ddd, (reservaDddMap.get(ddd) ?? 0) + 1); + } + + const topPlanos = Array.from(planMap.entries()) + .map(([plano, linhas]) => ({ plano, linhas })) + .sort((a, b) => b.linhas - a.linhas || a.plano.localeCompare(b.plano, 'pt-BR')) + .slice(0, this.resumoTopN); + + const topUsuarios = Array.from(userMap.entries()) + .map(([cliente, linhas]) => ({ cliente, linhas })) + .sort((a, b) => b.linhas - a.linhas || a.cliente.localeCompare(b.cliente, 'pt-BR')) + .slice(0, this.resumoTopN); + + const topReserva = Array.from(reservaDddMap.entries()) + .map(([ddd, total]) => ({ ddd, total, linhas: total })) + .sort((a, b) => b.total - a.total || a.ddd.localeCompare(b.ddd, 'pt-BR')) + .slice(0, this.resumoTopN); + + const franquiaOrder = ['Sem franquia', 'Até 10 GB', '10 a 20 GB', '20 a 50 GB', 'Acima de 50 GB']; + const franquiaLabels = franquiaOrder.filter((label) => (franquiaBandMap.get(label) ?? 0) > 0); + this.franquiaLabels = franquiaLabels.length ? franquiaLabels : franquiaOrder; + this.franquiaValues = this.franquiaLabels.map((label) => franquiaBandMap.get(label) ?? 0); + + this.tipoChipLabels = ['e-SIM', 'SIMCARD', 'Outros']; + this.tipoChipValues = [eSim, simCard, outrosChip]; + this.travelLabels = []; + this.travelValues = []; + this.adicionaisLabels = []; + this.adicionaisValues = []; + this.adicionaisTotals = []; + this.insights = null; + this.rebuildAdicionaisComparativo(null); + + this.statusResumo = { + total: totalLinhas, + ativos: ativas, + bloqueadas, + perdaRoubo: 0, + bloq120: 0, + reservas, + outras: outrosStatus, + }; + + this.clientOverview = { + hasData: totalLinhas > 0, + totalLinhas, + ativas, + bloqueadas, + reservas, + franquiaLineTotalGb, + planosContratados: planMap.size, + usuariosComLinha: userMap.size, + outrosStatus, + }; + + this.resumoTopPlanos = topPlanos; + this.resumoPlanosLabels = topPlanos.map((x) => x.plano); + this.resumoPlanosValues = topPlanos.map((x) => x.linhas); + + this.resumoTopClientes = topUsuarios; + this.resumoClientesLabels = topUsuarios.map((x) => x.cliente); + this.resumoClientesValues = topUsuarios.map((x) => x.linhas); + + this.resumoTopReserva = topReserva; + this.resumoReservaLabels = topReserva.map((x) => x.ddd); + this.resumoReservaValues = topReserva.map((x) => x.total); + + this.resumoPfPjLabels = []; + this.resumoPfPjValues = []; + this.resumoDiferencaPjPf = { + pfLinhas: null, + pjLinhas: null, + totalLinhas: null, + }; + + this.resumo = null; + this.rebuildPrimaryKpis(); + } + + private clearClientDashboardState() { + this.clientOverview = { + hasData: false, + totalLinhas: 0, + ativas: 0, + bloqueadas: 0, + reservas: 0, + franquiaLineTotalGb: 0, + planosContratados: 0, + usuariosComLinha: 0, + outrosStatus: 0, + }; + this.statusResumo = { + total: 0, + ativos: 0, + bloqueadas: 0, + perdaRoubo: 0, + bloq120: 0, + reservas: 0, + outras: 0, + }; + this.franquiaLabels = []; + this.franquiaValues = []; + this.tipoChipLabels = []; + this.tipoChipValues = []; + this.resumoTopClientes = []; + this.resumoTopPlanos = []; + this.resumoTopReserva = []; + this.resumoPlanosLabels = []; + this.resumoPlanosValues = []; + this.resumoClientesLabels = []; + this.resumoClientesValues = []; + this.resumoReservaLabels = []; + this.resumoReservaValues = []; + this.rebuildPrimaryKpis(); + this.destroyCharts(); + this.destroyResumoCharts(); + } + private isNetworkError(error: unknown): boolean { if (error instanceof HttpErrorResponse) { return error.status === 0; @@ -448,6 +723,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } onResumoTopNChange() { + if (this.isCliente) { + void this.loadClientDashboardData(); + return; + } this.buildResumoDerived(); this.tryBuildResumoCharts(); } @@ -783,6 +1062,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } private async loadFallbackFromLinesIfNeeded(force = false): Promise { + if (this.isCliente) return; if (!isPlatformBrowser(this.platformId) || this.fallbackInsightsLoading) return; const syncIndex = this.adicionaisLabels.findIndex( @@ -901,6 +1181,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return ''; } + private isReservaLine(line: DashboardLineListItemDto): boolean { + const cliente = this.normalizeSeriesKey(this.readLineString(line, 'cliente', 'Cliente')); + const usuario = this.normalizeSeriesKey(this.readLineString(line, 'usuario', 'Usuario')); + const skil = this.normalizeSeriesKey(this.readLineString(line, 'skil', 'Skil')); + return cliente === 'RESERVA' || usuario === 'RESERVA' || skil === 'RESERVA'; + } + + private resolveFranquiaLineBand(value: number): string { + if (!Number.isFinite(value) || value <= 0) return 'Sem franquia'; + if (value < 10) return 'Até 10 GB'; + if (value < 20) return '10 a 20 GB'; + if (value < 50) return '20 a 50 GB'; + return 'Acima de 50 GB'; + } + + private extractDddFromLine(value: string | null | undefined): string | null { + const digits = (value ?? '').replace(/\D/g, ''); + if (!digits) return null; + + if (digits.startsWith('55') && digits.length >= 12) { + return digits.slice(2, 4); + } + + if (digits.length >= 10) { + return digits.slice(0, 2); + } + + return null; + } + private destroyInsightsCharts() { try { this.chartFranquia?.destroy(); } catch {} try { this.chartAdicionais?.destroy(); } catch {} @@ -924,36 +1234,60 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private rebuildPrimaryKpis() { if (this.isCliente) { - this.kpis = [ + const overview = this.clientOverview; + const cards: KpiCard[] = [ { key: 'linhas_total', title: 'Total de Linhas', - value: this.formatInt(this.dashboardRaw?.totalLinhas ?? this.statusResumo.total), + value: this.formatInt(overview.totalLinhas), icon: 'bi bi-sim-fill', - hint: 'Base geral', + hint: 'Base do cliente', }, { key: 'linhas_ativas', title: 'Linhas Ativas', - value: this.formatInt(this.dashboardRaw?.ativos ?? this.statusResumo.ativos), + value: this.formatInt(overview.ativas), icon: 'bi bi-check2-circle', hint: 'Status ativo', }, { key: 'linhas_bloqueadas', title: 'Linhas Bloqueadas', - value: this.formatInt(this.dashboardRaw?.bloqueados ?? this.statusResumo.bloqueadas), + value: this.formatInt(overview.bloqueadas), icon: 'bi bi-slash-circle', - hint: 'Todos os bloqueios', + hint: 'Bloqueio/suspensão', }, { key: 'linhas_reserva', title: 'Linhas em Reserva', - value: this.formatInt(this.dashboardRaw?.reservas ?? this.statusResumo.reservas), + value: this.formatInt(overview.reservas), icon: 'bi bi-inboxes-fill', - hint: 'Base de reserva', + hint: 'Disponíveis para uso', + }, + { + key: 'franquia_line_total', + title: 'Franquia Line Total', + value: this.formatDataAllowance(overview.franquiaLineTotalGb), + icon: 'bi bi-wifi', + hint: 'Franquia contratada', + }, + { + key: 'planos_contratados', + title: 'Planos Contratados', + value: this.formatInt(overview.planosContratados), + icon: 'bi bi-diagram-3-fill', + hint: 'Planos ativos na base', + }, + { + key: 'usuarios_com_linha', + title: 'Usuários com Linha', + value: this.formatInt(overview.usuariosComLinha), + icon: 'bi bi-people-fill', + hint: 'Usuários vinculados', }, ]; + + this.kpis = cards; return; } @@ -976,7 +1310,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { add( 'franquia_vivo_total', 'Total Franquia Vivo', - this.formatGb(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0), + this.formatDataAllowance(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0), 'bi bi-diagram-3-fill', 'Soma das franquias (Geral)' ); @@ -1014,22 +1348,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { if (!this.viewReady || !this.dataReady) return; requestAnimationFrame(() => { - const canvases = ( - this.isCliente - ? [this.chartStatusPie?.nativeElement] - : [ - this.chartStatusPie?.nativeElement, - this.chartAdicionaisComparativo?.nativeElement, - this.chartVigenciaMesAno?.nativeElement, - this.chartVigenciaSupervisao?.nativeElement, - this.chartMureg12?.nativeElement, - this.chartTroca12?.nativeElement, - this.chartLinhasPorFranquia?.nativeElement, - this.chartAdicionaisPagos?.nativeElement, - this.chartTipoChip?.nativeElement, - this.chartTravelMundo?.nativeElement, - ] - ).filter(Boolean) as HTMLCanvasElement[]; + const canvases = [ + this.chartStatusPie?.nativeElement, + this.chartAdicionaisComparativo?.nativeElement, + this.chartVigenciaMesAno?.nativeElement, + this.chartVigenciaSupervisao?.nativeElement, + this.chartMureg12?.nativeElement, + this.chartTroca12?.nativeElement, + this.chartLinhasPorFranquia?.nativeElement, + this.chartAdicionaisPagos?.nativeElement, + this.chartTipoChip?.nativeElement, + this.chartTravelMundo?.nativeElement, + ].filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length) return; if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { @@ -1054,7 +1384,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartResumoReservaDdd?.nativeElement, ].filter(Boolean) as HTMLCanvasElement[]; - if (!canvases.length || canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { + if (!canvases.length) return; + if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { this.scheduleResumoChartRetry(); return; } @@ -1124,10 +1455,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }); } - if (this.isCliente) { - return; - } - if (this.chartAdicionaisComparativo?.nativeElement) { this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, { type: 'doughnut', @@ -1221,7 +1548,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { labels: this.tipoChipLabels, datasets: [{ data: this.tipoChipValues, - backgroundColor: [palette.blue, palette.brand], + backgroundColor: [palette.blue, palette.brand, '#94a3b8'], borderWidth: 0, hoverOffset: 4 }] @@ -1489,6 +1816,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const value = n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return `${value} GB`; } + + formatDataAllowance(v: any) { + const n = this.toNumberOrNull(v); + if (n === null) return '0 GB'; + if (n >= 1024) { + const tb = n / 1024; + const valueTb = tb.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + return `${valueTb} TB`; + } + const valueGb = n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + return `${valueGb} GB`; + } private toNumberOrNull(v: any) { if (v === null || v === undefined || v === '') return null; diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index c845975..388462a 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -214,7 +214,7 @@
-
+
Total Clientes @@ -313,7 +313,7 @@ {{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }} - +
- Origem: {{ sourceType }} (apenas tenants ativos). - Selecione um tenant-cliente. + Selecione um cliente.
- - + +
@@ -111,31 +103,11 @@ As senhas não conferem.
- -
- -
- -
- - Selecione ao menos uma role. - -
diff --git a/src/app/pages/system-provision-user/system-provision-user.ts b/src/app/pages/system-provision-user/system-provision-user.ts index bb60f0b..b1045b5 100644 --- a/src/app/pages/system-provision-user/system-provision-user.ts +++ b/src/app/pages/system-provision-user/system-provision-user.ts @@ -16,12 +16,6 @@ import { CreateSystemTenantUserResponse, } from '../../services/sysadmin.service'; -type RoleOption = { - value: string; - label: string; - description: string; -}; - @Component({ selector: 'app-system-provision-user', standalone: true, @@ -30,12 +24,6 @@ type RoleOption = { styleUrls: ['./system-provision-user.scss'], }) export class SystemProvisionUserPage implements OnInit { - readonly roleOptions: RoleOption[] = [ - { value: 'sysadmin', label: 'SysAdmin', description: 'Acesso administrativo global do sistema (apenas SystemTenant).' }, - { value: 'gestor', label: 'Gestor', description: 'Acesso global de gestão, sem permissões administrativas.' }, - { value: 'cliente', label: 'Cliente', description: 'Acesso restrito ao tenant do cliente.' }, - ]; - readonly sourceType = 'MobileLines.Cliente'; provisionForm: FormGroup; @@ -74,6 +62,7 @@ export class SystemProvisionUserPage implements OnInit { this.tenantsLoading = true; this.tenantsError = ''; + this.syncTenantControlAvailability(); this.sysadminService .listTenants({ source: this.sourceType, active: true }) @@ -83,6 +72,7 @@ export class SystemProvisionUserPage implements OnInit { (a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' }) ); this.tenantsLoading = false; + this.syncTenantControlAvailability(); }, error: (err: HttpErrorResponse) => { this.tenantsLoading = false; @@ -90,26 +80,11 @@ export class SystemProvisionUserPage implements OnInit { err, 'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.' ); + this.syncTenantControlAvailability(); }, }); } - isRoleSelected(role: string): boolean { - const selected = this.selectedRoles; - return selected.includes(role); - } - - toggleRole(role: string, checked: boolean): void { - const current = this.selectedRoles; - const next = checked - ? Array.from(new Set([...current, role])) - : current.filter((value) => value !== role); - - this.rolesControl.setValue(next); - this.rolesControl.markAsDirty(); - this.rolesControl.markAsTouched(); - } - onSubmit(): void { if (this.submitting) return; @@ -117,11 +92,8 @@ export class SystemProvisionUserPage implements OnInit { this.submitErrors = []; this.createdUser = null; - if (this.provisionForm.invalid || this.selectedRoles.length === 0) { + if (this.provisionForm.invalid) { this.provisionForm.markAllAsTouched(); - if (this.selectedRoles.length === 0) { - this.submitErrors = ['Selecione ao menos uma role para o usuário.']; - } return; } @@ -138,7 +110,8 @@ export class SystemProvisionUserPage implements OnInit { name: nameRaw, email, password, - roles: this.selectedRoles, + roles: ['cliente'], + clientCredentialsOnly: true, }) .subscribe({ next: (created) => { @@ -148,7 +121,7 @@ export class SystemProvisionUserPage implements OnInit { this.createdUser = created; const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId); const tenantName = tenant?.nomeOficial || 'cliente selecionado'; - this.successMessage = `Usuário ${created.email} criado com sucesso para ${tenantName}.`; + this.successMessage = `Credencial de acesso criada com sucesso para ${tenantName}.`; this.provisionForm.patchValue({ name: '', @@ -172,10 +145,6 @@ export class SystemProvisionUserPage implements OnInit { return tenant.tenantId; } - trackByRoleValue(_: number, role: RoleOption): string { - return role.value; - } - hasFieldError(field: string, error?: string): boolean { const control = this.provisionForm.get(field); if (!control) return false; @@ -188,15 +157,6 @@ export class SystemProvisionUserPage implements OnInit { return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']); } - get selectedRoles(): string[] { - const roles = this.rolesControl.value; - return Array.isArray(roles) ? roles : []; - } - - get rolesControl(): AbstractControl { - return this.provisionForm.get('roles') as AbstractControl; - } - private findTenantById(tenantId: string): SystemTenantDto | undefined { return this.tenants.find((tenant) => tenant.tenantId === tenantId); } @@ -208,6 +168,21 @@ export class SystemProvisionUserPage implements OnInit { } this.provisionForm.enable({ emitEvent: false }); + this.syncTenantControlAvailability(); + } + + private syncTenantControlAvailability(): void { + const tenantControl = this.provisionForm.get('tenantId'); + if (!tenantControl) return; + if (this.submitting) return; + + const shouldDisable = this.tenantsLoading || this.tenants.length === 0; + if (shouldDisable) { + tenantControl.disable({ emitEvent: false }); + return; + } + + tenantControl.enable({ emitEvent: false }); } private extractErrors(err: HttpErrorResponse): string[] { @@ -237,7 +212,7 @@ export class SystemProvisionUserPage implements OnInit { return ['Acesso negado. Este recurso é exclusivo para sysadmin.']; } - return ['Não foi possível criar o usuário para o cliente selecionado.']; + return ['Não foi possível criar a credencial para o cliente selecionado.']; } private extractErrorMessage(err: HttpErrorResponse, fallback: string): string { diff --git a/src/app/services/sysadmin.service.ts b/src/app/services/sysadmin.service.ts index a43b9e9..b59eefa 100644 --- a/src/app/services/sysadmin.service.ts +++ b/src/app/services/sysadmin.service.ts @@ -19,6 +19,7 @@ export type CreateSystemTenantUserPayload = { email: string; password: string; roles: string[]; + clientCredentialsOnly?: boolean; }; export type CreateSystemTenantUserResponse = {