Feat: Exportação por página e Bloquio e Desbloqueio de linhas em lote
This commit is contained in:
parent
5d0dc3b367
commit
8fc8a1303f
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ 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 { AuthService } from '../../services/auth.service';
|
||||
import { TableExportService } from '../../services/table-export.service';
|
||||
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// Interface para o Agrupamento
|
||||
interface ChipGroup {
|
||||
|
|
@ -86,6 +88,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: 'success' | 'danger' = 'success';
|
||||
exporting = false;
|
||||
private toastTimer: any = null;
|
||||
|
||||
chipDetailOpen = false;
|
||||
|
|
@ -124,7 +127,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 +421,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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ 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 { TableExportService } from '../../services/table-export.service';
|
||||
|
||||
import {
|
||||
DadosUsuariosService,
|
||||
|
|
@ -45,6 +47,7 @@ export class DadosUsuarios implements OnInit {
|
|||
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
|
||||
|
||||
loading = false;
|
||||
exporting = false;
|
||||
errorMsg = '';
|
||||
|
||||
// Filtros
|
||||
|
|
@ -116,7 +119,8 @@ export class DadosUsuarios implements OnInit {
|
|||
constructor(
|
||||
private service: DadosUsuariosService,
|
||||
private authService: AuthService,
|
||||
private linesService: LinesService
|
||||
private linesService: LinesService,
|
||||
private tableExportService: TableExportService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
@ -257,6 +261,112 @@ 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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ 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 { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface BillingClientGroup {
|
||||
cliente: string;
|
||||
|
|
@ -54,10 +56,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 = '';
|
||||
|
|
@ -415,6 +419,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();
|
||||
|
||||
|
|
@ -795,4 +878,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,22 +31,42 @@
|
|||
<small class="subtitle">Tabela de linhas e dados de telefonia</small>
|
||||
</div>
|
||||
|
||||
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
*ngIf="isSysAdmin"
|
||||
(click)="onImportExcel()"
|
||||
[disabled]="loading">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
|
||||
</button>
|
||||
<div class="header-actions d-flex gap-2 justify-content-end align-items-start" data-animate>
|
||||
<div class="d-flex flex-column gap-2 align-items-start" *ngIf="isSysAdmin; else exportOnlyTpl">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
(click)="onImportExcel()"
|
||||
[disabled]="loading">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm align-self-center"
|
||||
(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>
|
||||
<ng-template #exportOnlyTpl>
|
||||
<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>
|
||||
</ng-template>
|
||||
|
||||
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
|
||||
<input #batchExcelInput type="file" class="d-none" accept=".xlsx" (change)="onBatchExcelSelected($event)" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-brand btn-sm"
|
||||
class="btn btn-brand btn-sm align-self-start"
|
||||
*ngIf="!isClientRestricted"
|
||||
(click)="onCadastrarLinha()"
|
||||
[disabled]="loading">
|
||||
|
|
@ -275,6 +295,26 @@
|
|||
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-status-tools" *ngIf="!isClientRestricted">
|
||||
<span class="batch-status-count">
|
||||
Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
(click)="openBatchStatusModal('BLOCK')"
|
||||
[disabled]="!canOpenBatchStatusModal">
|
||||
<i class="bi bi-lock me-1"></i> Bloquear em lote
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
(click)="openBatchStatusModal('UNBLOCK')"
|
||||
[disabled]="!canOpenBatchStatusModal">
|
||||
<i class="bi bi-unlock me-1"></i> Desbloquear em lote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -312,6 +352,20 @@
|
|||
<i class="bi bi-check2-square me-1"></i>
|
||||
{{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-glass"
|
||||
type="button"
|
||||
(click)="openBatchStatusModal('BLOCK')"
|
||||
[disabled]="!canOpenBatchStatusModal">
|
||||
<i class="bi bi-lock me-1"></i> Bloquear
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-glass"
|
||||
type="button"
|
||||
(click)="openBatchStatusModal('UNBLOCK')"
|
||||
[disabled]="!canOpenBatchStatusModal">
|
||||
<i class="bi bi-unlock me-1"></i> Desbloquear
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isReservaExpandedGroup && hasGroupLineSelectionTools">
|
||||
<button
|
||||
|
|
@ -565,14 +619,14 @@
|
|||
<!-- Backdrop -->
|
||||
<div
|
||||
class="modal-backdrop-custom"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen || batchStatusOpen"
|
||||
(click)="closeAllModals()">
|
||||
</div>
|
||||
|
||||
<!-- Overlay (captura clique fora) -->
|
||||
<div
|
||||
class="modal-custom"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen || batchStatusOpen"
|
||||
(click)="closeAllModals()"
|
||||
>
|
||||
<!-- CREATE MODAL -->
|
||||
|
|
@ -1483,6 +1537,94 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BATCH BLOCK/UNBLOCK MODAL -->
|
||||
<div
|
||||
*ngIf="batchStatusOpen"
|
||||
class="modal-card modal-lg modal-batch-status"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg brand-soft">
|
||||
<i class="bi" [ngClass]="batchStatusAction === 'BLOCK' ? 'bi-lock' : 'bi-unlock'"></i>
|
||||
</span>
|
||||
{{ batchStatusActionLabel }} Linhas em Lote
|
||||
</div>
|
||||
|
||||
<div class="batch-status-header-actions">
|
||||
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="batchStatusSaving">
|
||||
<i class="bi bi-x-lg me-1"></i> Cancelar
|
||||
</button>
|
||||
<button class="btn btn-brand btn-sm" (click)="submitBatchStatusUpdate()" [disabled]="!canSubmitBatchStatusModal">
|
||||
<span *ngIf="!batchStatusSaving"><i class="bi bi-check2-circle me-1"></i> Confirmar</span>
|
||||
<span *ngIf="batchStatusSaving"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body modern-body bg-light-gray">
|
||||
<div class="details-dashboard">
|
||||
<div class="dashboard-column">
|
||||
<details class="detail-box" open>
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-sliders2 me-2"></i>Configuração da Operaçã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" *ngIf="batchStatusAction === 'BLOCK'">
|
||||
<label>Tipo de Bloqueio <span class="text-danger">*</span></label>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="blockedStatusOptions"
|
||||
[(ngModel)]="batchStatusType"
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field span-2">
|
||||
<label>Filtrar por Usuário (opcional)</label>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
[(ngModel)]="batchStatusUsuario"
|
||||
list="batch-status-user-options"
|
||||
placeholder="Ex.: JOAO SILVA"
|
||||
/>
|
||||
<datalist id="batch-status-user-options">
|
||||
<option *ngFor="let user of batchStatusUserOptions" [value]="user"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-column">
|
||||
<details class="detail-box" open>
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-info-circle me-2"></i>Resumo</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
</summary>
|
||||
|
||||
<div class="box-body">
|
||||
<div class="reserva-confirmation-pills">
|
||||
<div class="summary-pill total">Ação: {{ batchStatusActionLabel }}</div>
|
||||
<div class="summary-pill warn">Alvo: {{ batchStatusTargetDescription }}</div>
|
||||
<div class="summary-pill" *ngIf="batchStatusAction === 'BLOCK'">Status: {{ batchStatusType || '-' }}</div>
|
||||
<div class="summary-pill" *ngIf="batchStatusAction === 'UNBLOCK'">Status final: ATIVO</div>
|
||||
<div class="summary-pill" *ngIf="(batchStatusUsuario || '').trim()">Usuário: {{ batchStatusUsuario }}</div>
|
||||
</div>
|
||||
<small class="batch-status-note">
|
||||
A operação respeita os filtros ativos da página Geral.
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOVE TO RESERVA MODAL -->
|
||||
<div
|
||||
*ngIf="moveToReservaOpen"
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -581,6 +604,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" */
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel
|
|||
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 { firstValueFrom, Subscription, filter } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
||||
|
|
@ -262,6 +263,39 @@ 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,
|
||||
|
|
@ -289,7 +323,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
private planAutoFill: PlanAutoFillService,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
private tenantSyncService: TenantSyncService
|
||||
private tenantSyncService: TenantSyncService,
|
||||
private tableExportService: TableExportService
|
||||
) {}
|
||||
|
||||
private readonly apiBase = (() => {
|
||||
|
|
@ -303,6 +338,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
return `${apiBase}/templates`;
|
||||
})();
|
||||
loading = false;
|
||||
exporting = false;
|
||||
isSysAdmin = false;
|
||||
isGestor = false;
|
||||
isClientRestricted = false;
|
||||
|
|
@ -378,6 +414,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;
|
||||
|
|
@ -609,6 +651,46 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
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.isClientRestricted) 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;
|
||||
}
|
||||
|
|
@ -823,7 +905,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() {
|
||||
|
|
@ -851,6 +941,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;
|
||||
|
|
@ -869,8 +960,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();
|
||||
|
|
@ -1800,6 +1894,225 @@ 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 {
|
||||
if (this.filterSkil === 'PF') return 'pf';
|
||||
if (this.filterSkil === 'PJ') return 'pj';
|
||||
if (this.filterSkil === 'RESERVA') return 'reserva';
|
||||
return 'todas';
|
||||
}
|
||||
|
||||
async onImportExcel() {
|
||||
if (!this.isSysAdmin) {
|
||||
await this.showToast('Você não tem permissão para importar planilha.');
|
||||
|
|
@ -3167,6 +3480,116 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.reservaSelectedLineIds = [];
|
||||
}
|
||||
|
||||
async openBatchStatusModal(action: BatchStatusAction) {
|
||||
if (this.isClientRestricted) {
|
||||
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.');
|
||||
|
|
@ -3588,6 +4011,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,
|
||||
|
|
@ -3660,6 +4089,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ 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';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
|
|
@ -23,6 +25,7 @@ export class Historico implements OnInit {
|
|||
|
||||
logs: AuditLogDto[] = [];
|
||||
loading = false;
|
||||
exporting = false;
|
||||
error = false;
|
||||
errorMsg = '';
|
||||
toastMessage = '';
|
||||
|
|
@ -65,7 +68,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,6 +115,47 @@ 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.fetch();
|
||||
|
|
@ -217,14 +262,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 +287,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';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
</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>
|
||||
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
|
||||
<i class="bi bi-plus-circle me-1"></i> Nova Mureg
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import {
|
|||
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
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';
|
||||
|
||||
|
|
@ -75,6 +77,17 @@ 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],
|
||||
|
|
@ -84,6 +97,7 @@ interface MuregDetailDto {
|
|||
export class Mureg implements AfterViewInit {
|
||||
toastMessage = '';
|
||||
loading = false;
|
||||
exporting = false;
|
||||
|
||||
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
|
||||
|
||||
|
|
@ -91,7 +105,8 @@ export class Mureg implements AfterViewInit {
|
|||
@Inject(PLATFORM_ID) private platformId: object,
|
||||
private http: HttpClient,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private linesService: LinesService
|
||||
private linesService: LinesService,
|
||||
private tableExportService: TableExportService
|
||||
) {}
|
||||
|
||||
private readonly apiBase = (() => {
|
||||
|
|
@ -184,6 +199,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(() => {
|
||||
|
|
@ -770,6 +926,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 '-';
|
||||
|
||||
|
|
|
|||
|
|
@ -429,14 +429,14 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--pg-text-soft, #64748b);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 800;
|
||||
}
|
||||
.page-size span {
|
||||
color: var(--pg-text-soft, #64748b);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.select-glass {
|
||||
|
|
|
|||
|
|
@ -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,6 +25,10 @@
|
|||
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
|
||||
<i class="bi bi-arrow-repeat"></i> Atualizar
|
||||
</button>
|
||||
<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 class="btn-primary" type="button" (click)="openCreateModal()">
|
||||
<i class="bi bi-plus-circle"></i> Novo Parcelamento
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ 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 { AuthService } from '../../services/auth.service';
|
||||
import { TableExportService } from '../../services/table-export.service';
|
||||
import {
|
||||
ParcelamentosService,
|
||||
ParcelamentoListItem,
|
||||
|
|
@ -39,6 +40,7 @@ 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',
|
||||
|
|
@ -56,7 +58,12 @@ type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] };
|
|||
})
|
||||
export class Parcelamentos implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
exporting = false;
|
||||
errorMessage = '';
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: 'success' | 'danger' = 'success';
|
||||
private toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
debugMode = !environment.production;
|
||||
|
||||
|
|
@ -137,7 +144,8 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
|
||||
constructor(
|
||||
private parcelamentosService: ParcelamentosService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private tableExportService: TableExportService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
@ -147,6 +155,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelDetailRequest();
|
||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
|
|
@ -273,6 +282,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;
|
||||
|
|
@ -685,6 +738,125 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
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,6 +1154,14 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
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 normalizeText(value: any): string {
|
||||
return (value ?? '')
|
||||
.toString()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
ReservaPorDdd,
|
||||
ReservaTotal
|
||||
} from '../../services/resumo.service';
|
||||
import { TableExportService, type ExportCellType } from '../../services/table-export.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva';
|
||||
|
|
@ -85,6 +86,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,7 +145,8 @@ 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`;
|
||||
|
|
@ -172,6 +179,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 {
|
||||
|
|
@ -644,7 +652,7 @@ 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; }
|
||||
|
|
@ -677,6 +685,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');
|
||||
|
|
@ -1214,78 +1226,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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 +1303,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();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
</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>
|
||||
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
|
||||
<i class="bi bi-plus-circle me-1"></i> Nova Troca
|
||||
</button>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||
import { TableExportService } from '../../services/table-export.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
|
||||
|
|
@ -63,13 +65,15 @@ interface LineOptionDto {
|
|||
export class TrocaNumero implements AfterViewInit {
|
||||
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 tableExportService: TableExportService
|
||||
) {}
|
||||
|
||||
private readonly apiBase = (() => {
|
||||
|
|
@ -151,6 +155,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(() => {
|
||||
|
|
@ -542,6 +630,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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ 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';
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
|
@ -32,6 +33,7 @@ interface LineOptionDto {
|
|||
})
|
||||
export class VigenciaComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
exporting = false;
|
||||
errorMsg = '';
|
||||
|
||||
// Filtros
|
||||
|
|
@ -113,7 +115,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 {
|
||||
|
|
@ -295,6 +298,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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
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 raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue