Merge pull request #29 from eduardolopesx03/line-gestao-clientUsers

feat: tela e fluxo de criação de usuário do cliente
This commit is contained in:
Leon Nascimento Moreira 2026-02-26 17:20:59 -03:00 committed by GitHub
commit f78f1c891e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1282 additions and 310 deletions

View File

@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard';
import { systemAdminGuard } from './guards/system-admin.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
@ -19,6 +20,7 @@ import { Resumo } from './pages/resumo/resumo';
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
import { Historico } from './pages/historico/historico';
import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
export const routes: Routes = [
{ path: '', component: Home },
@ -37,6 +39,12 @@ export const routes: Routes = [
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{
path: 'system/fornecer-usuario',
component: SystemProvisionUserPage,
canActivate: [authGuard, systemAdminGuard],
title: 'Fornecer Usuário',
},
// ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },

View File

@ -41,6 +41,7 @@ export class AppComponent {
'/parcelamentos',
'/historico',
'/perfil',
'/system',
];
constructor(

View File

@ -191,6 +191,9 @@
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário
</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>
<button type="button" class="options-item danger" (click)="logout()">
<i class="bi bi-box-arrow-right"></i> Sair
@ -504,35 +507,38 @@
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</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>
</a>
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim"></i> <span>Geral</span>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</aside>

View File

@ -32,6 +32,8 @@ export class Header implements AfterViewInit, OnDestroy {
isLoggedHeader = false;
isHome = false;
isAdmin = false;
canViewAll = false;
isSystemAdmin = false;
notifications: NotificationDto[] = [];
notificationsLoading = false;
notificationsError = false;
@ -52,8 +54,9 @@ export class Header implements AfterViewInit, OnDestroy {
createUserForbidden = false;
createUserSuccess = '';
readonly permissionOptions = [
{ value: 'admin', label: 'Administrador' },
{ value: 'sysadmin', label: 'SysAdmin' },
{ value: 'gestor', label: 'Gestor' },
{ value: 'cliente', label: 'Cliente' },
];
manageUsersLoading = false;
@ -86,6 +89,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/parcelamentos',
'/historico',
'/perfil',
'/system',
];
constructor(
@ -200,9 +204,15 @@ export class Header implements AfterViewInit, OnDestroy {
private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) {
this.isAdmin = false;
this.canViewAll = false;
this.isSystemAdmin = false;
return;
}
this.isAdmin = this.authService.hasRole('admin');
const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor');
this.isAdmin = isSysAdmin;
this.canViewAll = isSysAdmin || isGestor;
this.isSystemAdmin = this.authService.hasRole('sysadmin');
}
toggleMenu() {
@ -227,6 +237,12 @@ export class Header implements AfterViewInit, OnDestroy {
this.router.navigate(['/perfil']);
}
goToSystemProvisionUser() {
if (!this.isSystemAdmin) return;
this.closeOptions();
this.router.navigate(['/system/fornecer-usuario']);
}
openCreateUserModal() {
if (!this.isAdmin) return;
this.createUserOpen = true;
@ -430,6 +446,8 @@ export class Header implements AfterViewInit, OnDestroy {
this.optionsOpen = false;
this.notificationsOpen = false;
this.isAdmin = false;
this.canViewAll = false;
this.isSystemAdmin = false;
this.router.navigate(['/']);
}

View File

@ -18,8 +18,8 @@ export const adminGuard: CanActivateFn = () => {
return router.parseUrl('/login');
}
const isAdmin = authService.hasRole('admin');
if (!isAdmin) {
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
if (!hasAccess) {
return router.parseUrl('/dashboard');
}

View File

@ -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;
};

View File

@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
this.fetchChips();
this.fetchControle();
}

View File

@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit {
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
this.fetch(1);
}

View File

@ -40,288 +40,331 @@
</div>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
<h2>Página Geral</h2>
<p>Distribuição e saúde atual da base de linhas.</p>
</div>
<ng-container *ngIf="!isCliente; else clienteDashboard">
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
<h2>Página Geral</h2>
<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="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 class="dashboard-section fade-in-up" [style.animation-delay]="'220ms'">
<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-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 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">
<canvas #chartStatusPie></canvas>
<canvas #chartVigenciaSupervisao></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 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 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 class="grid-triples mt-3">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Linhas por Franquia</h3>
<p>Distribuição da base por faixa de franquia</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartLinhasPorFranquia></canvas>
</div>
</div>
<div class="card-body-adicionais">
<div class="chart-wrapper-pie-sm">
<canvas #chartAdicionaisComparativo></canvas>
<div class="card-modern">
<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 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 class="chart-wrapper-bar compact">
<canvas #chartAdicionaisPagos></canvas>
</div>
</div>
<div class="card-modern">
<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>
<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">
<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 class="context-title fade-in-up" [style.animation-delay]="'320ms'">
<h2>Página Resumo</h2>
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
</div>
<div class="grid-triples mt-3">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Linhas por Franquia</h3>
<p>Distribuição da base por faixa de franquia</p>
<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 class="chart-wrapper-bar compact">
<canvas #chartLinhasPorFranquia></canvas>
</div>
</div>
<div class="card-modern">
<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 class="chart-wrapper-bar compact">
<canvas #chartAdicionaisPagos></canvas>
</div>
</div>
<div class="toolbar-controls">
<div class="control-group">
<label>Visualizar Top</label>
<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 class="card-modern">
<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="divider-v"></div>
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
<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>
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
</div>
</div>
<div class="toolbar-controls">
<div class="control-group">
<label>Visualizar Top</label>
<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 class="card-body-grid">
<div class="loading-overlay" *ngIf="resumoLoading">
<div class="spinner-border text-brand" role="status"></div>
</div>
<div class="divider-v"></div>
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
</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 class="error-state" *ngIf="!resumoLoading && resumoError">
<i class="bi bi-exclamation-circle"></i>
<span>{{ resumoError }}</span>
</div>
<div class="mini-chart-card">
<h6>Top Planos (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
</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 class="mini-chart-card">
<h6>PF vs PJ (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Top Planos (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Reserva por DDD</h6>
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
</div>
<div class="mini-chart-card">
<h6>PF vs PJ (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
</div>
<div class="mini-chart-card mini-metric-card">
<h6>DIFERENÇA PJ X PF</h6>
<div class="metric-stack">
<div class="metric-line">
<span>PF (Linhas)</span>
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
</div>
<div class="metric-line">
<span>PJ (Linhas)</span>
<strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
</div>
<div class="metric-line">
<span>Total Linhas</span>
<strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
<div class="mini-chart-card">
<h6>Reserva por DDD</h6>
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
</div>
<div class="mini-chart-card mini-metric-card">
<h6>DIFERENÇA PJ X PF</h6>
<div class="metric-stack">
<div class="metric-line">
<span>PF (Linhas)</span>
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
</div>
<div class="metric-line">
<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 class="context-title fade-in-up" [style.animation-delay]="'420ms'">
<h2>Histórico</h2>
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'420ms'">
<h2>Histórico</h2>
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
<div class="history-grid">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>MUREG (12 Meses)</h3>
<p>Histórico mensal de mudanças de plano/aparelho</p>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
<div class="history-grid">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>MUREG (12 Meses)</h3>
<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 class="chart-wrapper-bar compact-half">
<canvas #chartMureg12></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Troca de Número (12 Meses)</h3>
<p>Histórico mensal de trocas realizadas</p>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Troca de Número (12 Meses)</h3>
<p>Histórico mensal de trocas realizadas</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartTroca12></canvas>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartTroca12></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
<div class="header-text">
<h3>Vigência (Próx. 12 Meses)</h3>
<p>Contratos a encerrar por mês</p>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
<div class="header-text">
<h3>Vigência (Próx. 12 Meses)</h3>
<p>Contratos a encerrar por mês</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartVigenciaMesAno></canvas>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartVigenciaMesAno></canvas>
</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>
</section>

View File

@ -20,6 +20,7 @@ import {
ResumoResponse,
LineTotal,
} from '../../services/resumo.service';
import { AuthService } from '../../services/auth.service';
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
type KpiCard = {
@ -218,6 +219,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
loading = true;
errorMsg: string | null = null;
isCliente = false;
kpis: KpiCard[] = [];
@ -240,6 +242,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
statusResumo = {
total: 0,
ativos: 0,
bloqueadas: 0,
perdaRoubo: 0,
bloq120: 0,
reservas: 0,
@ -331,6 +334,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
constructor(
private http: HttpClient,
private resumoService: ResumoService,
private authService: AuthService,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
@ -340,9 +344,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor');
this.isCliente = !(isSysAdmin || isGestor);
this.loadDashboard();
this.loadInsights();
this.loadResumoExecutive();
if (!this.isCliente) {
this.loadInsights();
this.loadResumoExecutive();
}
}
ngAfterViewInit(): void {
@ -466,6 +476,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.statusResumo = {
total: k.totalLinhas ?? 0,
ativos: k.ativos ?? 0,
bloqueadas: k.bloqueados ?? 0,
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
bloq120: k.bloqueados120Dias ?? 0,
reservas: k.reservas ?? 0,
@ -912,6 +923,40 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
}
private rebuildPrimaryKpis() {
if (this.isCliente) {
this.kpis = [
{
key: 'linhas_total',
title: 'Total de Linhas',
value: this.formatInt(this.dashboardRaw?.totalLinhas ?? this.statusResumo.total),
icon: 'bi bi-sim-fill',
hint: 'Base geral',
},
{
key: 'linhas_ativas',
title: 'Linhas Ativas',
value: this.formatInt(this.dashboardRaw?.ativos ?? this.statusResumo.ativos),
icon: 'bi bi-check2-circle',
hint: 'Status ativo',
},
{
key: 'linhas_bloqueadas',
title: 'Linhas Bloqueadas',
value: this.formatInt(this.dashboardRaw?.bloqueados ?? this.statusResumo.bloqueadas),
icon: 'bi bi-slash-circle',
hint: 'Todos os bloqueios',
},
{
key: 'linhas_reserva',
title: 'Linhas em Reserva',
value: this.formatInt(this.dashboardRaw?.reservas ?? this.statusResumo.reservas),
icon: 'bi bi-inboxes-fill',
hint: 'Base de reserva',
},
];
return;
}
const cards: KpiCard[] = [];
const used = new Set<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;
requestAnimationFrame(() => {
const canvases = [
this.chartStatusPie?.nativeElement,
this.chartAdicionaisComparativo?.nativeElement,
this.chartVigenciaMesAno?.nativeElement,
this.chartVigenciaSupervisao?.nativeElement,
this.chartMureg12?.nativeElement,
this.chartTroca12?.nativeElement,
this.chartLinhasPorFranquia?.nativeElement,
this.chartAdicionaisPagos?.nativeElement,
this.chartTipoChip?.nativeElement,
this.chartTravelMundo?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[];
const canvases = (
this.isCliente
? [this.chartStatusPie?.nativeElement]
: [
this.chartStatusPie?.nativeElement,
this.chartAdicionaisComparativo?.nativeElement,
this.chartVigenciaMesAno?.nativeElement,
this.chartVigenciaSupervisao?.nativeElement,
this.chartMureg12?.nativeElement,
this.chartTroca12?.nativeElement,
this.chartLinhasPorFranquia?.nativeElement,
this.chartAdicionaisPagos?.nativeElement,
this.chartTipoChip?.nativeElement,
this.chartTravelMundo?.nativeElement,
]
).filter(Boolean) as HTMLCanvasElement[];
if (!canvases.length) return;
if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
@ -1033,26 +1082,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
// 1. Status Pie
if (this.chartStatusPie?.nativeElement) {
const chartLabels = this.isCliente
? ['Ativas', 'Bloqueadas', 'Reservas']
: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'];
const chartData = this.isCliente
? [this.statusResumo.ativos, this.statusResumo.bloqueadas, this.statusResumo.reservas]
: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras,
];
const chartColors = this.isCliente
? [palette.status.ativos, palette.status.blocked, palette.status.reserve]
: [
palette.status.ativos,
palette.status.blocked,
palette.status.purple,
palette.status.reserve,
'#cbd5e1',
];
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
type: 'doughnut',
data: {
labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'],
labels: chartLabels,
datasets: [{
data: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras
],
data: chartData,
borderWidth: 0,
backgroundColor: [
palette.status.ativos,
palette.status.blocked,
palette.status.purple,
palette.status.reserve,
'#cbd5e1'
],
backgroundColor: chartColors,
hoverOffset: 4
}]
},
@ -1065,6 +1124,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
});
}
if (this.isCliente) {
return;
}
if (this.chartAdicionaisComparativo?.nativeElement) {
this.chartAdicionaisComparativoDoughnut = new Chart(this.chartAdicionaisComparativo.nativeElement, {
type: 'doughnut',

View File

@ -160,7 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
setTimeout(() => {
this.refreshData(true);

View File

@ -46,6 +46,7 @@
<button
type="button"
class="btn btn-brand btn-sm"
*ngIf="!isClientRestricted"
(click)="onCadastrarLinha()"
[disabled]="loading">
<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">
Todos
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PF'" (click)="setFilter('PF')" [disabled]="loading">
<i class="bi bi-person me-1"></i> Pessoa Física
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PJ'" (click)="setFilter('PJ')" [disabled]="loading">
<i class="bi bi-building me-1"></i> Pessoa Jurídica
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'RESERVA'" (click)="setFilter('RESERVA')" [disabled]="loading">
<i class="bi bi-archive me-1"></i> Reservas
</button>
<ng-container *ngIf="!isClientRestricted">
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PF'" (click)="setFilter('PF')" [disabled]="loading">
<i class="bi bi-person me-1"></i> Pessoa Física
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'PJ'" (click)="setFilter('PJ')" [disabled]="loading">
<i class="bi bi-building me-1"></i> Pessoa Jurídica
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'RESERVA'" (click)="setFilter('RESERVA')" [disabled]="loading">
<i class="bi bi-archive me-1"></i> Reservas
</button>
</ng-container>
</div>
<!-- CLIENTE MULTI-SELECT -->
<div class="client-filter-wrap" (click)="$event.stopPropagation()">
<div class="client-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-client-filter"
@ -123,7 +126,7 @@
</div>
</div>
<div class="additional-filter-wrap" (click)="$event.stopPropagation()">
<div class="additional-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-client-filter btn-additional-filter"
@ -211,7 +214,7 @@
<!-- KPIs -->
<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="val val-loading" *ngIf="isKpiLoading">
<span class="spinner-border spinner-border-sm text-brand"></span>
@ -302,7 +305,7 @@
<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">
<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
</button>
</div>
@ -320,7 +323,7 @@
<th>LINHA</th>
<th>USUÁRIO</th>
<th>STATUS</th>
<th>VENCIMENTO</th>
<th *ngIf="!isClientRestricted">VENCIMENTO</th>
<th>AÇÕES</th>
</tr>
</thead>
@ -333,13 +336,15 @@
<td>
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td class="text-muted small fw-bold">{{ r.contrato }}</td>
<td class="text-muted small fw-bold" *ngIf="!isClientRestricted">{{ r.contrato }}</td>
<td>
<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 success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></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 *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></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>
</td>
</tr>
@ -365,7 +370,7 @@
<col style="width: 280px;">
<col style="width: 160px;">
<col style="width: 130px;">
<col style="width: 130px;">
<col style="width: 130px;" *ngIf="!isClientRestricted">
<col style="width: 160px;">
</colgroup>
@ -416,7 +421,7 @@
</div>
</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">
VENCIMENTO
<span class="sort-caret" [class.active]="sortKey==='contrato'">
@ -431,13 +436,13 @@
<tbody>
<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...
</td>
</tr>
<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.
</td>
</tr>
@ -450,13 +455,15 @@
<span class="status-pill" [ngClass]="statusClass(r.status)" [title]="r.status || ''">{{ statusLabel(r.status) }}</span>
</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">
<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 success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></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 *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></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>
</td>
</tr>
@ -1578,7 +1585,7 @@
</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="box-header justify-content-center">
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
@ -1632,7 +1639,7 @@
</div>
</div>
<div class="dashboard-column">
<div class="dashboard-column" *ngIf="!isClientRestricted">
<div class="detail-box h-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Status</span>

View File

@ -205,6 +205,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
})();
loading = false;
isAdmin = false;
isGestor = false;
isClientRestricted = false;
rows: LineRow[] = [];
clientGroups: ClientGroupDto[] = [];
@ -544,7 +546,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isClientRestricted = !(this.isAdmin || this.isGestor);
if (this.isClientRestricted) {
this.filterSkil = 'ALL';
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
this.selectedClients = [];
}
}
async ngAfterViewInit() {
@ -553,7 +564,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => {
this.refreshData();
this.loadClients();
if (!this.isClientRestricted) {
this.loadClients();
}
this.loadPlanRules();
this.loadAccountCompanies();
@ -574,7 +587,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!url.includes('/geral')) return;
this.searchResolvedClient = null;
this.loadClients();
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
});
}
@ -915,6 +930,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') {
if (this.isClientRestricted && type !== 'ALL') return;
const isSameFilter = this.filterSkil === type;
this.expandedGroup = null;
@ -927,13 +944,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.clientSearchTerm = '';
this.searchResolvedClient = null;
this.loadClients();
if (!this.isClientRestricted) {
this.loadClients();
}
this.page = 1;
this.refreshData();
}
setAdditionalMode(mode: AdditionalMode) {
if (this.isClientRestricted) return;
if (this.additionalMode === mode) return;
this.additionalMode = mode;
@ -947,6 +967,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
toggleAdditionalService(key: AdditionalServiceKey) {
if (this.isClientRestricted) return;
const idx = this.selectedAdditionalServices.indexOf(key);
if (idx >= 0) this.selectedAdditionalServices.splice(idx, 1);
else this.selectedAdditionalServices.push(key);
@ -965,6 +986,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
clearAdditionalFilters() {
if (this.isClientRestricted) return;
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
this.expandedGroup = null;
@ -1427,11 +1449,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
toggleClientMenu() {
if (this.isClientRestricted) return;
if (!this.showClientMenu) this.showAdditionalMenu = false;
this.showClientMenu = !this.showClientMenu;
}
toggleAdditionalMenu() {
if (this.isClientRestricted) return;
if (!this.showAdditionalMenu) this.showClientMenu = false;
this.showAdditionalMenu = !this.showAdditionalMenu;
}
@ -1450,6 +1474,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
selectClient(client: string | null) {
if (this.isClientRestricted) return;
if (client === null) {
this.selectedClients = [];
} else {
@ -1465,6 +1490,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
removeClient(client: string, event: Event) {
if (this.isClientRestricted) return;
event.stopPropagation();
const idx = this.selectedClients.indexOf(client);
if (idx >= 0) {
@ -1477,6 +1503,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
clearClientSelection(event?: Event) {
if (this.isClientRestricted) return;
if (event) event.stopPropagation();
this.selectedClients = [];
this.clientSearchTerm = '';
@ -1776,7 +1803,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
async onRemover(r: LineRow, fromGroup = false) {
if (!this.isAdmin) {
await this.showToast('Apenas administradores podem remover linhas.');
await this.showToast('Apenas sysadmin pode remover linhas.');
return;
}
@ -1808,6 +1835,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onCadastrarLinha() {
if (this.isClientRestricted) {
await this.showToast('Você não tem permissão para cadastrar novos clientes.');
return;
}
this.createMode = 'NEW_CLIENT';
this.resetCreateModel();
this.createOpen = true;
@ -1815,6 +1847,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onAddLineToGroup(clientName: string) {
if (this.isClientRestricted) {
await this.showToast('Você não tem permissão para adicionar linhas.');
return;
}
this.createMode = 'NEW_LINE_IN_GROUP';
this.resetCreateModel();

View File

@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
private syncPermissions(): void {
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
}
get totalPages(): number {

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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 };
}
}

View File

@ -113,7 +113,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.isAdmin = this.authService.hasRole('sysadmin');
this.loadClients();
this.loadPlanRules();
this.fetch(1);

View File

@ -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
);
}
}

View File

@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type UserPermission = 'admin' | 'gestor';
export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
export type UserDto = {
id: string;