feat: tela e fluxo de criação de usuário do cliente
This commit is contained in:
parent
ec3abc056f
commit
e88762c6da
|
|
@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento';
|
||||||
|
|
||||||
import { authGuard } from './guards/auth.guard';
|
import { authGuard } from './guards/auth.guard';
|
||||||
import { adminGuard } from './guards/admin.guard';
|
import { adminGuard } from './guards/admin.guard';
|
||||||
|
import { systemAdminGuard } from './guards/system-admin.guard';
|
||||||
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
||||||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||||
|
|
@ -19,6 +20,7 @@ import { Resumo } from './pages/resumo/resumo';
|
||||||
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
|
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
|
||||||
import { Historico } from './pages/historico/historico';
|
import { Historico } from './pages/historico/historico';
|
||||||
import { Perfil } from './pages/perfil/perfil';
|
import { Perfil } from './pages/perfil/perfil';
|
||||||
|
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: Home },
|
{ path: '', component: Home },
|
||||||
|
|
@ -37,6 +39,12 @@ export const routes: Routes = [
|
||||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
|
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
|
||||||
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
||||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||||
|
{
|
||||||
|
path: 'system/fornecer-usuario',
|
||||||
|
component: SystemProvisionUserPage,
|
||||||
|
canActivate: [authGuard, systemAdminGuard],
|
||||||
|
title: 'Fornecer Usuário',
|
||||||
|
},
|
||||||
|
|
||||||
// ✅ rota correta
|
// ✅ rota correta
|
||||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
|
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export class AppComponent {
|
||||||
'/parcelamentos',
|
'/parcelamentos',
|
||||||
'/historico',
|
'/historico',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
|
'/system',
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,9 @@
|
||||||
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
|
<button type="button" class="options-item" *ngIf="isAdmin" (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="isSystemAdmin" (click)="goToSystemProvisionUser()">
|
||||||
|
<i class="bi bi-shield-lock"></i> Fornecer usuário (cliente)
|
||||||
|
</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()">
|
||||||
<i class="bi bi-box-arrow-right"></i> Sair
|
<i class="bi bi-box-arrow-right"></i> Sair
|
||||||
|
|
@ -504,35 +507,38 @@
|
||||||
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-table"></i> <span>Resumo</span>
|
<i class="bi bi-table"></i> <span>Resumo</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-sim"></i> <span>Geral</span>
|
<i class="bi bi-sim"></i> <span>Geral</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="isAdmin" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="isAdmin" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-clock-history"></i> <span>Histórico</span>
|
<i class="bi bi-clock-history"></i> <span>Histórico</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
|
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="isAdmin" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
|
<i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<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="isSystemAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
|
<i class="bi bi-shield-lock-fill"></i> <span>Fornecer usuário</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
isLoggedHeader = false;
|
isLoggedHeader = false;
|
||||||
isHome = false;
|
isHome = false;
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
canViewAll = false;
|
||||||
|
isSystemAdmin = false;
|
||||||
notifications: NotificationDto[] = [];
|
notifications: NotificationDto[] = [];
|
||||||
notificationsLoading = false;
|
notificationsLoading = false;
|
||||||
notificationsError = false;
|
notificationsError = false;
|
||||||
|
|
@ -52,8 +54,9 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
createUserForbidden = false;
|
createUserForbidden = false;
|
||||||
createUserSuccess = '';
|
createUserSuccess = '';
|
||||||
readonly permissionOptions = [
|
readonly permissionOptions = [
|
||||||
{ value: 'admin', label: 'Administrador' },
|
{ value: 'sysadmin', label: 'SysAdmin' },
|
||||||
{ value: 'gestor', label: 'Gestor' },
|
{ value: 'gestor', label: 'Gestor' },
|
||||||
|
{ value: 'cliente', label: 'Cliente' },
|
||||||
];
|
];
|
||||||
|
|
||||||
manageUsersLoading = false;
|
manageUsersLoading = false;
|
||||||
|
|
@ -86,6 +89,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
'/parcelamentos',
|
'/parcelamentos',
|
||||||
'/historico',
|
'/historico',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
|
'/system',
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -200,9 +204,15 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
private syncPermissions() {
|
private syncPermissions() {
|
||||||
if (!isPlatformBrowser(this.platformId)) {
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
this.isAdmin = false;
|
this.isAdmin = false;
|
||||||
|
this.canViewAll = false;
|
||||||
|
this.isSystemAdmin = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
const isSysAdmin = this.authService.hasRole('sysadmin');
|
||||||
|
const isGestor = this.authService.hasRole('gestor');
|
||||||
|
this.isAdmin = isSysAdmin;
|
||||||
|
this.canViewAll = isSysAdmin || isGestor;
|
||||||
|
this.isSystemAdmin = this.authService.hasRole('sysadmin');
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
|
|
@ -227,6 +237,12 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
this.router.navigate(['/perfil']);
|
this.router.navigate(['/perfil']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goToSystemProvisionUser() {
|
||||||
|
if (!this.isSystemAdmin) return;
|
||||||
|
this.closeOptions();
|
||||||
|
this.router.navigate(['/system/fornecer-usuario']);
|
||||||
|
}
|
||||||
|
|
||||||
openCreateUserModal() {
|
openCreateUserModal() {
|
||||||
if (!this.isAdmin) return;
|
if (!this.isAdmin) return;
|
||||||
this.createUserOpen = true;
|
this.createUserOpen = true;
|
||||||
|
|
@ -430,6 +446,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
this.optionsOpen = false;
|
this.optionsOpen = false;
|
||||||
this.notificationsOpen = false;
|
this.notificationsOpen = false;
|
||||||
this.isAdmin = false;
|
this.isAdmin = false;
|
||||||
|
this.canViewAll = false;
|
||||||
|
this.isSystemAdmin = false;
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ export const adminGuard: CanActivateFn = () => {
|
||||||
return router.parseUrl('/login');
|
return router.parseUrl('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = authService.hasRole('admin');
|
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
|
||||||
if (!isAdmin) {
|
if (!hasAccess) {
|
||||||
return router.parseUrl('/dashboard');
|
return router.parseUrl('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { inject, PLATFORM_ID } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
export const systemAdminGuard: CanActivateFn = () => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const platformId = inject(PLATFORM_ID);
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
|
||||||
|
if (!isPlatformBrowser(platformId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authService.token;
|
||||||
|
if (!token) {
|
||||||
|
return router.parseUrl('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSystemAdmin = authService.hasRole('sysadmin');
|
||||||
|
if (!isSystemAdmin) {
|
||||||
|
return router.parseUrl('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
this.fetchChips();
|
this.fetchChips();
|
||||||
this.fetchControle();
|
this.fetchControle();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
this.fetch(1);
|
this.fetch(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,288 +40,331 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
|
<ng-container *ngIf="!isCliente; else clienteDashboard">
|
||||||
<h2>Página Geral</h2>
|
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
|
||||||
<p>Distribuição e saúde atual da base de linhas.</p>
|
<h2>Página Geral</h2>
|
||||||
</div>
|
<p>Distribuição e saúde atual da base de linhas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'220ms'">
|
<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 da Base</h3>
|
||||||
<p>Distribuição atual das linhas</p>
|
<p>Distribuição atual das linhas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body-split">
|
||||||
|
<div class="chart-wrapper-pie">
|
||||||
|
<canvas #chartStatusPie></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="status-list">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-active"></span>
|
||||||
|
<span class="lbl">Ativas</span>
|
||||||
|
<span class="val">{{ statusResumo.ativos | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-reserve"></span>
|
||||||
|
<span class="lbl">Reservas</span>
|
||||||
|
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-blocked"></span>
|
||||||
|
<span class="lbl">Bloq. (Perda/Roubo)</span>
|
||||||
|
<span class="val">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-blocked-soft"></span>
|
||||||
|
<span class="lbl">Bloq. (120 dias)</span>
|
||||||
|
<span class="val">{{ statusResumo.bloq120 | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item total-row">
|
||||||
|
<span class="lbl">Total Geral</span>
|
||||||
|
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body-split">
|
|
||||||
|
<div class="card-modern card-adicionais">
|
||||||
|
<div class="card-header-clean">
|
||||||
|
<div class="header-icon blue"><i class="bi bi-diagram-3-fill"></i></div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Serviços Adicionais</h3>
|
||||||
|
<p>Comparativo de linhas com e sem adicionais (Geral)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body-adicionais">
|
||||||
|
<div class="chart-wrapper-pie-sm">
|
||||||
|
<canvas #chartAdicionaisComparativo></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="compare-list">
|
||||||
|
<div class="compare-item">
|
||||||
|
<span class="dot d-com-add"></span>
|
||||||
|
<span class="lbl">Com adicionais</span>
|
||||||
|
<span class="val">{{ adicionaisComparativo.com | number:'1.0-0' }}</span>
|
||||||
|
<span class="pct">{{ adicionaisComparativo.pctCom }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-item">
|
||||||
|
<span class="dot d-sem-add"></span>
|
||||||
|
<span class="lbl">Sem adicionais</span>
|
||||||
|
<span class="val">{{ adicionaisComparativo.sem | number:'1.0-0' }}</span>
|
||||||
|
<span class="pct">{{ adicionaisComparativo.pctSem }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-item total-row">
|
||||||
|
<span class="lbl">Total analisado</span>
|
||||||
|
<span class="val">{{ adicionaisComparativo.total | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-section fade-in-up" [style.animation-delay]="'280ms'">
|
||||||
|
<div class="grid-halves">
|
||||||
|
<div class="card-modern">
|
||||||
|
<div class="card-header-clean">
|
||||||
|
<div class="header-icon warning"><i class="bi bi-shield-exclamation"></i></div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Vigência (Buckets)</h3>
|
||||||
|
<p>Status de vencimento atual</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chart-wrapper-pie">
|
<div class="chart-wrapper-pie">
|
||||||
<canvas #chartStatusPie></canvas>
|
<canvas #chartVigenciaSupervisao></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-list">
|
</div>
|
||||||
<div class="status-item">
|
|
||||||
<span class="dot d-active"></span>
|
<div class="card-modern">
|
||||||
<span class="lbl">Ativas</span>
|
<div class="card-header-clean">
|
||||||
<span class="val">{{ statusResumo.ativos | number:'1.0-0' }}</span>
|
<div class="header-icon blue"><i class="bi bi-globe2"></i></div>
|
||||||
</div>
|
<div class="header-text">
|
||||||
<div class="status-item">
|
<h3>Vivo Travel</h3>
|
||||||
<span class="dot d-reserve"></span>
|
<p>Linhas com e sem serviço ativo</p>
|
||||||
<span class="lbl">Reservas</span>
|
|
||||||
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="dot d-blocked"></span>
|
|
||||||
<span class="lbl">Bloq. (Perda/Roubo)</span>
|
|
||||||
<span class="val">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="dot d-blocked-soft"></span>
|
|
||||||
<span class="lbl">Bloq. (120 dias)</span>
|
|
||||||
<span class="val">{{ statusResumo.bloq120 | number:'1.0-0' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item total-row">
|
|
||||||
<span class="lbl">Total Geral</span>
|
|
||||||
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart-wrapper-pie">
|
||||||
|
<canvas #chartTravelMundo></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-modern card-adicionais">
|
<div class="grid-triples mt-3">
|
||||||
<div class="card-header-clean">
|
<div class="card-modern">
|
||||||
<div class="header-icon blue"><i class="bi bi-diagram-3-fill"></i></div>
|
<div class="card-header-clean">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h3>Serviços Adicionais</h3>
|
<h3>Linhas por Franquia</h3>
|
||||||
<p>Comparativo de linhas com e sem adicionais (Geral)</p>
|
<p>Distribuição da base por faixa de franquia</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrapper-bar compact">
|
||||||
|
<canvas #chartLinhasPorFranquia></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body-adicionais">
|
|
||||||
<div class="chart-wrapper-pie-sm">
|
<div class="card-modern">
|
||||||
<canvas #chartAdicionaisComparativo></canvas>
|
<div class="card-header-clean">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Adicionais Pagos (Serviços)</h3>
|
||||||
|
<p>Quantidade de linhas por serviço adicional ativo</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="compare-list">
|
<div class="chart-wrapper-bar compact">
|
||||||
<div class="compare-item">
|
<canvas #chartAdicionaisPagos></canvas>
|
||||||
<span class="dot d-com-add"></span>
|
</div>
|
||||||
<span class="lbl">Com adicionais</span>
|
</div>
|
||||||
<span class="val">{{ adicionaisComparativo.com | number:'1.0-0' }}</span>
|
|
||||||
<span class="pct">{{ adicionaisComparativo.pctCom }}</span>
|
<div class="card-modern">
|
||||||
</div>
|
<div class="card-header-clean">
|
||||||
<div class="compare-item">
|
<div class="header-text">
|
||||||
<span class="dot d-sem-add"></span>
|
<h3>Tipo de Chip</h3>
|
||||||
<span class="lbl">Sem adicionais</span>
|
<p>Quantidade de linhas e-SIM e SIMCARD</p>
|
||||||
<span class="val">{{ adicionaisComparativo.sem | number:'1.0-0' }}</span>
|
|
||||||
<span class="pct">{{ adicionaisComparativo.pctSem }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="compare-item total-row">
|
|
||||||
<span class="lbl">Total analisado</span>
|
|
||||||
<span class="val">{{ adicionaisComparativo.total | number:'1.0-0' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart-wrapper-pie">
|
||||||
|
<canvas #chartTipoChip></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'280ms'">
|
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
|
||||||
<div class="grid-halves">
|
<h2>Página Resumo</h2>
|
||||||
<div class="card-modern">
|
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
|
||||||
<div class="card-header-clean">
|
|
||||||
<div class="header-icon warning"><i class="bi bi-shield-exclamation"></i></div>
|
|
||||||
<div class="header-text">
|
|
||||||
<h3>Vigência (Buckets)</h3>
|
|
||||||
<p>Status de vencimento atual</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-wrapper-pie">
|
|
||||||
<canvas #chartVigenciaSupervisao></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-modern">
|
|
||||||
<div class="card-header-clean">
|
|
||||||
<div class="header-icon blue"><i class="bi bi-globe2"></i></div>
|
|
||||||
<div class="header-text">
|
|
||||||
<h3>Vivo Travel</h3>
|
|
||||||
<p>Linhas com e sem serviço ativo</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-wrapper-pie">
|
|
||||||
<canvas #chartTravelMundo></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-triples mt-3">
|
<div class="dashboard-section fade-in-up" [style.animation-delay]="'360ms'">
|
||||||
<div class="card-modern">
|
<div class="card-modern full-width">
|
||||||
<div class="card-header-clean">
|
<div class="toolbar-header">
|
||||||
<div class="header-text">
|
<div class="title-group">
|
||||||
<h3>Linhas por Franquia</h3>
|
<i class="bi bi-bar-chart-line text-brand"></i>
|
||||||
<p>Distribuição da base por faixa de franquia</p>
|
<div>
|
||||||
|
<h3>Resumo Operacional de Linhas</h3>
|
||||||
|
<p>Dados consolidados da página Resumo sem foco financeiro</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="chart-wrapper-bar compact">
|
|
||||||
<canvas #chartLinhasPorFranquia></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-modern">
|
<div class="toolbar-controls">
|
||||||
<div class="card-header-clean">
|
<div class="control-group">
|
||||||
<div class="header-text">
|
<label>Visualizar Top</label>
|
||||||
<h3>Adicionais Pagos (Serviços)</h3>
|
<select
|
||||||
<p>Quantidade de linhas por serviço adicional ativo</p>
|
[value]="resumoTopN"
|
||||||
</div>
|
(change)="resumoTopN = +($any($event.target).value); onResumoTopNChange()"
|
||||||
</div>
|
class="form-select-sm">
|
||||||
<div class="chart-wrapper-bar compact">
|
<option *ngFor="let size of resumoTopOptions" [value]="size">{{ size }}</option>
|
||||||
<canvas #chartAdicionaisPagos></canvas>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-modern">
|
<div class="divider-v"></div>
|
||||||
<div class="card-header-clean">
|
|
||||||
<div class="header-text">
|
|
||||||
<h3>Tipo de Chip</h3>
|
|
||||||
<p>Quantidade de linhas e-SIM e SIMCARD</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-wrapper-pie">
|
|
||||||
<canvas #chartTipoChip></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
|
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
|
||||||
<h2>Página Resumo</h2>
|
|
||||||
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'360ms'">
|
|
||||||
<div class="card-modern full-width">
|
|
||||||
<div class="toolbar-header">
|
|
||||||
<div class="title-group">
|
|
||||||
<i class="bi bi-bar-chart-line text-brand"></i>
|
|
||||||
<div>
|
|
||||||
<h3>Resumo Operacional de Linhas</h3>
|
|
||||||
<p>Dados consolidados da página Resumo sem foco financeiro</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-controls">
|
<div class="card-body-grid">
|
||||||
<div class="control-group">
|
<div class="loading-overlay" *ngIf="resumoLoading">
|
||||||
<label>Visualizar Top</label>
|
<div class="spinner-border text-brand" role="status"></div>
|
||||||
<select
|
|
||||||
[value]="resumoTopN"
|
|
||||||
(change)="resumoTopN = +($any($event.target).value); onResumoTopNChange()"
|
|
||||||
class="form-select-sm">
|
|
||||||
<option *ngFor="let size of resumoTopOptions" [value]="size">{{ size }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider-v"></div>
|
<div class="error-state" *ngIf="!resumoLoading && resumoError">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
|
<span>{{ resumoError }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body-grid">
|
|
||||||
<div class="loading-overlay" *ngIf="resumoLoading">
|
|
||||||
<div class="spinner-border text-brand" role="status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="error-state" *ngIf="!resumoLoading && resumoError">
|
|
||||||
<i class="bi bi-exclamation-circle"></i>
|
|
||||||
<span>{{ resumoError }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
|
|
||||||
<div class="mini-chart-card">
|
|
||||||
<h6>Top Clientes (Qtd. Linhas)</h6>
|
|
||||||
<div class="chart-area"><canvas #chartResumoTopClientes></canvas></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
|
||||||
<h6>Top Planos (Qtd. Linhas)</h6>
|
<div class="mini-chart-card">
|
||||||
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
|
<h6>Top Clientes (Qtd. Linhas)</h6>
|
||||||
</div>
|
<div class="chart-area"><canvas #chartResumoTopClientes></canvas></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>PF vs PJ (Qtd. Linhas)</h6>
|
<h6>Top Planos (Qtd. Linhas)</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
|
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>Reserva por DDD</h6>
|
<h6>PF vs PJ (Qtd. Linhas)</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
|
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card mini-metric-card">
|
<div class="mini-chart-card">
|
||||||
<h6>DIFERENÇA PJ X PF</h6>
|
<h6>Reserva por DDD</h6>
|
||||||
<div class="metric-stack">
|
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
|
||||||
<div class="metric-line">
|
</div>
|
||||||
<span>PF (Linhas)</span>
|
|
||||||
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
|
<div class="mini-chart-card mini-metric-card">
|
||||||
</div>
|
<h6>DIFERENÇA PJ X PF</h6>
|
||||||
<div class="metric-line">
|
<div class="metric-stack">
|
||||||
<span>PJ (Linhas)</span>
|
<div class="metric-line">
|
||||||
<strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
|
<span>PF (Linhas)</span>
|
||||||
</div>
|
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
|
||||||
<div class="metric-line">
|
</div>
|
||||||
<span>Total Linhas</span>
|
<div class="metric-line">
|
||||||
<strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
|
<span>PJ (Linhas)</span>
|
||||||
|
<strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-line">
|
||||||
|
<span>Total Linhas</span>
|
||||||
|
<strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="context-title fade-in-up" [style.animation-delay]="'420ms'">
|
<div class="context-title fade-in-up" [style.animation-delay]="'420ms'">
|
||||||
<h2>Histórico</h2>
|
<h2>Histórico</h2>
|
||||||
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
|
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
|
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
|
||||||
<div class="history-grid">
|
<div class="history-grid">
|
||||||
<div class="card-modern">
|
<div class="card-modern">
|
||||||
<div class="card-header-clean">
|
<div class="card-header-clean">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h3>MUREG (12 Meses)</h3>
|
<h3>MUREG (12 Meses)</h3>
|
||||||
<p>Histórico mensal de mudanças de plano/aparelho</p>
|
<p>Histórico mensal de mudanças de plano/aparelho</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrapper-bar compact-half">
|
||||||
|
<canvas #chartMureg12></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
|
||||||
<canvas #chartMureg12></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-modern">
|
<div class="card-modern">
|
||||||
<div class="card-header-clean">
|
<div class="card-header-clean">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h3>Troca de Número (12 Meses)</h3>
|
<h3>Troca de Número (12 Meses)</h3>
|
||||||
<p>Histórico mensal de trocas realizadas</p>
|
<p>Histórico mensal de trocas realizadas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrapper-bar compact-half">
|
||||||
|
<canvas #chartTroca12></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
|
||||||
<canvas #chartTroca12></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-modern">
|
<div class="card-modern">
|
||||||
<div class="card-header-clean">
|
<div class="card-header-clean">
|
||||||
<div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
|
<div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h3>Vigência (Próx. 12 Meses)</h3>
|
<h3>Vigência (Próx. 12 Meses)</h3>
|
||||||
<p>Contratos a encerrar por mês</p>
|
<p>Contratos a encerrar por mês</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrapper-bar compact-half">
|
||||||
|
<canvas #chartVigenciaMesAno></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="chart-wrapper-bar compact-half">
|
|
||||||
<canvas #chartVigenciaMesAno></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #clienteDashboard>
|
||||||
|
<div class="dashboard-section fade-in-up" [style.animation-delay]="'180ms'">
|
||||||
|
<div class="section-top-row">
|
||||||
|
<div class="card-modern card-status">
|
||||||
|
<div class="card-header-clean">
|
||||||
|
<div class="header-icon brand"><i class="bi bi-pie-chart-fill"></i></div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Status da Base</h3>
|
||||||
|
<p>Distribuição atual das linhas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body-split">
|
||||||
|
<div class="chart-wrapper-pie">
|
||||||
|
<canvas #chartStatusPie></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="status-list">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-active"></span>
|
||||||
|
<span class="lbl">Ativas</span>
|
||||||
|
<span class="val">{{ statusResumo.ativos | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-blocked"></span>
|
||||||
|
<span class="lbl">Bloqueadas</span>
|
||||||
|
<span class="val">{{ statusResumo.bloqueadas | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="dot d-reserve"></span>
|
||||||
|
<span class="lbl">Reserva</span>
|
||||||
|
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item total-row">
|
||||||
|
<span class="lbl">Total</span>
|
||||||
|
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
ResumoResponse,
|
ResumoResponse,
|
||||||
LineTotal,
|
LineTotal,
|
||||||
} from '../../services/resumo.service';
|
} from '../../services/resumo.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
|
||||||
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
|
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
|
||||||
type KpiCard = {
|
type KpiCard = {
|
||||||
|
|
@ -218,6 +219,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMsg: string | null = null;
|
errorMsg: string | null = null;
|
||||||
|
isCliente = false;
|
||||||
|
|
||||||
kpis: KpiCard[] = [];
|
kpis: KpiCard[] = [];
|
||||||
|
|
||||||
|
|
@ -240,6 +242,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
statusResumo = {
|
statusResumo = {
|
||||||
total: 0,
|
total: 0,
|
||||||
ativos: 0,
|
ativos: 0,
|
||||||
|
bloqueadas: 0,
|
||||||
perdaRoubo: 0,
|
perdaRoubo: 0,
|
||||||
bloq120: 0,
|
bloq120: 0,
|
||||||
reservas: 0,
|
reservas: 0,
|
||||||
|
|
@ -331,6 +334,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
private resumoService: ResumoService,
|
private resumoService: ResumoService,
|
||||||
|
private authService: AuthService,
|
||||||
@Inject(PLATFORM_ID) private platformId: object
|
@Inject(PLATFORM_ID) private platformId: object
|
||||||
) {
|
) {
|
||||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||||
|
|
@ -340,9 +344,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
|
const isSysAdmin = this.authService.hasRole('sysadmin');
|
||||||
|
const isGestor = this.authService.hasRole('gestor');
|
||||||
|
this.isCliente = !(isSysAdmin || isGestor);
|
||||||
|
|
||||||
this.loadDashboard();
|
this.loadDashboard();
|
||||||
this.loadInsights();
|
if (!this.isCliente) {
|
||||||
this.loadResumoExecutive();
|
this.loadInsights();
|
||||||
|
this.loadResumoExecutive();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
|
@ -466,6 +476,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.statusResumo = {
|
this.statusResumo = {
|
||||||
total: k.totalLinhas ?? 0,
|
total: k.totalLinhas ?? 0,
|
||||||
ativos: k.ativos ?? 0,
|
ativos: k.ativos ?? 0,
|
||||||
|
bloqueadas: k.bloqueados ?? 0,
|
||||||
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
|
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
|
||||||
bloq120: k.bloqueados120Dias ?? 0,
|
bloq120: k.bloqueados120Dias ?? 0,
|
||||||
reservas: k.reservas ?? 0,
|
reservas: k.reservas ?? 0,
|
||||||
|
|
@ -912,6 +923,40 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildPrimaryKpis() {
|
private rebuildPrimaryKpis() {
|
||||||
|
if (this.isCliente) {
|
||||||
|
this.kpis = [
|
||||||
|
{
|
||||||
|
key: 'linhas_total',
|
||||||
|
title: 'Total de Linhas',
|
||||||
|
value: this.formatInt(this.dashboardRaw?.totalLinhas ?? this.statusResumo.total),
|
||||||
|
icon: 'bi bi-sim-fill',
|
||||||
|
hint: 'Base geral',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'linhas_ativas',
|
||||||
|
title: 'Linhas Ativas',
|
||||||
|
value: this.formatInt(this.dashboardRaw?.ativos ?? this.statusResumo.ativos),
|
||||||
|
icon: 'bi bi-check2-circle',
|
||||||
|
hint: 'Status ativo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'linhas_bloqueadas',
|
||||||
|
title: 'Linhas Bloqueadas',
|
||||||
|
value: this.formatInt(this.dashboardRaw?.bloqueados ?? this.statusResumo.bloqueadas),
|
||||||
|
icon: 'bi bi-slash-circle',
|
||||||
|
hint: 'Todos os bloqueios',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'linhas_reserva',
|
||||||
|
title: 'Linhas em Reserva',
|
||||||
|
value: this.formatInt(this.dashboardRaw?.reservas ?? this.statusResumo.reservas),
|
||||||
|
icon: 'bi bi-inboxes-fill',
|
||||||
|
hint: 'Base de reserva',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cards: KpiCard[] = [];
|
const cards: KpiCard[] = [];
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
const add = (key: string, title: string, value: string, icon: string, hint?: string) => {
|
const add = (key: string, title: string, value: string, icon: string, hint?: string) => {
|
||||||
|
|
@ -969,18 +1014,22 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
if (!this.viewReady || !this.dataReady) return;
|
if (!this.viewReady || !this.dataReady) return;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const canvases = [
|
const canvases = (
|
||||||
this.chartStatusPie?.nativeElement,
|
this.isCliente
|
||||||
this.chartAdicionaisComparativo?.nativeElement,
|
? [this.chartStatusPie?.nativeElement]
|
||||||
this.chartVigenciaMesAno?.nativeElement,
|
: [
|
||||||
this.chartVigenciaSupervisao?.nativeElement,
|
this.chartStatusPie?.nativeElement,
|
||||||
this.chartMureg12?.nativeElement,
|
this.chartAdicionaisComparativo?.nativeElement,
|
||||||
this.chartTroca12?.nativeElement,
|
this.chartVigenciaMesAno?.nativeElement,
|
||||||
this.chartLinhasPorFranquia?.nativeElement,
|
this.chartVigenciaSupervisao?.nativeElement,
|
||||||
this.chartAdicionaisPagos?.nativeElement,
|
this.chartMureg12?.nativeElement,
|
||||||
this.chartTipoChip?.nativeElement,
|
this.chartTroca12?.nativeElement,
|
||||||
this.chartTravelMundo?.nativeElement,
|
this.chartLinhasPorFranquia?.nativeElement,
|
||||||
].filter(Boolean) as HTMLCanvasElement[];
|
this.chartAdicionaisPagos?.nativeElement,
|
||||||
|
this.chartTipoChip?.nativeElement,
|
||||||
|
this.chartTravelMundo?.nativeElement,
|
||||||
|
]
|
||||||
|
).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)) {
|
||||||
|
|
@ -1033,26 +1082,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
// 1. Status Pie
|
// 1. Status Pie
|
||||||
if (this.chartStatusPie?.nativeElement) {
|
if (this.chartStatusPie?.nativeElement) {
|
||||||
|
const chartLabels = this.isCliente
|
||||||
|
? ['Ativas', 'Bloqueadas', 'Reservas']
|
||||||
|
: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'];
|
||||||
|
const chartData = this.isCliente
|
||||||
|
? [this.statusResumo.ativos, this.statusResumo.bloqueadas, this.statusResumo.reservas]
|
||||||
|
: [
|
||||||
|
this.statusResumo.ativos,
|
||||||
|
this.statusResumo.perdaRoubo,
|
||||||
|
this.statusResumo.bloq120,
|
||||||
|
this.statusResumo.reservas,
|
||||||
|
this.statusResumo.outras,
|
||||||
|
];
|
||||||
|
const chartColors = this.isCliente
|
||||||
|
? [palette.status.ativos, palette.status.blocked, palette.status.reserve]
|
||||||
|
: [
|
||||||
|
palette.status.ativos,
|
||||||
|
palette.status.blocked,
|
||||||
|
palette.status.purple,
|
||||||
|
palette.status.reserve,
|
||||||
|
'#cbd5e1',
|
||||||
|
];
|
||||||
|
|
||||||
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
|
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'],
|
labels: chartLabels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [
|
data: chartData,
|
||||||
this.statusResumo.ativos,
|
|
||||||
this.statusResumo.perdaRoubo,
|
|
||||||
this.statusResumo.bloq120,
|
|
||||||
this.statusResumo.reservas,
|
|
||||||
this.statusResumo.outras
|
|
||||||
],
|
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
backgroundColor: [
|
backgroundColor: chartColors,
|
||||||
palette.status.ativos,
|
|
||||||
palette.status.blocked,
|
|
||||||
palette.status.purple,
|
|
||||||
palette.status.reserve,
|
|
||||||
'#cbd5e1'
|
|
||||||
],
|
|
||||||
hoverOffset: 4
|
hoverOffset: 4
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
|
|
@ -1065,6 +1124,10 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
this.initAnimations();
|
this.initAnimations();
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.refreshData(true);
|
this.refreshData(true);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-brand btn-sm"
|
class="btn btn-brand btn-sm"
|
||||||
|
*ngIf="!isClientRestricted"
|
||||||
(click)="onCadastrarLinha()"
|
(click)="onCadastrarLinha()"
|
||||||
[disabled]="loading">
|
[disabled]="loading">
|
||||||
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
|
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
|
||||||
|
|
@ -58,19 +59,21 @@
|
||||||
<button type="button" class="filter-tab" [class.active]="filterSkil === 'ALL'" (click)="setFilter('ALL')" [disabled]="loading">
|
<button type="button" class="filter-tab" [class.active]="filterSkil === 'ALL'" (click)="setFilter('ALL')" [disabled]="loading">
|
||||||
Todos
|
Todos
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PF'" (click)="setFilter('PF')" [disabled]="loading">
|
<ng-container *ngIf="!isClientRestricted">
|
||||||
<i class="bi bi-person me-1"></i> Pessoa Física
|
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PF'" (click)="setFilter('PF')" [disabled]="loading">
|
||||||
</button>
|
<i class="bi bi-person me-1"></i> Pessoa Física
|
||||||
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PJ'" (click)="setFilter('PJ')" [disabled]="loading">
|
</button>
|
||||||
<i class="bi bi-building me-1"></i> Pessoa Jurídica
|
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PJ'" (click)="setFilter('PJ')" [disabled]="loading">
|
||||||
</button>
|
<i class="bi bi-building me-1"></i> Pessoa Jurídica
|
||||||
<button type="button" class="filter-tab" [class.active]="filterSkil === 'RESERVA'" (click)="setFilter('RESERVA')" [disabled]="loading">
|
</button>
|
||||||
<i class="bi bi-archive me-1"></i> Reservas
|
<button type="button" class="filter-tab" [class.active]="filterSkil === 'RESERVA'" (click)="setFilter('RESERVA')" [disabled]="loading">
|
||||||
</button>
|
<i class="bi bi-archive me-1"></i> Reservas
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CLIENTE MULTI-SELECT -->
|
<!-- CLIENTE MULTI-SELECT -->
|
||||||
<div class="client-filter-wrap" (click)="$event.stopPropagation()">
|
<div class="client-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-client-filter"
|
class="btn-client-filter"
|
||||||
|
|
@ -123,7 +126,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="additional-filter-wrap" (click)="$event.stopPropagation()">
|
<div class="additional-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-client-filter btn-additional-filter"
|
class="btn-client-filter btn-additional-filter"
|
||||||
|
|
@ -211,7 +214,7 @@
|
||||||
|
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode">
|
<div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode">
|
||||||
<div class="kpi">
|
<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">
|
||||||
<span class="spinner-border spinner-border-sm text-brand"></span>
|
<span class="spinner-border spinner-border-sm text-brand"></span>
|
||||||
|
|
@ -302,7 +305,7 @@
|
||||||
<div class="group-body" *ngIf="expandedGroup === group.cliente">
|
<div class="group-body" *ngIf="expandedGroup === group.cliente">
|
||||||
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
|
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
|
||||||
<small class="text-muted fw-bold">Gerenciar Grupo</small>
|
<small class="text-muted fw-bold">Gerenciar Grupo</small>
|
||||||
<button class="btn btn-sm btn-add-line-group" (click)="onAddLineToGroup(group.cliente)">
|
<button class="btn btn-sm btn-add-line-group" *ngIf="!isClientRestricted" (click)="onAddLineToGroup(group.cliente)">
|
||||||
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
|
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -320,7 +323,7 @@
|
||||||
<th>LINHA</th>
|
<th>LINHA</th>
|
||||||
<th>USUÁRIO</th>
|
<th>USUÁRIO</th>
|
||||||
<th>STATUS</th>
|
<th>STATUS</th>
|
||||||
<th>VENCIMENTO</th>
|
<th *ngIf="!isClientRestricted">VENCIMENTO</th>
|
||||||
<th>AÇÕES</th>
|
<th>AÇÕES</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -333,13 +336,15 @@
|
||||||
<td>
|
<td>
|
||||||
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small fw-bold">{{ r.contrato }}</td>
|
<td class="text-muted small fw-bold" *ngIf="!isClientRestricted">{{ r.contrato }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-group justify-content-center">
|
<div class="action-group justify-content-center">
|
||||||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
<ng-container *ngIf="!isClientRestricted">
|
||||||
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
|
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -365,7 +370,7 @@
|
||||||
<col style="width: 280px;">
|
<col style="width: 280px;">
|
||||||
<col style="width: 160px;">
|
<col style="width: 160px;">
|
||||||
<col style="width: 130px;">
|
<col style="width: 130px;">
|
||||||
<col style="width: 130px;">
|
<col style="width: 130px;" *ngIf="!isClientRestricted">
|
||||||
<col style="width: 160px;">
|
<col style="width: 160px;">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
|
|
||||||
|
|
@ -416,7 +421,7 @@
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th class="sortable text-center" (click)="setSort('contrato')">
|
<th *ngIf="!isClientRestricted" class="sortable text-center" (click)="setSort('contrato')">
|
||||||
<div class="th-content justify-content-center">
|
<div class="th-content justify-content-center">
|
||||||
VENCIMENTO
|
VENCIMENTO
|
||||||
<span class="sort-caret" [class.active]="sortKey==='contrato'">
|
<span class="sort-caret" [class.active]="sortKey==='contrato'">
|
||||||
|
|
@ -431,13 +436,13 @@
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngIf="loading">
|
<tr *ngIf="loading">
|
||||||
<td colspan="7" class="text-center py-5 empty-state">
|
<td [attr.colspan]="isClientRestricted ? 6 : 7" class="text-center py-5 empty-state">
|
||||||
<span class="spinner-border spinner-border-sm me-2 text-brand"></span> Carregando...
|
<span class="spinner-border spinner-border-sm me-2 text-brand"></span> Carregando...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr *ngIf="!loading && pagedRows.length === 0">
|
<tr *ngIf="!loading && pagedRows.length === 0">
|
||||||
<td colspan="7" class="text-center py-5 empty-state text-muted fw-bold">
|
<td [attr.colspan]="isClientRestricted ? 6 : 7" class="text-center py-5 empty-state text-muted fw-bold">
|
||||||
Nenhum registro encontrado.
|
Nenhum registro encontrado.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -450,13 +455,15 @@
|
||||||
<span class="status-pill" [ngClass]="statusClass(r.status)" [title]="r.status || ''">{{ statusLabel(r.status) }}</span>
|
<span class="status-pill" [ngClass]="statusClass(r.status)" [title]="r.status || ''">{{ statusLabel(r.status) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center fw-bold text-muted small">{{ r.skil }}</td>
|
<td class="text-center fw-bold text-muted small">{{ r.skil }}</td>
|
||||||
<td class="text-center fw-bold text-muted small">{{ r.contrato }}</td>
|
<td class="text-center fw-bold text-muted small" *ngIf="!isClientRestricted">{{ r.contrato }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="action-group justify-content-center">
|
<div class="action-group justify-content-center">
|
||||||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
<ng-container *ngIf="!isClientRestricted">
|
||||||
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
|
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1578,7 +1585,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-column d-flex flex-column gap-2">
|
<div class="dashboard-column d-flex flex-column gap-2" *ngIf="!isClientRestricted">
|
||||||
<div class="detail-box">
|
<div class="detail-box">
|
||||||
<div class="box-header justify-content-center">
|
<div class="box-header justify-content-center">
|
||||||
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
|
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
|
||||||
|
|
@ -1632,7 +1639,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-column">
|
<div class="dashboard-column" *ngIf="!isClientRestricted">
|
||||||
<div class="detail-box h-100">
|
<div class="detail-box h-100">
|
||||||
<div class="box-header justify-content-center">
|
<div class="box-header justify-content-center">
|
||||||
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Status</span>
|
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Status</span>
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
})();
|
})();
|
||||||
loading = false;
|
loading = false;
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
isGestor = false;
|
||||||
|
isClientRestricted = false;
|
||||||
|
|
||||||
rows: LineRow[] = [];
|
rows: LineRow[] = [];
|
||||||
clientGroups: ClientGroupDto[] = [];
|
clientGroups: ClientGroupDto[] = [];
|
||||||
|
|
@ -544,7 +546,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
|
this.isGestor = this.authService.hasRole('gestor');
|
||||||
|
this.isClientRestricted = !(this.isAdmin || this.isGestor);
|
||||||
|
|
||||||
|
if (this.isClientRestricted) {
|
||||||
|
this.filterSkil = 'ALL';
|
||||||
|
this.additionalMode = 'ALL';
|
||||||
|
this.selectedAdditionalServices = [];
|
||||||
|
this.selectedClients = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngAfterViewInit() {
|
async ngAfterViewInit() {
|
||||||
|
|
@ -553,7 +564,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
this.loadClients();
|
if (!this.isClientRestricted) {
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
this.loadPlanRules();
|
this.loadPlanRules();
|
||||||
this.loadAccountCompanies();
|
this.loadAccountCompanies();
|
||||||
|
|
||||||
|
|
@ -574,7 +587,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
if (!url.includes('/geral')) return;
|
if (!url.includes('/geral')) return;
|
||||||
|
|
||||||
this.searchResolvedClient = null;
|
this.searchResolvedClient = null;
|
||||||
this.loadClients();
|
if (!this.isClientRestricted) {
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -915,6 +930,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') {
|
setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') {
|
||||||
|
if (this.isClientRestricted && type !== 'ALL') return;
|
||||||
|
|
||||||
const isSameFilter = this.filterSkil === type;
|
const isSameFilter = this.filterSkil === type;
|
||||||
|
|
||||||
this.expandedGroup = null;
|
this.expandedGroup = null;
|
||||||
|
|
@ -927,13 +944,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.clientSearchTerm = '';
|
this.clientSearchTerm = '';
|
||||||
this.searchResolvedClient = null;
|
this.searchResolvedClient = null;
|
||||||
|
|
||||||
this.loadClients();
|
if (!this.isClientRestricted) {
|
||||||
|
this.loadClients();
|
||||||
|
}
|
||||||
|
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAdditionalMode(mode: AdditionalMode) {
|
setAdditionalMode(mode: AdditionalMode) {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
if (this.additionalMode === mode) return;
|
if (this.additionalMode === mode) return;
|
||||||
|
|
||||||
this.additionalMode = mode;
|
this.additionalMode = mode;
|
||||||
|
|
@ -947,6 +967,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAdditionalService(key: AdditionalServiceKey) {
|
toggleAdditionalService(key: AdditionalServiceKey) {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
const idx = this.selectedAdditionalServices.indexOf(key);
|
const idx = this.selectedAdditionalServices.indexOf(key);
|
||||||
if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1);
|
if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1);
|
||||||
else this.selectedAdditionalServices.push(key);
|
else this.selectedAdditionalServices.push(key);
|
||||||
|
|
@ -965,6 +986,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAdditionalFilters() {
|
clearAdditionalFilters() {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
this.additionalMode = 'ALL';
|
this.additionalMode = 'ALL';
|
||||||
this.selectedAdditionalServices = [];
|
this.selectedAdditionalServices = [];
|
||||||
this.expandedGroup = null;
|
this.expandedGroup = null;
|
||||||
|
|
@ -1427,11 +1449,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleClientMenu() {
|
toggleClientMenu() {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
if (!this.showClientMenu) this.showAdditionalMenu = false;
|
if (!this.showClientMenu) this.showAdditionalMenu = false;
|
||||||
this.showClientMenu = !this.showClientMenu;
|
this.showClientMenu = !this.showClientMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAdditionalMenu() {
|
toggleAdditionalMenu() {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
if (!this.showAdditionalMenu) this.showClientMenu = false;
|
if (!this.showAdditionalMenu) this.showClientMenu = false;
|
||||||
this.showAdditionalMenu = !this.showAdditionalMenu;
|
this.showAdditionalMenu = !this.showAdditionalMenu;
|
||||||
}
|
}
|
||||||
|
|
@ -1450,6 +1474,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
selectClient(client: string | null) {
|
selectClient(client: string | null) {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
if (client === null) {
|
if (client === null) {
|
||||||
this.selectedClients = [];
|
this.selectedClients = [];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1465,6 +1490,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
removeClient(client: string, event: Event) {
|
removeClient(client: string, event: Event) {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const idx = this.selectedClients.indexOf(client);
|
const idx = this.selectedClients.indexOf(client);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
@ -1477,6 +1503,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearClientSelection(event?: Event) {
|
clearClientSelection(event?: Event) {
|
||||||
|
if (this.isClientRestricted) return;
|
||||||
if (event) event.stopPropagation();
|
if (event) event.stopPropagation();
|
||||||
this.selectedClients = [];
|
this.selectedClients = [];
|
||||||
this.clientSearchTerm = '';
|
this.clientSearchTerm = '';
|
||||||
|
|
@ -1776,7 +1803,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
async onRemover(r: LineRow, fromGroup = false) {
|
async onRemover(r: LineRow, fromGroup = false) {
|
||||||
if (!this.isAdmin) {
|
if (!this.isAdmin) {
|
||||||
await this.showToast('Apenas administradores podem remover linhas.');
|
await this.showToast('Apenas sysadmin pode remover linhas.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1808,6 +1835,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onCadastrarLinha() {
|
async onCadastrarLinha() {
|
||||||
|
if (this.isClientRestricted) {
|
||||||
|
await this.showToast('Você não tem permissão para cadastrar novos clientes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.createMode = 'NEW_CLIENT';
|
this.createMode = 'NEW_CLIENT';
|
||||||
this.resetCreateModel();
|
this.resetCreateModel();
|
||||||
this.createOpen = true;
|
this.createOpen = true;
|
||||||
|
|
@ -1815,6 +1847,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onAddLineToGroup(clientName: string) {
|
async onAddLineToGroup(clientName: string) {
|
||||||
|
if (this.isClientRestricted) {
|
||||||
|
await this.showToast('Você não tem permissão para adicionar linhas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.createMode = 'NEW_LINE_IN_GROUP';
|
this.createMode = 'NEW_LINE_IN_GROUP';
|
||||||
this.resetCreateModel();
|
this.resetCreateModel();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPermissions(): void {
|
private syncPermissions(): void {
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalPages(): number {
|
get totalPages(): number {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
<section class="system-provision-page">
|
||||||
|
<span class="page-blob blob-1" aria-hidden="true"></span>
|
||||||
|
<span class="page-blob blob-2" aria-hidden="true"></span>
|
||||||
|
<span class="page-blob blob-3" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<div class="container-shell">
|
||||||
|
<div class="card-shell">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="title-badge">
|
||||||
|
<i class="bi bi-shield-lock-fill"></i> SYSTEM ADMIN
|
||||||
|
</div>
|
||||||
|
<h1>Fornecer Usuário para Cliente</h1>
|
||||||
|
<p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert-box error" *ngIf="tenantsError">
|
||||||
|
{{ tenantsError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert-box success" *ngIf="successMessage">
|
||||||
|
{{ successMessage }}
|
||||||
|
<div class="mt-1" *ngIf="createdUser">
|
||||||
|
<small>
|
||||||
|
UserId: <strong>{{ createdUser.userId }}</strong> | TenantId:
|
||||||
|
<strong>{{ createdUser.tenantId }}</strong>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert-box error" *ngIf="submitErrors.length">
|
||||||
|
<strong>Falha ao criar usuário:</strong>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let err of submitErrors">{{ err }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="provisionForm" (ngSubmit)="onSubmit()" class="provision-form" novalidate>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field span-2">
|
||||||
|
<label for="tenantId">Cliente (Tenant)</label>
|
||||||
|
<div class="select-row">
|
||||||
|
<select
|
||||||
|
id="tenantId"
|
||||||
|
formControlName="tenantId"
|
||||||
|
class="form-control"
|
||||||
|
[disabled]="tenantsLoading || !tenants.length"
|
||||||
|
>
|
||||||
|
<option value="">Selecione um cliente...</option>
|
||||||
|
<option
|
||||||
|
*ngFor="let tenant of tenants; trackBy: trackByTenantId"
|
||||||
|
[value]="tenant.tenantId"
|
||||||
|
>
|
||||||
|
{{ tenant.nomeOficial }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-ghost" (click)="loadTenants()" [disabled]="tenantsLoading">
|
||||||
|
{{ tenantsLoading ? 'Atualizando...' : 'Atualizar lista' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="field-help">Origem: {{ sourceType }} (apenas tenants ativos).</small>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('tenantId', 'required')">
|
||||||
|
Selecione um tenant-cliente.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="name">Nome (opcional)</label>
|
||||||
|
<input id="name" type="text" class="form-control" formControlName="name" placeholder="Nome do usuário" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="usuario@cliente.com"
|
||||||
|
/>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('email', 'required')">Email é obrigatório.</small>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('email', 'email')">Email inválido.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">Senha</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="Defina uma senha"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('password', 'required')">Senha é obrigatória.</small>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('password', 'minlength')">Mínimo de 6 caracteres.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="confirmPassword">Confirmar senha</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
formControlName="confirmPassword"
|
||||||
|
placeholder="Repita a senha"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<small class="field-error" *ngIf="hasFieldError('confirmPassword', 'required')">
|
||||||
|
Confirmação é obrigatória.
|
||||||
|
</small>
|
||||||
|
<small class="field-error" *ngIf="passwordMismatch">As senhas não conferem.</small>
|
||||||
|
</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 class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="submitting || provisionForm.invalid">
|
||||||
|
<span *ngIf="!submitting">Criar usuário para cliente</span>
|
||||||
|
<span *ngIf="submitting">Criando...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
:host {
|
||||||
|
--brand: #e33dcf;
|
||||||
|
--brand-dark: #8b2d7f;
|
||||||
|
--text: #111214;
|
||||||
|
--muted: rgba(17, 18, 20, 0.66);
|
||||||
|
--danger: #dc3545;
|
||||||
|
--success: #198754;
|
||||||
|
--border: rgba(17, 18, 20, 0.12);
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.84);
|
||||||
|
--surface: #ffffff;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-provision-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 12px 110px;
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(780px 360px at 10% 0%, rgba(227, 61, 207, 0.16), transparent 60%),
|
||||||
|
radial-gradient(900px 380px at 90% 16%, rgba(3, 15, 170, 0.12), transparent 62%),
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-blob {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(36px);
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(circle at 40% 40%, rgba(227, 61, 207, 0.4), rgba(227, 61, 207, 0.08));
|
||||||
|
|
||||||
|
&.blob-1 {
|
||||||
|
width: 420px;
|
||||||
|
height: 420px;
|
||||||
|
top: -150px;
|
||||||
|
left: -160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blob-2 {
|
||||||
|
width: 560px;
|
||||||
|
height: 560px;
|
||||||
|
top: -230px;
|
||||||
|
right: -260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blob-3 {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
bottom: -160px;
|
||||||
|
left: 30%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shell {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid rgba(227, 61, 207, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 24px 50px rgba(17, 18, 20, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 24px 28px 18px;
|
||||||
|
border-bottom: 1px solid rgba(17, 18, 20, 0.08);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(227, 61, 207, 0.24);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--brand-dark);
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 8px 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(25, 135, 84, 0.1);
|
||||||
|
border: 1px solid rgba(25, 135, 84, 0.24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.provision-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&.span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid rgba(17, 18, 20, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(120deg, #e33dcf, #b131a0);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: rgba(17, 18, 20, 0.16);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.card-header,
|
||||||
|
.card-body {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.system-provision-page {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field.span-2 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ValidationErrors,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SystemAdminService,
|
||||||
|
SystemTenantDto,
|
||||||
|
CreateSystemTenantUserResponse,
|
||||||
|
} from '../../services/system-admin.service';
|
||||||
|
|
||||||
|
type RoleOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-system-provision-user',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './system-provision-user.html',
|
||||||
|
styleUrls: ['./system-provision-user.scss'],
|
||||||
|
})
|
||||||
|
export class SystemProvisionUserPage implements OnInit {
|
||||||
|
readonly roleOptions: RoleOption[] = [
|
||||||
|
{ value: 'sysadmin', label: 'SysAdmin', description: 'Acesso administrativo global do sistema (apenas SystemTenant).' },
|
||||||
|
{ value: 'gestor', label: 'Gestor', description: 'Acesso global de gestão, sem permissões administrativas.' },
|
||||||
|
{ value: 'cliente', label: 'Cliente', description: 'Acesso restrito ao tenant do cliente.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly sourceType = 'MobileLines.Cliente';
|
||||||
|
|
||||||
|
provisionForm: FormGroup;
|
||||||
|
tenants: SystemTenantDto[] = [];
|
||||||
|
tenantsLoading = false;
|
||||||
|
tenantsError = '';
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
submitErrors: string[] = [];
|
||||||
|
successMessage = '';
|
||||||
|
createdUser: CreateSystemTenantUserResponse | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private systemAdminService: SystemAdminService
|
||||||
|
) {
|
||||||
|
this.provisionForm = this.fb.group(
|
||||||
|
{
|
||||||
|
tenantId: ['', [Validators.required]],
|
||||||
|
name: [''],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||||
|
confirmPassword: ['', [Validators.required, Validators.minLength(6)]],
|
||||||
|
roles: this.fb.control<string[]>(['cliente'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{ validators: this.passwordsMatchValidator }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTenants(): void {
|
||||||
|
if (this.tenantsLoading) return;
|
||||||
|
|
||||||
|
this.tenantsLoading = true;
|
||||||
|
this.tenantsError = '';
|
||||||
|
|
||||||
|
this.systemAdminService
|
||||||
|
.listTenants({ source: this.sourceType, active: true })
|
||||||
|
.subscribe({
|
||||||
|
next: (tenants) => {
|
||||||
|
this.tenants = (tenants || []).slice().sort((a, b) =>
|
||||||
|
(a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
this.tenantsLoading = false;
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
this.tenantsLoading = false;
|
||||||
|
this.tenantsError = this.extractErrorMessage(
|
||||||
|
err,
|
||||||
|
'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRoleSelected(role: string): boolean {
|
||||||
|
const selected = this.selectedRoles;
|
||||||
|
return selected.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleRole(role: string, checked: boolean): void {
|
||||||
|
const current = this.selectedRoles;
|
||||||
|
const next = checked
|
||||||
|
? Array.from(new Set([...current, role]))
|
||||||
|
: current.filter((value) => value !== role);
|
||||||
|
|
||||||
|
this.rolesControl.setValue(next);
|
||||||
|
this.rolesControl.markAsDirty();
|
||||||
|
this.rolesControl.markAsTouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.submitting) return;
|
||||||
|
|
||||||
|
this.successMessage = '';
|
||||||
|
this.submitErrors = [];
|
||||||
|
this.createdUser = null;
|
||||||
|
|
||||||
|
if (this.provisionForm.invalid || this.selectedRoles.length === 0) {
|
||||||
|
this.provisionForm.markAllAsTouched();
|
||||||
|
if (this.selectedRoles.length === 0) {
|
||||||
|
this.submitErrors = ['Selecione ao menos uma role para o usuário.'];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = String(this.provisionForm.get('tenantId')?.value ?? '').trim();
|
||||||
|
const email = String(this.provisionForm.get('email')?.value ?? '').trim().toLowerCase();
|
||||||
|
const nameRaw = String(this.provisionForm.get('name')?.value ?? '').trim();
|
||||||
|
const password = String(this.provisionForm.get('password')?.value ?? '');
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.setFormDisabled(true);
|
||||||
|
|
||||||
|
this.systemAdminService
|
||||||
|
.createTenantUser(tenantId, {
|
||||||
|
name: nameRaw,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
roles: this.selectedRoles,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.setFormDisabled(false);
|
||||||
|
|
||||||
|
this.createdUser = created;
|
||||||
|
const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId);
|
||||||
|
const tenantName = tenant?.nomeOficial || 'cliente selecionado';
|
||||||
|
this.successMessage = `Usuário ${created.email} criado com sucesso para ${tenantName}.`;
|
||||||
|
|
||||||
|
this.provisionForm.patchValue({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
roles: ['cliente'],
|
||||||
|
});
|
||||||
|
this.provisionForm.markAsPristine();
|
||||||
|
this.provisionForm.markAsUntouched();
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.setFormDisabled(false);
|
||||||
|
this.submitErrors = this.extractErrors(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByTenantId(_: number, tenant: SystemTenantDto): string {
|
||||||
|
return tenant.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByRoleValue(_: number, role: RoleOption): string {
|
||||||
|
return role.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFieldError(field: string, error?: string): boolean {
|
||||||
|
const control = this.provisionForm.get(field);
|
||||||
|
if (!control) return false;
|
||||||
|
if (error) return !!(control.touched && control.hasError(error));
|
||||||
|
return !!(control.touched && control.invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordMismatch(): boolean {
|
||||||
|
const confirmTouched = this.provisionForm.get('confirmPassword')?.touched;
|
||||||
|
return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedRoles(): string[] {
|
||||||
|
const roles = this.rolesControl.value;
|
||||||
|
return Array.isArray(roles) ? roles : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get rolesControl(): AbstractControl<string[] | null, string[] | null> {
|
||||||
|
return this.provisionForm.get('roles') as AbstractControl<string[] | null, string[] | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTenantById(tenantId: string): SystemTenantDto | undefined {
|
||||||
|
return this.tenants.find((tenant) => tenant.tenantId === tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFormDisabled(disabled: boolean): void {
|
||||||
|
if (disabled) {
|
||||||
|
this.provisionForm.disable({ emitEvent: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.provisionForm.enable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractErrors(err: HttpErrorResponse): string[] {
|
||||||
|
const apiError = err?.error;
|
||||||
|
|
||||||
|
if (Array.isArray(apiError)) {
|
||||||
|
const list = apiError.map((entry) => String(entry ?? '').trim()).filter(Boolean);
|
||||||
|
if (list.length) return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(apiError?.errors)) {
|
||||||
|
const list = apiError.errors
|
||||||
|
.map((entry: unknown) => String((entry as { message?: string })?.message ?? entry ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length) return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiError?.message === 'string' && apiError.message.trim()) {
|
||||||
|
return [apiError.message.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiError === 'string' && apiError.trim()) {
|
||||||
|
return [apiError.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status === 403) {
|
||||||
|
return ['Acesso negado. Este recurso é exclusivo para sysadmin.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Não foi possível criar o usuário para o cliente selecionado.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractErrorMessage(err: HttpErrorResponse, fallback: string): string {
|
||||||
|
const messages = this.extractErrors(err);
|
||||||
|
if (messages.length) return messages[0];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
|
||||||
|
const password = group.get('password')?.value;
|
||||||
|
const confirm = group.get('confirmPassword')?.value;
|
||||||
|
if (!password || !confirm) return null;
|
||||||
|
return password === confirm ? null : { passwordMismatch: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -113,7 +113,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.isAdmin = this.authService.hasRole('admin');
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
this.loadClients();
|
this.loadClients();
|
||||||
this.loadPlanRules();
|
this.loadPlanRules();
|
||||||
this.fetch(1);
|
this.fetch(1);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export type SystemTenantDto = {
|
||||||
|
tenantId: string;
|
||||||
|
nomeOficial: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSystemTenantsParams = {
|
||||||
|
source?: string;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSystemTenantUserPayload = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSystemTenantUserResponse = {
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SystemAdminService {
|
||||||
|
private readonly baseApi: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||||
|
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
listTenants(params?: ListSystemTenantsParams): Observable<SystemTenantDto[]> {
|
||||||
|
let httpParams = new HttpParams();
|
||||||
|
if (params?.source) {
|
||||||
|
httpParams = httpParams.set('source', params.source);
|
||||||
|
}
|
||||||
|
if (typeof params?.active === 'boolean') {
|
||||||
|
httpParams = httpParams.set('active', String(params.active));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<SystemTenantDto[]>(`${this.baseApi}/system/tenants`, {
|
||||||
|
params: httpParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTenantUser(
|
||||||
|
tenantId: string,
|
||||||
|
payload: CreateSystemTenantUserPayload
|
||||||
|
): Observable<CreateSystemTenantUserResponse> {
|
||||||
|
return this.http.post<CreateSystemTenantUserResponse>(
|
||||||
|
`${this.baseApi}/system/tenants/${tenantId}/users`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export type UserPermission = 'admin' | 'gestor';
|
export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
|
||||||
|
|
||||||
export type UserDto = {
|
export type UserDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue