Feat: Aplicando Alterações/Ajustes

This commit is contained in:
Eduardo 2026-03-02 13:26:48 -03:00
parent 875345ea89
commit 3f5c55162e
12 changed files with 699 additions and 199 deletions

View File

@ -43,7 +43,7 @@ export const routes: Routes = [
path: 'system/fornecer-usuario', path: 'system/fornecer-usuario',
component: SystemProvisionUserPage, component: SystemProvisionUserPage,
canActivate: [authGuard, sysadminOnlyGuard], canActivate: [authGuard, sysadminOnlyGuard],
title: 'Fornecer Usuário', title: 'Criar Credenciais do Cliente',
}, },
// ✅ rota correta // ✅ rota correta

View File

@ -191,8 +191,11 @@
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageUsersModal()"> <button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário <i class="bi bi-people"></i> Editar usuário
</button> </button>
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageClientCredentialsModal()">
<i class="bi bi-person-badge"></i> Credenciais de clientes
</button>
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="goToSystemProvisionUser()"> <button type="button" class="options-item" *ngIf="isSysAdmin" (click)="goToSystemProvisionUser()">
<i class="bi bi-shield-lock"></i> Fornecer usuário (cliente) <i class="bi bi-shield-lock"></i> Criar credenciais do cliente
</button> </button>
<div class="divider"></div> <div class="divider"></div>
<button type="button" class="options-item danger" (click)="logout()"> <button type="button" class="options-item danger" (click)="logout()">
@ -293,7 +296,7 @@
<div class="modal-overlay" *ngIf="manageUsersOpen" (click)="closeManageUsersModal()"></div> <div class="modal-overlay" *ngIf="manageUsersOpen" (click)="closeManageUsersModal()"></div>
<div class="modal-card manage-users-modal" *ngIf="manageUsersOpen" (click)="$event.stopPropagation()"> <div class="modal-card manage-users-modal" *ngIf="manageUsersOpen" (click)="$event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<h3>Gestão de Usuários</h3> <h3>{{ manageModalTitle }}</h3>
<button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar"> <button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
@ -303,7 +306,7 @@
<div class="manage-search"> <div class="manage-search">
<div class="search-input-wrapper"> <div class="search-input-wrapper">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
<input type="text" placeholder="Buscar por nome ou email..." [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" /> <input type="text" [placeholder]="manageSearchPlaceholder" [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
</div> </div>
</div> </div>
@ -316,7 +319,7 @@
<thead> <thead>
<tr> <tr>
<th style="width: 40%;">Usuário</th> <th style="width: 40%;">Usuário</th>
<th style="width: 25%;" class="text-center">Permissão</th> <th style="width: 25%;" class="text-center">Perfil</th>
<th style="width: 15%;" class="text-center">Status</th> <th style="width: 15%;" class="text-center">Status</th>
<th style="width: 20%;" class="text-center">Ações</th> <th style="width: 20%;" class="text-center">Ações</th>
</tr> </tr>
@ -391,7 +394,7 @@
<div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div> <div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div>
<div class="info-text"> <div class="info-text">
<h4>{{ target.nome }}</h4> <h4>{{ target.nome }}</h4>
<span>Editando perfil</span> <span>{{ isManageClientsMode ? 'Editando credencial do cliente' : 'Editando perfil' }}</span>
</div> </div>
</div> </div>
@ -403,13 +406,13 @@
<form class="user-form refined-form" id="editUserHeaderForm" [formGroup]="editUserForm" (ngSubmit)="submitEditUser()"> <form class="user-form refined-form" id="editUserHeaderForm" [formGroup]="editUserForm" (ngSubmit)="submitEditUser()">
<div class="form-row"> <div class="form-row">
<div class="form-field"> <div class="form-field">
<label for="editHeaderNome">Nome Completo</label> <label for="editHeaderNome">{{ isManageClientsMode ? 'Nome do responsável' : 'Nome Completo' }}</label>
<input id="editHeaderNome" type="text" formControlName="nome" /> <input id="editHeaderNome" type="text" formControlName="nome" />
</div> </div>
</div> </div>
<div class="form-field"> <div class="form-field">
<label for="editHeaderEmail">Email Corporativo</label> <label for="editHeaderEmail">{{ isManageClientsMode ? 'Email de acesso' : 'Email Corporativo' }}</label>
<input id="editHeaderEmail" type="email" formControlName="email" /> <input id="editHeaderEmail" type="email" formControlName="email" />
</div> </div>
@ -427,7 +430,13 @@
<div class="form-row two-col align-end"> <div class="form-row two-col align-end">
<div class="form-field"> <div class="form-field">
<label for="editHeaderPermissao">Nível de Acesso</label> <label for="editHeaderPermissao">Nível de Acesso</label>
<app-select id="editHeaderPermissao" formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select> <app-select
id="editHeaderPermissao"
formControlName="permissao"
[options]="editPermissionOptions"
labelKey="label"
valueKey="value"
placeholder="Selecione o nivel"></app-select>
</div> </div>
<div class="form-field"> <div class="form-field">
@ -452,7 +461,7 @@
(click)="confirmPermanentDeleteUser(target)" (click)="confirmPermanentDeleteUser(target)"
[disabled]="editUserSubmitting" [disabled]="editUserSubmitting"
[title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'"> [title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'">
Excluir Permanentemente {{ isManageClientsMode ? 'Excluir Credencial' : 'Excluir Permanentemente' }}
</button> </button>
<button type="button" class="btn-ghost" (click)="cancelEditUser()" [disabled]="editUserSubmitting">Cancelar</button> <button type="button" class="btn-ghost" (click)="cancelEditUser()" [disabled]="editUserSubmitting">Cancelar</button>
<button type="submit" form="editUserHeaderForm" class="btn-primary" [disabled]="editUserSubmitting || !editUserTarget"> <button type="submit" form="editUserHeaderForm" class="btn-primary" [disabled]="editUserSubmitting || !editUserTarget">
@ -467,8 +476,8 @@
<div class="placeholder-icon"> <div class="placeholder-icon">
<i class="bi bi-person-gear"></i> <i class="bi bi-person-gear"></i>
</div> </div>
<h3>Editar Usuário</h3> <h3>{{ isManageClientsMode ? 'Editar Credencial' : 'Editar Usuário' }}</h3>
<p>Selecione um usuário na lista para visualizar e editar os detalhes.</p> <p>{{ isManageClientsMode ? 'Selecione uma credencial de cliente para visualizar e editar os detalhes.' : 'Selecione um usuário na lista para visualizar e editar os detalhes.' }}</p>
</div> </div>
</div> </div>
</div> </div>
@ -538,7 +547,7 @@
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span> <i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a> </a>
<a *ngIf="isSysAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="isSysAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-shield-lock-fill"></i> <span>Fornecer usuário</span> <i class="bi bi-shield-lock-fill"></i> <span>Criar credenciais do cliente</span>
</a> </a>
</div> </div>
</aside> </aside>

View File

@ -62,6 +62,7 @@ export class Header implements AfterViewInit, OnDestroy {
manageUsersErrors: ApiFieldError[] = []; manageUsersErrors: ApiFieldError[] = [];
manageUsersSuccess = ''; manageUsersSuccess = '';
private manageUsersFeedbackTimer: ReturnType<typeof setTimeout> | null = null; private manageUsersFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
manageMode: 'users' | 'clients' = 'users';
manageUsers: any[] = []; manageUsers: any[] = [];
manageSearch = ''; manageSearch = '';
managePage = 1; managePage = 1;
@ -254,6 +255,16 @@ export class Header implements AfterViewInit, OnDestroy {
openManageUsersModal() { openManageUsersModal() {
if (!this.isSysAdmin) return; 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.manageUsersOpen = true;
this.closeOptions(); this.closeOptions();
this.resetManageUsersState(); this.resetManageUsersState();
@ -263,6 +274,7 @@ export class Header implements AfterViewInit, OnDestroy {
closeManageUsersModal() { closeManageUsersModal() {
this.manageUsersOpen = false; this.manageUsersOpen = false;
this.resetManageUsersState(); this.resetManageUsersState();
this.manageMode = 'users';
} }
toggleNotifications() { toggleNotifications() {
@ -660,6 +672,7 @@ export class Header implements AfterViewInit, OnDestroy {
this.usersService this.usersService
.list({ .list({
search: this.manageSearch?.trim() || undefined, search: this.manageSearch?.trim() || undefined,
permissao: this.isManageClientsMode ? 'cliente' : undefined,
page: this.managePage, page: this.managePage,
pageSize: this.managePageSize, pageSize: this.managePageSize,
}) })
@ -720,17 +733,23 @@ export class Header implements AfterViewInit, OnDestroy {
this.usersService.getById(user.id).subscribe({ this.usersService.getById(user.id).subscribe({
next: (full) => { next: (full) => {
this.editUserTarget = full; this.editUserTarget = full;
const permissao = this.isManageClientsMode ? 'cliente' : (full.permissao ?? '');
this.editUserForm.reset({ this.editUserForm.reset({
nome: full.nome ?? '', nome: full.nome ?? '',
email: full.email ?? '', email: full.email ?? '',
senha: '', senha: '',
confirmarSenha: '', confirmarSenha: '',
permissao: full.permissao ?? '', permissao,
ativo: full.ativo ?? true, ativo: full.ativo ?? true,
}); });
if (this.isManageClientsMode) {
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
} else {
this.editUserForm.get('permissao')?.enable({ emitEvent: false });
}
}, },
error: () => { 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 payload: any = {};
const nome = (this.editUserForm.get('nome')?.value || '').toString().trim(); const nome = (this.editUserForm.get('nome')?.value || '').toString().trim();
const email = (this.editUserForm.get('email')?.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; const ativo = !!this.editUserForm.get('ativo')?.value;
if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome; if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome;
if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email; 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; if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo;
const senha = (this.editUserForm.get('senha')?.value || '').toString(); const senha = (this.editUserForm.get('senha')?.value || '').toString();
@ -794,18 +822,25 @@ export class Header implements AfterViewInit, OnDestroy {
const merged = this.mergeUserUpdate(currentTarget, payload); const merged = this.mergeUserUpdate(currentTarget, payload);
this.editUserSubmitting = false; this.editUserSubmitting = false;
this.setEditFormDisabled(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.editUserTarget = merged;
this.editUserForm.patchValue({ this.editUserForm.patchValue({
nome: merged.nome ?? '', nome: merged.nome ?? '',
email: merged.email ?? '', email: merged.email ?? '',
permissao: merged.permissao ?? '', permissao: this.isManageClientsMode ? 'cliente' : (merged.permissao ?? ''),
ativo: merged.ativo ?? true, ativo: merged.ativo ?? true,
senha: '', senha: '',
confirmarSenha: '', confirmarSenha: '',
}); });
this.upsertManageUser(merged); 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) => { error: (err: HttpErrorResponse) => {
this.editUserSubmitting = false; this.editUserSubmitting = false;
@ -814,12 +849,17 @@ export class Header implements AfterViewInit, OnDestroy {
if (Array.isArray(apiErrors)) { if (Array.isArray(apiErrors)) {
this.editUserErrors = apiErrors.map((e: any) => ({ this.editUserErrors = apiErrors.map((e: any) => ({
field: e?.field, 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 { } 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) { async confirmToggleUserStatus(user: any) {
const nextActive = user.ativo === false; const nextActive = user.ativo === false;
const actionLabel = nextActive ? 'reativar' : 'inativar'; 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({ const confirmed = await confirmActionModal({
title: nextActive ? 'Reativar Usuário' : 'Inativar Usuário', title: nextActive ? `Reativar ${entity}` : `Inativar ${entity}`,
message: nextActive message: nextActive
? `Deseja reativar o usuário ${user.nome}? Ele voltará a ter acesso ao sistema.` ? `Deseja reativar ${entityLower} ${user.nome}? O acesso ao sistema será liberado novamente.`
: `Deseja inativar o usuário ${user.nome}? A conta ficará sem acesso até ser reativada.`, : `Deseja inativar ${entityLower} ${user.nome}? O acesso ao sistema ficará bloqueado até reativação.`,
confirmLabel: nextActive ? 'Reativar' : 'Inativar', confirmLabel: nextActive ? 'Reativar' : 'Inativar',
tone: nextActive ? 'neutral' : 'warning', tone: nextActive ? 'neutral' : 'warning',
}); });
@ -845,11 +887,13 @@ export class Header implements AfterViewInit, OnDestroy {
this.editUserTarget = { ...this.editUserTarget, ativo: nextActive }; this.editUserTarget = { ...this.editUserTarget, ativo: nextActive };
this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' }); this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' });
this.editUserErrors = []; 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) => { 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) { if (this.editUserTarget?.id === user.id) {
this.editUserSuccess = ''; this.editUserSuccess = '';
this.editUserErrors = [{ message }]; this.editUserErrors = [{ message }];
@ -860,7 +904,9 @@ export class Header implements AfterViewInit, OnDestroy {
async confirmPermanentDeleteUser(user: any) { async confirmPermanentDeleteUser(user: any) {
if (user?.ativo !== false) { 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) { if (this.editUserTarget?.id === user?.id) {
this.editUserSuccess = ''; this.editUserSuccess = '';
this.editUserErrors = [{ message }]; this.editUserErrors = [{ message }];
@ -870,7 +916,9 @@ export class Header implements AfterViewInit, OnDestroy {
return; 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; if (!confirmed) return;
this.usersService.delete(user.id).subscribe({ this.usersService.delete(user.id).subscribe({
@ -883,8 +931,8 @@ export class Header implements AfterViewInit, OnDestroy {
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
const apiErrors = err?.error?.errors; const apiErrors = err?.error?.errors;
const message = Array.isArray(apiErrors) const message = Array.isArray(apiErrors)
? (apiErrors[0]?.message || 'Erro ao excluir usuario.') ? (apiErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.'))
: (err?.error?.message || '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) { if (this.editUserTarget?.id === user.id) {
this.editUserSuccess = ''; this.editUserSuccess = '';
@ -936,6 +984,30 @@ export class Header implements AfterViewInit, OnDestroy {
this.cancelEditUser(); 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 { private normalizeField(field?: string | null): string {
return (field || '').trim().toLowerCase(); return (field || '').trim().toLowerCase();
} }
@ -947,7 +1019,12 @@ export class Header implements AfterViewInit, OnDestroy {
private setEditFormDisabled(disabled: boolean) { private setEditFormDisabled(disabled: boolean) {
if (disabled) this.editUserForm.disable({ emitEvent: false }); 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) { private upsertManageUser(user: any) {

View File

@ -7,10 +7,12 @@
<div class="page-head fade-in-up"> <div class="page-head fade-in-up">
<div class="head-content"> <div class="head-content">
<div class="badge-pill"> <div class="badge-pill">
<i class="bi bi-grid-1x2-fill"></i> Visão Geral <i class="bi bi-grid-1x2-fill"></i> {{ isCliente ? 'Visão Cliente' : 'Visão Geral' }}
</div> </div>
<h1 class="page-title">Dashboard de Gestão de Linhas</h1> <h1 class="page-title">{{ isCliente ? 'Dashboard do Cliente' : 'Dashboard de Gestão de Linhas' }}</h1>
<p class="page-subtitle">Painel operacional com foco em status, cobertura e histórico da base.</p> <p class="page-subtitle">
{{ 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.' }}
</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
@ -27,7 +29,7 @@
</div> </div>
</div> </div>
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'"> <div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'" *ngIf="!isCliente || clientOverview.hasData">
<div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey"> <div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey">
<div class="hero-icon"> <div class="hero-icon">
<i [class]="k.icon"></i> <i [class]="k.icon"></i>
@ -326,14 +328,20 @@
</ng-container> </ng-container>
<ng-template #clienteDashboard> <ng-template #clienteDashboard>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'180ms'"> <ng-container *ngIf="clientOverview.hasData; else clienteSemDados">
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
<h2>Monitoramento da Sua Base</h2>
<p>Visão operacional das suas linhas para acompanhar uso, status e disponibilidade.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'220ms'">
<div class="section-top-row"> <div class="section-top-row">
<div class="card-modern card-status"> <div class="card-modern card-status">
<div class="card-header-clean"> <div class="card-header-clean">
<div class="header-icon brand"><i class="bi bi-pie-chart-fill"></i></div> <div class="header-icon brand"><i class="bi bi-pie-chart-fill"></i></div>
<div class="header-text"> <div class="header-text">
<h3>Status da Base</h3> <h3>Status das Linhas</h3>
<p>Distribuição atual das linhas</p> <p>Distribuição atual da sua base</p>
</div> </div>
</div> </div>
<div class="card-body-split"> <div class="card-body-split">
@ -356,6 +364,11 @@
<span class="lbl">Reserva</span> <span class="lbl">Reserva</span>
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span> <span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
</div> </div>
<div class="status-item">
<span class="dot d-blocked-soft"></span>
<span class="lbl">Outros Status</span>
<span class="val">{{ clientOverview.outrosStatus | number:'1.0-0' }}</span>
</div>
<div class="status-item total-row"> <div class="status-item total-row">
<span class="lbl">Total</span> <span class="lbl">Total</span>
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span> <span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
@ -363,8 +376,96 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon blue"><i class="bi bi-wifi"></i></div>
<div class="header-text">
<h3>Faixa de Franquia Line</h3>
<p>Quantidade de linhas por faixa de franquia contratada</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartLinhasPorFranquia></canvas>
</div>
</div>
</div>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'260ms'">
<div class="grid-halves">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Top Planos (Qtd. Linhas)</h3>
<p>Planos com maior volume na sua operação</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartResumoTopPlanos></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Top Usuários (Qtd. Linhas)</h3>
<p>Usuários com maior concentração de linhas</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartResumoTopClientes></canvas>
</div>
</div>
</div>
<div class="grid-halves mt-3">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Reserva por DDD</h3>
<p>Linhas disponíveis em reserva por região</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartResumoReservaDdd></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon brand"><i class="bi bi-sim"></i></div>
<div class="header-text">
<h3>Tipo de Chip</h3>
<p>Distribuição entre e-SIM, SIMCARD e outros</p>
</div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartTipoChip></canvas>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #clienteSemDados>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'180ms'">
<div class="card-modern full-width">
<div class="card-header-clean">
<div class="header-icon warning"><i class="bi bi-info-circle-fill"></i></div>
<div class="header-text">
<h3>Sem dados para exibição</h3>
<p>Não encontramos linhas vinculadas ao seu acesso no momento.</p>
</div>
</div>
<div class="card-body-grid">
<p class="mb-0 text-muted">
Assim que a base deste cliente estiver disponível na página Geral, os KPIs e gráficos serão atualizados automaticamente.
</p>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>
</ng-template>
</div> </div>
</section> </section>

View File

@ -163,6 +163,10 @@
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; gap: 16px;
margin-bottom: 32px; margin-bottom: 32px;
@media (min-width: 1500px) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
} }
.hero-card { .hero-card {

View File

@ -155,6 +155,13 @@ type DashboardGeralInsightsDto = {
}; };
type DashboardLineListItemDto = { 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; gestaoVozDados?: number | null;
skeelo?: number | null; skeelo?: number | null;
vivoNewsPlus?: number | null; vivoNewsPlus?: number | null;
@ -164,13 +171,6 @@ type DashboardLineListItemDto = {
tipoDeChip?: string | null; tipoDeChip?: string | null;
}; };
type DashboardLinesPageDto = {
page: number;
pageSize: number;
total: number;
items: DashboardLineListItemDto[];
};
type ResumoTopCliente = { type ResumoTopCliente = {
cliente: string; cliente: string;
linhas: number; linhas: number;
@ -193,6 +193,18 @@ type ResumoDiferencaPjPf = {
totalLinhas: number | null; totalLinhas: number | null;
}; };
type ClientDashboardOverview = {
hasData: boolean;
totalLinhas: number;
ativas: number;
bloqueadas: number;
reservas: number;
franquiaLineTotalGb: number;
planosContratados: number;
usuariosComLinha: number;
outrosStatus: number;
};
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
@ -286,6 +298,17 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
resumo: ResumoResponse | null = null; resumo: ResumoResponse | null = null;
resumoTopN = 5; resumoTopN = 5;
resumoTopOptions = [5, 10, 15]; 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 // Resumo Derived Data
resumoTopClientes: ResumoTopCliente[] = []; resumoTopClientes: ResumoTopCliente[] = [];
@ -348,12 +371,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const isGestor = this.authService.hasRole('gestor'); const isGestor = this.authService.hasRole('gestor');
this.isCliente = !(isSysAdmin || isGestor); this.isCliente = !(isSysAdmin || isGestor);
if (this.isCliente) {
this.loadClientDashboardData();
return;
}
this.loadDashboard(); this.loadDashboard();
if (!this.isCliente) {
this.loadInsights(); this.loadInsights();
this.loadResumoExecutive(); this.loadResumoExecutive();
} }
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.viewReady = true; this.viewReady = true;
@ -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<DashboardLineListItemDto[]> {
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<any>(`${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<string, number>();
const userMap = new Map<string, number>();
const reservaDddMap = new Map<string, number>();
const franquiaBandMap = new Map<string, number>();
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 { private isNetworkError(error: unknown): boolean {
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
return error.status === 0; return error.status === 0;
@ -448,6 +723,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
} }
onResumoTopNChange() { onResumoTopNChange() {
if (this.isCliente) {
void this.loadClientDashboardData();
return;
}
this.buildResumoDerived(); this.buildResumoDerived();
this.tryBuildResumoCharts(); this.tryBuildResumoCharts();
} }
@ -783,6 +1062,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
} }
private async loadFallbackFromLinesIfNeeded(force = false): Promise<void> { private async loadFallbackFromLinesIfNeeded(force = false): Promise<void> {
if (this.isCliente) return;
if (!isPlatformBrowser(this.platformId) || this.fallbackInsightsLoading) return; if (!isPlatformBrowser(this.platformId) || this.fallbackInsightsLoading) return;
const syncIndex = this.adicionaisLabels.findIndex( const syncIndex = this.adicionaisLabels.findIndex(
@ -901,6 +1181,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
return ''; 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() { private destroyInsightsCharts() {
try { this.chartFranquia?.destroy(); } catch {} try { this.chartFranquia?.destroy(); } catch {}
try { this.chartAdicionais?.destroy(); } catch {} try { this.chartAdicionais?.destroy(); } catch {}
@ -924,36 +1234,60 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private rebuildPrimaryKpis() { private rebuildPrimaryKpis() {
if (this.isCliente) { if (this.isCliente) {
this.kpis = [ const overview = this.clientOverview;
const cards: KpiCard[] = [
{ {
key: 'linhas_total', key: 'linhas_total',
title: 'Total de Linhas', title: 'Total de Linhas',
value: this.formatInt(this.dashboardRaw?.totalLinhas ?? this.statusResumo.total), value: this.formatInt(overview.totalLinhas),
icon: 'bi bi-sim-fill', icon: 'bi bi-sim-fill',
hint: 'Base geral', hint: 'Base do cliente',
}, },
{ {
key: 'linhas_ativas', key: 'linhas_ativas',
title: 'Linhas Ativas', title: 'Linhas Ativas',
value: this.formatInt(this.dashboardRaw?.ativos ?? this.statusResumo.ativos), value: this.formatInt(overview.ativas),
icon: 'bi bi-check2-circle', icon: 'bi bi-check2-circle',
hint: 'Status ativo', hint: 'Status ativo',
}, },
{ {
key: 'linhas_bloqueadas', key: 'linhas_bloqueadas',
title: 'Linhas Bloqueadas', title: 'Linhas Bloqueadas',
value: this.formatInt(this.dashboardRaw?.bloqueados ?? this.statusResumo.bloqueadas), value: this.formatInt(overview.bloqueadas),
icon: 'bi bi-slash-circle', icon: 'bi bi-slash-circle',
hint: 'Todos os bloqueios', hint: 'Bloqueio/suspensão',
}, },
{ {
key: 'linhas_reserva', key: 'linhas_reserva',
title: 'Linhas em Reserva', title: 'Linhas em Reserva',
value: this.formatInt(this.dashboardRaw?.reservas ?? this.statusResumo.reservas), value: this.formatInt(overview.reservas),
icon: 'bi bi-inboxes-fill', 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; return;
} }
@ -976,7 +1310,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
add( add(
'franquia_vivo_total', 'franquia_vivo_total',
'Total Franquia Vivo', 'Total Franquia Vivo',
this.formatGb(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0), this.formatDataAllowance(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0),
'bi bi-diagram-3-fill', 'bi bi-diagram-3-fill',
'Soma das franquias (Geral)' 'Soma das franquias (Geral)'
); );
@ -1014,10 +1348,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
if (!this.viewReady || !this.dataReady) return; if (!this.viewReady || !this.dataReady) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const canvases = ( const canvases = [
this.isCliente
? [this.chartStatusPie?.nativeElement]
: [
this.chartStatusPie?.nativeElement, this.chartStatusPie?.nativeElement,
this.chartAdicionaisComparativo?.nativeElement, this.chartAdicionaisComparativo?.nativeElement,
this.chartVigenciaMesAno?.nativeElement, this.chartVigenciaMesAno?.nativeElement,
@ -1028,8 +1359,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.chartAdicionaisPagos?.nativeElement, this.chartAdicionaisPagos?.nativeElement,
this.chartTipoChip?.nativeElement, this.chartTipoChip?.nativeElement,
this.chartTravelMundo?.nativeElement, this.chartTravelMundo?.nativeElement,
] ].filter(Boolean) as HTMLCanvasElement[];
).filter(Boolean) as HTMLCanvasElement[];
if (!canvases.length) return; if (!canvases.length) return;
if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) { if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
@ -1054,7 +1384,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.chartResumoReservaDdd?.nativeElement, this.chartResumoReservaDdd?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[]; ].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(); this.scheduleResumoChartRetry();
return; return;
} }
@ -1124,10 +1455,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
if (this.isCliente) {
return;
}
if (this.chartAdicionaisComparativo?.nativeElement) { if (this.chartAdicionaisComparativo?.nativeElement) {
this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, { this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, {
type: 'doughnut', type: 'doughnut',
@ -1221,7 +1548,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
labels: this.tipoChipLabels, labels: this.tipoChipLabels,
datasets: [{ datasets: [{
data: this.tipoChipValues, data: this.tipoChipValues,
backgroundColor: [palette.blue, palette.brand], backgroundColor: [palette.blue, palette.brand, '#94a3b8'],
borderWidth: 0, borderWidth: 0,
hoverOffset: 4 hoverOffset: 4
}] }]
@ -1490,6 +1817,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
return `${value} GB`; 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) { private toNumberOrNull(v: any) {
if (v === null || v === undefined || v === '') return null; if (v === null || v === undefined || v === '') return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null; if (typeof v === 'number') return Number.isFinite(v) ? v : null;

View File

@ -214,7 +214,7 @@
</div> </div>
<!-- KPIs --> <!-- KPIs -->
<div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode"> <div class="geral-kpis mt-4 animate-fade-in" [class.geral-kpis-client]="isClientRestricted" *ngIf="isGroupMode">
<div class="kpi" *ngIf="!isClientRestricted"> <div class="kpi" *ngIf="!isClientRestricted">
<span class="lbl">Total Clientes</span> <span class="lbl">Total Clientes</span>
<span class="val val-loading" *ngIf="isKpiLoading"> <span class="val val-loading" *ngIf="isKpiLoading">
@ -313,7 +313,7 @@
{{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }} {{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }}
</button> </button>
</ng-container> </ng-container>
<ng-container *ngIf="isReservaExpandedGroup"> <ng-container *ngIf="isReservaExpandedGroup && hasGroupLineSelectionTools">
<button <button
class="btn btn-sm btn-brand" class="btn btn-sm btn-brand"
type="button" type="button"
@ -1676,6 +1676,7 @@
*ngIf="detailOpen" *ngIf="detailOpen"
#detailModal #detailModal
class="modal-card modal-xl-custom" class="modal-card modal-xl-custom"
[class.modal-client-detail]="isClientRestricted"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<div class="modal-header"> <div class="modal-header">

View File

@ -251,6 +251,19 @@
/* KPIs */ /* 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; } } .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; } }
.geral-kpis.geral-kpis-client {
grid-template-columns: repeat(3, minmax(180px, 240px));
justify-content: center;
@media (max-width: 992px) {
grid-template-columns: repeat(2, minmax(170px, 1fr));
justify-content: stretch;
}
@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 { 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; } .kpi .val-loading { font-size: 0.86rem; font-weight: 900; color: var(--muted); display: inline-flex; align-items: center; }
@ -504,6 +517,14 @@
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; } .modal-body .box-body { overflow: visible; }
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.modal-card.modal-client-detail {
width: min(560px, 95vw);
}
.modal-card.modal-client-detail .details-dashboard {
grid-template-columns: 1fr;
max-width: 520px;
margin: 0 auto;
}
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; } .modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); } .modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
.modal-card.modal-move-reserva { .modal-card.modal-move-reserva {

View File

@ -567,7 +567,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
get hasGroupLineSelectionTools(): boolean { get hasGroupLineSelectionTools(): boolean {
return !!(this.expandedGroup ?? '').trim(); return !this.isClientRestricted && !!(this.expandedGroup ?? '').trim();
} }
get canMoveSelectedLinesToReserva(): boolean { get canMoveSelectedLinesToReserva(): boolean {

View File

@ -9,8 +9,8 @@
<div class="title-badge"> <div class="title-badge">
<i class="bi bi-shield-lock-fill"></i> SYSADMIN <i class="bi bi-shield-lock-fill"></i> SYSADMIN
</div> </div>
<h1>Fornecer Usuário para Cliente</h1> <h1>Criar Credenciais do Cliente</h1>
<p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p> <p>Selecione o cliente e gere o acesso para acompanhamento das linhas no sistema.</p>
</header> </header>
<div class="card-body"> <div class="card-body">
@ -20,16 +20,10 @@
<div class="alert-box success" *ngIf="successMessage"> <div class="alert-box success" *ngIf="successMessage">
{{ successMessage }} {{ successMessage }}
<div class="mt-1" *ngIf="createdUser">
<small>
UserId: <strong>{{ createdUser.userId }}</strong> | TenantId:
<strong>{{ createdUser.tenantId }}</strong>
</small>
</div>
</div> </div>
<div class="alert-box error" *ngIf="submitErrors.length"> <div class="alert-box error" *ngIf="submitErrors.length">
<strong>Falha ao criar usuário:</strong> <strong>Falha ao criar credencial:</strong>
<ul> <ul>
<li *ngFor="let err of submitErrors">{{ err }}</li> <li *ngFor="let err of submitErrors">{{ err }}</li>
</ul> </ul>
@ -38,13 +32,12 @@
<form [formGroup]="provisionForm" (ngSubmit)="onSubmit()" class="provision-form" novalidate> <form [formGroup]="provisionForm" (ngSubmit)="onSubmit()" class="provision-form" novalidate>
<div class="form-grid"> <div class="form-grid">
<div class="form-field span-2"> <div class="form-field span-2">
<label for="tenantId">Cliente (Tenant)</label> <label for="tenantId">Cliente</label>
<div class="select-row"> <div class="select-row">
<select <select
id="tenantId" id="tenantId"
formControlName="tenantId" formControlName="tenantId"
class="form-control" class="form-control"
[disabled]="tenantsLoading || !tenants.length"
> >
<option value="">Selecione um cliente...</option> <option value="">Selecione um cliente...</option>
<option <option
@ -58,15 +51,14 @@
{{ tenantsLoading ? 'Atualizando...' : 'Atualizar lista' }} {{ tenantsLoading ? 'Atualizando...' : 'Atualizar lista' }}
</button> </button>
</div> </div>
<small class="field-help">Origem: {{ sourceType }} (apenas tenants ativos).</small>
<small class="field-error" *ngIf="hasFieldError('tenantId', 'required')"> <small class="field-error" *ngIf="hasFieldError('tenantId', 'required')">
Selecione um tenant-cliente. Selecione um cliente.
</small> </small>
</div> </div>
<div class="form-field"> <div class="form-field">
<label for="name">Nome (opcional)</label> <label for="name">Nome</label>
<input id="name" type="text" class="form-control" formControlName="name" placeholder="Nome do usuário" /> <input id="name" type="text" class="form-control" formControlName="name" placeholder="Nome do responsável" />
</div> </div>
<div class="form-field"> <div class="form-field">
@ -111,31 +103,11 @@
</small> </small>
<small class="field-error" *ngIf="passwordMismatch">As senhas não conferem.</small> <small class="field-error" *ngIf="passwordMismatch">As senhas não conferem.</small>
</div> </div>
<div class="form-field span-2">
<label>Roles do usuário</label>
<div class="roles-grid">
<label class="role-item" *ngFor="let role of roleOptions; trackBy: trackByRoleValue">
<input
type="checkbox"
[checked]="isRoleSelected(role.value)"
(change)="toggleRole(role.value, $any($event.target).checked)"
/>
<div class="role-content">
<strong>{{ role.label }}</strong>
<span>{{ role.description }}</span>
</div>
</label>
</div>
<small class="field-error" *ngIf="selectedRoles.length === 0">
Selecione ao menos uma role.
</small>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="submitting || provisionForm.invalid"> <button type="submit" class="btn btn-primary" [disabled]="submitting || provisionForm.invalid">
<span *ngIf="!submitting">Criar usuário para cliente</span> <span *ngIf="!submitting">Criar credencial de acesso</span>
<span *ngIf="submitting">Criando...</span> <span *ngIf="submitting">Criando...</span>
</button> </button>
</div> </div>

View File

@ -16,12 +16,6 @@ import {
CreateSystemTenantUserResponse, CreateSystemTenantUserResponse,
} from '../../services/sysadmin.service'; } from '../../services/sysadmin.service';
type RoleOption = {
value: string;
label: string;
description: string;
};
@Component({ @Component({
selector: 'app-system-provision-user', selector: 'app-system-provision-user',
standalone: true, standalone: true,
@ -30,12 +24,6 @@ type RoleOption = {
styleUrls: ['./system-provision-user.scss'], styleUrls: ['./system-provision-user.scss'],
}) })
export class SystemProvisionUserPage implements OnInit { 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'; readonly sourceType = 'MobileLines.Cliente';
provisionForm: FormGroup; provisionForm: FormGroup;
@ -74,6 +62,7 @@ export class SystemProvisionUserPage implements OnInit {
this.tenantsLoading = true; this.tenantsLoading = true;
this.tenantsError = ''; this.tenantsError = '';
this.syncTenantControlAvailability();
this.sysadminService this.sysadminService
.listTenants({ source: this.sourceType, active: true }) .listTenants({ source: this.sourceType, active: true })
@ -83,6 +72,7 @@ export class SystemProvisionUserPage implements OnInit {
(a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' }) (a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' })
); );
this.tenantsLoading = false; this.tenantsLoading = false;
this.syncTenantControlAvailability();
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
this.tenantsLoading = false; this.tenantsLoading = false;
@ -90,26 +80,11 @@ export class SystemProvisionUserPage implements OnInit {
err, err,
'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.' '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 { onSubmit(): void {
if (this.submitting) return; if (this.submitting) return;
@ -117,11 +92,8 @@ export class SystemProvisionUserPage implements OnInit {
this.submitErrors = []; this.submitErrors = [];
this.createdUser = null; this.createdUser = null;
if (this.provisionForm.invalid || this.selectedRoles.length === 0) { if (this.provisionForm.invalid) {
this.provisionForm.markAllAsTouched(); this.provisionForm.markAllAsTouched();
if (this.selectedRoles.length === 0) {
this.submitErrors = ['Selecione ao menos uma role para o usuário.'];
}
return; return;
} }
@ -138,7 +110,8 @@ export class SystemProvisionUserPage implements OnInit {
name: nameRaw, name: nameRaw,
email, email,
password, password,
roles: this.selectedRoles, roles: ['cliente'],
clientCredentialsOnly: true,
}) })
.subscribe({ .subscribe({
next: (created) => { next: (created) => {
@ -148,7 +121,7 @@ export class SystemProvisionUserPage implements OnInit {
this.createdUser = created; this.createdUser = created;
const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId); const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId);
const tenantName = tenant?.nomeOficial || 'cliente selecionado'; 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({ this.provisionForm.patchValue({
name: '', name: '',
@ -172,10 +145,6 @@ export class SystemProvisionUserPage implements OnInit {
return tenant.tenantId; return tenant.tenantId;
} }
trackByRoleValue(_: number, role: RoleOption): string {
return role.value;
}
hasFieldError(field: string, error?: string): boolean { hasFieldError(field: string, error?: string): boolean {
const control = this.provisionForm.get(field); const control = this.provisionForm.get(field);
if (!control) return false; if (!control) return false;
@ -188,15 +157,6 @@ export class SystemProvisionUserPage implements OnInit {
return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']); return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']);
} }
get selectedRoles(): string[] {
const roles = this.rolesControl.value;
return Array.isArray(roles) ? roles : [];
}
get rolesControl(): AbstractControl<string[] | null, string[] | null> {
return this.provisionForm.get('roles') as AbstractControl<string[] | null, string[] | null>;
}
private findTenantById(tenantId: string): SystemTenantDto | undefined { private findTenantById(tenantId: string): SystemTenantDto | undefined {
return this.tenants.find((tenant) => tenant.tenantId === tenantId); return this.tenants.find((tenant) => tenant.tenantId === tenantId);
} }
@ -208,6 +168,21 @@ export class SystemProvisionUserPage implements OnInit {
} }
this.provisionForm.enable({ emitEvent: false }); 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[] { private extractErrors(err: HttpErrorResponse): string[] {
@ -237,7 +212,7 @@ export class SystemProvisionUserPage implements OnInit {
return ['Acesso negado. Este recurso é exclusivo para sysadmin.']; 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 { private extractErrorMessage(err: HttpErrorResponse, fallback: string): string {

View File

@ -19,6 +19,7 @@ export type CreateSystemTenantUserPayload = {
email: string; email: string;
password: string; password: string;
roles: string[]; roles: string[];
clientCredentialsOnly?: boolean;
}; };
export type CreateSystemTenantUserResponse = { export type CreateSystemTenantUserResponse = {