Refatorando o codigo, separando modais e components

This commit is contained in:
Leon 2026-03-06 17:34:39 -03:00
commit a7cb6d5d95
94 changed files with 9624 additions and 4633 deletions

1916
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"exceljs": "^4.4.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"

View File

@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.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 { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
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 { Parcelamentos } from './pages/parcelamentos/parcelamentos';
import { Historico } from './pages/historico/historico';
import { HistoricoLinhas } from './pages/historico-linhas/historico-linhas';
import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
@ -30,15 +32,16 @@ export const routes: Routes = [
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ 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: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' },
{ 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-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' },
{ path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{

View File

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

View File

@ -539,15 +539,18 @@
<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="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>
</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>
</a>
<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 *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="/solicitacoes" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-envelope-paper"></i> <span>Solicitações</span>
</a>

View File

@ -13,6 +13,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmActionModal, confirmDeletionWithTyping, showDeletionWarning } from '../../utils/destructive-confirmation';
import { buildApiBaseUrl } from '../../utils/api-base.util';
@Component({
selector: 'app-header',
@ -34,7 +35,10 @@ export class Header implements AfterViewInit, OnDestroy {
isLoggedHeader = false;
isHome = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
canViewAll = false;
canViewFinancialPages = false;
clientTenantDisplayName = '';
private clientTenantNameTenantId: string | null = null;
private readonly baseApi: string;
@ -60,8 +64,9 @@ export class Header implements AfterViewInit, OnDestroy {
readonly permissionOptions = [
{ value: 'sysadmin', label: 'SysAdmin' },
{ value: 'gestor', label: 'Gestor' },
{ value: 'financeiro', label: 'Financeiro' },
{ value: 'cliente', label: 'Cliente' },
];
];
manageUsersLoading = false;
manageUsersErrors: ApiFieldError[] = [];
@ -93,6 +98,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/resumo',
'/parcelamentos',
'/historico',
'/historico-linhas',
'/solicitacoes',
'/perfil',
'/system',
@ -108,8 +114,7 @@ export class Header implements AfterViewInit, OnDestroy {
private hostElement: ElementRef<HTMLElement>,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
this.createUserForm = this.fb.group(
{
@ -214,15 +219,22 @@ export class Header implements AfterViewInit, OnDestroy {
private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) {
this.isSysAdmin = false;
this.isGestor = false;
this.isFinanceiro = false;
this.canViewAll = false;
this.canViewFinancialPages = false;
this.clientTenantDisplayName = '';
this.clientTenantNameTenantId = null;
return;
}
const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor');
const isFinanceiro = this.authService.hasRole('financeiro');
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) {
this.clientTenantDisplayName = '';
@ -498,7 +510,10 @@ export class Header implements AfterViewInit, OnDestroy {
this.optionsOpen = false;
this.notificationsOpen = false;
this.isSysAdmin = false;
this.isGestor = false;
this.isFinanceiro = false;
this.canViewAll = false;
this.canViewFinancialPages = false;
this.router.navigate(['/']);
}

View File

@ -0,0 +1,14 @@
<ng-container *ngIf="open">
<div
*ngIf="showBackdrop"
[ngClass]="backdropClass"
(click)="onOverlayClick()"
></div>
<div
[ngClass]="overlayClass"
(click)="onOverlayClick()"
>
<ng-content></ng-content>
</div>
</ng-container>

View File

@ -0,0 +1,35 @@
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9990;
backdrop-filter: blur(4px);
}
.modal-custom {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.lg-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.lg-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9990;
backdrop-filter: blur(2px);
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-modal-layer',
standalone: true,
imports: [CommonModule],
templateUrl: './modal-layer.html',
styleUrls: ['./modal-layer.scss'],
})
export class ModalLayerComponent {
@Input() open = false;
@Input() showBackdrop = true;
@Input() closeOnOverlay = true;
@Input() backdropClass = 'modal-backdrop-custom';
@Input() overlayClass = 'modal-custom';
@Output() close = new EventEmitter<void>();
onOverlayClick(): void {
if (!this.closeOnOverlay) return;
this.close.emit();
}
}

View File

@ -0,0 +1,370 @@
<app-modal-layer
[open]="chipDetailOpen || controleDetailOpen || chipEditOpen || chipDeleteOpen || controleEditOpen || controleDeleteOpen || chipCreateOpen || controleCreateOpen"
(close)="closeChipDetail(); closeControleDetail(); closeChipEdit(); cancelChipDelete(); closeControleEdit(); cancelControleDelete(); closeChipCreate(); closeControleCreate()"
>
<!-- MODAL CHIP -->
<ng-container *ngIf="chipDetailOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-sim"></i></span>
Detalhes do Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="chipDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!chipDetailLoading && chipDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações do Chip</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(chipDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Número do Chip</span>
<span class="val text-brand font-monospace">{{ display(chipDetailData.numeroDoChip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Observações</span>
<span class="val">{{ display(chipDetailData.observacoes) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<!-- MODAL CONTROLE -->
<ng-container *ngIf="controleDetailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-clipboard-data"></i></span>
Detalhes do Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="controleDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!controleDetailLoading && controleDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da NF</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Ano</span>
<span class="val">{{ display(controleDetailData.ano) }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(controleDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Nota Fiscal</span>
<span class="val">{{ display(controleDetailData.notaFiscal) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Chip</span>
<span class="val font-monospace">{{ display(controleDetailData.chip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Serial</span>
<span class="val font-monospace">{{ display(controleDetailData.serial) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Conteúdo da NF</span>
<span class="val">{{ display(controleDetailData.conteudoDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Número da Linha</span>
<span class="val font-monospace">{{ display(controleDetailData.numeroDaLinha) }}</span>
</div>
<div class="info-item">
<span class="lbl">Quantidade</span>
<span class="val">{{ display(controleDetailData.quantidade) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor Unit</span>
<span class="val">{{ formatMoney(controleDetailData.valorUnit) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor da NF</span>
<span class="val text-brand">{{ formatMoney(controleDetailData.valorDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Data da NF</span>
<span class="val">{{ formatDate(controleDetailData.dataDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Recebimento</span>
<span class="val">{{ formatDate(controleDetailData.dataDoRecebimento) }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ isResumo(controleDetailData) ? "RESUMO" : "DETALHE" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<!-- MODAL CHIP CREATE -->
<ng-container *ngIf="chipCreateOpen">
<div class="modal-card modal-lg create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Informações do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipCreateModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="chipCreateSaving" (click)="saveChipCreate()">
{{ chipCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</ng-container>
<!-- MODAL CONTROLE CREATE -->
<ng-container *ngIf="controleCreateOpen">
<div class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-card-list me-2"></i> Dados da Nota</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.notaFiscal" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.conteudoDaNf" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.serial" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.chip" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.numeroDaLinha" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-currency-exchange me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorUnit" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.quantidade" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Valor da NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorDaNf" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Data da NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateDataNf" (ngModelChange)="onControleCreateDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateRecebimento" /></div>
<div class="form-field"><label>Resumo</label><input class="form-check-input ms-2" type="checkbox" [(ngModel)]="controleCreateModel.isResumo" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="controleCreateSaving" (click)="saveControleCreate()">
{{ controleCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</ng-container>
<!-- MODAL CHIP EDIT -->
<ng-container *ngIf="chipEditOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Identificação do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipEditModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="chipEditSaving" (click)="saveChipEdit()">
{{ chipEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</ng-container>
<!-- MODAL CHIP DELETE -->
<ng-container *ngIf="chipDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Chip
</div>
<button class="btn btn-sm btn-icon" (click)="cancelChipDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o chip <strong>{{ chipDeleteTarget?.numeroDoChip }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelChipDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmChipDelete()">Excluir</button>
</div>
</div>
</ng-container>
<!-- MODAL CONTROLE EDIT -->
<ng-container *ngIf="controleEditOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-receipt-cutoff me-2"></i> Documento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.notaFiscal" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.chip" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.serial" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.conteudoDaNf" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.numeroDaLinha" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="controleEditModel.isResumo">
<option [ngValue]="false">DETALHE</option>
<option [ngValue]="true">RESUMO</option>
</select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-cash-coin me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.quantidade" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorUnit" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorDaNf" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Data NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditDataNf" (ngModelChange)="onControleEditDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditRecebimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="controleEditSaving" (click)="saveControleEdit()">
{{ controleEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</ng-container>
<!-- MODAL CONTROLE DELETE -->
<ng-container *ngIf="controleDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelControleDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover a NF <strong>{{ controleDeleteTarget?.notaFiscal }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelControleDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmControleDelete()">Excluir</button>
</div>
</div>
</ng-container>
</app-modal-layer>

View File

@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-chips-controle-modals',
standalone: true,
imports: [CommonModule, FormsModule, ModalLayerComponent],
templateUrl: './chips-controle-modals.html',
styleUrls: ['../../../pages/chips-controle-recebidos/chips-controle-recebidos.scss'],
})
export class ChipsControleModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,277 @@
<app-modal-layer
[open]="detailsOpen || editOpen || deleteOpen || createOpen"
(close)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"
>
<div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
Detalhes do Usuário
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div>
<div class="form-field"><label>TIPO</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'PESSOA JURÍDICA' : 'PESSOA FÍSICA' }}</div></div>
<div class="form-field span-2"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'RAZÃO SOCIAL' : 'NOME' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.razaoSocial || selectedRow?.cliente || '-') : (selectedRow?.nome || selectedRow?.cliente || '-') }}</div></div>
<div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div>
<div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div>
<div class="form-field"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'CNPJ' : 'CPF' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.cnpj || '-') : (selectedRow?.cpf || '-') }}</div></div>
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div>
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
<div class="form-field"><label>CELULAR</label><div>{{ selectedRow?.celular || '-' }}</div></div>
<div class="form-field"><label>TELEFONE FIXO</label><div>{{ selectedRow?.telefoneFixo || '-' }}</div></div>
<div class="form-field span-2"><label>ENDEREÇO</label><div>{{ selectedRow?.endereco || '-' }}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div *ngIf="createOpen" class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Usuário
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="createModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com Reserva</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Linha (RESERVA)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[searchable]="true"
searchPlaceholder="Pesquisar linha da reserva..."
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading"
placeholder="Selecione uma linha da Reserva..."
></app-select>
<small class="field-hint" *ngIf="createLinesLoading">Carregando linhas da Reserva...</small>
</div>
<div class="form-field">
<label>Total Franquia Line</label>
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(createFranquiaLineTotal)" readonly />
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-vcard me-2"></i> Dados do Usuário</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="createModel.tipoPessoa"
(ngModelChange)="onCreateTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.razaoSocial" />
</div>
<div class="form-field field-line">
<label>Linha</label>
<input class="form-control form-control-sm bg-light" [value]="createModel.linha || ''" readonly />
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="createModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>CPF</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cpf" /></div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'"><label>CNPJ</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cnpj" /></div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="createModel.rg" /></div>
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" [(ngModel)]="createModel.email" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="createModel.endereco" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="createModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="createModel.telefoneFixo" /></div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>Data de Nascimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createDateNascimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Usuário
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="editModel.tipoPessoa"
(ngModelChange)="onEditTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.razaoSocial" />
</div>
<div class="form-field field-line">
<label>Linha (Reserva)</label>
<app-select
class="form-select"
size="sm"
[options]="editLineOptions"
labelKey="label"
valueKey="id"
[searchable]="true"
searchPlaceholder="Pesquisar linha da reserva..."
[(ngModel)]="editSelectedLineId"
(ngModelChange)="onEditLineChange()"
[disabled]="createLinesLoading"
placeholder="Selecione uma linha da Reserva..."
></app-select>
</div>
<div class="form-field field-auto">
<label>Total Franquia Line</label>
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(editFranquiaLineTotal)" readonly />
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>CPF</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cpf" />
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>CNPJ</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cnpj" />
</div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="editModel.rg" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-envelope-paper me-2"></i> Contato</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid contact-modal-grid">
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" type="email" [(ngModel)]="editModel.email" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="editModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="editModel.telefoneFixo" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="editModel.endereco" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Complemento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Data Nascimento</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editDateNascimento" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Usuário
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../custom-select/custom-select';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-dados-usuarios-modals',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './dados-usuarios-modals.html',
styleUrls: ['../../../pages/dados-usuarios/dados-usuarios.scss'],
})
export class DadosUsuariosModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,246 @@
<app-modal-layer
[open]="detailOpen || compareOpen || editOpen || deleteOpen"
(close)="closeAllModals()"
>
<!-- DETAIL MODAL -->
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg detail-icon"><i class="bi bi-receipt"></i></span>
Detalhes do Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="detailData; else detailLoading">
<div class="mb-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex flex-column">
<div class="fw-black detail-client">
{{ detailData.cliente || '—' }}
</div>
<small class="text-muted fw-bold">
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
</small>
</div>
</div>
<div class="details-dashboard details-single">
<!-- IDENTIFICAÇÃO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val text-dark" [title]="detailData.cliente || ''">{{ detailData.cliente || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ detailData.tipo || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Qtd Linhas</span>
<span class="val">{{ detailData.qtdLinhas ?? 0 }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Aparelho</span>
<span class="val" [title]="detailData.aparelho || ''">{{ detailData.aparelho || '—' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Forma de Pagamento</span>
<span class="val" [title]="detailData.formaPagamento || ''">{{ detailData.formaPagamento || '—' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #detailLoading>
<div class="p-5 text-center text-muted">Carregando detalhes...</div>
</ng-template>
</div>
<!-- COMPARATIVO MODAL -->
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg compare-icon"><i class="bi bi-columns-gap"></i></span> Comparativo Vivo x Line
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="compareData; else compareLoading">
<div class="finance-dashboard">
<div class="finance-card vivo-card">
<div class="card-header-f"><i class="bi bi-telephone-fill me-2"></i> Vivo</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia</span> <strong>{{ formatFranquia(compareData.franquiaVivo) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Vivo (R$)</span> <strong>{{ formatMoney(compareData.valorContratoVivo) }}</strong></div>
</div>
</div>
<div class="finance-card line-card">
<div class="card-header-f"><i class="bi bi-hdd-network-fill me-2"></i> Line Móvel</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia Line</span> <strong>{{ formatFranquia(compareData.franquiaLine) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Line (R$)</span> <strong>{{ formatMoney(compareData.valorContratoLine) }}</strong></div>
</div>
</div>
</div>
<div class="finance-summary mt-3">
<div class="summary-item">
<span class="lbl">Forma de Pagamento</span>
<span class="val text-dark">{{ compareData.formaPagamento || '—' }}</span>
</div>
<div class="vertical-line"></div>
<div class="summary-item">
<span class="lbl">Lucro</span>
<span class="val text-brand">{{ formatMoney(compareData.lucro) }}</span>
</div>
</div>
</div>
<ng-template #compareLoading>
<div class="p-5 text-center text-muted">Carregando comparativo...</div>
</ng-template>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span> Editar Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipo">
<option value="PF">PF</option>
<option value="PJ">PJ</option>
</select>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Qtd Linhas</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.qtdLinhas" />
</div>
<div class="form-field">
<label>Aparelho</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelho" />
</div>
<div class="form-field span-2">
<label>Forma de Pagamento</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.formaPagamento" />
</div>
</div>
</div>
</details>
<details open class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Faturamento Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Vivo</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaVivo" />
</div>
<div class="form-field">
<label>Valor Vivo (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoVivo" />
</div>
</div>
</div>
</details>
<details open class="detail-box line-border">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Faturamento Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Line</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaLine" />
</div>
<div class="form-field">
<label>Valor Line (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoLine" />
</div>
<div class="form-field span-2">
<label>Lucro (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.lucro" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span> Remover Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma a exclusão do registro <strong>{{ deleteTarget?.cliente }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-faturamento-modals',
standalone: true,
imports: [CommonModule, FormsModule, ModalLayerComponent],
templateUrl: './faturamento-modals.html',
styleUrls: ['../../../pages/faturamento/faturamento.scss'],
})
export class FaturamentoModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../custom-select/custom-select';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-geral-modals',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './geral-modals.html',
styleUrls: ['../../../pages/geral/geral.scss'],
})
export class GeralModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,309 @@
<app-modal-layer
[open]="editOpen || createOpen || deleteOpen || detailOpen"
(close)="closeEdit(); closeCreate(); closeDelete(); closeDetail()"
>
<!-- ============================== -->
<!-- EDIT MODAL -->
<!-- ============================== -->
<div *ngIf="editOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Registro Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-card-text me-2"></i> Informações</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="editModel.selectedClient" (ngModelChange)="onEditClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select da Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL)</label>
<app-select class="form-control" size="sm" [options]="lineOptionsEdit" labelKey="label" valueKey="id" [(ngModel)]="editModel.mobileLineId" (ngModelChange)="onEditLineChange()" [disabled]="!editModel.selectedClient || editLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataDaMureg" />
</div>
<!-- LinhaAntiga (snapshot) - preenchido automaticamente -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="editModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ editModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">
<span class="spinner-border me-2"></span> Preparando edição...
</div>
</ng-template>
</div>
</div>
<!-- ============================== -->
<!-- CREATE MODAL -->
<!-- ============================== -->
<div *ngIf="createOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-pencil me-2"></i> Preencha os dados</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="createModel.selectedClient" (ngModelChange)="onCreateClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
<app-select class="form-control" size="sm" [options]="lineOptionsCreate" labelKey="label" valueKey="id" [(ngModel)]="createModel.mobileLineId" (ngModelChange)="onCreateLineChange()" [disabled]="!createModel.selectedClient || createLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Mureg (automática)</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" readonly />
</div>
<!-- snapshot -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova <span class="text-danger">*</span></label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="createModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ createModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================== -->
<!-- DETAIL MODAL -->
<!-- ============================== -->
<div *ngIf="detailOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-eye"></i></span>
Detalhes da Mureg
</div>
<button class="btn btn-sm btn-icon" (click)="closeDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="detailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!detailLoading && detailData">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Mureg</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Linha Nova</span>
<span class="val text-blue fs-4">{{ detailData.linhaNova || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Linha Antiga</span>
<span class="val">{{ detailData.linhaAntiga || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ detailData.cliente || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ detailData.usuario || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ detailData.item || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Data Mureg</span>
<span class="val">{{ displayValue('dataDaMureg', detailData.dataDaMureg) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">ICCID</span>
<span class="val small-text">{{ detailData.iccid || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Skil</span>
<span class="val">{{ detailData.skil || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================== -->
<!-- DELETE MODAL -->
<!-- ============================== -->
<div *ngIf="deleteOpen" class="modal-card modal-sm" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Excluir Mureg
</div>
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
</div>
<div class="modal-body">
<p class="mb-2 fw-bold">Tem certeza que deseja excluir esta Mureg?</p>
<div class="text-muted small">
<div><strong>Cliente:</strong> {{ deleteTarget?.cliente || '-' }}</div>
<div><strong>Linha nova:</strong> {{ deleteTarget?.linhaNova || '-' }}</div>
<div><strong>Linha antiga:</strong> {{ deleteTarget?.linhaAntiga || '-' }}</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
Cancelar
</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()" [disabled]="deleteSaving">
<span *ngIf="!deleteSaving"><i class="bi bi-trash me-1"></i> Excluir</span>
<span *ngIf="deleteSaving"><span class="spinner-border spinner-border-sm me-2"></span> Excluindo...</span>
</button>
</div>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../custom-select/custom-select';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-mureg-modals',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './mureg-modals.html',
styleUrls: ['../../../pages/mureg/mureg.scss'],
})
export class MuregModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -1,6 +1,10 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card" (click)="$event.stopPropagation()">
<app-modal-layer
[open]="open"
backdropClass="lg-backdrop"
overlayClass="lg-modal"
(close)="close.emit()"
>
<div *ngIf="open" class="lg-modal-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-plus-circle"></i></span>
@ -148,4 +152,4 @@
</button>
</div>
</div>
</div>
</app-modal-layer>

View File

@ -5,26 +5,6 @@
--focus-ring: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.15), rgba(15, 23, 42, 0.66) 42%),
rgba(15, 23, 42, 0.58);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1040px, 96vw);
max-height: 92vh;

View File

@ -1,7 +1,8 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
import { CustomSelectComponent } from '../../../../custom-select/custom-select';
import { ModalLayerComponent } from '../../../../modal-layer/modal-layer';
export type MonthOption = { value: number; label: string };
@ -31,7 +32,7 @@ type PreviewRow = {
@Component({
selector: 'app-parcelamento-create-modal',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './parcelamento-create-modal.html',
styleUrls: ['./parcelamento-create-modal.scss'],
})

View File

@ -1,6 +1,10 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card annual-card" (click)="$event.stopPropagation()">
<app-modal-layer
[open]="open"
backdropClass="lg-backdrop"
overlayClass="lg-modal"
(close)="close.emit()"
>
<div *ngIf="open" class="lg-modal-card annual-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-table"></i></span>
@ -53,4 +57,4 @@
<button class="btn-primary" type="button" (click)="close.emit()">Fechar</button>
</div>
</div>
</div>
</app-modal-layer>

View File

@ -4,24 +4,6 @@
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16);
}
.lg-backdrop {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.2), rgba(0, 0, 0, 0.56) 42%);
z-index: 9990;
backdrop-filter: blur(5px);
}
.lg-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.88);

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalLayerComponent } from '../../../../modal-layer/modal-layer';
export type AnnualMonthValue = {
month: number;
@ -20,7 +21,7 @@ export type AnnualRow = {
@Component({
selector: 'app-parcelamento-detalhamento-anual-modal',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, ModalLayerComponent],
templateUrl: './parcelamento-detalhamento-anual-modal.html',
styleUrls: ['./parcelamento-detalhamento-anual-modal.scss'],
})

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
import { CustomSelectComponent } from '../../../../custom-select/custom-select';
export type MonthOption = { value: number; label: string };

View File

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

View File

@ -429,14 +429,14 @@
display: inline-flex;
align-items: center;
gap: 8px;
}
span {
.page-size span {
color: var(--pg-text-soft, #64748b);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
}
}
.select-glass {

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ParcelamentoListItem } from '../../../../services/parcelamentos.service';
import { ParcelamentoListItem } from '../../../../../services/parcelamentos.service';
export type ParcelamentoSegment = 'todos' | 'ativos' | 'futuros' | 'finalizados';
@ -26,7 +26,8 @@ export class ParcelamentosTableComponent {
@Input() items: ParcelamentoViewItem[] = [];
@Input() loading = false;
@Input() errorMessage = '';
@Input() isSysAdmin = false;
@Input() canEdit = false;
@Input() canDelete = false;
@Input() segment: ParcelamentoSegment = 'todos';
@Input() segmentCounts: Record<ParcelamentoSegment, number> = {

View File

@ -0,0 +1,180 @@
<!-- Modal detalhes -->
<app-modal-layer
[open]="detailOpen"
backdropClass="lg-backdrop"
overlayClass="lg-modal"
(close)="closeDetails()"
>
<div *ngIf="detailOpen" class="lg-modal-card parcelamento-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-card-list"></i></span>
<span>Detalhes do Parcelamento</span>
</div>
<div class="modal-actions">
<button class="btn-icon" type="button" (click)="closeDetails()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="detail-state" *ngIf="detailLoading && !selectedDetail">
<div class="spinner-border text-brand" role="status"></div>
<span>Carregando detalhes...</span>
</div>
<div class="detail-state error" *ngIf="!detailLoading && detailError && !selectedDetail">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ detailError }}</span>
</div>
<ng-container *ngIf="selectedDetail as detail">
<div class="detail-grid">
<div class="detail-card">
<small>Cliente</small>
<span class="detail-strong">{{ detail.cliente || '-' }}</span>
</div>
<div class="detail-card">
<small>Linha</small>
<span class="detail-strong text-blue">{{ detail.linha || '-' }}</span>
</div>
<div class="detail-card">
<small>AnoRef</small>
<span>{{ detail.anoRef ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Item</small>
<span>{{ detail.item ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Qt Parcelas</small>
<span>{{ displayQtParcelas(detail) }}</span>
</div>
<div class="detail-card">
<small>Parcela Atual</small>
<span class="detail-strong">{{ detail.parcelaAtual ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Total Parcelas</small>
<span>{{ detail.totalParcelas ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Status</small>
<span class="status-pill">{{ detailStatus }}</span>
</div>
<div class="detail-card">
<small>Valor Cheio</small>
<span>{{ formatMoney(detail.valorCheio) }}</span>
</div>
<div class="detail-card">
<small>Desconto</small>
<span class="text-danger">{{ formatMoney(detail.desconto) }}</span>
</div>
<div class="detail-card highlight">
<small>Valor com Desconto</small>
<span class="detail-strong money-strong">{{ formatMoney(detail.valorComDesconto) }}</span>
</div>
</div>
<div class="annual-section">
<div class="annual-head">
<div class="section-title">
<i class="bi bi-table"></i>
<span>Detalhamento anual</span>
</div>
</div>
<div class="annual-table-shell" *ngIf="annualRows.length > 0; else annualEmpty">
<table class="table-modern annual-table">
<thead>
<tr>
<th class="sticky-col col-1">Ano</th>
<th class="sticky-col col-2 text-end">Total</th>
<th *ngFor="let m of annualMonthHeaders" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of annualRows">
<td class="sticky-col col-1">{{ row.year }}</td>
<td class="sticky-col col-2 text-end">{{ row.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td *ngFor="let m of row.months" class="text-end">
{{ m.value !== null && m.value !== undefined ? (m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR') : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #annualEmpty>
<div class="annual-empty">
Sem dados anuais.
</div>
</ng-template>
</div>
</ng-container>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="closeDetails()">Fechar</button>
</div>
</div>
</app-modal-layer>
<app-parcelamento-create-modal
[open]="createOpen"
[model]="createModel"
[monthOptions]="monthOptions"
[loading]="createSaving"
[errorMessage]="createError"
title="Novo Parcelamento"
submitLabel="Salvar"
(close)="closeCreateModal()"
(save)="saveNewParcelamento($event)">
</app-parcelamento-create-modal>
<app-parcelamento-create-modal
*ngIf="editOpen && editModel"
[open]="editOpen"
[model]="editModel"
[monthOptions]="monthOptions"
[loading]="editSaving"
[errorMessage]="editError"
title="Editar Parcelamento"
submitLabel="Atualizar"
(close)="closeEditModal()"
(save)="saveEditParcelamento($event)">
</app-parcelamento-create-modal>
<!-- Delete modal -->
<app-modal-layer
[open]="deleteOpen"
backdropClass="lg-backdrop"
overlayClass="lg-modal"
(close)="cancelDelete()"
>
<div *ngIf="deleteOpen" class="lg-modal-card modal-compact" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Parcelamento
</div>
<button class="btn-icon" type="button" (click)="cancelDelete()" aria-label="Fechar modal de exclusao">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o parcelamento <strong>{{ deleteTarget?.linha }}</strong>?</p>
<small class="text-danger" *ngIf="deleteError">{{ deleteError }}</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="cancelDelete()">Cancelar</button>
<button class="btn-danger" type="button" [disabled]="deleteLoading" (click)="confirmDelete()">
{{ deleteLoading ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { ParcelamentoCreateModalComponent } from './components/parcelamento-create-modal/parcelamento-create-modal';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-parcelamentos-modals',
standalone: true,
imports: [CommonModule, ModalLayerComponent, ParcelamentoCreateModalComponent],
templateUrl: './parcelamentos-modals.html',
styleUrls: ['../../../pages/parcelamentos/parcelamentos.scss'],
})
export class ParcelamentosModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,181 @@
<app-modal-layer
[open]="editOpen || createOpen"
(close)="closeEdit(); closeCreate()"
>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Troca de Número
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataTroca" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.motivo" />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="editModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">Preparando edição...</div>
</ng-template>
</div>
</div>
<!-- CREATE MODAL (✅ BEBENDO DO GERAL) -->
<div *ngIf="createOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Troca
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-pencil me-2"></i> Preencha os dados</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataTroca" />
</div>
<!-- ✅ Cliente (GERAL) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="clientsFromGeral" [(ngModel)]="selectedCliente" (ngModelChange)="onClienteChange()" placeholder="Selecione..."></app-select>
<small class="hint" *ngIf="loadingClients">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
</small>
</div>
<!-- ✅ Linha do Cliente (GERAL) -->
<div class="form-field span-2">
<label>Linha do Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="linesFromClient" labelKey="label" valueKey="id" [(ngModel)]="selectedLineId" (ngModelChange)="onLineChange()" [disabled]="!selectedCliente || loadingLines" placeholder="Selecione a linha do cliente..."></app-select>
<small class="hint" *ngIf="loadingLines">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
</small>
<small class="hint warn" *ngIf="selectedCliente && !loadingLines && linesFromClient.length === 0">
Nenhuma linha encontrada para este cliente no GERAL.
</small>
</div>
<!-- ✅ Linha Antiga (auto do GERAL) -->
<div class="form-field">
<label>Linha Antiga (auto)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<!-- Linha Nova -->
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<!-- ✅ ICCID (auto do GERAL) -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.motivo" placeholder="Ex: perda/roubo, troca de colaborador..." />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../custom-select/custom-select';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-troca-numero-modals',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './troca-numero-modals.html',
styleUrls: ['../../../pages/troca-numero/troca-numero.scss'],
})
export class TrocaNumeroModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,262 @@
<app-modal-layer
[open]="detailsOpen || editOpen || deleteOpen || createOpen"
[showBackdrop]="false"
overlayClass="lg-modal"
(close)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"
>
<div *ngIf="detailsOpen" class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
Detalhes da Vigência
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Linha</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Linha</span>
<span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Conta</span>
<span class="val">{{ selectedRow?.conta || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ selectedRow?.usuario || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Plano</span>
<span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Efetivação</span>
<span class="val">{{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Término</span>
<span class="val" [class.text-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Situação</span>
<span class="status-pill" [class.is-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Renovação</span>
<span class="val">
{{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div>
</div>
<!-- CREATE MODAL -->
<div *ngIf="createOpen" class="lg-modal-card modal-xl create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Nova Vigência
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com GERAL</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="clientsFromGeral"
[(ngModel)]="createModel.selectedClient"
(ngModelChange)="onCreateClientChange()"
[disabled]="createClientsLoading"
></app-select>
</div>
<div class="form-field span-2">
<label>Linha (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="createModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="createModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /></div>
<div class="form-field span-2">
<label>Plano</label>
<app-select
*ngIf="planOptions.length > 0"
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
></app-select>
<input
*ngIf="planOptions.length === 0"
class="form-control form-control-sm"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
/>
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="createModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="lg-modal-card modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Vigência
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
<div class="form-field span-2"><label>Plano</label><input class="form-control form-control-sm" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onEditPlanChange()" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Vigência
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</app-modal-layer>

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../custom-select/custom-select';
import { ModalLayerComponent } from '../../modal-layer/modal-layer';
import { VmProxyHost } from '../vm-proxy-host';
@Component({
selector: 'app-vigencia-modals',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent],
templateUrl: './vigencia-modals.html',
styleUrls: ['../../../pages/vigencia/vigencia.scss'],
})
export class VigenciaModalsComponent extends VmProxyHost {
@Input() set vm(value: any) {
this.attachVm(value);
}
}

View File

@ -0,0 +1,52 @@
export abstract class VmProxyHost {
[key: string]: any;
private __boundKeys = new Set<string>();
protected attachVm(vm: any): void {
if (!vm) return;
// Bind own enumerable fields so template reads/writes keep source state in sync.
Object.keys(vm).forEach((key) => this.bindField(vm, key));
let proto: any = Object.getPrototypeOf(vm);
while (proto && proto !== Object.prototype) {
for (const key of Object.getOwnPropertyNames(proto)) {
if (key === 'constructor' || key.startsWith('ng')) continue;
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
if (!descriptor) continue;
if (descriptor.get || descriptor.set) {
this.bindField(vm, key);
continue;
}
const value = (vm as any)[key];
if (typeof value === 'function' && !this.__boundKeys.has(key)) {
(this as any)[key] = (...args: any[]) => value.apply(vm, args);
this.__boundKeys.add(key);
}
}
proto = Object.getPrototypeOf(proto);
}
}
private bindField(vm: any, key: string): void {
if (this.__boundKeys.has(key)) return;
if (Object.prototype.hasOwnProperty.call(this, key)) return;
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => (vm as any)[key],
set: (value: unknown) => {
(vm as any)[key] = value;
},
});
this.__boundKeys.add(key);
}
}

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');
}
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
const hasAccess =
authService.hasRole('sysadmin') ||
authService.hasRole('gestor') ||
authService.hasRole('financeiro');
if (!hasAccess) {
return router.parseUrl('/dashboard');
}

View File

@ -35,6 +35,14 @@
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button
class="btn btn-glass btn-sm"
(click)="onExport()"
[disabled]="activeLoading || exporting"
>
<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>
<button
*ngIf="isSysAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm"
@ -384,372 +392,4 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen || chipEditOpen || chipDeleteOpen || controleEditOpen || controleDeleteOpen || chipCreateOpen || controleCreateOpen" (click)="closeChipDetail(); closeControleDetail(); closeChipEdit(); cancelChipDelete(); closeControleEdit(); cancelControleDelete(); closeChipCreate(); closeControleCreate()"></div>
<!-- MODAL CHIP -->
<div class="modal-custom" *ngIf="chipDetailOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-sim"></i></span>
Detalhes do Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="chipDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!chipDetailLoading && chipDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações do Chip</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(chipDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Número do Chip</span>
<span class="val text-brand font-monospace">{{ display(chipDetailData.numeroDoChip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Observações</span>
<span class="val">{{ display(chipDetailData.observacoes) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL CONTROLE -->
<div class="modal-custom" *ngIf="controleDetailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-clipboard-data"></i></span>
Detalhes do Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="controleDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!controleDetailLoading && controleDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da NF</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Ano</span>
<span class="val">{{ display(controleDetailData.ano) }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(controleDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Nota Fiscal</span>
<span class="val">{{ display(controleDetailData.notaFiscal) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Chip</span>
<span class="val font-monospace">{{ display(controleDetailData.chip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Serial</span>
<span class="val font-monospace">{{ display(controleDetailData.serial) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Conteúdo da NF</span>
<span class="val">{{ display(controleDetailData.conteudoDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Número da Linha</span>
<span class="val font-monospace">{{ display(controleDetailData.numeroDaLinha) }}</span>
</div>
<div class="info-item">
<span class="lbl">Quantidade</span>
<span class="val">{{ display(controleDetailData.quantidade) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor Unit</span>
<span class="val">{{ formatMoney(controleDetailData.valorUnit) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor da NF</span>
<span class="val text-brand">{{ formatMoney(controleDetailData.valorDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Data da NF</span>
<span class="val">{{ formatDate(controleDetailData.dataDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Recebimento</span>
<span class="val">{{ formatDate(controleDetailData.dataDoRecebimento) }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ isResumo(controleDetailData) ? "RESUMO" : "DETALHE" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL CHIP CREATE -->
<div class="modal-custom" *ngIf="chipCreateOpen">
<div class="modal-card modal-lg create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Informações do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipCreateModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="chipCreateSaving" (click)="saveChipCreate()">
{{ chipCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE CREATE -->
<div class="modal-custom" *ngIf="controleCreateOpen">
<div class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-card-list me-2"></i> Dados da Nota</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.notaFiscal" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.conteudoDaNf" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.serial" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.chip" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.numeroDaLinha" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-currency-exchange me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorUnit" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.quantidade" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Valor da NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorDaNf" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Data da NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateDataNf" (ngModelChange)="onControleCreateDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateRecebimento" /></div>
<div class="form-field"><label>Resumo</label><input class="form-check-input ms-2" type="checkbox" [(ngModel)]="controleCreateModel.isResumo" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="controleCreateSaving" (click)="saveControleCreate()">
{{ controleCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP EDIT -->
<div class="modal-custom" *ngIf="chipEditOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Identificação do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipEditModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="chipEditSaving" (click)="saveChipEdit()">
{{ chipEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP DELETE -->
<div class="modal-custom" *ngIf="chipDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Chip
</div>
<button class="btn btn-sm btn-icon" (click)="cancelChipDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o chip <strong>{{ chipDeleteTarget?.numeroDoChip }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelChipDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmChipDelete()">Excluir</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE EDIT -->
<div class="modal-custom" *ngIf="controleEditOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-receipt-cutoff me-2"></i> Documento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.notaFiscal" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.chip" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.serial" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.conteudoDaNf" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.numeroDaLinha" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="controleEditModel.isResumo">
<option [ngValue]="false">DETALHE</option>
<option [ngValue]="true">RESUMO</option>
</select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-cash-coin me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.quantidade" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorUnit" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorDaNf" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Data NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditDataNf" (ngModelChange)="onControleEditDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditRecebimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="controleEditSaving" (click)="saveControleEdit()">
{{ controleEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE DELETE -->
<div class="modal-custom" *ngIf="controleDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelControleDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover a NF <strong>{{ controleDeleteTarget?.notaFiscal }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelControleDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmControleDelete()">Excluir</button>
</div>
</div>
</div>
<app-chips-controle-modals [vm]="$any(vm)"></app-chips-controle-modals>

View File

@ -616,8 +616,6 @@
/* ========================================================== */
/* MODAIS (mantidos) */
/* ========================================================== */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; }
.modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; }

View File

@ -4,8 +4,18 @@ import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir, UpdateChipVirgemRequest, UpdateControleRecebidoRequest, CreateChipVirgemRequest, CreateControleRecebidoRequest } from '../../services/chips-controle.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { ChipsControleModalsComponent } from '../../components/page-modals/chips-controle-modals/chips-controle-modals';
import { AuthService } from '../../services/auth.service';
import { TableExportService } from '../../services/table-export.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { firstValueFrom } from 'rxjs';
// Interface para o Agrupamento
interface ChipGroup {
@ -35,11 +45,12 @@ type ControleSortKey =
@Component({
selector: 'app-chips-controle-recebidos',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, ChipsControleModalsComponent],
templateUrl: './chips-controle-recebidos.html',
styleUrls: ['./chips-controle-recebidos.scss']
})
export class ChipsControleRecebidos implements OnInit, OnDestroy {
readonly vm = this;
activeTab: 'chips' | 'controle' = 'chips';
// --- Chips ---
@ -86,6 +97,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
exporting = false;
private toastTimer: any = null;
chipDetailOpen = false;
@ -124,7 +136,8 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
@Inject(PLATFORM_ID) private platformId: object,
private service: ChipsControleService,
private http: HttpClient,
private authService: AuthService
private authService: AuthService,
private tableExportService: TableExportService
) {}
ngOnInit(): void {
@ -417,6 +430,129 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
this.fetchControle();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
if (this.activeTab === 'chips') {
const baseRows = [...(this.chipsRows ?? [])].sort((a, b) => (a.item ?? 0) - (b.item ?? 0));
const rows = await this.fetchDetailedChipRowsForExport(baseRows);
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<ChipVirgemListDto>({
fileName: `chips_virgens_${timestamp}`,
sheetName: 'ChipsVirgens',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
{ header: 'Numero do Chip', value: (row) => row.numeroDoChip ?? '' },
{ header: 'Observacoes', value: (row) => row.observacoes ?? '' },
{ header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
],
});
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
return;
}
const baseRows = [...(this.controleRows ?? [])].sort((a, b) => {
const byAno = (this.toNullableNumber(a.ano) ?? 0) - (this.toNullableNumber(b.ano) ?? 0);
if (byAno !== 0) return byAno;
return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0);
});
const rows = await this.fetchDetailedControleRowsForExport(baseRows);
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<ControleRecebidoListDto>({
fileName: `controle_recebidos_${timestamp}`,
sheetName: 'ControleRecebidos',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Ano', type: 'number', value: (row) => this.toNullableNumber(row.ano) ?? 0 },
{ header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
{ header: 'Nota Fiscal', value: (row) => row.notaFiscal ?? '' },
{ header: 'Chip', value: (row) => row.chip ?? '' },
{ header: 'Serial', value: (row) => row.serial ?? '' },
{ header: 'Conteudo da NF', value: (row) => row.conteudoDaNf ?? '' },
{ header: 'Numero da Linha', value: (row) => row.numeroDaLinha ?? '' },
{ header: 'Valor Unitario', type: 'currency', value: (row) => this.toNullableNumber(row.valorUnit) ?? 0 },
{ header: 'Valor da NF', type: 'currency', value: (row) => this.toNullableNumber(row.valorDaNf) ?? 0 },
{ header: 'Data da NF', type: 'date', value: (row) => row.dataDaNf ?? '' },
{ header: 'Data do Recebimento', type: 'date', value: (row) => row.dataDoRecebimento ?? '' },
{ header: 'Quantidade', type: 'number', value: (row) => this.toNullableNumber(row.quantidade) ?? 0 },
{ header: 'Resumo', type: 'boolean', value: (row) => !!row.isResumo },
{ header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
],
});
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
} catch {
this.showToast('Erro ao exportar planilha.', 'danger');
} finally {
this.exporting = false;
}
}
private async fetchDetailedChipRowsForExport(rows: ChipVirgemListDto[]): Promise<ChipVirgemListDto[]> {
if (!rows.length) return [];
const detailed: ChipVirgemListDto[] = [];
const chunkSize = 12;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(this.service.getChipVirgemById(row.id));
} catch {
return row;
}
})
);
detailed.push(...resolved);
}
return detailed;
}
private async fetchDetailedControleRowsForExport(rows: ControleRecebidoListDto[]): Promise<ControleRecebidoListDto[]> {
if (!rows.length) return [];
const detailed: ControleRecebidoListDto[] = [];
const chunkSize = 12;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(this.service.getControleRecebidoById(row.id));
} catch {
return row;
}
})
);
detailed.push(...resolved);
}
return detailed;
}
setControleSort(key: ControleSortKey) {
if (this.controleSortBy === key) {
this.controleSortDir = this.controleSortDir === 'asc' ? 'desc' : 'asc';
@ -720,27 +856,19 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
get activePage() { return this.activeTab === 'chips' ? this.chipsPage : this.controlePage; }
get activeTotal() { return this.activeTab === 'chips' ? this.chipsTotal : this.controleTotal; }
get activePageSize() { return this.activeTab === 'chips' ? this.chipsPageSize : this.controlePageSize; }
get activeTotalPages() { return Math.max(1, Math.ceil((this.activeTotal || 0) / (this.activePageSize || 10))); }
get activeTotalPages() { return computeTotalPages(this.activeTotal || 0, this.activePageSize || 10); }
get activePageStart() { return this.activeTotal === 0 ? 0 : (this.activePage - 1) * this.activePageSize + 1; }
get activePageEnd() { return this.activeTotal === 0 ? 0 : Math.min(this.activePage * this.activePageSize, this.activeTotal); }
get activePageStart() { return computePageStart(this.activeTotal || 0, this.activePage, this.activePageSize); }
get activePageEnd() { return computePageEnd(this.activeTotal || 0, this.activePage, this.activePageSize); }
get activeLoading() { return this.activeTab === 'chips' ? this.chipsLoading : this.controleLoading; } // ✅ novo
get activePageNumbers() {
const total = this.activeTotalPages;
const current = this.activePage;
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 = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
return buildPageNumbers(this.activePage, this.activeTotalPages);
}
goToPage(p: number) {
const target = Math.max(1, Math.min(this.activeTotalPages, p));
const target = clampPage(p, this.activeTotalPages);
if (this.activeTab === 'chips') {
this.chipsPage = target;

View File

@ -32,6 +32,10 @@
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<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">
<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>
<button *ngIf="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button>
@ -88,7 +92,6 @@
<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>
@ -181,278 +184,4 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="modal-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()">
<div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
Detalhes do Usuário
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div>
<div class="form-field"><label>TIPO</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'PESSOA JURÍDICA' : 'PESSOA FÍSICA' }}</div></div>
<div class="form-field span-2"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'RAZÃO SOCIAL' : 'NOME' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.razaoSocial || selectedRow?.cliente || '-') : (selectedRow?.nome || selectedRow?.cliente || '-') }}</div></div>
<div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div>
<div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div>
<div class="form-field"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'CNPJ' : 'CPF' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.cnpj || '-') : (selectedRow?.cpf || '-') }}</div></div>
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div>
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
<div class="form-field"><label>CELULAR</label><div>{{ selectedRow?.celular || '-' }}</div></div>
<div class="form-field"><label>TELEFONE FIXO</label><div>{{ selectedRow?.telefoneFixo || '-' }}</div></div>
<div class="form-field span-2"><label>ENDEREÇO</label><div>{{ selectedRow?.endereco || '-' }}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div *ngIf="createOpen" class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Usuário
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="createModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com Reserva</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Linha (RESERVA)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[searchable]="true"
searchPlaceholder="Pesquisar linha da reserva..."
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading"
placeholder="Selecione uma linha da Reserva..."
></app-select>
<small class="field-hint" *ngIf="createLinesLoading">Carregando linhas da Reserva...</small>
</div>
<div class="form-field">
<label>Total Franquia Line</label>
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(createFranquiaLineTotal)" readonly />
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-vcard me-2"></i> Dados do Usuário</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="createModel.tipoPessoa"
(ngModelChange)="onCreateTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.razaoSocial" />
</div>
<div class="form-field field-line">
<label>Linha</label>
<input class="form-control form-control-sm bg-light" [value]="createModel.linha || ''" readonly />
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="createModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>CPF</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cpf" /></div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'"><label>CNPJ</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cnpj" /></div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="createModel.rg" /></div>
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" [(ngModel)]="createModel.email" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="createModel.endereco" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="createModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="createModel.telefoneFixo" /></div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>Data de Nascimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createDateNascimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Usuário
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="editModel.tipoPessoa"
(ngModelChange)="onEditTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.razaoSocial" />
</div>
<div class="form-field field-line">
<label>Linha (Reserva)</label>
<app-select
class="form-select"
size="sm"
[options]="editLineOptions"
labelKey="label"
valueKey="id"
[searchable]="true"
searchPlaceholder="Pesquisar linha da reserva..."
[(ngModel)]="editSelectedLineId"
(ngModelChange)="onEditLineChange()"
[disabled]="createLinesLoading"
placeholder="Selecione uma linha da Reserva..."
></app-select>
</div>
<div class="form-field field-auto">
<label>Total Franquia Line</label>
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(editFranquiaLineTotal)" readonly />
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>CPF</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cpf" />
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>CNPJ</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cnpj" />
</div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="editModel.rg" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-envelope-paper me-2"></i> Contato</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid contact-modal-grid">
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" type="email" [(ngModel)]="editModel.email" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="editModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="editModel.telefoneFixo" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="editModel.endereco" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Complemento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Data Nascimento</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editDateNascimento" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Usuário
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>
<app-dados-usuarios-modals [vm]="$any(vm)"></app-dados-usuarios-modals>

View File

@ -350,8 +350,6 @@
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODALS */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: clamp(12px, 2.2vw, 20px); }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-xl-custom { width: min(1050px, 95vw); max-height: 86vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }

View File

@ -2,7 +2,10 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } 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 { DadosUsuariosModalsComponent } from '../../components/page-modals/dados-usuarios-modals/dados-usuarios-modals';
import { TableExportService } from '../../services/table-export.service';
import {
DadosUsuariosService,
@ -16,6 +19,13 @@ import {
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
type ViewMode = 'lines' | 'groups';
@ -36,15 +46,17 @@ interface SimpleOption {
@Component({
selector: 'app-dados-usuarios',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, DadosUsuariosModalsComponent],
templateUrl: './dados-usuarios.html',
styleUrls: ['./dados-usuarios.scss']
})
export class DadosUsuarios implements OnInit {
readonly vm = this;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
loading = false;
exporting = false;
errorMsg = '';
// Filtros
@ -116,7 +128,8 @@ export class DadosUsuarios implements OnInit {
constructor(
private service: DadosUsuariosService,
private authService: AuthService,
private linesService: LinesService
private linesService: LinesService,
private tableExportService: TableExportService
) {}
ngOnInit(): void {
@ -136,26 +149,17 @@ export class DadosUsuarios implements OnInit {
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
return computeTotalPages(this.total || 0, this.pageSize || 10);
}
get pageStart(): number { return (this.page - 1) * this.pageSize + 1; }
get pageStart(): number { return computePageStart(this.total || 0, this.page, this.pageSize); }
get pageEnd(): number {
const end = this.page * this.pageSize;
return end > this.total ? this.total : end;
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
fetch(goToPage?: number): void {
@ -257,13 +261,119 @@ export class DadosUsuarios implements OnInit {
clearFilters() { this.search = ''; this.fetch(1); }
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.fetchAllRowsForExport();
const rows = await this.fetchDetailedRowsForExport(baseRows);
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
const fileName = `dados_usuarios_${this.tipoFilter.toLowerCase()}_${timestamp}`;
await this.tableExportService.exportAsXlsx<UserDataRow>({
fileName,
sheetName: 'DadosUsuarios',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Tipo', value: (row) => this.normalizeTipo(row) },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{
header: this.tipoFilter === 'PJ' ? 'Razao Social' : 'Nome',
value: (row) => (this.normalizeTipo(row) === 'PJ' ? (row.razaoSocial ?? row.cliente ?? '') : (row.nome ?? row.cliente ?? '')),
},
{ header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
{ header: 'Linha', value: (row) => row.linha ?? '' },
{ header: 'CPF', value: (row) => row.cpf ?? '' },
{ header: 'CNPJ', value: (row) => row.cnpj ?? '' },
{ header: 'E-mail', value: (row) => row.email ?? '' },
{ header: 'Celular', value: (row) => row.celular ?? '' },
{ header: 'Telefone Fixo', value: (row) => row.telefoneFixo ?? '' },
{ header: 'RG', value: (row) => row.rg ?? '' },
{ header: 'Endereco', value: (row) => row.endereco ?? '' },
{ header: 'Data de Nascimento', type: 'date', value: (row) => row.dataNascimento ?? '' },
],
});
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
} catch {
this.showToast('Erro ao exportar planilha.', 'danger');
} finally {
this.exporting = false;
}
}
private async fetchAllRowsForExport(): Promise<UserDataRow[]> {
const pageSize = 500;
let page = 1;
let expectedTotal = 0;
const all: UserDataRow[] = [];
while (page <= 500) {
const response = await firstValueFrom(
this.service.getRows({
search: this.search?.trim(),
tipo: this.tipoFilter,
page,
pageSize,
sortBy: 'item',
sortDir: 'asc',
})
);
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.sort((a, b) => {
const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
if (byClient !== 0) return byClient;
return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0);
});
}
private async fetchDetailedRowsForExport(rows: UserDataRow[]): Promise<UserDataRow[]> {
if (!rows.length) return [];
const detailed: UserDataRow[] = [];
const chunkSize = 10;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(this.service.getById(row.id));
} catch {
return row;
}
})
);
detailed.push(...resolved);
}
return detailed;
}
onPageSizeChange() {
this.page = 1;
this.fetch();
}
goToPage(p: number) {
this.page = p;
this.page = clampPage(p, this.totalPages);
this.fetch();
}

View File

@ -21,6 +21,7 @@ import {
LineTotal,
} from '../../services/resumo.service';
import { AuthService } from '../../services/auth.service';
import { buildApiBaseUrl } from '../../utils/api-base.util';
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
type KpiCard = {
@ -384,8 +385,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private router: Router,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
ngOnInit(): void {
@ -393,7 +393,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor');
this.isCliente = !(isSysAdmin || isGestor);
const isFinanceiro = this.authService.hasRole('financeiro');
this.isCliente = !(isSysAdmin || isGestor || isFinanceiro);
if (this.isCliente) {
this.loadClientDashboardData();

View File

@ -33,7 +33,12 @@
<small class="subtitle">Totais, lucro e comparativo Vivo x Line</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate></div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<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>
<!-- FILTROS -->
@ -184,7 +189,6 @@
<div class="select-wrapper">
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -327,248 +331,5 @@
</section>
<!-- MODAIS -->
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()"></div>
<div class="modal-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()">
<!-- DETAIL MODAL -->
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg detail-icon"><i class="bi bi-receipt"></i></span>
Detalhes do Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="detailData; else detailLoading">
<div class="mb-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex flex-column">
<div class="fw-black detail-client">
{{ detailData.cliente || '—' }}
</div>
<small class="text-muted fw-bold">
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
</small>
</div>
</div>
<div class="details-dashboard details-single">
<!-- IDENTIFICAÇÃO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val text-dark" [title]="detailData.cliente || ''">{{ detailData.cliente || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ detailData.tipo || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Qtd Linhas</span>
<span class="val">{{ detailData.qtdLinhas ?? 0 }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Aparelho</span>
<span class="val" [title]="detailData.aparelho || ''">{{ detailData.aparelho || '—' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Forma de Pagamento</span>
<span class="val" [title]="detailData.formaPagamento || ''">{{ detailData.formaPagamento || '—' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #detailLoading>
<div class="p-5 text-center text-muted">Carregando detalhes...</div>
</ng-template>
</div>
<!-- COMPARATIVO MODAL -->
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg compare-icon"><i class="bi bi-columns-gap"></i></span> Comparativo Vivo x Line
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="compareData; else compareLoading">
<div class="finance-dashboard">
<div class="finance-card vivo-card">
<div class="card-header-f"><i class="bi bi-telephone-fill me-2"></i> Vivo</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia</span> <strong>{{ formatFranquia(compareData.franquiaVivo) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Vivo (R$)</span> <strong>{{ formatMoney(compareData.valorContratoVivo) }}</strong></div>
</div>
</div>
<div class="finance-card line-card">
<div class="card-header-f"><i class="bi bi-hdd-network-fill me-2"></i> Line Móvel</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia Line</span> <strong>{{ formatFranquia(compareData.franquiaLine) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Line (R$)</span> <strong>{{ formatMoney(compareData.valorContratoLine) }}</strong></div>
</div>
</div>
</div>
<div class="finance-summary mt-3">
<div class="summary-item">
<span class="lbl">Forma de Pagamento</span>
<span class="val text-dark">{{ compareData.formaPagamento || '—' }}</span>
</div>
<div class="vertical-line"></div>
<div class="summary-item">
<span class="lbl">Lucro</span>
<span class="val text-brand">{{ formatMoney(compareData.lucro) }}</span>
</div>
</div>
</div>
<ng-template #compareLoading>
<div class="p-5 text-center text-muted">Carregando comparativo...</div>
</ng-template>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span> Editar Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipo">
<option value="PF">PF</option>
<option value="PJ">PJ</option>
</select>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Qtd Linhas</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.qtdLinhas" />
</div>
<div class="form-field">
<label>Aparelho</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelho" />
</div>
<div class="form-field span-2">
<label>Forma de Pagamento</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.formaPagamento" />
</div>
</div>
</div>
</details>
<details open class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Faturamento Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Vivo</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaVivo" />
</div>
<div class="form-field">
<label>Valor Vivo (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoVivo" />
</div>
</div>
</div>
</details>
<details open class="detail-box line-border">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Faturamento Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Line</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaLine" />
</div>
<div class="form-field">
<label>Valor Line (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoLine" />
</div>
<div class="form-field span-2">
<label>Lucro (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.lucro" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span> Remover Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma a exclusão do registro <strong>{{ deleteTarget?.cliente }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>
<app-faturamento-modals [vm]="$any(vm)"></app-faturamento-modals>

View File

@ -754,24 +754,6 @@
.fw-black { font-weight: 950; }
/* MODALS (mantidos do seu arquivo) */
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 9990;
backdrop-filter: blur(4px);
}
.modal-custom {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.8);

View File

@ -13,6 +13,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { FaturamentoModalsComponent } from '../../components/page-modals/faturamento-modals/faturamento-modals';
import {
BillingService,
@ -25,7 +26,17 @@ import {
} from '../../services/billing';
import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { TableExportService } from '../../services/table-export.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { normalizeAccentInsensitive } from '../../utils/text-normalization.util';
import { firstValueFrom } from 'rxjs';
interface BillingClientGroup {
cliente: string;
@ -38,11 +49,12 @@ interface BillingClientGroup {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent, FaturamentoModalsComponent],
templateUrl: './faturamento.html',
styleUrls: ['./faturamento.scss']
})
export class Faturamento implements AfterViewInit, OnDestroy {
readonly vm = this;
toastMessage = '';
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ -54,10 +66,12 @@ export class Faturamento implements AfterViewInit, OnDestroy {
private billing: BillingService,
private linesService: LinesService,
private cdr: ChangeDetectorRef,
private authService: AuthService
private authService: AuthService,
private tableExportService: TableExportService
) {}
loading = false;
exporting = false;
// filtros
searchTerm = '';
@ -218,15 +232,6 @@ export class Faturamento implements AfterViewInit, OnDestroy {
return s ? s : '—';
}
private normalizeText(s: any): string {
return (s ?? '')
.toString()
.trim()
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private buildGlobalSearchBlob(row: BillingItem): string {
const parts = [
row.tipo,
@ -242,13 +247,13 @@ export class Faturamento implements AfterViewInit, OnDestroy {
row.formaPagamento,
];
return this.normalizeText(parts.join(' '));
return normalizeAccentInsensitive(parts.join(' '));
}
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
if (filtro === 'ALL') return true;
const t = this.normalizeText(itemTipo);
const t = normalizeAccentInsensitive(itemTipo);
if (filtro === 'PF') return t === 'PF' || t.includes('FISICA');
if (filtro === 'PJ') return t === 'PJ' || t.includes('JURIDICA');
@ -415,6 +420,85 @@ export class Faturamento implements AfterViewInit, OnDestroy {
this.loadAllAndApply(forceReloadAll);
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = this.getRowsForExport();
const rows = await this.fetchDetailedRowsForExport(baseRows);
if (!rows.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
const suffix = this.filterTipo === 'ALL' ? 'todos' : this.filterTipo.toLowerCase();
await this.tableExportService.exportAsXlsx<BillingItem>({
fileName: `faturamento_${suffix}_${timestamp}`,
sheetName: 'Faturamento',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Tipo', value: (row) => row.tipo ?? '' },
{ header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{ header: 'Qtd Linhas', type: 'number', value: (row) => this.toNullableNumber(row.qtdLinhas) ?? 0 },
{ header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 },
{ header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 },
{ header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 },
{ header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 },
{ header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 },
{ header: 'Aparelho', value: (row) => row.aparelho ?? '' },
{ header: 'Forma de Pagamento', value: (row) => row.formaPagamento ?? '' },
{ header: 'Observacao', value: (row) => this.getObservacao(row) },
{ header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
],
});
await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar planilha.');
} finally {
this.exporting = false;
}
}
private getRowsForExport(): BillingItem[] {
const rows: BillingItem[] = [];
this.rowsByClient.forEach((items) => rows.push(...items));
return rows.sort((a, b) => {
const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
if (byClient !== 0) return byClient;
return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0);
});
}
private async fetchDetailedRowsForExport(rows: BillingItem[]): Promise<BillingItem[]> {
if (!rows.length) return [];
const detailed: BillingItem[] = [];
const chunkSize = 10;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(this.billing.getById(row.id));
} catch {
return row;
}
})
);
detailed.push(...resolved);
}
return detailed;
}
private getAllItems(force = false): Promise<BillingItem[]> {
const now = Date.now();
@ -549,16 +633,16 @@ export class Faturamento implements AfterViewInit, OnDestroy {
let arr = [...baseTipo];
if (this.selectedClients.length > 0) {
const set = new Set(this.selectedClients.map((x) => this.normalizeText(x)));
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
const set = new Set(this.selectedClients.map((x) => normalizeAccentInsensitive(x)));
arr = arr.filter((r) => set.has(normalizeAccentInsensitive(r.cliente)));
}
const term = this.normalizeText(this.searchTerm);
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x)));
const term = normalizeAccentInsensitive(this.searchTerm);
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => normalizeAccentInsensitive(x)));
if (term) {
arr = arr.filter((r) =>
this.buildGlobalSearchBlob(r).includes(term) ||
(resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente)))
(resolvedClientsSet.size > 0 && resolvedClientsSet.has(normalizeAccentInsensitive(r.cliente)))
);
}
@ -576,7 +660,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
const key = this.normalizeText(c);
const key = normalizeAccentInsensitive(c);
if (!key) continue;
const vivo = Number(r.valorContratoVivo ?? 0) || 0;
@ -652,7 +736,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.applyGroupPagination();
this.cdr.detectChanges();
}
@ -666,27 +750,19 @@ export class Faturamento implements AfterViewInit, OnDestroy {
}
get totalPages() {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
return computeTotalPages(this.total || 0, this.pageSize);
}
get pageStart() {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd() {
return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
get pageNumbers() {
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
// --------------------------
@ -795,4 +871,22 @@ export class Faturamento implements AfterViewInit, OnDestroy {
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -273,6 +273,29 @@
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.search-group { max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease; &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); } .input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; padding-right: 8px; display: flex; align-items: center; i { font-size: 1rem; } } .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } &:focus { outline: none; } } .btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; display: flex; align-items: center; cursor: pointer; transition: color 0.2s; &:hover { color: #dc3545; } i { font-size: 1rem; } } }
.page-size { margin-left: auto; @media (max-width: 500px) { margin-left: 0; width: 100%; justify-content: space-between; } }
.batch-status-tools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
@media (max-width: 900px) {
margin-left: 0;
width: 100%;
}
}
.batch-status-count {
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(17, 18, 20, 0.62);
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(17, 18, 20, 0.12);
background: rgba(255, 255, 255, 0.8);
}
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass { background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(17, 18, 20, 0.15); border-radius: 12px; color: var(--blue); font-weight: 800; font-size: 0.9rem; text-align: left; padding: 8px 36px 8px 14px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); cursor: pointer; transition: all 0.2s ease; width: 100%; &:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1); } &:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); } }
.select-icon { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); font-size: 0.75rem; transition: transform 0.2s ease; }
@ -500,8 +523,6 @@
/* ========================================================== */
/* 8. MODALS E FORMULÁRIOS COMPLETOS (RESTAURADOS ✅) */
/* ========================================================== */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@ -581,6 +602,140 @@
padding: 20px 22px;
}
}
.modal-card.modal-batch-status {
width: min(1120px, 96vw);
max-height: 92vh;
.modal-header {
padding: 18px 22px 16px;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.modal-title {
flex: 1 1 320px;
min-width: 0;
line-height: 1.2;
}
.batch-status-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.details-dashboard {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: start;
@media (max-width: 980px) {
grid-template-columns: 1fr;
gap: 12px;
}
}
.modal-body {
padding: 18px 22px 22px;
}
.detail-box {
height: 100%;
}
.box-body {
padding: 14px 16px 16px;
}
.form-grid {
gap: 14px 16px;
}
.form-field {
gap: 8px;
}
.reserva-confirmation-pills {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-top: 2px;
.summary-pill {
margin: 0;
white-space: normal;
line-height: 1.25;
}
}
.batch-status-note {
display: block;
margin-top: 10px;
font-size: 0.8rem;
line-height: 1.4;
color: rgba(17, 18, 20, 0.58);
}
@media (max-width: 980px) {
.modal-header {
padding: 16px 18px 14px;
}
.batch-status-header-actions {
width: 100%;
justify-content: flex-end;
}
.modal-body {
padding: 16px 18px 18px;
}
.form-grid {
gap: 12px;
}
}
@media (max-width: 640px) {
.modal-header {
padding: 14px 14px 12px;
}
.modal-title {
font-size: 1rem;
gap: 10px;
}
.batch-status-header-actions {
justify-content: stretch;
.btn {
flex: 1 1 140px;
}
}
.modal-body {
padding: 14px;
}
.box-body {
padding: 12px;
}
.reserva-confirmation-pills {
gap: 8px;
.summary-pill {
width: 100%;
justify-content: flex-start;
}
}
}
}
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */

View File

@ -19,13 +19,22 @@ import {
} from '@angular/common/http';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
import { TableExportService } from '../../services/table-export.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { buildApiEndpoint } from '../../utils/api-base.util';
import {
BATCH_MASS_COLUMN_GUIDE,
type BatchMassApplyMode,
@ -264,14 +273,48 @@ interface AssignReservaLinesResultDto {
items: AssignReservaLineItemResultDto[];
}
type BatchStatusAction = 'BLOCK' | 'UNBLOCK';
interface BatchLineStatusUpdateRequestDto {
action: 'block' | 'unblock';
blockStatus?: string | null;
applyToAllFiltered: boolean;
lineIds: string[];
search?: string | null;
skil?: string | null;
clients?: string[];
additionalMode?: string | null;
additionalServices?: string | null;
usuario?: string | null;
}
interface BatchLineStatusUpdateItemResultDto {
id: string;
item?: number;
linha?: string | null;
usuario?: string | null;
statusAnterior?: string | null;
statusNovo?: string | null;
success: boolean;
message: string;
}
interface BatchLineStatusUpdateResultDto {
requested: number;
updated: number;
failed: number;
items: BatchLineStatusUpdateItemResultDto[];
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, GeralModalsComponent],
templateUrl: './geral.html',
styleUrls: ['./geral.scss']
})
export class Geral implements OnInit, AfterViewInit, OnDestroy {
readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = '';
@ -293,22 +336,17 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private router: Router,
private route: ActivatedRoute,
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService
) {}
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
private readonly templatesApiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/templates`;
})();
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates');
loading = false;
exporting = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
isClientRestricted = false;
rows: LineRow[] = [];
@ -385,6 +423,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
};
reservaTransferLastResult: AssignReservaLinesResultDto | null = null;
moveToReservaLastResult: AssignReservaLinesResultDto | null = null;
batchStatusOpen = false;
batchStatusSaving = false;
batchStatusAction: BatchStatusAction = 'BLOCK';
batchStatusType = '';
batchStatusUsuario = '';
batchStatusLastResult: BatchLineStatusUpdateResultDto | null = null;
detailData: any = null;
financeData: any = null;
@ -398,6 +442,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null;
private searchRequestVersion = 0;
private kpiRequestVersion = 0;
private groupsRequestVersion = 0;
private linesRequestVersion = 0;
@ -609,13 +654,57 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
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 {
return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva;
}
get blockedStatusOptions(): string[] {
return this.statusOptions.filter((status) => !this.isActiveStatus(status));
}
get batchStatusSelectionCount(): number {
return this.reservaSelectedCount;
}
get canOpenBatchStatusModal(): boolean {
if (!this.canManageLines) return false;
if (this.loading || this.batchStatusSaving) return false;
return this.batchStatusSelectionCount > 0;
}
get canSubmitBatchStatusModal(): boolean {
if (this.batchStatusSaving) return false;
if (this.batchStatusSelectionCount <= 0) return false;
if (this.batchStatusAction === 'BLOCK' && !String(this.batchStatusType ?? '').trim()) return false;
return true;
}
get batchStatusActionLabel(): string {
return this.batchStatusAction === 'BLOCK' ? 'Bloquear' : 'Desbloquear';
}
get batchStatusTargetDescription(): string {
return `${this.batchStatusSelectionCount} linha(s) selecionada(s)`;
}
get batchStatusUserOptions(): string[] {
const users = (this.groupLines ?? [])
.map((x) => (x.usuario ?? '').toString().trim())
.filter((x) => !!x);
const current = (this.batchStatusUsuario ?? '').toString().trim();
if (current) users.push(current);
return Array.from(new Set(users)).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
get reservaSelectedCount(): number {
return this.reservaSelectedLineIds.length;
}
@ -739,7 +828,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return;
this.isSysAdmin = this.authService.hasRole('sysadmin');
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) {
this.filterSkil = 'ALL';
@ -836,6 +926,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.groupLines = [];
this.selectedClients = [];
this.clientSearchTerm = '';
this.searchTerm = '';
this.searchResolvedClient = null;
this.page = 1;
}
@ -972,7 +1064,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
// ============================================================
private anyModalOpen(): boolean {
return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen);
return !!(
this.detailOpen ||
this.financeOpen ||
this.editOpen ||
this.createOpen ||
this.reservaTransferOpen ||
this.moveToReservaOpen ||
this.batchStatusOpen
);
}
private cleanupModalArtifacts() {
@ -1000,6 +1100,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.createOpen = false;
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
this.batchStatusOpen = false;
this.detailData = null;
this.financeData = null;
@ -1019,8 +1120,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.batchExcelTemplateDownloading = false;
this.reservaTransferSaving = false;
this.moveToReservaSaving = false;
this.batchStatusSaving = false;
this.reservaTransferLastResult = null;
this.moveToReservaLastResult = null;
this.batchStatusLastResult = null;
this.batchStatusUsuario = '';
// Limpa overlays/locks residuais
this.cleanupModalArtifacts();
@ -1094,6 +1198,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(async () => {
const requestVersion = ++this.searchRequestVersion;
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
@ -1101,6 +1207,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const term = (this.searchTerm ?? '').trim();
if (!term) {
if (requestVersion !== this.searchRequestVersion) return;
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
@ -1110,16 +1217,23 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.isSpecificSearchTerm(term)) {
const client = await this.resolveSearchToClient(term);
if (requestVersion !== this.searchRequestVersion) return;
if (client) {
this.searchResolvedClient = client;
this.loadKpis();
await this.loadOnlyThisClientGroup(client);
if (requestVersion !== this.searchRequestVersion) return;
this.expandedGroup = client;
this.fetchGroupLines(client, term);
return;
}
}
if (requestVersion !== this.searchRequestVersion) return;
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
@ -1833,27 +1947,68 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.fetchGroupLines(clientName, useTerm);
}
fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion;
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
let params = new HttpParams()
private async fetchAllGroupLines(
clientName: string,
search: string | undefined,
requestVersion: number
): Promise<void> {
try {
let baseParams = new HttpParams()
.set('client', clientName)
.set('page', '1')
.set('pageSize', '500')
.set('sortBy', 'item')
.set('sortDir', 'asc');
params = this.applyBaseFilters(params);
if (search) params = params.set('search', search);
baseParams = this.applyBaseFilters(baseParams);
if (search) {
baseParams = baseParams.set('search', search);
}
const pageSize = 5000;
let page = 1;
let expectedTotal = 0;
const allItems: ApiLineList[] = [];
while (page <= 500) {
const params = baseParams
.set('page', String(page))
.set('pageSize', String(pageSize));
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, {
params: this.withNoCache(params)
})
);
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) }).subscribe({
next: (res) => {
if (requestVersion !== this.linesRequestVersion) return;
const filteredItems = this.applyAdditionalFiltersClientSide(res.items ?? []);
this.groupLines = filteredItems.map((x) => ({
const items = response?.items ?? [];
expectedTotal = this.toInt(response?.total);
allItems.push(...items);
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && allItems.length >= expectedTotal) break;
page += 1;
}
if (requestVersion !== this.linesRequestVersion) return;
const filteredItems = this.applyAdditionalFiltersClientSide(allItems);
this.groupLines = filteredItems.map((x) => this.mapApiLineListToLineRow(x));
this.loadingLines = false;
this.cdr.detectChanges();
} catch {
if (requestVersion !== this.linesRequestVersion) return;
this.loadingLines = false;
await this.showToast('Erro ao carregar linhas do grupo.');
}
}
private mapApiLineListToLineRow(x: ApiLineList): LineRow {
return {
id: x.id,
item: String(x.item ?? ''),
linha: x.linha ?? '',
@ -1867,15 +2022,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
status: x.status ?? '',
skil: x.skil ?? '',
contrato: x.vencConta ?? ''
}));
this.loadingLines = false;
},
error: () => {
if (requestVersion !== this.linesRequestVersion) return;
this.loadingLines = false;
this.showToast('Erro ao carregar linhas do grupo.');
};
}
});
fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion;
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
void this.fetchAllGroupLines(clientName, search, requestVersion);
}
toggleClientMenu() {
@ -1969,7 +2125,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.refreshData({ keepCurrentPage: true });
}
@ -1984,7 +2140,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
get totalPages() {
if (this.selectedClients.length > 0) return 1;
if (this.searchResolvedClient) return 1;
return Math.ceil((this.total || 0) / this.pageSize) || 1;
return computeTotalPages(this.total || 0, this.pageSize);
}
get filteredCount() {
@ -1992,7 +2148,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get pageStart() {
return this.filteredCount === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
return computePageStart(this.filteredCount, this.page, this.pageSize);
}
get pageEnd() {
@ -2010,15 +2166,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get pageNumbers() {
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
clearSearch() {
@ -2030,6 +2178,242 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.getRowsForExport();
const rows = await this.getDetailedRowsForExport(baseRows);
if (!rows.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const suffix = this.getExportFilterSuffix();
const timestamp = this.tableExportService.buildTimestamp();
const fileName = `geral_${suffix}_${timestamp}`;
const templateBuffer = await this.getGeralTemplateBuffer();
await this.tableExportService.exportAsXlsx<ApiLineDetail>({
fileName,
sheetName: 'Geral',
templateBuffer,
rows,
columns: [
{ header: 'ID', value: (row) => row.id },
{ header: 'Item', type: 'number', value: (row) => this.toInt(row.item) },
{ header: 'Empresa (Conta)', value: (row) => this.findEmpresaByConta(row.conta) },
{ header: 'Conta', value: (row) => row.conta ?? '' },
{ header: 'Linha', value: (row) => row.linha ?? '' },
{ header: 'Chip', value: (row) => row.chip ?? '' },
{ header: 'Tipo de Chip', value: (row) => row.tipoDeChip ?? '' },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{ header: 'Usuario', value: (row) => row.usuario ?? '' },
{ header: 'Centro de Custos', value: (row) => row.centroDeCustos ?? '' },
{ header: 'Setor ID', value: (row) => row.setorId ?? '' },
{ header: 'Setor', value: (row) => row.setorNome ?? '' },
{ header: 'Aparelho ID', value: (row) => row.aparelhoId ?? '' },
{ header: 'Aparelho', value: (row) => row.aparelhoNome ?? '' },
{ header: 'Cor do Aparelho', value: (row) => row.aparelhoCor ?? '' },
{ header: 'IMEI do Aparelho', value: (row) => row.aparelhoImei ?? '' },
{ header: 'NF do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoNotaFiscalTemArquivo },
{ header: 'Recibo do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoReciboTemArquivo },
{ header: 'Plano Contrato', value: (row) => row.planoContrato ?? '' },
{ header: 'Status', value: (row) => row.status ?? '' },
{ header: 'Tipo (Skil)', value: (row) => row.skil ?? '' },
{ header: 'Modalidade', value: (row) => row.modalidade ?? '' },
{ header: 'Cedente', value: (row) => row.cedente ?? '' },
{ header: 'Solicitante', value: (row) => row.solicitante ?? '' },
{ header: 'Data de Bloqueio', type: 'date', value: (row) => row.dataBloqueio ?? '' },
{ header: 'Data Entrega Operadora', type: 'date', value: (row) => row.dataEntregaOpera ?? '' },
{ header: 'Data Entrega Cliente', type: 'date', value: (row) => row.dataEntregaCliente ?? '' },
{ header: 'Dt. Efetivacao Servico', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' },
{ header: 'Dt. Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' },
{ header: 'Vencimento da Conta', value: (row) => row.vencConta ?? '' },
{ header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 },
{ header: 'Valor Plano Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorPlanoVivo) ?? 0 },
{ header: 'Gestao Voz e Dados', type: 'currency', value: (row) => this.toNullableNumber(row.gestaoVozDados) ?? 0 },
{ header: 'Skeelo', type: 'currency', value: (row) => this.toNullableNumber(row.skeelo) ?? 0 },
{ header: 'Vivo News Plus', type: 'currency', value: (row) => this.toNullableNumber(row.vivoNewsPlus) ?? 0 },
{ header: 'Vivo Travel Mundo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoTravelMundo) ?? 0 },
{ header: 'Vivo Gestao Dispositivo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoGestaoDispositivo) ?? 0 },
{ header: 'Vivo Sync', type: 'currency', value: (row) => this.toNullableNumber(row.vivoSync) ?? 0 },
{ header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 },
{ header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 },
{ header: 'Franquia Gestao', type: 'number', value: (row) => this.toNullableNumber(row.franquiaGestao) ?? 0 },
{ header: 'Locacao AP', type: 'currency', value: (row) => this.toNullableNumber(row.locacaoAp) ?? 0 },
{ header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 },
{ header: 'Desconto', type: 'currency', value: (row) => this.toNullableNumber(row.desconto) ?? 0 },
{ header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 },
{ header: 'Criado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['createdAt', 'CreatedAt']) ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['updatedAt', 'UpdatedAt']) ?? '' },
],
});
await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar a planilha.');
} finally {
this.exporting = false;
}
}
private async getRowsForExport(): Promise<LineRow[]> {
let lines = await this.fetchLinesForGrouping();
if (this.selectedClients.length > 0) {
const selected = new Set(
this.selectedClients.map((client) => (client ?? '').toString().trim().toUpperCase())
);
lines = lines.filter((line) => selected.has((line.cliente ?? '').toString().trim().toUpperCase()));
}
const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE';
const mapped = lines.map((line) => ({
id: (line.id ?? '').toString(),
item: String(line.item ?? ''),
linha: line.linha ?? '',
chip: line.chip ?? '',
cliente: ((line.cliente ?? '').toString().trim()) || fallbackClient,
usuario: line.usuario ?? '',
centroDeCustos: line.centroDeCustos ?? '',
setorNome: line.setorNome ?? '',
aparelhoNome: line.aparelhoNome ?? '',
aparelhoCor: line.aparelhoCor ?? '',
status: line.status ?? '',
skil: line.skil ?? '',
contrato: line.vencConta ?? '',
}));
return mapped.sort((a, b) => {
const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
if (byClient !== 0) return byClient;
const byItem = this.toInt(a.item) - this.toInt(b.item);
if (byItem !== 0) return byItem;
return (a.linha ?? '').localeCompare(b.linha ?? '', 'pt-BR', { sensitivity: 'base' });
});
}
private async getDetailedRowsForExport(baseRows: LineRow[]): Promise<ApiLineDetail[]> {
if (!baseRows.length) return [];
const result: ApiLineDetail[] = [];
const chunkSize = 8;
for (let i = 0; i < baseRows.length; i += chunkSize) {
const chunk = baseRows.slice(i, i + chunkSize);
const fetched = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(
this.http.get<ApiLineDetail>(`${this.apiBase}/${row.id}`, {
params: this.withNoCache(new HttpParams()),
})
);
} catch {
return this.toDetailFallback(row);
}
})
);
result.push(...fetched);
}
return result;
}
private toDetailFallback(row: LineRow): ApiLineDetail {
return {
id: row.id,
item: this.toInt(row.item),
qtdLinhas: null,
conta: row.contrato ?? null,
linha: row.linha ?? null,
chip: row.chip ?? null,
tipoDeChip: null,
cliente: row.cliente ?? null,
usuario: row.usuario ?? null,
centroDeCustos: row.centroDeCustos ?? null,
setorId: null,
setorNome: row.setorNome ?? null,
aparelhoId: null,
aparelhoNome: row.aparelhoNome ?? null,
aparelhoCor: row.aparelhoCor ?? null,
aparelhoImei: null,
aparelhoNotaFiscalTemArquivo: false,
aparelhoReciboTemArquivo: false,
planoContrato: null,
status: row.status ?? null,
skil: row.skil ?? null,
modalidade: null,
dataBloqueio: null,
cedente: null,
solicitante: null,
dataEntregaOpera: null,
dataEntregaCliente: null,
dtEfetivacaoServico: null,
dtTerminoFidelizacao: null,
vencConta: row.contrato ?? null,
franquiaVivo: null,
valorPlanoVivo: null,
gestaoVozDados: null,
skeelo: null,
vivoNewsPlus: null,
vivoTravelMundo: null,
vivoGestaoDispositivo: null,
vivoSync: null,
valorContratoVivo: null,
franquiaLine: null,
franquiaGestao: null,
locacaoAp: null,
valorContratoLine: null,
desconto: null,
lucro: null,
};
}
private async getGeralTemplateBuffer(): Promise<ArrayBuffer | null> {
try {
const params = new HttpParams().set('_', `${Date.now()}`);
const blob = await firstValueFrom(
this.http.get(`${this.templatesApiBase}/planilha-geral`, {
params,
responseType: 'blob',
})
);
return await blob.arrayBuffer();
} catch {
return null;
}
}
private getExportFilterSuffix(): string {
const parts: string[] = [];
if (this.filterSkil === 'PF') parts.push('pf');
else if (this.filterSkil === 'PJ') parts.push('pj');
else if (this.filterSkil === 'RESERVA') parts.push('reserva');
else parts.push('todas');
if (this.filterStatus === 'BLOCKED') {
if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120');
else parts.push('bloqueadas');
}
if (this.additionalMode === 'WITH') parts.push('com-adicionais');
else if (this.additionalMode === 'WITHOUT') parts.push('sem-adicionais');
if (this.selectedAdditionalServices.length > 0) {
parts.push(this.selectedAdditionalServices.join('-'));
}
return parts.join('_');
}
async onImportExcel() {
if (!this.isSysAdmin) {
await this.showToast('Você não tem permissão para importar planilha.');
@ -2093,6 +2477,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onEditar(r: LineRow) {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.editOpen = true;
this.editSaving = false;
this.requestSaving = false;
@ -2173,6 +2562,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async saveEdit() {
if (this.isFinanceiro) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editingId || !this.editModel || this.requestSaving) return;
this.editSaving = true;
@ -2307,6 +2700,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async requestLineBlock() {
if (!this.isClientRestricted) {
await this.showToast('Somente cliente pode solicitar bloqueio por essa ação.');
return;
}
if (!this.editingId || this.requestSaving) return;
this.requestSaving = true;
@ -2324,7 +2722,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const msg = (err as HttpErrorResponse)?.error?.message || 'Erro ao enviar solicitação de bloqueio.';
await this.showToast(msg);
}
}
}
onAparelhoNotaFiscalSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
@ -2469,7 +2867,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onCadastrarLinha() {
if (this.isClientRestricted) {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para cadastrar novos clientes.');
return;
}
@ -2482,7 +2880,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onAddLineToGroup(clientName: string) {
if (this.isClientRestricted) {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para adicionar linhas.');
return;
}
@ -3464,6 +3862,116 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.reservaSelectedLineIds = [];
}
async openBatchStatusModal(action: BatchStatusAction) {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.');
return;
}
if (this.batchStatusSelectionCount <= 0) {
await this.showToast('Selecione ao menos uma linha para processar.');
return;
}
this.batchStatusAction = action;
this.batchStatusSaving = false;
this.batchStatusLastResult = null;
this.batchStatusUsuario = '';
if (action === 'BLOCK') {
const current = (this.batchStatusType ?? '').toString().trim();
const options = this.blockedStatusOptions;
if (!current || !options.some((x) => x === current)) {
this.batchStatusType = options[0] ?? '';
}
} else {
this.batchStatusType = '';
}
this.batchStatusOpen = true;
this.cdr.detectChanges();
}
async submitBatchStatusUpdate() {
if (this.batchStatusSaving) return;
if (!this.canSubmitBatchStatusModal) return;
const payload = this.buildBatchStatusPayload();
this.batchStatusSaving = true;
this.http.post<BatchLineStatusUpdateResultDto>(`${this.apiBase}/batch-status-update`, payload).subscribe({
next: async (res) => {
this.batchStatusSaving = false;
this.batchStatusLastResult = res;
const ok = Number(res?.updated ?? 0) || 0;
const failed = Number(res?.failed ?? 0) || 0;
this.batchStatusOpen = false;
this.clearReservaSelection();
this.batchStatusUsuario = '';
await this.showToast(
failed > 0
? `${this.batchStatusActionLabel} em lote concluído com pendências: ${ok} linha(s) processada(s), ${failed} falha(s).`
: `${this.batchStatusActionLabel} em lote concluído: ${ok} linha(s) processada(s).`
);
if (this.expandedGroup) {
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
this.loadKpis();
},
error: async (err: HttpErrorResponse) => {
this.batchStatusSaving = false;
const msg = (err.error as any)?.message || 'Erro ao processar bloqueio/desbloqueio em lote.';
await this.showToast(msg);
}
});
}
private buildBatchStatusPayload(): BatchLineStatusUpdateRequestDto {
const clients = this.searchResolvedClient
? [this.searchResolvedClient]
: [...this.selectedClients];
const normalizedClients = clients
.map((x) => (x ?? '').toString().trim())
.filter((x) => !!x);
const userFilter = (this.batchStatusUsuario ?? '').toString().trim();
return {
action: this.batchStatusAction === 'BLOCK' ? 'block' : 'unblock',
blockStatus: this.batchStatusAction === 'BLOCK' ? (this.batchStatusType || null) : null,
applyToAllFiltered: false,
lineIds: [...this.reservaSelectedLineIds],
search: (this.searchTerm ?? '').toString().trim() || null,
skil: this.resolveFilterSkilForApi(),
clients: normalizedClients,
additionalMode: this.resolveAdditionalModeForApi(),
additionalServices: this.selectedAdditionalServices.length > 0 ? this.selectedAdditionalServices.join(',') : null,
usuario: userFilter || null
};
}
private resolveFilterSkilForApi(): string | null {
if (this.filterSkil === 'PF') return 'PESSOA FÍSICA';
if (this.filterSkil === 'PJ') return 'PESSOA JURÍDICA';
if (this.filterSkil === 'RESERVA') return 'RESERVA';
return null;
}
private resolveAdditionalModeForApi(): string | null {
if (this.additionalMode === 'WITH') return 'with';
if (this.additionalMode === 'WITHOUT') return 'without';
return null;
}
async openReservaTransferModal() {
if (!this.isReservaExpandedGroup) {
await this.showToast('Abra um grupo no filtro Reserva para selecionar e atribuir linhas.');
@ -3885,6 +4393,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return v || '-';
}
private isActiveStatus(status: string | null | undefined): boolean {
const normalized = (status ?? '').toString().trim().toLowerCase();
if (!normalized) return false;
return normalized.includes('ativo');
}
private toEditModel(d: ApiLineDetail): any {
return {
...d,
@ -3963,6 +4477,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return Math.abs(a - b) < 0.000001;
}
private getAnyField(row: unknown, keys: string[]): unknown {
const source = row as Record<string, unknown>;
for (const key of keys) {
if (source && source[key] !== undefined && source[key] !== null && source[key] !== '') {
return source[key];
}
}
return null;
}
private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim();
if (!v) return list;

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,596 @@
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';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
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 = clampPage(target, this.totalPages);
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 computeTotalPages(this.total || 0, this.pageSize);
}
get pageNumbers(): number[] {
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
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

@ -33,6 +33,10 @@
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<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">
<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>

View File

@ -2,9 +2,18 @@ import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PL
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, HistoricoQuery } from '../../services/historico.service';
import { TableExportService } from '../../services/table-export.service';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
interface SelectOption {
value: string;
@ -23,6 +32,7 @@ export class Historico implements OnInit {
logs: AuditLogDto[] = [];
loading = false;
exporting = false;
error = false;
errorMsg = '';
toastMessage = '';
@ -65,7 +75,8 @@ export class Historico implements OnInit {
constructor(
private historicoService: HistoricoService,
private cdr: ChangeDetectorRef,
@Inject(PLATFORM_ID) private platformId: object
@Inject(PLATFORM_ID) private platformId: object,
private tableExportService: TableExportService
) {}
ngOnInit(): void {
@ -111,35 +122,66 @@ export class Historico implements OnInit {
this.fetch();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const logs = await this.fetchAllLogsForExport();
if (!logs.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<AuditLogDto>({
fileName: `historico_${timestamp}`,
sheetName: 'Historico',
rows: logs,
columns: [
{ header: 'ID', value: (log) => log.id ?? '' },
{ header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' },
{ header: 'Usuario', value: (log) => this.displayUserName(log) },
{ header: 'E-mail', value: (log) => log.userEmail ?? '' },
{ header: 'Pagina', value: (log) => log.page ?? '' },
{ header: 'Acao', value: (log) => this.formatAction(log.action) },
{ header: 'Entidade', value: (log) => this.displayEntity(log) },
{ header: 'Id Entidade', value: (log) => log.entityId ?? '' },
{ header: 'Metodo HTTP', value: (log) => log.requestMethod ?? '' },
{ header: 'Endpoint', value: (log) => log.requestPath ?? '' },
{ header: 'IP', value: (log) => log.ipAddress ?? '' },
{ header: 'Mudancas', value: (log) => this.formatChangesSummary(log) },
{ header: 'Qtd Mudancas', type: 'number', value: (log) => log.changes?.length ?? 0 },
],
});
await this.showToast(`Planilha exportada com ${logs.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar planilha.');
} finally {
this.exporting = false;
}
}
goToPage(p: number): void {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.fetch();
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
return computeTotalPages(this.total || 0, this.pageSize);
}
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
toggleDetails(log: AuditLogDto, event?: Event): void {
@ -217,14 +259,9 @@ export class Historico implements OnInit {
this.expandedLogId = null;
const query: HistoricoQuery = {
...this.buildBaseQuery(),
page: this.page,
pageSize: this.pageSize,
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
user: this.filterUser?.trim() || undefined,
search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
this.historicoService.list(query).subscribe({
@ -247,6 +284,58 @@ export class Historico implements OnInit {
});
}
private async fetchAllLogsForExport(): Promise<AuditLogDto[]> {
const pageSize = 500;
let page = 1;
let expectedTotal = 0;
const all: AuditLogDto[] = [];
while (page <= 500) {
const response = await firstValueFrom(
this.historicoService.list({
...this.buildBaseQuery(),
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<HistoricoQuery, 'page' | 'pageSize'> {
return {
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
user: this.filterUser?.trim() || undefined,
search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
}
private formatChangesSummary(log: AuditLogDto): string {
const changes = log?.changes ?? [];
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 toIsoDate(value: string, endOfDay: boolean): string | null {
if (!value) return null;
const time = endOfDay ? '23:59:59' : '00:00:00';

View File

@ -31,7 +31,11 @@
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<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>
<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
</button>
</div>
@ -173,10 +177,10 @@
<button class="btn-icon info" (click)="onView(r)" title="Ver Detalhes">
<i class="bi bi-eye"></i>
</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>
</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>
</button>
</div>
@ -218,316 +222,4 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen || deleteOpen || detailOpen" (click)="closeEdit(); closeCreate(); closeDelete(); closeDetail()"></div>
<!-- ============================== -->
<!-- EDIT MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Registro Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-card-text me-2"></i> Informações</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="editModel.selectedClient" (ngModelChange)="onEditClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select da Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL)</label>
<app-select class="form-control" size="sm" [options]="lineOptionsEdit" labelKey="label" valueKey="id" [(ngModel)]="editModel.mobileLineId" (ngModelChange)="onEditLineChange()" [disabled]="!editModel.selectedClient || editLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataDaMureg" />
</div>
<!-- LinhaAntiga (snapshot) - preenchido automaticamente -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="editModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ editModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">
<span class="spinner-border me-2"></span> Preparando edição...
</div>
</ng-template>
</div>
</div>
</div>
<!-- ============================== -->
<!-- CREATE MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-pencil me-2"></i> Preencha os dados</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="createModel.selectedClient" (ngModelChange)="onCreateClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
<app-select class="form-control" size="sm" [options]="lineOptionsCreate" labelKey="label" valueKey="id" [(ngModel)]="createModel.mobileLineId" (ngModelChange)="onCreateLineChange()" [disabled]="!createModel.selectedClient || createLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" />
</div>
<!-- snapshot -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova <span class="text-danger">*</span></label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="createModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ createModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================== -->
<!-- DETAIL MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="detailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-eye"></i></span>
Detalhes da Mureg
</div>
<button class="btn btn-sm btn-icon" (click)="closeDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="detailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!detailLoading && detailData">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Mureg</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Linha Nova</span>
<span class="val text-blue fs-4">{{ detailData.linhaNova || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Linha Antiga</span>
<span class="val">{{ detailData.linhaAntiga || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ detailData.cliente || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ detailData.usuario || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ detailData.item || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Data Mureg</span>
<span class="val">{{ displayValue('dataDaMureg', detailData.dataDaMureg) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">ICCID</span>
<span class="val small-text">{{ detailData.iccid || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Skil</span>
<span class="val">{{ detailData.skil || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================== -->
<!-- DELETE MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="deleteOpen">
<div class="modal-card modal-sm" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Excluir Mureg
</div>
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
</div>
<div class="modal-body">
<p class="mb-2 fw-bold">Tem certeza que deseja excluir esta Mureg?</p>
<div class="text-muted small">
<div><strong>Cliente:</strong> {{ deleteTarget?.cliente || '-' }}</div>
<div><strong>Linha nova:</strong> {{ deleteTarget?.linhaNova || '-' }}</div>
<div><strong>Linha antiga:</strong> {{ deleteTarget?.linhaAntiga || '-' }}</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
Cancelar
</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()" [disabled]="deleteSaving">
<span *ngIf="!deleteSaving"><i class="bi bi-trash me-1"></i> Excluir</span>
<span *ngIf="deleteSaving"><span class="spinner-border spinner-border-sm me-2"></span> Excluindo...</span>
</button>
</div>
</div>
</div>
</div>
<app-mureg-modals [vm]="$any(vm)"></app-mureg-modals>

View File

@ -277,8 +277,6 @@
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODALS */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
.modal-card.modal-xl-custom { width: min(920px, 90vw); max-height: 78vh; }
.modal-card.modal-sm { width: min(480px, 100%); }

View File

@ -10,10 +10,22 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import { MuregModalsComponent } from '../../components/page-modals/mureg-modals/mureg-modals';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { buildApiEndpoint } from '../../utils/api-base.util';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
@ -75,15 +87,28 @@ interface MuregDetailDto {
statusNaGeral: string | null;
}
type MuregExportRow = MuregRow & {
usuario?: string | null;
skil?: string | null;
linhaAtualNaGeral?: string | null;
chipNaGeral?: string | null;
contaNaGeral?: string | null;
statusNaGeral?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
};
@Component({
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, MuregModalsComponent],
templateUrl: './mureg.html',
styleUrls: ['./mureg.scss']
})
export class Mureg implements AfterViewInit {
readonly vm = this;
toastMessage = '';
loading = false;
exporting = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ -91,14 +116,12 @@ export class Mureg implements AfterViewInit {
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef,
private linesService: LinesService
private authService: AuthService,
private linesService: LinesService,
private tableExportService: TableExportService
) {}
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/mureg`;
})();
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'mureg');
// ====== DATA ======
clientGroups: ClientGroup[] = [];
@ -162,9 +185,20 @@ export class Mureg implements AfterViewInit {
clienteInfo: ''
};
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => {
this.preloadClients(); // ✅ já deixa o select pronto
this.refresh();
@ -184,6 +218,147 @@ export class Mureg implements AfterViewInit {
this.loadForGroups();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.fetchAllRowsForExport();
const rows = await this.fetchDetailedRowsForExport(baseRows);
if (!rows.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<MuregExportRow>({
fileName: `mureg_${timestamp}`,
sheetName: 'Mureg',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Cliente', value: (row) => row.cliente },
{ header: 'Usuario', value: (row) => row.usuario ?? '' },
{ header: 'Skil', value: (row) => row.skil ?? '' },
{ header: 'Item', type: 'number', value: (row) => this.toIntOrZero(row.item) },
{ header: 'Linha Antiga', value: (row) => row.linhaAntiga },
{ header: 'Linha Nova', value: (row) => row.linhaNova },
{ header: 'ICCID', value: (row) => row.iccid },
{ header: 'Data da Mureg', type: 'date', value: (row) => row.dataDaMureg },
{ header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') },
{ header: 'Linha ID (Geral)', value: (row) => row.mobileLineId ?? '' },
{ header: 'Linha Atual na Geral', value: (row) => row.linhaAtualNaGeral ?? '' },
{ header: 'Chip na Geral', value: (row) => row.chipNaGeral ?? '' },
{ header: 'Conta na Geral', value: (row) => row.contaNaGeral ?? '' },
{ header: 'Status na Geral', value: (row) => row.statusNaGeral ?? '' },
{ header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
],
});
await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar planilha.');
} finally {
this.exporting = false;
}
}
private async fetchAllRowsForExport(): Promise<MuregRow[]> {
const pageSize = 2000;
let page = 1;
let expectedTotal = 0;
const rows: MuregRow[] = [];
while (page <= 500) {
const params = new HttpParams()
.set('page', String(page))
.set('pageSize', String(pageSize))
.set('search', (this.searchTerm ?? '').trim())
.set('sortBy', 'cliente')
.set('sortDir', 'asc');
const response = await firstValueFrom(
this.http.get<ApiPagedResult<any> | any[]>(this.apiBase, { params })
);
const items = Array.isArray(response) ? response : (response.items ?? []);
const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx));
rows.push(...normalized);
expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0);
if (Array.isArray(response)) break;
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && rows.length >= expectedTotal) break;
page += 1;
}
return rows.sort((a, b) => {
const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
if (byClient !== 0) return byClient;
const byItem = this.toIntOrZero(a.item) - this.toIntOrZero(b.item);
if (byItem !== 0) return byItem;
return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' });
});
}
private async fetchDetailedRowsForExport(rows: MuregRow[]): Promise<MuregExportRow[]> {
if (!rows.length) return [];
const result: MuregExportRow[] = [];
const chunkSize = 10;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const detailedChunk = await Promise.all(
chunk.map(async (row) => {
try {
const detail = await firstValueFrom(this.http.get<MuregDetailDto>(`${this.apiBase}/${row.id}`));
const merged: MuregExportRow = {
...row,
item: detail.item !== undefined && detail.item !== null ? String(detail.item) : row.item,
linhaAntiga: detail.linhaAntiga ?? row.linhaAntiga,
linhaNova: detail.linhaNova ?? row.linhaNova,
iccid: detail.iccid ?? row.iccid,
dataDaMureg: detail.dataDaMureg ?? row.dataDaMureg,
cliente: detail.cliente ?? row.cliente,
mobileLineId: detail.mobileLineId ?? row.mobileLineId,
usuario: detail.usuario ?? null,
skil: detail.skil ?? null,
linhaAtualNaGeral: detail.linhaAtualNaGeral ?? null,
chipNaGeral: detail.chipNaGeral ?? null,
contaNaGeral: detail.contaNaGeral ?? null,
statusNaGeral: detail.statusNaGeral ?? null,
createdAt: this.getRawField(detail, ['createdAt', 'CreatedAt']) ?? this.getRawField(row.raw, ['createdAt', 'CreatedAt']),
updatedAt: this.getRawField(detail, ['updatedAt', 'UpdatedAt']) ?? this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']),
};
return merged;
} catch {
return {
...row,
usuario: this.getRawField(row.raw, ['usuario', 'Usuario']),
skil: this.getRawField(row.raw, ['skil', 'Skil']),
linhaAtualNaGeral: this.getRawField(row.raw, ['linhaAtualNaGeral', 'LinhaAtualNaGeral']),
chipNaGeral: this.getRawField(row.raw, ['chipNaGeral', 'ChipNaGeral']),
contaNaGeral: this.getRawField(row.raw, ['contaNaGeral', 'ContaNaGeral']),
statusNaGeral: this.getRawField(row.raw, ['statusNaGeral', 'StatusNaGeral']),
createdAt: this.getRawField(row.raw, ['createdAt', 'CreatedAt']),
updatedAt: this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']),
};
}
})
);
result.push(...detailedChunk);
}
return result;
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
@ -208,29 +383,20 @@ export class Mureg implements AfterViewInit {
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.applyPagination();
}
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); }
get pageNumbers() {
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); }
get pageEnd() {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
trackById(_: number, row: MuregRow) { return row.id; }
@ -468,6 +634,11 @@ export class Mureg implements AfterViewInit {
// CREATE MODAL
// =======================================================================
onCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.preloadClients();
this.createOpen = true;
@ -480,7 +651,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: '',
dataDaMureg: this.nowDateInput(),
clienteInfo: ''
};
@ -507,6 +678,11 @@ export class Mureg implements AfterViewInit {
}
saveCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
const mobileLineId = String(this.createModel.mobileLineId ?? '').trim();
const linhaNova = String(this.createModel.linhaNova ?? '').trim();
@ -523,7 +699,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: (this.createModel.linhaAntiga ?? '') || null,
linhaNova: (this.createModel.linhaNova ?? '') || 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;
@ -547,6 +723,11 @@ export class Mureg implements AfterViewInit {
// EDIT MODAL
// =======================================================================
onEditar(r: MuregRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.preloadClients();
this.editOpen = true;
@ -614,6 +795,11 @@ export class Mureg implements AfterViewInit {
}
saveEdit() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editModel || !this.editModel.id) return;
const mobileLineId = String(this.editModel.mobileLineId ?? '').trim();
@ -688,6 +874,11 @@ export class Mureg implements AfterViewInit {
// DELETE MODAL
// =======================================================================
onDelete(row: MuregRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.deleteTarget = row;
this.deleteOpen = true;
this.deleteSaving = false;
@ -700,6 +891,11 @@ export class Mureg implements AfterViewInit {
}
async confirmDelete() {
if (!this.canManageRecords) {
await this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.deleteTarget?.id) return;
if (!(await confirmDeletionWithTyping('esta Mureg'))) return;
@ -758,6 +954,14 @@ export class Mureg implements AfterViewInit {
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 {
try {
const m1 = err?.error?.message;
@ -770,6 +974,15 @@ export class Mureg implements AfterViewInit {
}
}
private getRawField(source: any, keys: string[]): string | null {
for (const key of keys) {
const value = source?.[key];
if (value === undefined || value === null || String(value).trim() === '') continue;
return String(value);
}
return null;
}
displayValue(key: MuregKey, v: any): string {
if (v === null || v === undefined || String(v).trim() === '') return '-';

View File

@ -1,4 +1,14 @@
<section class="parcelamentos-page">
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 12000;">
<div class="toast border-0 shadow" [class.show]="toastOpen" [class.text-bg-success]="toastType === 'success'" [class.text-bg-danger]="toastType === 'danger'">
<div class="toast-header border-bottom-0">
<strong class="me-auto">LineGestao</strong>
<button type="button" class="btn-close" (click)="toastOpen = false"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark fw-bold">{{ toastMessage }}</div>
</div>
</div>
<div class="container-geral-responsive">
<div class="parcelamentos-shell">
<header class="page-header">
@ -15,7 +25,11 @@
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-repeat"></i> Atualizar
</button>
<button class="btn-primary" type="button" (click)="openCreateModal()">
<button class="btn-ghost" type="button" (click)="onExport()" [disabled]="loading || exporting">
<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>
</button>
<button *ngIf="canManageRecords" class="btn-primary" type="button" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i> Novo Parcelamento
</button>
</div>
@ -65,7 +79,8 @@
[total]="total"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[isSysAdmin]="isSysAdmin"
[canEdit]="$any(canManageRecords)"
[canDelete]="$any(isSysAdmin)"
(segmentChange)="setSegment($event)"
(detail)="openDetails($event)"
(edit)="openEdit($event)"
@ -77,175 +92,4 @@
</div>
</section>
<!-- Modal detalhes -->
<div class="lg-backdrop" *ngIf="detailOpen" (click)="closeDetails()"></div>
<div class="lg-modal" *ngIf="detailOpen">
<div class="lg-modal-card parcelamento-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-card-list"></i></span>
<span>Detalhes do Parcelamento</span>
</div>
<div class="modal-actions">
<button class="btn-icon" type="button" (click)="closeDetails()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="detail-state" *ngIf="detailLoading && !selectedDetail">
<div class="spinner-border text-brand" role="status"></div>
<span>Carregando detalhes...</span>
</div>
<div class="detail-state error" *ngIf="!detailLoading && detailError && !selectedDetail">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ detailError }}</span>
</div>
<ng-container *ngIf="selectedDetail as detail">
<div class="detail-grid">
<div class="detail-card">
<small>Cliente</small>
<span class="detail-strong">{{ detail.cliente || '-' }}</span>
</div>
<div class="detail-card">
<small>Linha</small>
<span class="detail-strong text-blue">{{ detail.linha || '-' }}</span>
</div>
<div class="detail-card">
<small>AnoRef</small>
<span>{{ detail.anoRef ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Item</small>
<span>{{ detail.item ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Qt Parcelas</small>
<span>{{ displayQtParcelas(detail) }}</span>
</div>
<div class="detail-card">
<small>Parcela Atual</small>
<span class="detail-strong">{{ detail.parcelaAtual ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Total Parcelas</small>
<span>{{ detail.totalParcelas ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Status</small>
<span class="status-pill">{{ detailStatus }}</span>
</div>
<div class="detail-card">
<small>Valor Cheio</small>
<span>{{ formatMoney(detail.valorCheio) }}</span>
</div>
<div class="detail-card">
<small>Desconto</small>
<span class="text-danger">{{ formatMoney(detail.desconto) }}</span>
</div>
<div class="detail-card highlight">
<small>Valor com Desconto</small>
<span class="detail-strong money-strong">{{ formatMoney(detail.valorComDesconto) }}</span>
</div>
</div>
<div class="annual-section">
<div class="annual-head">
<div class="section-title">
<i class="bi bi-table"></i>
<span>Detalhamento anual</span>
</div>
</div>
<div class="annual-table-shell" *ngIf="annualRows.length > 0; else annualEmpty">
<table class="table-modern annual-table">
<thead>
<tr>
<th class="sticky-col col-1">Ano</th>
<th class="sticky-col col-2 text-end">Total</th>
<th *ngFor="let m of annualMonthHeaders" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of annualRows">
<td class="sticky-col col-1">{{ row.year }}</td>
<td class="sticky-col col-2 text-end">{{ row.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td *ngFor="let m of row.months" class="text-end">
{{ m.value !== null && m.value !== undefined ? (m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR') : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #annualEmpty>
<div class="annual-empty">
Sem dados anuais.
</div>
</ng-template>
</div>
</ng-container>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>
<app-parcelamento-create-modal
[open]="createOpen"
[model]="createModel"
[monthOptions]="monthOptions"
[loading]="createSaving"
[errorMessage]="createError"
title="Novo Parcelamento"
submitLabel="Salvar"
(close)="closeCreateModal()"
(save)="saveNewParcelamento($event)">
</app-parcelamento-create-modal>
<app-parcelamento-create-modal
*ngIf="editOpen && editModel"
[open]="editOpen"
[model]="editModel"
[monthOptions]="monthOptions"
[loading]="editSaving"
[errorMessage]="editError"
title="Editar Parcelamento"
submitLabel="Atualizar"
(close)="closeEditModal()"
(save)="saveEditParcelamento($event)">
</app-parcelamento-create-modal>
<!-- Delete modal -->
<div class="lg-backdrop" *ngIf="deleteOpen"></div>
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-compact" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Parcelamento
</div>
<button class="btn-icon" type="button" (click)="cancelDelete()" aria-label="Fechar modal de exclusao">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o parcelamento <strong>{{ deleteTarget?.linha }}</strong>?</p>
<small class="text-danger" *ngIf="deleteError">{{ deleteError }}</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="cancelDelete()">Cancelar</button>
<button class="btn-danger" type="button" [disabled]="deleteLoading" (click)="confirmDelete()">
{{ deleteLoading ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</div>
<app-parcelamentos-modals [vm]="$any(vm)"></app-parcelamentos-modals>

View File

@ -226,26 +226,6 @@
color: var(--pg-primary-strong);
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%),
rgba(15, 23, 42, 0.6);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1180px, 98vw);
max-height: 92vh;

View File

@ -2,8 +2,10 @@ import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { environment } from '../../../environments/environment';
import { finalize, Subscription, timeout } from 'rxjs';
import { finalize, Subscription, firstValueFrom, timeout } from 'rxjs';
import { ParcelamentosModalsComponent } from '../../components/page-modals/parcelamento-modals/parcelamentos-modals';
import { AuthService } from '../../services/auth.service';
import { TableExportService } from '../../services/table-export.service';
import {
ParcelamentosService,
ParcelamentoListItem,
@ -18,27 +20,35 @@ import {
import {
ParcelamentosKpisComponent,
ParcelamentoKpi,
} from './components/parcelamentos-kpis/parcelamentos-kpis';
} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis';
import {
ParcelamentosFiltersComponent,
ParcelamentosFiltersModel,
FilterChip,
} from './components/parcelamentos-filters/parcelamentos-filters';
} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters';
import {
ParcelamentosTableComponent,
ParcelamentoSegment,
ParcelamentoViewItem,
} from './components/parcelamentos-table/parcelamentos-table';
} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table';
import {
ParcelamentoCreateModalComponent,
ParcelamentoCreateModel,
} from './components/parcelamento-create-modal/parcelamento-create-modal';
} from '../../components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { normalizeAccentInsensitive } from '../../utils/text-normalization.util';
type MonthOption = { value: number; label: string };
type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados';
type AnnualMonthValue = { month: number; label: string; value: number | null };
type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] };
type ParcelamentoExportRow = ParcelamentoViewItem & Partial<ParcelamentoDetail>;
@Component({
selector: 'app-parcelamentos',
@ -46,17 +56,23 @@ type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] };
imports: [
CommonModule,
FormsModule,
ParcelamentosModalsComponent,
ParcelamentosKpisComponent,
ParcelamentosFiltersComponent,
ParcelamentosTableComponent,
ParcelamentoCreateModalComponent,
],
templateUrl: './parcelamentos.html',
styleUrls: ['./parcelamentos.scss'],
})
export class Parcelamentos implements OnInit, OnDestroy {
readonly vm = this;
loading = false;
exporting = false;
errorMessage = '';
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: ReturnType<typeof setTimeout> | null = null;
debugMode = !environment.production;
@ -88,6 +104,12 @@ export class Parcelamentos implements OnInit, OnDestroy {
activeChips: FilterChip[] = [];
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
detailOpen = false;
detailLoading = false;
@ -137,7 +159,8 @@ export class Parcelamentos implements OnInit, OnDestroy {
constructor(
private parcelamentosService: ParcelamentosService,
private authService: AuthService
private authService: AuthService,
private tableExportService: TableExportService
) {}
ngOnInit(): void {
@ -147,6 +170,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.cancelDetailRequest();
if (this.toastTimer) clearTimeout(this.toastTimer);
}
@HostListener('document:keydown.escape')
@ -159,30 +183,24 @@ export class Parcelamentos implements OnInit, OnDestroy {
private syncPermissions(): void {
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
return computeTotalPages(this.total || 0, this.pageSize || 10);
}
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
get competenciaInvalid(): boolean {
@ -273,6 +291,50 @@ export class Parcelamentos implements OnInit, OnDestroy {
this.load();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.fetchAllItemsForExport();
const rows = await this.fetchDetailedItemsForExport(baseRows);
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<ParcelamentoExportRow>({
fileName: `parcelamentos_${this.activeSegment}_${timestamp}`,
sheetName: 'Parcelamentos',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Ano Ref', type: 'number', value: (row) => this.toNumber(row.anoRef) ?? 0 },
{ header: 'Item', type: 'number', value: (row) => this.toNumber(row.item) ?? 0 },
{ header: 'Linha', value: (row) => row.linha ?? '' },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{ header: 'Status', value: (row) => row.statusLabel },
{ header: 'Parcela Atual', type: 'number', value: (row) => this.toNumber(row.parcelaAtual) ?? 0 },
{ header: 'Total Parcelas', type: 'number', value: (row) => this.toNumber(row.totalParcelas) ?? 0 },
{ header: 'Qt Parcelas', value: (row) => row.qtParcelas ?? '' },
{ header: 'Valor Cheio', type: 'currency', value: (row) => this.toNumber(row.valorCheio) ?? 0 },
{ header: 'Desconto', type: 'currency', value: (row) => this.toNumber(row.desconto) ?? 0 },
{ header: 'Valor c/ Desconto', type: 'currency', value: (row) => this.toNumber(row.valorComDesconto) ?? 0 },
{ header: 'Valor Parcela', type: 'currency', value: (row) => this.toNumber(row.valorParcela) ?? 0 },
{ header: 'Parcelas Mensais', value: (row) => this.stringifyParcelasMensais(row.parcelasMensais) },
{ header: 'Detalhamento Anual', value: (row) => this.stringifyAnnualRows(row.annualRows) },
],
});
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
} catch {
this.showToast('Erro ao exportar planilha.', 'danger');
} finally {
this.exporting = false;
}
}
onPageSizeChange(size: number): void {
this.pageSize = size;
this.page = 1;
@ -280,7 +342,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
goToPage(p: number): void {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.load();
}
@ -356,6 +418,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
openCreateModal(): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
this.createModel = this.buildCreateModel();
this.createError = '';
this.createOpen = true;
@ -368,6 +435,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
saveNewParcelamento(model: ParcelamentoCreateModel): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
if (this.createSaving) return;
this.createSaving = true;
this.createError = '';
@ -386,6 +458,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
openEdit(item: ParcelamentoListItem): void {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
return;
}
const id = this.getItemId(item);
if (!id) return;
this.editOpen = true;
@ -421,6 +498,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
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;
this.editSaving = true;
this.editError = '';
@ -669,7 +751,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
private applySearch(list: ParcelamentoViewItem[], term: string): ParcelamentoViewItem[] {
const search = this.normalizeText(term);
const search = normalizeAccentInsensitive(term);
if (!search) return list;
return list.filter((item) => {
const payload = [
@ -681,10 +763,129 @@ export class Parcelamentos implements OnInit, OnDestroy {
]
.map((v) => (v ?? '').toString())
.join(' ');
return this.normalizeText(payload).includes(search);
return normalizeAccentInsensitive(payload).includes(search);
});
}
private async fetchAllItemsForExport(): Promise<ParcelamentoViewItem[]> {
const anoRef = this.parseNumber(this.filters.anoRef);
const competenciaAno = this.parseNumber(this.filters.competenciaAno);
const competenciaMes = this.parseNumber(this.filters.competenciaMes);
const sendCompetencia = competenciaAno !== null && competenciaMes !== null;
const pageSize = 500;
let page = 1;
let expectedTotal = 0;
const allItems: ParcelamentoListItem[] = [];
while (page <= 500) {
const response = await firstValueFrom(
this.parcelamentosService.list({
anoRef: anoRef ?? undefined,
linha: this.filters.linha?.trim() || undefined,
cliente: this.filters.cliente?.trim() || undefined,
competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined,
competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined,
page,
pageSize,
})
);
const normalized = this.normalizeListResponse(response);
allItems.push(...normalized.items);
expectedTotal = normalized.total;
if (normalized.items.length === 0) break;
if (normalized.items.length < pageSize) break;
if (expectedTotal > 0 && allItems.length >= expectedTotal) break;
page += 1;
}
const base = allItems.map((item) => this.toViewItem(item));
const searched = this.applySearch(base, this.filters.search);
return this.activeSegment === 'todos'
? searched
: searched.filter((item) => item.status === this.activeSegment);
}
private async fetchDetailedItemsForExport(rows: ParcelamentoViewItem[]): Promise<ParcelamentoExportRow[]> {
if (!rows.length) return [];
const detailedRows: ParcelamentoExportRow[] = [];
const chunkSize = 10;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
const id = this.getItemId(row);
if (!id) return row;
try {
const detailRes = await firstValueFrom(this.parcelamentosService.getById(id));
const detail = this.normalizeDetail(detailRes);
return {
...row,
...detail,
};
} catch {
return row;
}
})
);
detailedRows.push(...resolved);
}
return detailedRows;
}
private stringifyParcelasMensais(parcelas?: ParcelamentoParcela[] | null): string {
if (!parcelas?.length) return '';
return parcelas
.map((parcela) => {
const competencia = (parcela.competencia ?? '').toString().trim();
const valor = this.toNumber(parcela.valor);
const valorFmt = valor === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor);
return `${competencia || '-'}: ${valorFmt}`;
})
.join(' | ');
}
private stringifyAnnualRows(rows?: ParcelamentoAnnualRow[] | null): string {
if (!rows?.length) return '';
return rows
.map((row) => {
const year = this.parseNumber(row.year);
const total = this.toNumber(row.total);
const totalFmt = total === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(total);
const months = (row.months ?? [])
.map((month) => {
const monthNum = this.parseNumber(month.month);
const monthValue = this.toNumber(month.valor);
const monthLabel = monthNum ? String(monthNum).padStart(2, '0') : '--';
const monthFmt = monthValue === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(monthValue);
return `${monthLabel}:${monthFmt}`;
})
.join(', ');
return `${year ?? '----'} (Total ${totalFmt})${months ? ` [${months}]` : ''}`;
})
.join(' | ');
}
private normalizeListResponse(response: any): { items: ParcelamentoListItem[]; total: number } {
const anyRes: any = response ?? {};
const items = Array.isArray(anyRes.items)
? anyRes.items.filter(Boolean)
: Array.isArray(anyRes.Items)
? anyRes.Items.filter(Boolean)
: [];
const total = typeof anyRes.total === 'number'
? anyRes.total
: (typeof anyRes.Total === 'number' ? anyRes.Total : 0);
return { items, total };
}
private resolveStatus(item: ParcelamentoListItem): ParcelamentoStatus {
const total = this.toNumber(item.totalParcelas);
const atual = this.toNumber(item.parcelaAtual);
@ -982,13 +1183,12 @@ export class Parcelamentos implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
private normalizeText(value: any): string {
return (value ?? '')
.toString()
.trim()
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
private showToast(message: string, type: 'success' | 'danger'): void {
this.toastMessage = message;
this.toastType = type;
this.toastOpen = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000);
}
private onlyDigits(value: string): string {

View File

@ -1,4 +1,14 @@
<section class="resumo-page">
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 12000;">
<div class="toast border-0 shadow" [class.show]="toastOpen" [class.text-bg-success]="toastType === 'success'" [class.text-bg-danger]="toastType === 'danger'">
<div class="toast-header border-bottom-0">
<strong class="me-auto">LineGestao</strong>
<button type="button" class="btn-close" (click)="toastOpen = false"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark fw-bold">{{ toastMessage }}</div>
</div>
</div>
<div class="wrap">
<div class="resumo-container">
@ -118,9 +128,9 @@
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()">
<i class="bi bi-download"></i>
<span class="hide-mobile">Exportar</span>
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()" [disabled]="isExporting('macrophony-planos')">
<i class="bi" [class.bi-download]="!isExporting('macrophony-planos')" [class.bi-hourglass-split]="isExporting('macrophony-planos')"></i>
<span class="hide-mobile">{{ isExporting('macrophony-planos') ? 'Exportando...' : 'Exportar' }}</span>
</button>
</div>
</div>
@ -437,9 +447,9 @@
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)">
<i class="bi bi-download"></i>
<span class="hide-mobile">Exportar</span>
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)" [disabled]="isExporting(file)">
<i class="bi" [class.bi-download]="!isExporting(file)" [class.bi-hourglass-split]="isExporting(file)"></i>
<span class="hide-mobile">{{ isExporting(file) ? 'Exportando...' : 'Exportar' }}</span>
</button>
</div>
</div>

View File

@ -31,7 +31,17 @@ import {
ReservaPorDdd,
ReservaTotal
} from '../../services/resumo.service';
import { TableExportService, type ExportCellType } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { normalizeAccentInsensitive } from '../../utils/text-normalization.util';
import { buildApiBaseUrl } from '../../utils/api-base.util';
type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva';
@ -85,6 +95,11 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
loading = false;
errorMessage = '';
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: ReturnType<typeof setTimeout> | null = null;
private exportingKeys = new Set<string>();
resumo: ResumoResponse | null = null;
activeTab: ResumoTab = 'planos';
@ -139,10 +154,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
private resumoService: ResumoService,
private route: ActivatedRoute,
private router: Router,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private tableExportService: TableExportService
) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
this.initTables();
this.initGroupTables();
// Default chart configuration for Enterprise look
@ -172,6 +187,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
Object.values(this.charts).forEach(c => c?.destroy());
if (this.toastTimer) clearTimeout(this.toastTimer);
}
setTab(tab: ResumoTab): void {
@ -636,7 +652,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
toggleMacrophonyGroup(key: string) { if (this.macrophonyOpen.has(key)) this.macrophonyOpen.delete(key); else this.macrophonyOpen.add(key); }
openMacrophonyDetail(g: MacrophonyGroup) { this.macrophonyDetailGroup = g; this.macrophonyDetailOpen = true; }
closeMacrophonyDetail() { this.macrophonyDetailOpen = false; this.macrophonyDetailGroup = null; }
goToMacrophonyPage(p: number) { this.macrophonyPage = p; this.updateMacrophonyView(); }
goToMacrophonyPage(p: number) { this.macrophonyPage = clampPage(p, this.macrophonyTotalPages); this.updateMacrophonyView(); }
onGroupedSearch<T>(g: GroupedTableState<T>, value?: string) {
if (typeof value === 'string') g.search = value;
@ -644,25 +660,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
this.updateGroupView(g);
}
toggleGroupedCompact<T>(g: GroupedTableState<T>) { g.compact = !g.compact; }
exportGroupedCsv<T>(g: GroupedTableState<T>, file: string) { this.exportCsv(g.table, file); }
exportGroupedCsv<T>(g: GroupedTableState<T>, file: string) { void this.exportTableAsXlsx(g.table, file); }
isGroupedOpen<T>(g: GroupedTableState<T>, key: string) { return g.open.has(key); }
toggleGroupedOpen<T>(g: GroupedTableState<T>, key: string) { if (g.open.has(key)) g.open.delete(key); else g.open.add(key); }
openGroupedDetail<T>(g: GroupedTableState<T>, item: GroupItem<T>) { g.detailGroup = item; g.detailOpen = true; }
closeGroupedDetail<T>(g: GroupedTableState<T>) { g.detailOpen = false; g.detailGroup = null; }
getGroupedPageStart<T>(g: GroupedTableState<T>) { return g.filtered.length ? ((g.page - 1) * g.pageSize + 1) : 0; }
getGroupedPageEnd<T>(g: GroupedTableState<T>) { return g.filtered.length ? Math.min(g.page * g.pageSize, g.filtered.length) : 0; }
getGroupedPageStart<T>(g: GroupedTableState<T>) { return computePageStart(g.filtered.length, g.page, g.pageSize); }
getGroupedPageEnd<T>(g: GroupedTableState<T>) { return computePageEnd(g.filtered.length, g.page, g.pageSize); }
getGroupedPageNumbers<T>(g: GroupedTableState<T>) {
const total = this.getGroupedTotalPages(g);
if (total <= 1) return [1];
const current = Math.min(Math.max(g.page, 1), total);
const start = Math.max(1, current - 2);
const end = Math.min(total, start + 4);
const adjustedStart = Math.max(1, end - 4);
return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i);
return buildPageNumbers(g.page, this.getGroupedTotalPages(g));
}
getGroupedTotalPages<T>(g: GroupedTableState<T>) { return Math.max(1, Math.ceil(g.filtered.length / g.pageSize)); }
getGroupedTotalPages<T>(g: GroupedTableState<T>) { return computeTotalPages(g.filtered.length, g.pageSize); }
goToGroupedPage<T>(g: GroupedTableState<T>, p: number) {
g.page = Math.min(this.getGroupedTotalPages(g), Math.max(1, p));
g.page = clampPage(p, this.getGroupedTotalPages(g));
this.updateGroupView(g);
}
getTableRowClass<T>(_: TableState<T>, __: T) { return false; }
@ -677,6 +687,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return normalized === 'true' || normalized === '1' || normalized === 'sim';
}
isExporting(key: string): boolean {
return this.exportingKeys.has(key);
}
private initTables() {
const hideMoneyColumns = <T>(cols: TableColumn<T>[]) =>
this.showFinancial ? cols : cols.filter((c) => c.type !== 'money');
@ -1045,12 +1059,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
});
this.macrophonyGroups = groups;
const search = this.normalizeText(this.macrophonySearch);
const search = normalizeAccentInsensitive(this.macrophonySearch);
this.macrophonyFiltered = !search
? groups
: groups.filter((group) =>
this.normalizeText(group.plano).includes(search) ||
this.normalizeText(group.gbLabel).includes(search)
normalizeAccentInsensitive(group.plano).includes(search) ||
normalizeAccentInsensitive(group.gbLabel).includes(search)
);
const totalPages = Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize));
@ -1086,12 +1100,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
if (group.groupSort) groups.sort(group.groupSort);
group.groups = groups;
const search = this.normalizeText(group.search);
const search = normalizeAccentInsensitive(group.search);
group.filtered = !search
? groups
: groups.filter((g) =>
this.normalizeText(g.title).includes(search) ||
this.normalizeText(g.subtitle).includes(search)
normalizeAccentInsensitive(g.title).includes(search) ||
normalizeAccentInsensitive(g.subtitle).includes(search)
);
const totalPages = Math.max(1, Math.ceil(group.filtered.length / group.pageSize));
@ -1106,15 +1120,6 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
}
}
private normalizeText(value: any): string {
return (value ?? '')
.toString()
.trim()
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private sumGroup<T>(rows: T[], getter: (row: T) => any): number {
return rows.reduce((acc, row) => acc + (this.toNumber(getter(row)) ?? 0), 0);
}
@ -1133,7 +1138,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
rows.forEach((row) => {
const planoContrato = (row.planoContrato ?? '-').toString().trim() || '-';
const key = this.normalizeText(planoContrato);
const key = normalizeAccentInsensitive(planoContrato);
const gb =
this.extractGbFromPlanName(planoContrato) ??
this.toNumber(row.gb ?? row.franquiaGb);
@ -1214,78 +1219,59 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Number.isNaN(parsed) ? null : parsed;
}
private exportCsv<T>(table: TableState<T>, filename: string) {
private async exportTableAsXlsx<T>(table: TableState<T>, fileKey: string): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
if (this.exportingKeys.has(fileKey)) return;
const rows = table.data ?? [];
const columns = table.columns ?? [];
const generatedAt = new Date().toLocaleString('pt-BR');
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const headerHtml = columns
.map((column) => `<th class="${column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : ''}">${escapeHtml(column.label)}</th>`)
.join('');
this.exportingKeys.add(fileKey);
try {
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<T>({
fileName: `${fileKey}_${timestamp}`,
sheetName: table.label || 'Resumo',
rows,
columns: (table.columns ?? []).map((column) => ({
header: column.label,
type: this.mapColumnType(column.type),
value: (row: T) => this.getExportColumnValue(column, row),
})),
});
const bodyHtml = rows
.map((row, index) => {
const cells = columns
.map((column) => {
const value = this.formatCell(column, row);
const toneClass = column.tone ? this.getToneClass(column.value(row)) : '';
const alignClass = column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : '';
const classes = [alignClass, toneClass].filter(Boolean).join(' ');
return `<td class="${classes}">${escapeHtml(String(value))}</td>`;
})
.join('');
return `<tr class="${index % 2 === 0 ? 'even' : 'odd'}">${cells}</tr>`;
})
.join('');
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
} catch {
this.showToast('Erro ao exportar planilha.', 'danger');
} finally {
this.exportingKeys.delete(fileKey);
}
}
const html = `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; color: #0f172a; }
.sheet-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.sheet-subtitle { font-size: 12px; color: #64748b; margin-bottom: 14px; }
table { border-collapse: collapse; width: 100%; table-layout: auto; }
th, td { border: 1px solid #dbe2ef; padding: 8px 10px; font-size: 12px; }
th { background: #e8eefc; color: #1e3a8a; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; }
tr.even td { background: #ffffff; }
tr.odd td { background: #f8fafc; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-success { color: #047857; font-weight: 700; }
.text-danger { color: #b91c1c; font-weight: 700; }
</style>
</head>
<body>
<div class="sheet-title">${escapeHtml(table.label || 'Resumo')}</div>
<div class="sheet-subtitle">Exportado em ${escapeHtml(generatedAt)} | Total de linhas: ${rows.length}</div>
<table>
<thead>
<tr>${headerHtml}</tr>
</thead>
<tbody>
${bodyHtml}
</tbody>
</table>
</body>
</html>`;
private getExportColumnValue<T>(column: TableColumn<T>, row: T): unknown {
const rawValue = column.value(row);
if (column.type === 'money' || column.type === 'number' || column.type === 'gb') {
const numeric = this.toNumber(rawValue);
if (numeric !== null) return numeric;
}
return this.formatCell(column, row);
}
const blob = new Blob([`\uFEFF${html}`], { type: 'application/vnd.ms-excel;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.xls`;
a.click();
URL.revokeObjectURL(url);
private mapColumnType(type: TableColumn<any>['type']): ExportCellType {
if (type === 'money') return 'currency';
if (type === 'number' || type === 'gb') return 'number';
return 'text';
}
private showToast(message: string, type: 'success' | 'danger'): void {
this.toastMessage = message;
this.toastType = type;
this.toastOpen = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000);
}
private getReservaPorDddChartData(): Array<{ label: string; totalLinhas: number }> {
@ -1310,7 +1296,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Array.from(map.entries()).map(([label, totalLinhas]) => ({ label, totalLinhas }));
}
exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); }
exportMacrophonyCsv() { void this.exportTableAsXlsx(this.tableMacrophony, 'macrophony-planos'); }
findLineTotal(k: string[]): LineTotal | null {
const keys = k.map((item) => item.toUpperCase());
const list = this.getEffectiveLineTotais();
@ -1335,19 +1321,13 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : [];
}
get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; }
get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); }
get macrophonyPageStart() { return computePageStart(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); }
get macrophonyPageEnd() { return computePageEnd(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); }
get macrophonyFilteredGroups() { return this.macrophonyFiltered; }
get macrophonyPageNumbers() {
const total = this.macrophonyTotalPages;
if (total <= 1) return [1];
const current = Math.min(Math.max(this.macrophonyPage, 1), total);
const start = Math.max(1, current - 2);
const end = Math.min(total, start + 4);
const adjustedStart = Math.max(1, end - 4);
return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i);
return buildPageNumbers(this.macrophonyPage, this.macrophonyTotalPages);
}
get macrophonyTotalPages() { return Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); }
get macrophonyTotalPages() { return computeTotalPages(this.macrophonyFiltered.length, this.macrophonyPageSize); }
get planosTotals() { return this.resumo?.macrophonyTotals; }
get contratosTotals() { return this.resumo?.planoContratoTotal; }
get clientesTotals() { return this.resumo?.vivoLineTotals; }

View File

@ -3,6 +3,13 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { SolicitacaoLinhaDto, SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
@Component({
selector: 'app-solicitacoes-linhas',
@ -64,34 +71,24 @@ export class SolicitacoesLinhas implements OnInit, OnDestroy {
}
goToPage(pageNumber: number): void {
this.page = Math.max(1, Math.min(this.totalPages, pageNumber));
this.page = clampPage(pageNumber, this.totalPages);
this.fetch();
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
return computeTotalPages(this.total || 0, this.pageSize);
}
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
private parseDate(value?: string | null): Date | null {

View File

@ -31,7 +31,11 @@
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<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>
<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
</button>
</div>
@ -86,7 +90,6 @@
<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>
@ -153,7 +156,7 @@
<td class="td-clip" style="max-width: 360px;" [title]="r.observacao">{{ r.observacao || '-' }}</td>
<td>
<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>
</button>
</div>
@ -194,184 +197,4 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<!-- EDIT MODAL -->
<div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Troca de Número
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataTroca" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.motivo" />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="editModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">Preparando edição...</div>
</ng-template>
</div>
</div>
</div>
<!-- CREATE MODAL (✅ BEBENDO DO GERAL) -->
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Troca
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-pencil me-2"></i> Preencha os dados</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataTroca" />
</div>
<!-- ✅ Cliente (GERAL) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="clientsFromGeral" [(ngModel)]="selectedCliente" (ngModelChange)="onClienteChange()" placeholder="Selecione..."></app-select>
<small class="hint" *ngIf="loadingClients">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
</small>
</div>
<!-- ✅ Linha do Cliente (GERAL) -->
<div class="form-field span-2">
<label>Linha do Cliente (GERAL)</label>
<app-select class="form-control" size="sm" [options]="linesFromClient" labelKey="label" valueKey="id" [(ngModel)]="selectedLineId" (ngModelChange)="onLineChange()" [disabled]="!selectedCliente || loadingLines" placeholder="Selecione a linha do cliente..."></app-select>
<small class="hint" *ngIf="loadingLines">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
</small>
<small class="hint warn" *ngIf="selectedCliente && !loadingLines && linesFromClient.length === 0">
Nenhuma linha encontrada para este cliente no GERAL.
</small>
</div>
<!-- ✅ Linha Antiga (auto do GERAL) -->
<div class="form-field">
<label>Linha Antiga (auto)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<!-- Linha Nova -->
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<!-- ✅ ICCID (auto do GERAL) -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.motivo" placeholder="Ex: perda/roubo, troca de colaborador..." />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<app-troca-numero-modals [vm]="$any(vm)"></app-troca-numero-modals>

View File

@ -534,9 +534,6 @@
}
/* MODALS */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.8);

View File

@ -10,8 +10,20 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
import { TrocaNumeroModalsComponent } from '../../components/page-modals/troca-numero-modals/troca-numero-modals';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
import { buildApiEndpoint } from '../../utils/api-base.util';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
@ -56,34 +68,30 @@ interface LineOptionDto {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, TrocaNumeroModalsComponent],
templateUrl: './troca-numero.html',
styleUrls: ['./troca-numero.scss']
})
export class TrocaNumero implements AfterViewInit {
readonly vm = this;
toastMessage = '';
loading = false;
exporting = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private authService: AuthService,
private tableExportService: TableExportService
) {}
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/trocanumero`;
})();
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'trocanumero');
/** ✅ base do GERAL (para buscar clientes/linhas no modal) */
private readonly linesApiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
private readonly linesApiBase = buildApiEndpoint(environment.apiUrl, 'lines');
// ====== DATA ======
groups: GroupItem[] = [];
@ -132,9 +140,20 @@ export class TrocaNumero implements AfterViewInit {
loadingClients = false;
loadingLines = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
get canManageRecords(): boolean {
return this.isSysAdmin || this.isGestor;
}
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => this.refresh());
}
@ -151,6 +170,90 @@ export class TrocaNumero implements AfterViewInit {
this.loadForGroups();
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const rows = await this.fetchAllRowsForExport();
if (!rows.length) {
await this.showToast('Nenhum registro encontrado para exportar.');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<TrocaRow>({
fileName: `troca_numero_${timestamp}`,
sheetName: 'TrocaNumero',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Motivo', value: (row) => row.motivo },
{ header: 'Cliente', value: (row) => this.getRawField(row, ['cliente', 'Cliente']) ?? '' },
{ header: 'Usuario', value: (row) => this.getRawField(row, ['usuario', 'Usuario']) ?? '' },
{ header: 'Skil', value: (row) => this.getRawField(row, ['skil', 'Skil']) ?? '' },
{ header: 'Item', type: 'number', value: (row) => this.toNumberOrNull(row.item) ?? 0 },
{ header: 'Linha Antiga', value: (row) => row.linhaAntiga },
{ header: 'Linha Nova', value: (row) => row.linhaNova },
{ header: 'ICCID', value: (row) => row.iccid },
{ header: 'Data da Troca', type: 'date', value: (row) => row.dataTroca },
{ header: 'Observacao', value: (row) => row.observacao },
{ header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') },
{ header: 'Linha ID (Geral)', value: (row) => this.getRawField(row, ['mobileLineId', 'MobileLineId']) ?? '' },
{ header: 'Criado Em', type: 'datetime', value: (row) => this.getRawField(row, ['createdAt', 'CreatedAt']) ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => this.getRawField(row, ['updatedAt', 'UpdatedAt']) ?? '' },
],
});
await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
} catch {
await this.showToast('Erro ao exportar planilha.');
} finally {
this.exporting = false;
}
}
private async fetchAllRowsForExport(): Promise<TrocaRow[]> {
const pageSize = 2000;
let page = 1;
let expectedTotal = 0;
const rows: TrocaRow[] = [];
while (page <= 500) {
const params = new HttpParams()
.set('page', String(page))
.set('pageSize', String(pageSize))
.set('search', (this.searchTerm ?? '').trim())
.set('sortBy', 'motivo')
.set('sortDir', 'asc');
const response = await firstValueFrom(
this.http.get<ApiPagedResult<any> | any[]>(this.apiBase, { params })
);
const items = Array.isArray(response) ? response : (response.items ?? []);
const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx));
rows.push(...normalized);
expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0);
if (Array.isArray(response)) break;
if (items.length === 0) break;
if (items.length < pageSize) break;
if (expectedTotal > 0 && rows.length >= expectedTotal) break;
page += 1;
}
return rows.sort((a, b) => {
const byMotivo = (a.motivo ?? '').localeCompare(b.motivo ?? '', 'pt-BR', { sensitivity: 'base' });
if (byMotivo !== 0) return byMotivo;
const byItem = (this.toNumberOrNull(a.item) ?? 0) - (this.toNumberOrNull(b.item) ?? 0);
if (byItem !== 0) return byItem;
return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' });
});
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
@ -175,29 +278,19 @@ export class TrocaNumero implements AfterViewInit {
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.page = clampPage(p, this.totalPages);
this.applyPagination();
}
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); }
get pageNumbers() {
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++) pages.push(i);
return pages;
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); }
get pageEnd() {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
return computePageEnd(this.total || 0, this.page, this.pageSize);
}
trackById(_: number, row: TrocaRow) { return row.id; }
@ -414,6 +507,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL EDIÇÃO ======
onEditar(r: TrocaRow) {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.editOpen = true;
this.editSaving = false;
@ -436,6 +534,11 @@ export class TrocaNumero implements AfterViewInit {
}
saveEdit() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
if (!this.editModel || !this.editModel.id) return;
this.editSaving = true;
@ -467,6 +570,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL CRIAÇÃO ======
onCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
this.createOpen = true;
this.createSaving = false;
@ -496,6 +604,11 @@ export class TrocaNumero implements AfterViewInit {
}
saveCreate() {
if (!this.canManageRecords) {
this.showToast('Perfil Financeiro possui acesso somente leitura.');
return;
}
// ✅ validações do "beber do GERAL"
if (!String(this.selectedCliente ?? '').trim()) {
this.showToast('Selecione um Cliente do GERAL.');
@ -542,6 +655,15 @@ export class TrocaNumero implements AfterViewInit {
return Number.isFinite(n) ? n : null;
}
private getRawField(row: TrocaRow, keys: string[]): string | null {
for (const key of keys) {
const value = row?.raw?.[key];
if (value === undefined || value === null || String(value).trim() === '') continue;
return String(value);
}
return null;
}
private isoToDateInput(iso: string | null | undefined): string {
if (!iso) return '';
const dt = new Date(iso);

View File

@ -24,6 +24,10 @@
<small class="subtitle">Controle de contratos e fidelização</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<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>
<button *ngIf="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button>
@ -188,268 +192,4 @@
</div>
</section>
<div class="lg-backdrop" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="lg-modal" *ngIf="detailsOpen">
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
Detalhes da Vigência
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Linha</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Linha</span>
<span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Conta</span>
<span class="val">{{ selectedRow?.conta || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ selectedRow?.usuario || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Plano</span>
<span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Efetivação</span>
<span class="val">{{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Término</span>
<span class="val" [class.text-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Situação</span>
<span class="status-pill" [class.is-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Renovação</span>
<span class="val">
{{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div class="lg-modal" *ngIf="createOpen">
<div class="lg-modal-card modal-xl create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Nova Vigência
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com GERAL</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="clientsFromGeral"
[(ngModel)]="createModel.selectedClient"
(ngModelChange)="onCreateClientChange()"
[disabled]="createClientsLoading"
></app-select>
</div>
<div class="form-field span-2">
<label>Linha (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="createModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="createModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /></div>
<div class="form-field span-2">
<label>Plano</label>
<app-select
*ngIf="planOptions.length > 0"
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
></app-select>
<input
*ngIf="planOptions.length === 0"
class="form-control form-control-sm"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
/>
</div>
<div class="form-field field-item field-auto">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="createModel.item" readonly title="Gerado automaticamente pelo sistema" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- EDIT MODAL -->
<div class="lg-modal" *ngIf="editOpen">
<div class="lg-modal-card modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Vigência
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
<div class="form-field span-2"><label>Plano</label><input class="form-control form-control-sm" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onEditPlanChange()" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- DELETE MODAL -->
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Vigência
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>
<app-vigencia-modals [vm]="$any(vm)"></app-vigencia-modals>

View File

@ -372,14 +372,6 @@
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODAL */
.lg-backdrop {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%);
z-index: 9990;
backdrop-filter: blur(5px);
}
.lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.86);

View File

@ -3,13 +3,16 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { Subscription, firstValueFrom } from 'rxjs';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { TableExportService } from '../../services/table-export.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import { computeTotalPages } from '../../utils/pagination.util';
import { VigenciaModalsComponent } from '../../components/page-modals/vigencia-modals/vigencia-modals';
type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger';
@ -26,12 +29,14 @@ interface LineOptionDto {
@Component({
selector: 'app-vigencia',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, VigenciaModalsComponent],
templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'],
})
export class VigenciaComponent implements OnInit, OnDestroy {
readonly vm = this;
loading = false;
exporting = false;
errorMsg = '';
// Filtros
@ -113,7 +118,8 @@ export class VigenciaComponent implements OnInit, OnDestroy {
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService,
private route: ActivatedRoute
private route: ActivatedRoute,
private tableExportService: TableExportService
) {}
ngOnInit(): void {
@ -157,7 +163,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
return computeTotalPages(this.total || 0, this.pageSize || 10);
}
fetch(goToPage?: number): void {
@ -295,6 +301,107 @@ export class VigenciaComponent implements OnInit, OnDestroy {
this.fetch(1);
}
async onExport(): Promise<void> {
if (this.exporting) return;
this.exporting = true;
try {
const baseRows = await this.fetchAllRowsForExport();
const rows = await this.fetchDetailedRowsForExport(baseRows);
if (!rows.length) {
this.showToast('Nenhum registro encontrado para exportar.', 'danger');
return;
}
const timestamp = this.tableExportService.buildTimestamp();
await this.tableExportService.exportAsXlsx<VigenciaRow>({
fileName: `vigencia_${timestamp}`,
sheetName: 'Vigencia',
rows,
columns: [
{ header: 'ID', value: (row) => row.id ?? '' },
{ header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
{ header: 'Linha', value: (row) => row.linha ?? '' },
{ header: 'Conta', value: (row) => row.conta ?? '' },
{ header: 'Cliente', value: (row) => row.cliente ?? '' },
{ header: 'Usuario', value: (row) => row.usuario ?? '' },
{ header: 'Plano', value: (row) => row.planoContrato ?? '' },
{ header: 'Efetivacao', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' },
{ header: 'Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' },
{ header: 'Status', value: (row) => (this.isVencido(row.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo') },
{ header: 'Auto Renovacao (anos)', type: 'number', value: (row) => this.toNullableNumber(row.autoRenewYears) ?? 0 },
{ header: 'Auto Renovacao Referencia', type: 'date', value: (row) => row.autoRenewReferenceEndDate ?? '' },
{ header: 'Auto Renovacao Configurada Em', type: 'datetime', value: (row) => row.autoRenewConfiguredAt ?? '' },
{ header: 'Ultima Auto Renovacao', type: 'datetime', value: (row) => row.lastAutoRenewedAt ?? '' },
{ header: 'Total', type: 'currency', value: (row) => this.toNullableNumber(row.total) ?? 0 },
{ header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
{ header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
],
});
this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
} catch {
this.showToast('Erro ao exportar planilha.', 'danger');
} finally {
this.exporting = false;
}
}
private async fetchAllRowsForExport(): Promise<VigenciaRow[]> {
const pageSize = 500;
let page = 1;
let expectedTotal = 0;
const all: VigenciaRow[] = [];
while (page <= 500) {
const response = await firstValueFrom(
this.vigenciaService.getVigencia({
search: this.search?.trim(),
client: this.client?.trim(),
page,
pageSize,
sortBy: 'item',
sortDir: 'asc',
})
);
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 async fetchDetailedRowsForExport(rows: VigenciaRow[]): Promise<VigenciaRow[]> {
if (!rows.length) return [];
const detailedRows: VigenciaRow[] = [];
const chunkSize = 10;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const resolved = await Promise.all(
chunk.map(async (row) => {
try {
return await firstValueFrom(this.vigenciaService.getById(row.id));
} catch {
return row;
}
})
);
detailedRows.push(...resolved);
}
return detailedRows;
}
scheduleAutoRenew(row: VigenciaRow): void {
if (!row?.id) return;
const years = 2;

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
@ -75,8 +76,7 @@ export class BillingService {
private readonly baseUrl: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
const apiBase = buildApiBaseUrl(environment.apiUrl);
this.baseUrl = `${apiBase}/billing`;
}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@ -71,8 +72,7 @@ export class ChipsControleService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getChipsVirgens(opts: {

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@ -75,8 +76,7 @@ export class DadosUsuariosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getGroups(opts: {

View File

@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type AuditChangeType = 'added' | 'modified' | 'removed';
@ -50,13 +51,24 @@ export interface HistoricoQuery {
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' })
export class HistoricoService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(params: HistoricoQuery): Observable<PagedResult<AuditLogDto>> {
@ -74,4 +86,20 @@ export class HistoricoService {
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

@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export interface PagedResult<T> {
page: number;
@ -72,8 +73,7 @@ export class LinesService {
private readonly baseUrl: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
const apiBase = buildApiBaseUrl(environment.apiUrl);
this.baseUrl = `${apiBase}/lines`;
}

View File

@ -3,6 +3,7 @@ import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
@ -40,8 +41,7 @@ export class NotificationsService {
readonly events$ = this.eventsSubject.asObservable();
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(): Observable<NotificationDto[]> {

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export interface PagedResult<T> {
page: number;
@ -76,8 +77,7 @@ export class ParcelamentosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(filters: {

View File

@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type ProfileMeDto = {
id: string;
@ -26,8 +27,7 @@ export class ProfileService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getMe(): Observable<ProfileMeDto> {

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export interface MacrophonyPlan {
planoContrato?: string | null;
@ -119,8 +120,7 @@ export class ResumoService {
private readonly apiBase: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.apiBase = buildApiBaseUrl(environment.apiUrl);
}
getResumo() {

View File

@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export interface PagedResult<T> {
page: number;
@ -37,8 +38,7 @@ export class SolicitacoesLinhasService {
private readonly baseUrl: string;
constructor(private readonly http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
const apiBase = buildApiBaseUrl(environment.apiUrl);
this.baseUrl = `${apiBase}/solicitacoes-linhas`;
}

View File

@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type SystemTenantDto = {
tenantId: string;
@ -34,8 +35,7 @@ export class SysadminService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
listTenants(params?: ListSystemTenantsParams): Observable<SystemTenantDto[]> {

View File

@ -0,0 +1,462 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type ExportCellType = 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean';
export interface TableExportColumn<T> {
header: string;
key?: string;
type?: ExportCellType;
width?: number;
value: (row: T, index: number) => unknown;
}
export interface TableExportRequest<T> {
fileName: string;
sheetName?: string;
columns: TableExportColumn<T>[];
rows: T[];
templateBuffer?: ArrayBuffer | null;
}
type CellStyleSnapshot = {
font?: Partial<import('exceljs').Font>;
fill?: import('exceljs').Fill;
border?: Partial<import('exceljs').Borders>;
alignment?: Partial<import('exceljs').Alignment>;
};
type TemplateStyleSnapshot = {
headerStyles: CellStyleSnapshot[];
bodyStyle?: CellStyleSnapshot;
bodyAltStyle?: CellStyleSnapshot;
columnWidths: Array<number | undefined>;
};
@Injectable({ providedIn: 'root' })
export class TableExportService {
private readonly templatesApiBase = (() => {
const apiBase = buildApiBaseUrl(environment.apiUrl);
return `${apiBase}/templates`;
})();
private defaultTemplateBufferPromise: Promise<ArrayBuffer | null> | null = null;
private cachedDefaultTemplateStyle?: TemplateStyleSnapshot;
constructor(private readonly http: HttpClient) {}
async exportAsXlsx<T>(request: TableExportRequest<T>): Promise<void> {
const ExcelJS = await import('exceljs');
const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer());
const templateStyle = await this.resolveTemplateStyle(ExcelJS, templateBuffer);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados'));
const rawColumns = request.columns ?? [];
const columns = rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header));
const rows = request.rows ?? [];
if (!columns.length) {
throw new Error('Nenhuma coluna exportavel apos remover ITEM/ID.');
}
const headerValues = columns.map((c) => c.header ?? '');
sheet.addRow(headerValues);
rows.forEach((row, rowIndex) => {
const values = columns.map((column) => this.normalizeValue(column.value(row, rowIndex), column.type));
sheet.addRow(values);
});
this.applyHeaderStyle(sheet, columns.length, templateStyle);
this.applyBodyStyle(sheet, columns, rows.length, templateStyle);
this.applyColumnWidths(sheet, columns, rows, templateStyle);
this.applyAutoFilter(sheet, columns.length);
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const extensionSafeName = this.ensureXlsxExtension(request.fileName);
const buffer = await workbook.xlsx.writeBuffer();
this.downloadBuffer(buffer, extensionSafeName);
}
buildTimestamp(date: Date = new Date()): string {
const year = date.getFullYear();
const month = this.pad2(date.getMonth() + 1);
const day = this.pad2(date.getDate());
const hour = this.pad2(date.getHours());
const minute = this.pad2(date.getMinutes());
return `${year}-${month}-${day}_${hour}-${minute}`;
}
private applyHeaderStyle(
sheet: import('exceljs').Worksheet,
columnCount: number,
templateStyle?: TemplateStyleSnapshot,
): void {
const headerRow = sheet.getRow(1);
headerRow.height = 24;
for (let col = 1; col <= columnCount; col += 1) {
const cell = headerRow.getCell(col);
const templateCell = this.getTemplateStyleByIndex(templateStyle, col - 1);
cell.font = this.cloneStyle(templateCell?.font) || { bold: true, color: { argb: 'FFFFFFFF' }, name: 'Calibri', size: 11 };
cell.fill = this.cloneStyle(templateCell?.fill) || {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF0A58CA' },
};
cell.alignment = this.cloneStyle(templateCell?.alignment) || { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder();
}
}
private applyBodyStyle<T>(
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
rowCount: number,
templateStyle?: TemplateStyleSnapshot,
): void {
for (let rowIndex = 2; rowIndex <= rowCount + 1; rowIndex += 1) {
const row = sheet.getRow(rowIndex);
const isEven = (rowIndex - 1) % 2 === 0;
const templateRowStyle = isEven
? (templateStyle?.bodyAltStyle ?? templateStyle?.bodyStyle)
: (templateStyle?.bodyStyle ?? templateStyle?.bodyAltStyle);
columns.forEach((column, columnIndex) => {
const cell = row.getCell(columnIndex + 1);
cell.font = this.cloneStyle(templateRowStyle?.font) || { name: 'Calibri', size: 11, color: { argb: 'FF1F2937' } };
cell.border = this.cloneStyle(templateRowStyle?.border) || this.getDefaultBorder();
cell.alignment = this.cloneStyle(templateRowStyle?.alignment) || this.getAlignment(column.type);
if (templateRowStyle?.fill) {
const fill = this.cloneStyle(templateRowStyle.fill);
if (fill) cell.fill = fill;
} else if (isEven) {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF7FAFF' },
};
}
if (column.type === 'number') cell.numFmt = '#,##0.00';
if (column.type === 'currency') cell.numFmt = '"R$" #,##0.00';
if (column.type === 'date') cell.numFmt = 'dd/mm/yyyy';
if (column.type === 'datetime') cell.numFmt = 'dd/mm/yyyy hh:mm';
});
}
}
private applyColumnWidths<T>(
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
rows: T[],
templateStyle?: TemplateStyleSnapshot,
): void {
columns.forEach((column, columnIndex) => {
if (column.width && column.width > 0) {
sheet.getColumn(columnIndex + 1).width = column.width;
return;
}
const templateWidth = templateStyle?.columnWidths?.[columnIndex];
if (templateWidth && templateWidth > 0) {
sheet.getColumn(columnIndex + 1).width = templateWidth;
return;
}
const headerLength = (column.header ?? '').length;
let maxLength = headerLength;
rows.forEach((row, rowIndex) => {
const value = column.value(row, rowIndex);
const printable = this.toPrintableValue(value, column.type);
if (printable.length > maxLength) maxLength = printable.length;
});
const target = Math.max(12, Math.min(maxLength + 3, 48));
sheet.getColumn(columnIndex + 1).width = target;
});
}
private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number): void {
if (columnCount <= 0) return;
sheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: columnCount },
};
}
private normalizeValue(value: unknown, type?: ExportCellType): string | number | Date | boolean | null {
if (value === null || value === undefined || value === '') return null;
if (type === 'number' || type === 'currency') {
const numeric = this.toNumber(value);
return numeric ?? String(value);
}
if (type === 'date' || type === 'datetime') {
const parsedDate = this.toDate(value);
return parsedDate ?? String(value);
}
if (type === 'boolean') {
if (typeof value === 'boolean') return value;
return this.normalizeBoolean(value);
}
return String(value);
}
private toPrintableValue(value: unknown, type?: ExportCellType): string {
if (value === null || value === undefined || value === '') return '';
if (type === 'date' || type === 'datetime') {
const parsedDate = this.toDate(value);
if (!parsedDate) return String(value);
const datePart = `${this.pad2(parsedDate.getDate())}/${this.pad2(parsedDate.getMonth() + 1)}/${parsedDate.getFullYear()}`;
if (type === 'date') return datePart;
return `${datePart} ${this.pad2(parsedDate.getHours())}:${this.pad2(parsedDate.getMinutes())}`;
}
if (type === 'number' || type === 'currency') {
const numeric = this.toNumber(value);
if (numeric === null) return String(value);
if (type === 'currency') {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(numeric);
}
return new Intl.NumberFormat('pt-BR').format(numeric);
}
if (type === 'boolean') {
return this.normalizeBoolean(value) ? 'Sim' : 'Nao';
}
return String(value);
}
private toNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const normalized = trimmed
.replace(/[^\d,.-]/g, '')
.replace(/\.(?=\d{3}(\D|$))/g, '')
.replace(',', '.');
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
private toDate(value: unknown): Date | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const brDate = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}))?$/);
if (brDate) {
const day = Number(brDate[1]);
const month = Number(brDate[2]) - 1;
const year = Number(brDate[3]);
const hour = Number(brDate[4] ?? '0');
const minute = Number(brDate[5] ?? '0');
const parsedBr = new Date(year, month, day, hour, minute);
return Number.isNaN(parsedBr.getTime()) ? null : parsedBr;
}
const parsed = new Date(trimmed);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
private normalizeBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '')
.trim()
.toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'sim' || normalized === 'yes';
}
private ensureXlsxExtension(fileName: string): string {
const safe = (fileName ?? 'export').trim() || 'export';
return safe.toLowerCase().endsWith('.xlsx') ? safe : `${safe}.xlsx`;
}
private sanitizeSheetName(name: string): string {
const safe = (name ?? 'Dados').replace(/[\\/*?:[\]]/g, '').trim();
return (safe || 'Dados').slice(0, 31);
}
private shouldExcludeColumnByHeader(header: string | undefined): boolean {
const normalized = this.normalizeHeader(header);
if (!normalized) return false;
const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
if (!tokens.length) return false;
return tokens.includes('id') || tokens.includes('item');
}
private normalizeHeader(value: string | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
private downloadBuffer(buffer: ArrayBuffer, fileName: string): void {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
private getAlignment(type?: ExportCellType): Partial<import('exceljs').Alignment> {
if (type === 'number' || type === 'currency') {
return { vertical: 'middle', horizontal: 'right' };
}
if (type === 'boolean') {
return { vertical: 'middle', horizontal: 'center' };
}
return { vertical: 'middle', horizontal: 'left', wrapText: true };
}
private getDefaultBorder(): Partial<import('exceljs').Borders> {
return {
top: { style: 'thin', color: { argb: 'FFD6DCE8' } },
left: { style: 'thin', color: { argb: 'FFD6DCE8' } },
right: { style: 'thin', color: { argb: 'FFD6DCE8' } },
bottom: { style: 'thin', color: { argb: 'FFD6DCE8' } },
};
}
private pad2(value: number): string {
return value.toString().padStart(2, '0');
}
private async extractTemplateStyle(
excelJsModule: typeof import('exceljs'),
templateBuffer: ArrayBuffer | null,
): Promise<TemplateStyleSnapshot | undefined> {
if (!templateBuffer) return undefined;
try {
const workbook = new excelJsModule.Workbook();
await workbook.xlsx.load(templateBuffer);
const sheet = workbook.getWorksheet(1);
if (!sheet) return undefined;
const headerRow = sheet.getRow(1);
const headerCount = Math.max(headerRow.actualCellCount, 1);
const headerStyles: CellStyleSnapshot[] = [];
for (let col = 1; col <= headerCount; col += 1) {
headerStyles.push(this.captureCellStyle(headerRow.getCell(col)));
}
const bodyStyle = this.captureFirstStyledCellRow(sheet.getRow(2));
const bodyAltStyle = this.captureFirstStyledCellRow(sheet.getRow(3));
const columnWidths = (sheet.columns ?? []).map((column) => column.width);
return { headerStyles, bodyStyle, bodyAltStyle, columnWidths };
} catch {
return undefined;
}
}
private async resolveTemplateStyle(
excelJsModule: typeof import('exceljs'),
templateBuffer: ArrayBuffer | null,
): Promise<TemplateStyleSnapshot | undefined> {
if (templateBuffer) {
const style = await this.extractTemplateStyle(excelJsModule, templateBuffer);
if (style) this.cachedDefaultTemplateStyle = style;
return style;
}
return this.cachedDefaultTemplateStyle;
}
private async getDefaultTemplateBuffer(): Promise<ArrayBuffer | null> {
if (this.defaultTemplateBufferPromise) {
return this.defaultTemplateBufferPromise;
}
this.defaultTemplateBufferPromise = this.fetchDefaultTemplateBuffer();
const buffer = await this.defaultTemplateBufferPromise;
if (!buffer) this.defaultTemplateBufferPromise = null;
return buffer;
}
private async fetchDefaultTemplateBuffer(): Promise<ArrayBuffer | null> {
try {
const params = new HttpParams().set('_', `${Date.now()}`);
const blob = await firstValueFrom(
this.http.get(`${this.templatesApiBase}/planilha-geral`, {
params,
responseType: 'blob',
})
);
return await blob.arrayBuffer();
} catch {
return null;
}
}
private captureFirstStyledCellRow(row: import('exceljs').Row): CellStyleSnapshot | undefined {
if (!row) return undefined;
const cellCount = Math.max(row.actualCellCount, 1);
for (let col = 1; col <= cellCount; col += 1) {
const captured = this.captureCellStyle(row.getCell(col));
if (captured.font || captured.fill || captured.border || captured.alignment) {
return captured;
}
}
return undefined;
}
private captureCellStyle(cell: import('exceljs').Cell): CellStyleSnapshot {
return {
font: this.cloneStyle(cell.font),
fill: this.cloneStyle(cell.fill),
border: this.cloneStyle(cell.border),
alignment: this.cloneStyle(cell.alignment),
};
}
private getTemplateStyleByIndex(style: TemplateStyleSnapshot | undefined, index: number): CellStyleSnapshot | undefined {
if (!style || !style.headerStyles.length) return undefined;
return style.headerStyles[index] ?? style.headerStyles[style.headerStyles.length - 1];
}
private cloneStyle<T>(value: T | undefined): T | undefined {
if (!value) return undefined;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
}

View File

@ -3,8 +3,9 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
export type UserPermission = 'sysadmin' | 'gestor' | 'financeiro' | 'cliente';
export type UserDto = {
id: string;
@ -60,8 +61,7 @@ export class UsersService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
create(payload: CreateUserPayload): Observable<UserDto> {

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@ -76,8 +77,7 @@ export class VigenciaService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getVigencia(opts: { search?: string; client?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable<PagedResult<VigenciaRow>> {

View File

@ -0,0 +1,11 @@
export function buildApiBaseUrl(apiUrl: string | null | undefined): string {
const raw = (apiUrl ?? '').toString().trim().replace(/\/+$/, '');
if (!raw) return '/api';
return raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
export function buildApiEndpoint(apiUrl: string | null | undefined, resourcePath: string): string {
const base = buildApiBaseUrl(apiUrl);
const cleanedPath = (resourcePath ?? '').toString().trim().replace(/^\/+/, '');
return cleanedPath ? `${base}/${cleanedPath}` : base;
}

View File

@ -0,0 +1,35 @@
export function computeTotalPages(total: number, pageSize: number): number {
const safeTotal = Number.isFinite(total) ? Math.max(0, total) : 0;
const safeSize = Number.isFinite(pageSize) ? Math.max(1, pageSize) : 1;
return Math.max(1, Math.ceil(safeTotal / safeSize));
}
export function clampPage(page: number, totalPages: number): number {
return Math.max(1, Math.min(totalPages, page));
}
export function buildPageNumbers(currentPage: number, totalPages: number, maxVisible = 5): number[] {
const safeTotal = Math.max(1, totalPages);
const safeCurrent = clampPage(currentPage, safeTotal);
const safeMax = Math.max(1, maxVisible);
let start = Math.max(1, safeCurrent - Math.floor(safeMax / 2));
let end = Math.min(safeTotal, start + (safeMax - 1));
start = Math.max(1, end - (safeMax - 1));
const pages: number[] = [];
for (let i = start; i <= end; i += 1) pages.push(i);
return pages;
}
export function computePageStart(total: number, page: number, pageSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
return (clampPage(page, computeTotalPages(total, pageSize)) - 1) * Math.max(1, pageSize) + 1;
}
export function computePageEnd(total: number, page: number, pageSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
const safeSize = Math.max(1, pageSize);
const safePage = clampPage(page, computeTotalPages(total, safeSize));
return Math.min(safePage * safeSize, total);
}

View File

@ -0,0 +1,16 @@
export type NormalizeCaseMode = 'upper' | 'lower' | 'none';
export function normalizeAccentInsensitive(
value: unknown,
caseMode: NormalizeCaseMode = 'upper'
): string {
const normalized = (value ?? '')
.toString()
.trim()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
if (caseMode === 'lower') return normalized.toLowerCase();
if (caseMode === 'none') return normalized;
return normalized.toUpperCase();
}

View File

@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"noPropertyAccessFromIndexSignature": false,
"types": [
"node"
]