Feat: Novas Implementações

This commit is contained in:
Eduardo Lopes 2026-03-06 13:09:34 -03:00
parent 8fc8a1303f
commit 4b7c74195e
23 changed files with 2258 additions and 607 deletions

1064
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard'; import { authGuard } from './guards/auth.guard';
import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard'; import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard';
import { sysadminOrFinanceiroGuard } from './guards/sysadmin-or-financeiro.guard';
import { sysadminOnlyGuard } from './guards/sysadmin-only.guard'; import { sysadminOnlyGuard } from './guards/sysadmin-only.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';
@ -19,6 +20,7 @@ import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-c
import { Resumo } from './pages/resumo/resumo'; 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 { HistoricoLinhas } from './pages/historico-linhas/historico-linhas';
import { Perfil } from './pages/perfil/perfil'; import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user'; import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
@ -29,15 +31,16 @@ export const routes: Routes = [
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' }, { path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' }, { path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Faturamento' }, { path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrFinanceiroGuard], title: 'Faturamento' },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' }, { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' },
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' }, { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrFinanceiroGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' }, { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
{ path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{ {
path: 'system/fornecer-usuario', path: 'system/fornecer-usuario',

View File

@ -40,6 +40,7 @@ export class AppComponent {
'/resumo', '/resumo',
'/parcelamentos', '/parcelamentos',
'/historico', '/historico',
'/historico-linhas',
'/perfil', '/perfil',
'/system', '/system',
]; ];

View File

@ -539,15 +539,18 @@
<a *ngIf="canViewAll" 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="canViewAll" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="canViewFinancialPages" 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="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="canViewFinancialPages" 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="canViewAll" 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 *ngIf="canViewAll" routerLink="/historico-linhas" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-diagram-3"></i> <span>Histórico de Linhas</span>
</a>
<a *ngIf="canViewAll" 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>

View File

@ -34,7 +34,10 @@ export class Header implements AfterViewInit, OnDestroy {
isLoggedHeader = false; isLoggedHeader = false;
isHome = false; isHome = false;
isSysAdmin = false; isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
canViewAll = false; canViewAll = false;
canViewFinancialPages = false;
clientTenantDisplayName = ''; clientTenantDisplayName = '';
private clientTenantNameTenantId: string | null = null; private clientTenantNameTenantId: string | null = null;
private readonly baseApi: string; private readonly baseApi: string;
@ -60,7 +63,7 @@ export class Header implements AfterViewInit, OnDestroy {
readonly permissionOptions = [ readonly permissionOptions = [
{ value: 'sysadmin', label: 'SysAdmin' }, { value: 'sysadmin', label: 'SysAdmin' },
{ value: 'gestor', label: 'Gestor' }, { value: 'gestor', label: 'Gestor' },
{ value: 'cliente', label: 'Cliente' }, { value: 'financeiro', label: 'Financeiro' },
]; ];
manageUsersLoading = false; manageUsersLoading = false;
@ -93,6 +96,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/resumo', '/resumo',
'/parcelamentos', '/parcelamentos',
'/historico', '/historico',
'/historico-linhas',
'/perfil', '/perfil',
'/system', '/system',
]; ];
@ -213,15 +217,22 @@ export class Header implements AfterViewInit, OnDestroy {
private syncPermissions() { private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) { if (!isPlatformBrowser(this.platformId)) {
this.isSysAdmin = false; this.isSysAdmin = false;
this.isGestor = false;
this.isFinanceiro = false;
this.canViewAll = false; this.canViewAll = false;
this.canViewFinancialPages = false;
this.clientTenantDisplayName = ''; this.clientTenantDisplayName = '';
this.clientTenantNameTenantId = null; this.clientTenantNameTenantId = null;
return; return;
} }
const isSysAdmin = this.authService.hasRole('sysadmin'); const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor'); const isGestor = this.authService.hasRole('gestor');
const isFinanceiro = this.authService.hasRole('financeiro');
this.isSysAdmin = isSysAdmin; this.isSysAdmin = isSysAdmin;
this.canViewAll = isSysAdmin || isGestor; this.isGestor = isGestor;
this.isFinanceiro = isFinanceiro;
this.canViewAll = isSysAdmin || isGestor || isFinanceiro;
this.canViewFinancialPages = isSysAdmin || isFinanceiro;
if (!this.isClientHeader) { if (!this.isClientHeader) {
this.clientTenantDisplayName = ''; this.clientTenantDisplayName = '';
@ -497,7 +508,10 @@ export class Header implements AfterViewInit, OnDestroy {
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false; this.notificationsOpen = false;
this.isSysAdmin = false; this.isSysAdmin = false;
this.isGestor = false;
this.isFinanceiro = false;
this.canViewAll = false; this.canViewAll = false;
this.canViewFinancialPages = false;
this.router.navigate(['/']); this.router.navigate(['/']);
} }

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 sysadminOrFinanceiroGuard: CanActivateFn = () => {
const router = inject(Router);
const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
if (!isPlatformBrowser(platformId)) {
// Em SSR não há storage do usuário para validar sessão/perfil.
return true;
}
const token = authService.token;
if (!token) {
return router.parseUrl('/login');
}
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('financeiro');
if (!hasAccess) {
return router.parseUrl('/dashboard');
}
return true;
};

View File

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

View File

@ -370,7 +370,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const isSysAdmin = this.authService.hasRole('sysadmin'); const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor'); const isGestor = this.authService.hasRole('gestor');
this.isCliente = !(isSysAdmin || isGestor); const isFinanceiro = this.authService.hasRole('financeiro');
this.isCliente = !(isSysAdmin || isGestor || isFinanceiro);
if (this.isCliente) { if (this.isCliente) {
this.loadClientDashboardData(); this.loadClientDashboardData();

View File

@ -67,7 +67,7 @@
<button <button
type="button" type="button"
class="btn btn-brand btn-sm align-self-start" class="btn btn-brand btn-sm align-self-start"
*ngIf="!isClientRestricted" *ngIf="canManageLines"
(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
@ -296,7 +296,7 @@
</div> </div>
</div> </div>
<div class="batch-status-tools" *ngIf="!isClientRestricted"> <div class="batch-status-tools" *ngIf="canManageLines">
<span class="batch-status-count"> <span class="batch-status-count">
Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong> Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong>
</span> </span>
@ -387,7 +387,7 @@
<i class="bi bi-box-arrow-left me-1"></i> Enviar p/ Reserva ({{ reservaSelectedCount }}) <i class="bi bi-box-arrow-left me-1"></i> Enviar p/ Reserva ({{ reservaSelectedCount }})
</button> </button>
</ng-container> </ng-container>
<button class="btn btn-sm btn-add-line-group" *ngIf="!isClientRestricted" (click)="onAddLineToGroup(group.cliente)"> <button class="btn btn-sm btn-add-line-group" *ngIf="canManageLines" (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>
@ -452,7 +452,7 @@
<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 primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button *ngIf="!isFinanceiro" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<ng-container *ngIf="!isClientRestricted"> <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 success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
@ -571,7 +571,7 @@
<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 primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button *ngIf="!isFinanceiro" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<ng-container *ngIf="!isClientRestricted"> <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 success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>

View File

@ -341,6 +341,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
exporting = false; exporting = false;
isSysAdmin = false; isSysAdmin = false;
isGestor = false; isGestor = false;
isFinanceiro = false;
isClientRestricted = false; isClientRestricted = false;
rows: LineRow[] = []; rows: LineRow[] = [];
@ -644,7 +645,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
get hasGroupLineSelectionTools(): boolean { get hasGroupLineSelectionTools(): boolean {
return !this.isClientRestricted && !!(this.expandedGroup ?? '').trim(); return this.canManageLines && !!(this.expandedGroup ?? '').trim();
}
get canManageLines(): boolean {
return this.isSysAdmin || this.isGestor;
} }
get canMoveSelectedLinesToReserva(): boolean { get canMoveSelectedLinesToReserva(): boolean {
@ -660,7 +665,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
get canOpenBatchStatusModal(): boolean { get canOpenBatchStatusModal(): boolean {
if (this.isClientRestricted) return false; if (!this.canManageLines) return false;
if (this.loading || this.batchStatusSaving) return false; if (this.loading || this.batchStatusSaving) return false;
return this.batchStatusSelectionCount > 0; return this.batchStatusSelectionCount > 0;
} }
@ -810,7 +815,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor'); this.isGestor = this.authService.hasRole('gestor');
this.isClientRestricted = !(this.isSysAdmin || this.isGestor); this.isFinanceiro = this.authService.hasRole('financeiro');
this.isClientRestricted = !(this.isSysAdmin || this.isGestor || this.isFinanceiro);
if (this.isClientRestricted) { if (this.isClientRestricted) {
this.filterSkil = 'ALL'; this.filterSkil = 'ALL';
@ -2176,6 +2182,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async onEditar(r: LineRow) { async onEditar(r: LineRow) {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.editOpen = true; this.editOpen = true;
this.editSaving = false; this.editSaving = false;
this.editModel = null; this.editModel = null;
@ -2255,6 +2266,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async saveEdit() { async saveEdit() {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editingId || !this.editModel) return; if (!this.editingId || !this.editModel) return;
this.editSaving = true; this.editSaving = true;
@ -2485,7 +2501,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async onCadastrarLinha() { async onCadastrarLinha() {
if (this.isClientRestricted) { if (!this.canManageLines) {
await this.showToast('Você não tem permissão para cadastrar novos clientes.'); await this.showToast('Você não tem permissão para cadastrar novos clientes.');
return; return;
} }
@ -2498,7 +2514,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async onAddLineToGroup(clientName: string) { async onAddLineToGroup(clientName: string) {
if (this.isClientRestricted) { if (!this.canManageLines) {
await this.showToast('Você não tem permissão para adicionar linhas.'); await this.showToast('Você não tem permissão para adicionar linhas.');
return; return;
} }
@ -3481,7 +3497,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async openBatchStatusModal(action: BatchStatusAction) { async openBatchStatusModal(action: BatchStatusAction) {
if (this.isClientRestricted) { if (!this.canManageLines) {
await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.'); await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.');
return; return;
} }

View File

@ -0,0 +1,278 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-danger border-0 shadow" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header border-bottom-0">
<strong class="me-auto text-primary">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark">
{{ toastMessage }}
</div>
</div>
</div>
<section class="historico-linhas-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>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-diagram-3-fill"></i> Linha
</div>
<div class="header-title">
<h5 class="title mb-0">Histórico de Linhas</h5>
<small class="subtitle">Timeline completa das alterações feitas em uma linha específica.</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading || !hasLineFilter">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting || !hasLineFilter">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
</div>
<div class="filters-card mt-4">
<div class="filters-head">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros</span>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="applyFilters()" [disabled]="loading">
<i class="bi bi-check2"></i> Aplicar
</button>
<button class="btn-ghost" type="button" (click)="clearFilters()" [disabled]="loading">
<i class="bi bi-x-circle"></i> Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field line-field">
<label>Linha (obrigatório)</label>
<input
type="text"
inputmode="numeric"
placeholder="Ex.: 11988887777"
[(ngModel)]="filterLine"
[disabled]="loading"
/>
</div>
<div class="filter-field">
<label>Origem</label>
<app-select
class="select-glass"
size="sm"
[options]="pageOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterPageName"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Ação</label>
<app-select
class="select-glass"
size="sm"
[options]="actionOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterAction"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Usuário</label>
<input type="text" placeholder="Nome ou e-mail" [(ngModel)]="filterUser" [disabled]="loading" />
</div>
<div class="filter-field period-field">
<label>Período (De)</label>
<input type="date" [(ngModel)]="dateFrom" [disabled]="loading" />
</div>
<div class="filter-field period-field">
<label>Período (Até)</label>
<input type="date" [(ngModel)]="dateTo" [disabled]="loading" />
</div>
</div>
</div>
<div class="kpi-grid mt-3" *ngIf="logs.length > 0">
<div class="kpi-card">
<span class="kpi-label">Eventos (filtro)</span>
<strong class="kpi-value">{{ total }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Status (página)</span>
<strong class="kpi-value">{{ statusCountInPage }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Trocas de Número (página)</span>
<strong class="kpi-value">{{ trocaCountInPage }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Mureg (página)</span>
<strong class="kpi-value">{{ muregCountInPage }}</strong>
</div>
</div>
</div>
<div class="geral-body">
<div class="table-wrap">
<div class="empty-group helper" *ngIf="!loading && !error && !hasLineFilter">
Informe a linha no filtro para carregar o histórico detalhado.
</div>
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="alert alert-danger m-4" role="alert" *ngIf="!loading && error">
{{ errorMsg || 'Erro ao carregar histórico da linha.' }}
<button class="btn btn-sm btn-outline-danger ms-3" type="button" (click)="refresh()">Tentar novamente</button>
</div>
<div class="empty-group" *ngIf="!loading && !error && hasLineFilter && logs.length === 0">
Nenhuma alteração encontrada para a linha informada.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !error && logs.length > 0">
<thead>
<tr>
<th>Data/Hora</th>
<th>Usuário</th>
<th>Origem</th>
<th>Ação</th>
<th>Resumo da alteração</th>
<th class="actions-col">Detalhes</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let log of logs; trackBy: trackByLog">
<tr class="table-row-item" [class.expanded]="expandedLogId === log.id">
<td class="fw-bold text-muted">{{ formatDateTime(log.occurredAtUtc) }}</td>
<td>
<div class="user-cell">
<span class="user-name">{{ displayUserName(log) }}</span>
<small class="user-email">{{ log.userEmail || '-' }}</small>
</div>
</td>
<td>
<span class="origin-pill">{{ log.page || '-' }}</span>
</td>
<td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td>
<td class="summary-col">
<ng-container *ngIf="summaryFor(log) as summary">
<div class="summary-title" [ngClass]="toneClass(summary.tone)">{{ summary.title }}</div>
<div class="summary-description">{{ summary.description }}</div>
<div class="summary-diff" *ngIf="summary.before || summary.after">
<span class="old">{{ formatChangeValue(summary.before) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(summary.after) }}</span>
</div>
<div class="summary-ddd" *ngIf="summary.beforeDdd || summary.afterDdd">
DDD: {{ formatChangeValue(summary.beforeDdd) }} <i class="bi bi-arrow-right"></i> {{ formatChangeValue(summary.afterDdd) }}
</div>
</ng-container>
</td>
<td class="actions-col">
<button
class="expand-btn"
type="button"
(click)="toggleDetails(log, $event)"
[attr.aria-expanded]="expandedLogId === log.id"
[attr.aria-label]="expandedLogId === log.id ? 'Fechar detalhes' : 'Abrir detalhes'">
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
</button>
</td>
</tr>
<tr class="details-row" *ngIf="expandedLogId === log.id">
<td colspan="6">
<div class="details-panel">
<div class="details-section">
<div class="section-title">
<i class="bi bi-pencil-square"></i> Mudanças de campos
</div>
<ng-container *ngIf="visibleChanges(log) as changes">
<div class="changes-list" *ngIf="changes.length; else noChanges">
<div class="change-item" *ngFor="let change of changes; trackBy: trackByField">
<div class="change-head">
<span class="change-field">{{ change.field }}</span>
<span class="change-type" [ngClass]="changeTypeClass(change.changeType)">
{{ changeTypeLabel(change.changeType) }}
</span>
</div>
<div class="change-values">
<span class="old">{{ formatChangeValue(change.oldValue) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(change.newValue) }}</span>
</div>
</div>
</div>
</ng-container>
<ng-template #noChanges>
<div class="empty-state">Sem mudanças detalhadas nesse evento.</div>
</ng-template>
</div>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div class="geral-footer">
<div class="footer-meta">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} registros</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<app-select
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="pageSize"
(ngModelChange)="onPageSizeChange()"
[disabled]="loading">
</app-select>
</div>
</div>
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading">
<button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="page === totalPages || loading">
<button class="page-link" (click)="goToPage(page + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,648 @@
:host {
--brand: #e33dcf;
--brand-soft: rgba(227, 61, 207, 0.12);
--blue: #030faa;
--text: #111214;
--muted: rgba(17, 18, 20, 0.64);
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: #ffffff;
--line: rgba(15, 23, 42, 0.11);
--radius-xl: 22px;
--radius-lg: 16px;
--shadow-card: 0 20px 44px rgba(17, 18, 20, 0.1);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
.historico-linhas-page {
min-height: 100vh;
padding: 0 12px;
display: flex;
align-items: flex-start;
justify-content: center;
position: relative;
overflow-y: auto;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 255, 255, 0.25);
}
}
.page-blob {
position: fixed;
pointer-events: none;
border-radius: 999px;
filter: blur(34px);
opacity: 0.55;
z-index: 0;
background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.55), rgba(227, 61, 207, 0.06));
animation: floaty 10s ease-in-out infinite;
&.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; }
&.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; }
&.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; }
&.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: 0.45; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
.container-geral-responsive {
width: 98% !important;
max-width: 1500px !important;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 200px;
}
.geral-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface);
border: 1px solid rgba(227, 61, 207, 0.16);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
min-height: 80vh;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: calc(var(--radius-xl) - 1px);
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.65);
opacity: 0.75;
}
}
.geral-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2));
flex-shrink: 0;
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
text-align: center;
gap: 14px;
.title-badge { justify-self: center; }
.header-actions { justify-self: center; }
}
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(227, 61, 207, 0.22);
color: var(--text);
font-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
text-align: center;
}
.title {
font-size: 26px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
}
.subtitle {
color: var(--muted);
font-weight: 700;
}
.header-actions {
justify-self: end;
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
filter: brightness(1.05);
}
}
.btn-glass {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(17, 18, 20, 0.16);
color: rgba(17, 18, 20, 0.85);
border-radius: 12px;
font-weight: 700;
}
.filters-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 14px;
box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08);
}
.filters-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 900;
font-size: 14px;
color: rgba(17, 18, 20, 0.82);
}
.filters-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
}
.filter-field {
display: grid;
gap: 6px;
grid-column: span 2;
min-width: 0;
label {
font-size: 11px;
font-weight: 800;
color: rgba(17, 18, 20, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input {
width: 100%;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15);
padding: 0 12px;
font-size: 14px;
background: #fff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12);
}
}
.line-field {
grid-column: span 4;
}
.period-field {
grid-column: span 3;
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: 10px;
border: none;
font-weight: 700;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
}
.btn-primary {
background: linear-gradient(135deg, var(--brand), #bc30ac);
color: #fff;
box-shadow: 0 8px 16px rgba(227, 61, 207, 0.24);
}
.btn-ghost {
background: rgba(15, 23, 42, 0.06);
color: rgba(15, 23, 42, 0.85);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
background: var(--surface-strong);
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 14px;
padding: 12px 14px;
display: grid;
gap: 6px;
box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06);
}
.kpi-label {
font-size: 12px;
font-weight: 700;
color: rgba(17, 18, 20, 0.62);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kpi-value {
font-size: 22px;
line-height: 1;
font-weight: 900;
color: var(--blue);
}
.geral-body {
padding: 18px 24px;
flex: 1;
}
.table-wrap {
width: 100%;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 14px;
overflow: hidden;
}
.table-modern {
margin: 0;
min-width: 980px;
thead th {
background: linear-gradient(180deg, rgba(3, 15, 170, 0.92), rgba(3, 15, 170, 0.82));
color: #fff;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
border: none;
padding: 12px;
white-space: nowrap;
}
tbody td {
border-top: 1px solid rgba(15, 23, 42, 0.08);
vertical-align: middle;
padding: 12px;
background: rgba(255, 255, 255, 0.92);
}
tbody tr.table-row-item:hover td {
background: rgba(227, 61, 207, 0.05);
}
tbody tr.table-row-item.expanded td {
background: rgba(227, 61, 207, 0.08);
}
}
.user-cell {
display: grid;
line-height: 1.2;
}
.user-name {
font-weight: 800;
}
.user-email {
color: rgba(17, 18, 20, 0.55);
}
.origin-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
background: rgba(3, 15, 170, 0.1);
border: 1px solid rgba(3, 15, 170, 0.2);
color: rgba(3, 15, 170, 0.88);
font-size: 12px;
font-weight: 700;
}
.badge-action {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 800;
border: 1px solid transparent;
&.action-create {
color: #157347;
background: rgba(25, 135, 84, 0.12);
border-color: rgba(25, 135, 84, 0.24);
}
&.action-update {
color: #0a58ca;
background: rgba(13, 110, 253, 0.12);
border-color: rgba(13, 110, 253, 0.24);
}
&.action-delete {
color: #b02a37;
background: rgba(220, 53, 69, 0.12);
border-color: rgba(220, 53, 69, 0.24);
}
&.action-default {
color: #495057;
background: rgba(108, 117, 125, 0.12);
border-color: rgba(108, 117, 125, 0.24);
}
}
.summary-col {
min-width: 360px;
}
.summary-title {
font-size: 13px;
font-weight: 900;
margin-bottom: 2px;
}
.summary-description {
font-size: 12px;
color: rgba(17, 18, 20, 0.66);
}
.summary-diff {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.05);
padding: 4px 8px;
.old {
color: #b02a37;
font-weight: 700;
}
.new {
color: #157347;
font-weight: 700;
}
}
.summary-ddd {
margin-top: 5px;
font-size: 11px;
color: rgba(17, 18, 20, 0.62);
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 6px;
}
.tone-mureg { color: #005f73; }
.tone-troca { color: #6f42c1; }
.tone-status { color: #0a58ca; }
.tone-linha { color: #0d6efd; }
.tone-chip { color: #198754; }
.tone-generic { color: #495057; }
.actions-col {
width: 84px;
text-align: center;
}
.expand-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15);
background: #fff;
color: rgba(15, 23, 42, 0.85);
}
.details-row td {
background: rgba(255, 255, 255, 0.94);
}
.details-panel {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.details-section {
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 12px;
background: #fff;
padding: 10px 12px;
}
.section-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 800;
color: rgba(17, 18, 20, 0.84);
margin-bottom: 8px;
}
.changes-list {
display: grid;
gap: 8px;
}
.change-item {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
padding: 8px;
background: rgba(248, 249, 250, 0.85);
}
.change-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.change-field {
font-size: 12px;
font-weight: 800;
color: #111;
}
.change-type {
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
font-weight: 800;
border: 1px solid transparent;
&.change-added {
background: rgba(25, 135, 84, 0.12);
color: #157347;
border-color: rgba(25, 135, 84, 0.24);
}
&.change-removed {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
border-color: rgba(220, 53, 69, 0.24);
}
&.change-modified {
background: rgba(13, 110, 253, 0.12);
color: #0a58ca;
border-color: rgba(13, 110, 253, 0.24);
}
}
.change-values {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
.old {
color: #b02a37;
font-weight: 700;
}
.new {
color: #157347;
font-weight: 700;
}
}
.empty-state {
font-size: 13px;
color: rgba(17, 18, 20, 0.62);
font-weight: 600;
}
.empty-group {
text-align: center;
padding: 28px;
color: rgba(17, 18, 20, 0.65);
font-weight: 700;
}
.empty-group.helper {
background: rgba(3, 15, 170, 0.05);
border-bottom: 1px solid rgba(3, 15, 170, 0.12);
}
.geral-footer {
display: none;
}
.footer-meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.pagination-modern .page-link {
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15);
color: rgba(15, 23, 42, 0.85);
margin: 0 2px;
}
.pagination-modern .page-item.active .page-link {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
@media (max-width: 1200px) {
.filters-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.filter-field { grid-column: span 2; }
.line-field { grid-column: span 3; }
.period-field { grid-column: span 3; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.details-panel { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.geral-header,
.geral-body,
.geral-footer {
padding-left: 14px;
padding-right: 14px;
}
.filters-grid { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.filter-field,
.line-field {
grid-column: span 1;
}
.kpi-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,598 @@
import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import {
HistoricoService,
AuditLogDto,
AuditChangeType,
AuditFieldChangeDto,
LineHistoricoQuery
} from '../../services/historico.service';
import { TableExportService } from '../../services/table-export.service';
interface SelectOption {
value: string;
label: string;
}
type EventTone = 'mureg' | 'troca' | 'status' | 'linha' | 'chip' | 'generic';
interface EventSummary {
title: string;
description: string;
before?: string | null;
after?: string | null;
beforeDdd?: string | null;
afterDdd?: string | null;
tone: EventTone;
}
@Component({
selector: 'app-historico-linhas',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './historico-linhas.html',
styleUrls: ['./historico-linhas.scss'],
})
export class HistoricoLinhas implements OnInit {
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
logs: AuditLogDto[] = [];
loading = false;
exporting = false;
error = false;
errorMsg = '';
toastMessage = '';
expandedLogId: string | null = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
filterLine = '';
filterPageName = '';
filterAction = '';
filterUser = '';
dateFrom = '';
dateTo = '';
readonly pageOptions: SelectOption[] = [
{ value: '', label: 'Todas as origens' },
{ value: 'Geral', label: 'Geral' },
{ value: 'Mureg', label: 'Mureg' },
{ value: 'Troca de número', label: 'Troca de número' },
{ value: 'Vigência', label: 'Vigência' },
{ value: 'Parcelamentos', label: 'Parcelamentos' },
];
readonly actionOptions: SelectOption[] = [
{ value: '', label: 'Todas as ações' },
{ value: 'CREATE', label: 'Criação' },
{ value: 'UPDATE', label: 'Atualização' },
{ value: 'DELETE', label: 'Exclusão' },
];
private readonly summaryCache = new Map<string, EventSummary>();
private readonly idFieldExceptions = new Set<string>(['iccid']);
constructor(
private readonly historicoService: HistoricoService,
private readonly cdr: ChangeDetectorRef,
@Inject(PLATFORM_ID) private readonly platformId: object,
private readonly tableExportService: TableExportService
) {}
ngOnInit(): void {
// Tela inicia aguardando o usuário informar a linha.
}
applyFilters(): void {
this.page = 1;
this.fetch();
}
refresh(): void {
this.fetch();
}
clearFilters(): void {
this.filterLine = '';
this.filterPageName = '';
this.filterAction = '';
this.filterUser = '';
this.dateFrom = '';
this.dateTo = '';
this.page = 1;
this.logs = [];
this.total = 0;
this.error = false;
this.errorMsg = '';
this.summaryCache.clear();
}
onPageSizeChange(): void {
this.page = 1;
this.fetch();
}
goToPage(target: number): void {
this.page = Math.max(1, Math.min(this.totalPages, target));
this.fetch();
}
toggleDetails(log: AuditLogDto, event?: Event): void {
if (event) event.stopPropagation();
this.expandedLogId = this.expandedLogId === log.id ? null : log.id;
}
async onExport(): Promise<void> {
if (this.exporting) return;
const lineTerm = this.normalizedLineTerm;
if (!lineTerm) {
await this.showToast('Informe a linha para exportar.');
return;
}
this.exporting = true;
try {
const allLogs = await this.fetchAllLogsForExport();
if (!allLogs.length) {
await this.showToast('Nenhum evento encontrado para exportar.');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<AuditLogDto>({
fileName: `historico_linhas_${timestamp}`,
sheetName: 'HistoricoLinhas',
rows: allLogs,
columns: [
{ header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' },
{ header: 'Usuario', value: (log) => this.displayUserName(log) },
{ header: 'E-mail', value: (log) => log.userEmail ?? '' },
{ header: 'Origem', value: (log) => log.page ?? '' },
{ header: 'Acao', value: (log) => this.formatAction(log.action) },
{ header: 'Evento', value: (log) => this.summaryFor(log).title },
{ header: 'Resumo', value: (log) => this.summaryFor(log).description },
{ header: 'Valor Anterior', value: (log) => this.summaryFor(log).before ?? '' },
{ header: 'Valor Novo', value: (log) => this.summaryFor(log).after ?? '' },
{ header: 'DDD Anterior', value: (log) => this.summaryFor(log).beforeDdd ?? '' },
{ header: 'DDD Novo', value: (log) => this.summaryFor(log).afterDdd ?? '' },
{ header: 'Mudancas', value: (log) => this.formatChangesSummary(log) },
],
});
await this.showToast(`Planilha exportada com ${allLogs.length} evento(s).`);
} catch {
await this.showToast('Erro ao exportar histórico de linhas.');
} finally {
this.exporting = false;
}
}
formatDateTime(value?: string | null): string {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return '-';
return dt.toLocaleString('pt-BR');
}
displayUserName(log: AuditLogDto): string {
const name = (log.userName || '').trim();
return name ? name : 'SISTEMA';
}
formatAction(action?: string | null): string {
const value = (action || '').toUpperCase();
if (!value) return '-';
if (value === 'CREATE') return 'Criação';
if (value === 'UPDATE') return 'Atualização';
if (value === 'DELETE') return 'Exclusão';
return 'Outro';
}
actionClass(action?: string | null): string {
const value = (action || '').toUpperCase();
if (value === 'CREATE') return 'action-create';
if (value === 'UPDATE') return 'action-update';
if (value === 'DELETE') return 'action-delete';
return 'action-default';
}
changeTypeLabel(type?: AuditChangeType | string | null): string {
if (!type) return 'Alterado';
if (type === 'added') return 'Adicionado';
if (type === 'removed') return 'Removido';
return 'Alterado';
}
changeTypeClass(type?: AuditChangeType | string | null): string {
if (type === 'added') return 'change-added';
if (type === 'removed') return 'change-removed';
return 'change-modified';
}
formatChangeValue(value?: string | null): string {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
summaryFor(log: AuditLogDto): EventSummary {
const cached = this.summaryCache.get(log.id);
if (cached) return cached;
const summary = this.buildEventSummary(log);
this.summaryCache.set(log.id, summary);
return summary;
}
toneClass(tone: EventTone): string {
return `tone-${tone}`;
}
trackByLog(_: number, log: AuditLogDto): string {
return log.id;
}
trackByField(_: number, change: AuditFieldChangeDto): string {
return `${change.field}-${change.oldValue ?? ''}-${change.newValue ?? ''}`;
}
visibleChanges(log: AuditLogDto): AuditFieldChangeDto[] {
return this.publicChanges(log);
}
get normalizedLineTerm(): string {
return (this.filterLine || '').trim();
}
get hasLineFilter(): boolean {
return !!this.normalizedLineTerm;
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
}
get pageNumbers(): number[] {
const total = this.totalPages;
const current = this.page;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages: number[] = [];
for (let i = start; i <= end; i += 1) pages.push(i);
return pages;
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
get statusCountInPage(): number {
return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length;
}
get trocaCountInPage(): number {
return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length;
}
get muregCountInPage(): number {
return this.logs.filter((log) => this.summaryFor(log).tone === 'mureg').length;
}
private fetch(): void {
const lineTerm = this.normalizedLineTerm;
if (!lineTerm) {
this.logs = [];
this.total = 0;
this.error = true;
this.errorMsg = 'Informe a linha para consultar o histórico.';
this.loading = false;
this.summaryCache.clear();
return;
}
this.loading = true;
this.error = false;
this.errorMsg = '';
this.expandedLogId = null;
const query: LineHistoricoQuery = {
...this.buildBaseQuery(),
line: lineTerm,
page: this.page,
pageSize: this.pageSize,
};
this.historicoService.listByLine(query).subscribe({
next: (res) => {
this.logs = res.items || [];
this.total = res.total || 0;
this.page = res.page || this.page;
this.pageSize = res.pageSize || this.pageSize;
this.loading = false;
this.rebuildSummaryCache();
},
error: (err: HttpErrorResponse) => {
this.loading = false;
this.error = true;
this.logs = [];
this.total = 0;
this.summaryCache.clear();
if (err?.status === 400) {
this.errorMsg = err?.error?.message || 'Informe uma linha válida.';
return;
}
if (err?.status === 403) {
this.errorMsg = 'Acesso restrito.';
return;
}
this.errorMsg = 'Erro ao carregar histórico da linha. Tente novamente.';
}
});
}
private async fetchAllLogsForExport(): Promise<AuditLogDto[]> {
const lineTerm = this.normalizedLineTerm;
if (!lineTerm) return [];
const pageSize = 500;
let page = 1;
let expectedTotal = 0;
const all: AuditLogDto[] = [];
while (page <= 500) {
const response = await firstValueFrom(
this.historicoService.listByLine({
...this.buildBaseQuery(),
line: lineTerm,
page,
pageSize,
})
);
const items = response?.items ?? [];
expectedTotal = response?.total ?? 0;
all.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && all.length >= expectedTotal) break;
page += 1;
}
return all;
}
private buildBaseQuery(): Omit<LineHistoricoQuery, 'line' | 'page' | 'pageSize'> {
return {
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
user: this.filterUser?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
}
private rebuildSummaryCache(): void {
this.summaryCache.clear();
this.logs.forEach((log) => {
this.summaryCache.set(log.id, this.buildEventSummary(log));
});
}
private buildEventSummary(log: AuditLogDto): EventSummary {
const page = (log.page || '').toLowerCase();
const entity = (log.entityName || '').toLowerCase();
const linhaChange = this.findChange(log, 'linha');
const statusChange = this.findChange(log, 'status');
const chipChange = this.findChange(log, 'chip', 'iccid');
const linhaAntiga = this.findChange(log, 'linhaantiga');
const linhaNova = this.findChange(log, 'linhanova');
const muregLike = entity === 'muregline' || page.includes('mureg');
if (muregLike) {
const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue);
const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue);
return {
title: 'Troca de Mureg',
description: 'Linha alterada no fluxo de Mureg.',
before,
after,
beforeDdd: this.extractDdd(before),
afterDdd: this.extractDdd(after),
tone: 'mureg',
};
}
const trocaLike = entity === 'trocanumeroline' || page.includes('troca');
if (trocaLike) {
const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue);
const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue);
return {
title: 'Troca de Número',
description: 'Linha antiga substituída por uma nova.',
before,
after,
beforeDdd: this.extractDdd(before),
afterDdd: this.extractDdd(after),
tone: 'troca',
};
}
if (statusChange) {
const oldStatus = this.firstFilled(statusChange.oldValue);
const newStatus = this.firstFilled(statusChange.newValue);
const wasBlocked = this.isBlockedStatus(oldStatus);
const isBlocked = this.isBlockedStatus(newStatus);
let description = 'Status da linha atualizado.';
if (!wasBlocked && isBlocked) description = 'Linha foi bloqueada.';
if (wasBlocked && !isBlocked) description = 'Linha foi desbloqueada.';
return {
title: 'Status da Linha',
description,
before: oldStatus,
after: newStatus,
tone: 'status',
};
}
if (linhaChange) {
return {
title: 'Alteração da Linha',
description: 'Número da linha foi atualizado.',
before: this.firstFilled(linhaChange.oldValue),
after: this.firstFilled(linhaChange.newValue),
beforeDdd: this.extractDdd(linhaChange.oldValue),
afterDdd: this.extractDdd(linhaChange.newValue),
tone: 'linha',
};
}
if (chipChange) {
return {
title: 'Alteração de Chip',
description: 'ICCID/chip atualizado na linha.',
before: this.firstFilled(chipChange.oldValue),
after: this.firstFilled(chipChange.newValue),
tone: 'chip',
};
}
const first = this.publicChanges(log)[0];
if (first) {
return {
title: 'Outras alterações',
description: `Campo ${first.field} foi atualizado.`,
before: this.firstFilled(first.oldValue),
after: this.firstFilled(first.newValue),
tone: 'generic',
};
}
return {
title: 'Sem detalhes',
description: 'Não há mudanças detalhadas registradas para este evento.',
tone: 'generic',
};
}
private findChange(log: AuditLogDto, ...fields: string[]): AuditFieldChangeDto | null {
if (!fields.length) return null;
const normalizedTargets = new Set(fields.map((field) => this.normalizeField(field)));
return (log.changes || []).find((change) => normalizedTargets.has(this.normalizeField(change.field))) || null;
}
private normalizeField(value?: string | null): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.trim();
}
private firstFilled(...values: Array<string | null | undefined>): string | null {
for (const value of values) {
const normalized = (value ?? '').toString().trim();
if (normalized) return normalized;
}
return null;
}
private formatChangesSummary(log: AuditLogDto): string {
const changes = this.publicChanges(log);
if (!changes.length) return '';
return changes
.map((change) => {
const field = change?.field ?? 'campo';
const oldValue = this.formatChangeValue(change?.oldValue);
const newValue = this.formatChangeValue(change?.newValue);
return `${field}: ${oldValue} -> ${newValue}`;
})
.join(' | ');
}
private publicChanges(log: AuditLogDto): AuditFieldChangeDto[] {
return (log?.changes ?? []).filter((change) => !this.isHiddenIdField(change?.field));
}
private isHiddenIdField(field?: string | null): boolean {
const normalized = this.normalizeField(field);
if (!normalized) return false;
if (this.idFieldExceptions.has(normalized)) return false;
if (normalized === 'id') return true;
return normalized.endsWith('id');
}
private isBlockedStatus(status?: string | null): boolean {
const normalized = (status ?? '').toLowerCase().trim();
if (!normalized) return false;
return (
normalized.includes('bloque') ||
normalized.includes('perda') ||
normalized.includes('roubo') ||
normalized.includes('suspens')
);
}
private extractDdd(value?: string | null): string | null {
const digits = this.digitsOnly(value);
if (!digits) return null;
if (digits.startsWith('55') && digits.length >= 12) {
return digits.slice(2, 4);
}
if (digits.length >= 10) {
return digits.slice(0, 2);
}
if (digits.length >= 2) {
return digits.slice(0, 2);
}
return null;
}
private digitsOnly(value?: string | null): string {
return (value ?? '').replace(/\D/g, '');
}
private toIsoDate(value: string, endOfDay: boolean): string | null {
if (!value) return null;
const time = endOfDay ? '23:59:59' : '00:00:00';
const date = new Date(`${value}T${time}`);
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
private async showToast(message: string): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
this.toastMessage = message;
this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return;
try {
const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, {
autohide: true,
delay: 3000
});
toastInstance.show();
} catch (error) {
console.error(error);
}
}
}

View File

@ -35,7 +35,7 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading"> <button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg <i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button> </button>
</div> </div>
@ -177,10 +177,10 @@
<button class="btn-icon info" (click)="onView(r)" title="Ver Detalhes"> <button class="btn-icon info" (click)="onView(r)" title="Ver Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro"> <button *ngIf="canManageRecords" class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
</button> </button>
<button class="btn-icon danger" (click)="onDelete(r)" title="Excluir Registro"> <button *ngIf="canManageRecords" class="btn-icon danger" (click)="onDelete(r)" title="Excluir Registro">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@ -391,8 +391,8 @@
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Data Mureg</label> <label>Data Mureg (automática)</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" /> <input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" readonly />
</div> </div>
<!-- snapshot --> <!-- snapshot -->

View File

@ -11,6 +11,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service'; import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service'; import { TableExportService } from '../../services/table-export.service';
@ -105,6 +106,7 @@ export class Mureg implements AfterViewInit {
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient, private http: HttpClient,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private authService: AuthService,
private linesService: LinesService, private linesService: LinesService,
private tableExportService: TableExportService private tableExportService: TableExportService
) {} ) {}
@ -177,9 +179,20 @@ export class Mureg implements AfterViewInit {
clienteInfo: '' clienteInfo: ''
}; };
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
async ngAfterViewInit() { async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => { setTimeout(() => {
this.preloadClients(); // ✅ já deixa o select pronto this.preloadClients(); // ✅ já deixa o select pronto
this.refresh(); this.refresh();
@ -624,6 +637,11 @@ export class Mureg implements AfterViewInit {
// CREATE MODAL // CREATE MODAL
// ======================================================================= // =======================================================================
onCreate() { onCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.preloadClients(); this.preloadClients();
this.createOpen = true; this.createOpen = true;
@ -636,7 +654,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: '', linhaAntiga: '',
linhaNova: '', linhaNova: '',
iccid: '', iccid: '',
dataDaMureg: '', dataDaMureg: this.nowDateInput(),
clienteInfo: '' clienteInfo: ''
}; };
@ -663,6 +681,11 @@ export class Mureg implements AfterViewInit {
} }
saveCreate() { saveCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
const mobileLineId = String(this.createModel.mobileLineId ?? '').trim(); const mobileLineId = String(this.createModel.mobileLineId ?? '').trim();
const linhaNova = String(this.createModel.linhaNova ?? '').trim(); const linhaNova = String(this.createModel.linhaNova ?? '').trim();
@ -679,7 +702,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: (this.createModel.linhaAntiga ?? '') || null, linhaAntiga: (this.createModel.linhaAntiga ?? '') || null,
linhaNova: (this.createModel.linhaNova ?? '') || null, linhaNova: (this.createModel.linhaNova ?? '') || null,
iccid: (this.createModel.iccid ?? '') || null, iccid: (this.createModel.iccid ?? '') || null,
dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg) dataDaMureg: new Date().toISOString()
}; };
if (!payload.item || payload.item <= 0) delete payload.item; if (!payload.item || payload.item <= 0) delete payload.item;
@ -703,6 +726,11 @@ export class Mureg implements AfterViewInit {
// EDIT MODAL // EDIT MODAL
// ======================================================================= // =======================================================================
onEditar(r: MuregRow) { onEditar(r: MuregRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.preloadClients(); this.preloadClients();
this.editOpen = true; this.editOpen = true;
@ -770,6 +798,11 @@ export class Mureg implements AfterViewInit {
} }
saveEdit() { saveEdit() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editModel || !this.editModel.id) return; if (!this.editModel || !this.editModel.id) return;
const mobileLineId = String(this.editModel.mobileLineId ?? '').trim(); const mobileLineId = String(this.editModel.mobileLineId ?? '').trim();
@ -844,6 +877,11 @@ export class Mureg implements AfterViewInit {
// DELETE MODAL // DELETE MODAL
// ======================================================================= // =======================================================================
onDelete(row: MuregRow) { onDelete(row: MuregRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.deleteTarget = row; this.deleteTarget = row;
this.deleteOpen = true; this.deleteOpen = true;
this.deleteSaving = false; this.deleteSaving = false;
@ -856,6 +894,11 @@ export class Mureg implements AfterViewInit {
} }
async confirmDelete() { async confirmDelete() {
if (!this.canManageRecords) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.deleteTarget?.id) return; if (!this.deleteTarget?.id) return;
if (!(await confirmDeletionWithTyping('esta Mureg'))) return; if (!(await confirmDeletionWithTyping('esta Mureg'))) return;
@ -914,6 +957,14 @@ export class Mureg implements AfterViewInit {
return dt.toISOString(); return dt.toISOString();
} }
private nowDateInput(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private extractApiMessage(err: any): string | null { private extractApiMessage(err: any): string | null {
try { try {
const m1 = err?.error?.message; const m1 = err?.error?.message;

View File

@ -100,6 +100,7 @@
</button> </button>
<button <button
*ngIf="canEdit"
class="btn-icon ghost" class="btn-icon ghost"
type="button" type="button"
title="Editar" title="Editar"
@ -113,7 +114,7 @@
type="button" type="button"
title="Excluir" title="Excluir"
aria-label="Excluir" aria-label="Excluir"
*ngIf="isSysAdmin" *ngIf="canDelete"
(click)="remove.emit(row)"> (click)="remove.emit(row)">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>

View File

@ -26,7 +26,8 @@ export class ParcelamentosTableComponent {
@Input() items: ParcelamentoViewItem[] = []; @Input() items: ParcelamentoViewItem[] = [];
@Input() loading = false; @Input() loading = false;
@Input() errorMessage = ''; @Input() errorMessage = '';
@Input() isSysAdmin = false; @Input() canEdit = false;
@Input() canDelete = false;
@Input() segment: ParcelamentoSegment = 'todos'; @Input() segment: ParcelamentoSegment = 'todos';
@Input() segmentCounts: Record<ParcelamentoSegment, number> = { @Input() segmentCounts: Record<ParcelamentoSegment, number> = {

View File

@ -29,7 +29,7 @@
<span *ngIf="!exporting"><i class="bi bi-download"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button class="btn-primary" type="button" (click)="openCreateModal()"> <button *ngIf="canManageRecords" class="btn-primary" type="button" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i> Novo Parcelamento <i class="bi bi-plus-circle"></i> Novo Parcelamento
</button> </button>
</div> </div>
@ -79,7 +79,8 @@
[total]="total" [total]="total"
[pageSize]="pageSize" [pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions" [pageSizeOptions]="pageSizeOptions"
[isSysAdmin]="isSysAdmin" [canEdit]="canManageRecords"
[canDelete]="isSysAdmin"
(segmentChange)="setSegment($event)" (segmentChange)="setSegment($event)"
(detail)="openDetails($event)" (detail)="openDetails($event)"
(edit)="openEdit($event)" (edit)="openEdit($event)"

View File

@ -95,6 +95,12 @@ export class Parcelamentos implements OnInit, OnDestroy {
activeChips: FilterChip[] = []; activeChips: FilterChip[] = [];
isSysAdmin = false; isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
detailOpen = false; detailOpen = false;
detailLoading = false; detailLoading = false;
@ -168,6 +174,8 @@ export class Parcelamentos implements OnInit, OnDestroy {
private syncPermissions(): void { private syncPermissions(): void {
this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
} }
get totalPages(): number { get totalPages(): number {
@ -409,6 +417,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
openCreateModal(): void { openCreateModal(): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
this.createModel = this.buildCreateModel(); this.createModel = this.buildCreateModel();
this.createError = ''; this.createError = '';
this.createOpen = true; this.createOpen = true;
@ -421,6 +434,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
saveNewParcelamento(model: ParcelamentoCreateModel): void { saveNewParcelamento(model: ParcelamentoCreateModel): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
if (this.createSaving) return; if (this.createSaving) return;
this.createSaving = true; this.createSaving = true;
this.createError = ''; this.createError = '';
@ -439,6 +457,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
openEdit(item: ParcelamentoListItem): void { openEdit(item: ParcelamentoListItem): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
const id = this.getItemId(item); const id = this.getItemId(item);
if (!id) return; if (!id) return;
this.editOpen = true; this.editOpen = true;
@ -474,6 +497,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
saveEditParcelamento(model: ParcelamentoCreateModel): void { saveEditParcelamento(model: ParcelamentoCreateModel): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
if (this.editSaving || !this.editModel || !this.editId) return; if (this.editSaving || !this.editModel || !this.editId) return;
this.editSaving = true; this.editSaving = true;
this.editError = ''; this.editError = '';

View File

@ -35,7 +35,7 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading"> <button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Troca <i class="bi bi-plus-circle me-1"></i> Nova Troca
</button> </button>
</div> </div>
@ -156,7 +156,7 @@
<td class="td-clip" style="max-width: 360px;" [title]="r.observacao">{{ r.observacao || '-' }}</td> <td class="td-clip" style="max-width: 360px;" [title]="r.observacao">{{ r.observacao || '-' }}</td>
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro"> <button *ngIf="canManageRecords" class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
</button> </button>
</div> </div>

View File

@ -11,6 +11,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service'; import { TableExportService } from '../../services/table-export.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@ -73,6 +74,7 @@ export class TrocaNumero implements AfterViewInit {
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient, private http: HttpClient,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private authService: AuthService,
private tableExportService: TableExportService private tableExportService: TableExportService
) {} ) {}
@ -136,9 +138,20 @@ export class TrocaNumero implements AfterViewInit {
loadingClients = false; loadingClients = false;
loadingLines = false; loadingLines = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
async ngAfterViewInit() { async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => this.refresh()); setTimeout(() => this.refresh());
} }
@ -502,6 +515,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL EDIÇÃO ====== // ====== MODAL EDIÇÃO ======
onEditar(r: TrocaRow) { onEditar(r: TrocaRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.editOpen = true; this.editOpen = true;
this.editSaving = false; this.editSaving = false;
@ -524,6 +542,11 @@ export class TrocaNumero implements AfterViewInit {
} }
saveEdit() { saveEdit() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editModel || !this.editModel.id) return; if (!this.editModel || !this.editModel.id) return;
this.editSaving = true; this.editSaving = true;
@ -555,6 +578,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL CRIAÇÃO ====== // ====== MODAL CRIAÇÃO ======
onCreate() { onCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.createOpen = true; this.createOpen = true;
this.createSaving = false; this.createSaving = false;
@ -584,6 +612,11 @@ export class TrocaNumero implements AfterViewInit {
} }
saveCreate() { saveCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
// ✅ validações do "beber do GERAL" // ✅ validações do "beber do GERAL"
if (!String(this.selectedCliente ?? '').trim()) { if (!String(this.selectedCliente ?? '').trim()) {
this.showToast('Selecione um Cliente do GERAL.'); this.showToast('Selecione um Cliente do GERAL.');

View File

@ -50,6 +50,18 @@ export interface HistoricoQuery {
pageSize?: number; pageSize?: number;
} }
export interface LineHistoricoQuery {
line: string;
pageName?: string;
action?: AuditAction | string;
user?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class HistoricoService { export class HistoricoService {
private readonly baseApi: string; private readonly baseApi: string;
@ -74,4 +86,20 @@ export class HistoricoService {
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico`, { params: httpParams }); return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico`, { params: httpParams });
} }
listByLine(params: LineHistoricoQuery): Observable<PagedResult<AuditLogDto>> {
let httpParams = new HttpParams();
if (params.line) httpParams = httpParams.set('line', params.line);
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
if (params.action) httpParams = httpParams.set('action', params.action);
if (params.user) httpParams = httpParams.set('user', params.user);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
httpParams = httpParams.set('page', String(params.page || 1));
httpParams = httpParams.set('pageSize', String(params.pageSize || 10));
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico/linhas`, { params: httpParams });
}
} }

View File

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