Compare commits

..

No commits in common. "2d7c4d4a0e2f98339dc3d514764859529b6029b6" and "31999870e4bd7ce52ca75eed54e33656e7e3a832" have entirely different histories.

11 changed files with 105 additions and 318 deletions

View File

@ -6,7 +6,6 @@
[attr.aria-expanded]="isOpen"
[attr.aria-disabled]="disabled"
>
<i class="bi app-select-leading-icon" *ngIf="leadingIcon" [ngClass]="leadingIcon"></i>
<span class="app-select-label">{{ displayLabel }}</span>
<i class="bi bi-chevron-down"></i>
</button>

View File

@ -67,12 +67,6 @@
padding-right: 24px;
}
.app-select-leading-icon {
flex: 0 0 auto;
color: #64748b;
font-size: 13px;
}
.app-select-label {
flex: 1 1 auto;
min-width: 0;

View File

@ -29,7 +29,6 @@ export class CustomSelectComponent implements ControlValueAccessor, OnDestroy {
@Input() disabled = false;
@Input() searchable = false;
@Input() searchPlaceholder = 'Pesquisar...';
@Input() leadingIcon = '';
isOpen = false;
value: any = null;

View File

@ -1,28 +0,0 @@
<div class="input-group input-group-sm smart-search-group">
<span class="input-group-text">
<i
class="bi"
[class.bi-search]="!loading"
[class.bi-hourglass-split]="loading"
[class.text-brand]="loading"></i>
</span>
<input
class="form-control"
type="text"
[placeholder]="placeholder"
[value]="value"
[disabled]="disabled"
autocomplete="off"
(input)="onInput($event)"
(blur)="onBlur()" />
<button
class="btn btn-outline-secondary btn-clear"
type="button"
[disabled]="disabled"
*ngIf="value"
(click)="clear($event)">
<i class="bi bi-x-lg"></i>
</button>
</div>

View File

@ -1,72 +0,0 @@
:host {
min-width: 0;
}
.smart-search-group {
width: 100%;
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, #E33DCF);
box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15);
transform: translateY(-1px);
}
}
.input-group-text {
background: transparent;
border: none;
color: rgba(17, 18, 20, 0.65);
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: #111214;
box-shadow: none;
&::placeholder {
color: rgba(17, 18, 20, 0.4);
font-weight: 500;
}
&:focus {
outline: none;
}
}
.btn-clear {
background: transparent;
border: none;
color: rgba(17, 18, 20, 0.65);
padding: 0 12px;
display: flex;
align-items: center;
cursor: pointer;
transition: color 0.2s;
&:hover:not(:disabled) {
color: #dc3545;
}
i {
font-size: 1rem;
}
}

View File

@ -1,71 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-smart-search-input',
standalone: true,
imports: [CommonModule],
templateUrl: './smart-search-input.html',
styleUrls: ['./smart-search-input.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SmartSearchInputComponent),
multi: true,
},
],
})
export class SmartSearchInputComponent implements ControlValueAccessor {
@Input() placeholder = 'Pesquisar...';
@Input() loading = false;
@Input() disabled = false;
@Input() maxWidth = '270px';
@HostBinding('style.display') readonly hostDisplay = 'block';
@HostBinding('style.width')
get hostWidth(): string {
return this.maxWidth === '100%' ? '100%' : `min(100%, ${this.maxWidth})`;
}
value = '';
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string | null | undefined): void {
this.value = value ?? '';
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onInput(event: Event): void {
const nextValue = (event.target as HTMLInputElement | null)?.value ?? '';
this.value = nextValue;
this.onChange(nextValue);
}
clear(event?: Event): void {
event?.stopPropagation();
if (!this.value) return;
this.value = '';
this.onChange('');
this.onTouched();
}
onBlur(): void {
this.onTouched();
}
}

View File

@ -46,8 +46,8 @@
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(click)="onExport()"
[disabled]="loading || exporting">
<span class="header-action-btn-content" *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span class="header-action-btn-content" *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
<ng-template #exportOnlyTpl>
@ -57,8 +57,8 @@
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(click)="onExport()"
[disabled]="loading || exporting">
<span class="header-action-btn-content" *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span class="header-action-btn-content" *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
</ng-template>
@ -66,11 +66,11 @@
<div class="header-actions-secondary">
<button
type="button"
class="btn btn-glass btn-sm header-action-btn header-action-btn-template"
class="btn btn-glass btn-sm header-action-btn"
(click)="onExportTemplate()"
[disabled]="loading || exportingTemplate">
<span class="header-action-btn-content" *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span class="header-action-btn-content" *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button
@ -113,11 +113,9 @@
</button>
<div class="filter-blocked-select-box">
<app-select
class="select-glass blocked-select-glass"
class="select-glass"
size="sm"
[options]="blockedStatusFilterOptions"
placeholder="Bloqueio"
leadingIcon="bi-lock"
labelKey="label"
valueKey="value"
[ngModel]="blockedStatusSelectValue"
@ -337,12 +335,21 @@
<!-- CONTROLS -->
<div class="controls mt-3 mb-2" data-animate>
<app-smart-search-input
[(ngModel)]="searchTerm"
(ngModelChange)="onSearch()"
[loading]="loading"
placeholder="Pesquisar..."
></app-smart-search-input>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i
class="bi"
[class.bi-search]="!loading"
[class.bi-hourglass-split]="loading"
[class.text-brand]="loading"></i>
</span>
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="controls-right">
<div class="batch-status-tools" *ngIf="canManageLines">
@ -453,17 +460,6 @@
</div>
</div>
<div class="group-search-row">
<app-smart-search-input
class="group-search-input"
[(ngModel)]="groupSearchTerm"
(ngModelChange)="onGroupSearchChange()"
[loading]="loadingLines"
maxWidth="100%"
placeholder="Pesquisar..."
></app-smart-search-input>
</div>
<!-- ✅ wrapper com classe extra para permitir MAIS ALTURA em notebook/TV via SCSS -->
<div class="table-wrap inner-table-wrap table-wrap-responsive table-wrap-tall">
<div *ngIf="loadingLines" class="p-4 text-center text-muted">

View File

@ -173,30 +173,16 @@
}
.header-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
white-space: nowrap;
}
.header-action-btn-content {
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-action-btn-export {
align-self: center;
min-width: 152px;
}
.header-action-btn-wide {
min-width: 220px;
}
.header-action-btn-template {
min-width: 220px;
.header-action-btn-export {
align-self: center;
min-width: 140px;
}
@media (max-width: 1366px) {
@ -220,8 +206,8 @@
}
.header-action-btn,
.header-action-btn-export,
.header-action-btn-wide {
.header-action-btn-wide,
.header-action-btn-export {
width: 100%;
min-width: 0;
}
@ -261,12 +247,7 @@
}
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); flex-wrap: wrap; justify-content: center; }
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; flex: 0 0 auto; white-space: nowrap; line-height: 1.1; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.filter-blocked-select-box {
width: 196px;
flex: 0 0 auto;
position: relative;
z-index: 80;
}
.filter-blocked-select-box { width: 196px; flex: 0 0 auto; position: relative; z-index: 80; }
@media (max-width: 1366px) {
.filter-tabs {
@ -548,15 +529,6 @@
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
.client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.group-search-row {
padding: 14px 24px 0;
background: rgba(255, 255, 255, 0.82);
}
.group-search-input {
width: min(100%, 420px);
}
.btn-add-line-group {
display: inline-flex;
align-items: center;

View File

@ -19,7 +19,6 @@ import {
} from '@angular/common/http';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { SmartSearchInputComponent } from '../../components/smart-search-input/smart-search-input';
import { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
@ -179,8 +178,6 @@ interface ApiLineDetail {
desconto?: number | null;
lucro?: number | null;
createdAt?: string | null;
updatedAt?: string | null;
}
type UpdateMobileLineRequest = Omit<ApiLineDetail, 'id'>;
@ -362,7 +359,7 @@ interface MveApplySelectionSummary {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent, SmartSearchInputComponent, GeralModalsComponent],
imports: [CommonModule, FormsModule, CustomSelectComponent, GeralModalsComponent],
templateUrl: './geral.html',
styleUrls: ['./geral.scss']
})
@ -418,7 +415,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
loadingLines = false;
searchTerm = '';
groupSearchTerm = '';
filterSkil: SkilFilterMode = 'ALL';
filterStatus: 'ALL' | 'ACTIVE' | 'BLOCKED' = 'ALL';
blockedStatusMode: BlockedStatusMode = 'ALL';
@ -427,7 +423,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
filterOperadora: OperadoraFilterMode = 'ALL';
filterContaEmpresa = '';
readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusFilterValue }> = [
{ label: 'Todos os bloqueios', value: 'ALL' },
{ label: 'Todos os status', value: '' },
{ label: 'Bloqueadas', value: 'ALL' },
{ label: 'Bloqueio Perda/Roubo', value: 'PERDA_ROUBO' },
{ label: 'Bloqueio por 120 dias', value: 'BLOQUEIO_120' },
{ label: 'Bloqueio de Pré Ativação', value: 'PRE_ATIVACAO' },
@ -529,7 +526,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private editingId: string | null = null;
private searchTimer: any = null;
private groupSearchTimer: any = null;
private navigationSub?: Subscription;
private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false;
@ -1044,7 +1040,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer);
this.navigationSub?.unsubscribe();
this.dropdownSyncSub?.unsubscribe();
}
@ -1473,22 +1468,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return false;
}
private resetGroupSearchState(): void {
this.groupSearchTerm = '';
if (this.groupSearchTimer) {
clearTimeout(this.groupSearchTimer);
this.groupSearchTimer = null;
}
}
private getActiveExpandedGroupSearchTerm(): string | undefined {
const groupTerm = (this.groupSearchTerm ?? '').trim();
if (groupTerm) return groupTerm;
const globalTerm = (this.searchTerm ?? '').trim();
return globalTerm || undefined;
}
private normalizeDigits(value: unknown): string {
return String(value ?? '').replace(/\D/g, '');
}
@ -1653,7 +1632,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.searchTimer = setTimeout(async () => {
const requestVersion = ++this.searchRequestVersion;
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
@ -1701,15 +1679,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}, 300);
}
onGroupSearchChange(): void {
if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer);
this.groupSearchTimer = setTimeout(() => {
if (!this.expandedGroup) return;
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
}, 300);
}
private loadOnlyThisClientGroup(clientName: string): Promise<void> {
const requestVersion = ++this.groupsRequestVersion;
this.loading = true;
@ -2516,17 +2485,18 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleGroup(clientName: string) {
if (this.expandedGroup === clientName) {
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.clearReservaSelection();
return;
}
this.resetGroupSearchState();
this.clearReservaSelection();
this.expandedGroup = clientName;
this.fetchGroupLines(clientName, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(clientName, useTerm);
}
private async fetchAllGroupLines(
@ -2612,9 +2582,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
const normalizedSearch = (search ?? '').trim() || undefined;
void this.fetchAllGroupLines(clientName, normalizedSearch, requestVersion);
void this.fetchAllGroupLines(clientName, search, requestVersion);
}
toggleClientMenu() {
@ -2771,7 +2740,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
clearSearch() {
this.searchTerm = '';
this.searchResolvedClient = null;
this.resetGroupSearchState();
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
@ -2793,10 +2761,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
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 },
@ -2912,27 +2882,29 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
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 {
const ids = Array.from(
new Set(
baseRows
.map((row) => (row.id ?? '').toString().trim())
.filter((id) => !!id)
)
return await firstValueFrom(
this.http.get<ApiLineDetail>(`${this.apiBase}/${row.id}`, {
params: this.withNoCache(new HttpParams()),
})
);
const fetched = await firstValueFrom(
this.http.post<ApiLineDetail[]>(`${this.apiBase}/export-details`, { ids })
);
if (Array.isArray(fetched) && fetched.length > 0) {
return fetched;
}
} catch {
// Fallback local preserva a exportacao se o endpoint em lote falhar.
return this.toDetailFallback(row);
}
})
);
result.push(...fetched);
}
return baseRows.map((row) => this.toDetailFallback(row));
return result;
}
private toDetailFallback(row: LineRow): ApiLineDetail {
@ -2982,11 +2954,24 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
valorContratoLine: null,
desconto: null,
lucro: null,
createdAt: null,
updatedAt: null,
};
}
private async getGeralTemplateBuffer(): Promise<ArrayBuffer | null> {
try {
const params = new HttpParams().set('_', `${Date.now()}`);
const blob = await firstValueFrom(
this.http.get(`${this.templatesApiBase}/planilha-geral`, {
params,
responseType: 'blob',
})
);
return await blob.arrayBuffer();
} catch {
return null;
}
}
private getExportFilterSuffix(): string {
const parts: string[] = [];
@ -3503,7 +3488,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
await this.showToast(msg);
if (this.isGroupMode && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
this.loadGroups();
this.loadKpis();
} else {
@ -3529,7 +3516,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
if (this.isGroupMode && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
this.loadGroups();
this.loadKpis();
} else {
@ -3693,7 +3683,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.loading = false;
if (fromGroup && this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
this.loadGroups();
this.loadKpis();
} else {
@ -4487,7 +4480,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
await this.showToast(this.getCreateSuccessMessage(createdCount));
if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === targetClient) {
this.fetchGroupLines(this.expandedGroup!, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup!, useTerm);
this.loadGroups();
this.loadKpis();
} else {
@ -4766,7 +4762,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
);
if (this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
@ -4925,7 +4923,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
);
if (this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
this.loadKpis();
@ -4979,7 +4979,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
);
if (this.expandedGroup) {
this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm());
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
this.loadKpis();

View File

@ -280,7 +280,6 @@ export class ImportPageTemplateService {
rows: [],
headerRows,
excludeItemAndIdColumns: false,
skipTemplateStyle: true,
});
}

View File

@ -22,7 +22,6 @@ export interface TableExportRequest<T> {
headerRows?: Array<Array<string | number | boolean | Date | null | undefined>>;
excludeItemAndIdColumns?: boolean;
templateBuffer?: ArrayBuffer | null;
skipTemplateStyle?: boolean;
}
type ExcelJsModule = typeof import('exceljs');
@ -62,9 +61,7 @@ export class TableExportService {
async exportAsXlsx<T>(request: TableExportRequest<T>): Promise<void> {
const excelJsModule = await this.getExcelJs();
const templateBuffer = request.skipTemplateStyle
? null
: (request.templateBuffer ?? (await this.getDefaultTemplateBuffer()));
const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer());
const templateStyle = await this.resolveTemplateStyle(excelJsModule, templateBuffer);
const workbook = new excelJsModule.Workbook();
const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados'));