Ajusta exportacoes e modelos das telas

This commit is contained in:
Eduardo Lopes 2026-03-12 20:28:32 -03:00
parent af49f8265c
commit ccea2fe13b
20 changed files with 794 additions and 116 deletions

View File

@ -28,6 +28,11 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "exceljs.min.js",
"input": "node_modules/exceljs/dist",
"output": "/vendor"
}
],
"styles": [
@ -99,6 +104,11 @@
{
"glob": "**/*",
"input": "public"
},
{
"glob": "exceljs.min.js",
"input": "node_modules/exceljs/dist",
"output": "/vendor"
}
],
"styles": [

View File

@ -43,6 +43,14 @@
<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
class="btn btn-glass btn-sm"
(click)="onExportTemplate()"
[disabled]="activeLoading || exportingTemplate"
>
<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
*ngIf="isSysAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm"

View File

@ -7,6 +7,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel
import { ChipsControleModalsComponent } from '../../components/page-modals/chips-controle-modals/chips-controle-modals';
import { AuthService } from '../../services/auth.service';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
@ -98,6 +99,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
exporting = false;
exportingTemplate = false;
private toastTimer: any = null;
chipDetailOpen = false;
@ -137,7 +139,8 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
private service: ChipsControleService,
private http: HttpClient,
private authService: AuthService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
ngOnInit(): void {
@ -507,6 +510,26 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
if (this.activeTab === 'chips') {
await this.importPageTemplateService.exportChipsVirgensTemplate();
} else {
const selectedYear = typeof this.controleAno === 'number' ? this.controleAno : undefined;
await this.importPageTemplateService.exportControleRecebidosTemplate(selectedYear);
}
this.showToast('Modelo da aba exportado.', 'success');
} catch {
this.showToast('Erro ao exportar o modelo da aba.', 'danger');
} finally {
this.exportingTemplate = false;
}
}
private async fetchDetailedChipRowsForExport(rows: ChipVirgemListDto[]): Promise<ChipVirgemListDto[]> {
if (!rows.length) return [];

View File

@ -36,6 +36,10 @@
<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-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<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 *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>

View File

@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { DadosUsuariosModalsComponent } from '../../components/page-modals/dados-usuarios-modals/dados-usuarios-modals';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import {
DadosUsuariosService,
@ -57,6 +58,7 @@ export class DadosUsuarios implements OnInit {
loading = false;
exporting = false;
exportingTemplate = false;
errorMsg = '';
// Filtros
@ -129,7 +131,8 @@ export class DadosUsuarios implements OnInit {
private service: DadosUsuariosService,
private authService: AuthService,
private linesService: LinesService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
ngOnInit(): void {
@ -309,6 +312,20 @@ export class DadosUsuarios implements OnInit {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportDadosUsuariosTemplate(this.tipoFilter);
this.showToast('Modelo da página exportado.', 'success');
} catch {
this.showToast('Erro ao exportar o modelo da página.', 'danger');
} finally {
this.exportingTemplate = false;
}
}
private async fetchAllRowsForExport(): Promise<UserDataRow[]> {
const pageSize = 500;
let page = 1;

View File

@ -38,6 +38,10 @@
<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-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<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>
</div>
</div>

View File

@ -27,6 +27,7 @@ import {
import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
@ -67,11 +68,13 @@ export class Faturamento implements AfterViewInit, OnDestroy {
private linesService: LinesService,
private cdr: ChangeDetectorRef,
private authService: AuthService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
loading = false;
exporting = false;
exportingTemplate = false;
// filtros
searchTerm = '';
@ -465,6 +468,20 @@ export class Faturamento implements AfterViewInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportFaturamentoTemplate(this.filterTipo);
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private getRowsForExport(): BillingItem[] {
const rows: BillingItem[] = [];
this.rowsByClient.forEach((items) => rows.push(...items));

View File

@ -31,11 +31,11 @@
<small class="subtitle">Tabela de linhas e dados de telefonia</small>
</div>
<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">
<div class="header-actions" data-animate>
<div class="header-actions-stack" *ngIf="isSysAdmin; else exportOnlyTpl">
<button
type="button"
class="btn btn-glass btn-sm"
class="btn btn-glass btn-sm header-action-btn header-action-btn-wide"
(click)="onImportExcel()"
[disabled]="loading">
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
@ -43,7 +43,7 @@
<button
type="button"
class="btn btn-glass btn-sm align-self-center"
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(click)="onExport()"
[disabled]="loading || exporting">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
@ -51,27 +51,40 @@
</button>
</div>
<ng-template #exportOnlyTpl>
<div class="header-actions-stack header-actions-stack-single">
<button
type="button"
class="btn btn-glass btn-sm"
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(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>
<div class="header-actions-secondary">
<button
type="button"
class="btn btn-glass btn-sm header-action-btn"
(click)="onExportTemplate()"
[disabled]="loading || exportingTemplate">
<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
type="button"
class="btn btn-brand btn-sm header-action-btn"
*ngIf="canManageLines"
(click)="onCadastrarLinha()"
[disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
</button>
</div>
<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 align-self-start"
*ngIf="canManageLines"
(click)="onCadastrarLinha()"
[disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
</button>
</div>
</div>
@ -99,17 +112,16 @@
<i class="bi bi-check2-circle me-1"></i> Ativos
</button>
<div class="filter-blocked-select-box">
<i class="bi bi-slash-circle blocked-status-select-icon" aria-hidden="true"></i>
<select
class="blocked-status-native-select"
[value]="blockedStatusMode"
(change)="onBlockedStatusSelectChange($event)"
<app-select
class="select-glass"
size="sm"
[options]="blockedStatusFilterOptions"
labelKey="label"
valueKey="value"
[ngModel]="blockedStatusSelectValue"
(ngModelChange)="onBlockedStatusSelectChange($event)"
[disabled]="loading"
>
<option *ngFor="let option of blockedStatusFilterOptions" [value]="option.value">
{{ option.label }}
</option>
</select>
></app-select>
</div>
</div>
</div>

View File

@ -111,12 +111,107 @@
/* 3. HEADER, FILTROS E KPIs */
/* ========================================================== */
.geral-header { padding: 16px 24px; border-bottom: 1px solid rgba(17, 18, 20, 0.06); background: linear-gradient(180deg, rgba(227,61,207,0.06), rgba(255,255,255,0.2)); flex-shrink: 0; }
.header-row-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; @media (max-width: 768px) { grid-template-columns: 1fr; text-align: center; gap: 16px; .title-badge { justify-self: center; margin-bottom: 8px; } .header-actions { justify-self: center; } } }
.header-row-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 12px;
@media (max-width: 1366px) {
grid-template-columns: 1fr;
text-align: center;
gap: 16px;
.title-badge {
justify-self: center;
margin-bottom: 8px;
}
.header-title {
justify-self: center;
align-items: center;
text-align: center;
}
.header-actions {
justify-self: center;
}
}
}
.title-badge { justify-self: start; display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.78); border: 1px solid rgba(227, 61, 207, 0.22); backdrop-filter: blur(10px); color: var(--text); font-size: 13px; font-weight: 800; i { color: var(--brand); } }
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions { justify-self: end; }
.header-actions {
justify-self: end;
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
max-width: 100%;
}
.header-actions-stack,
.header-actions-secondary {
display: flex;
flex-direction: column;
gap: 8px;
}
.header-actions-stack {
align-items: stretch;
min-width: 220px;
}
.header-actions-stack-single {
min-width: auto;
}
.header-actions-secondary {
align-items: stretch;
}
.header-action-btn {
white-space: nowrap;
}
.header-action-btn-wide {
min-width: 220px;
}
.header-action-btn-export {
align-self: center;
min-width: 140px;
}
@media (max-width: 1366px) {
.header-actions {
justify-content: center;
align-items: stretch;
}
}
@media (max-width: 920px) {
.header-actions {
flex-direction: column;
align-items: center;
width: 100%;
}
.header-actions-stack,
.header-actions-secondary,
.header-actions-stack-single {
width: min(100%, 280px);
}
.header-action-btn,
.header-action-btn-wide,
.header-action-btn-export {
width: 100%;
min-width: 0;
}
}
/* Botões */
.btn-brand { background-color: var(--brand); border-color: var(--brand); color: #fff; font-weight: 900; border-radius: 12px; transition: transform 0.2s, box-shadow 0.2s; &:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); filter: brightness(1.05); } &:disabled { opacity: 0.7; cursor: not-allowed; transform: none; } }
@ -153,49 +248,6 @@
.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; }
.blocked-status-select-icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
pointer-events: none;
color: rgba(17, 18, 20, 0.68);
font-size: 0.82rem;
}
.blocked-status-native-select {
width: 100%;
height: 36px;
border-radius: 12px;
border: 1px solid rgba(17, 18, 20, 0.15);
background: rgba(255, 255, 255, 0.7);
color: #0f172a;
font-size: 0.78rem;
font-weight: 800;
padding: 0 30px 0 30px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
appearance: none;
-webkit-appearance: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
background: #fff;
border-color: rgba(17, 18, 20, 0.7);
box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1);
}
&:focus {
outline: none;
border-color: #e33dcf;
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
}
&:disabled {
background-color: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
}
@media (max-width: 1366px) {
.filter-tabs {

View File

@ -24,6 +24,7 @@ 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 { ImportPageTemplateService } from '../../services/import-page-template.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import {
MveAuditService,
@ -69,6 +70,7 @@ type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT';
type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM';
type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120' | 'PRE_ATIVACAO';
type BlockedStatusFilterValue = '' | BlockedStatusMode;
type SkilFilterMode = 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 'ESTOQUE';
interface LineRow {
@ -391,6 +393,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService,
private mveAuditService: MveAuditService,
private dropdownCoordinator: DropdownCoordinatorService
) {}
@ -399,6 +402,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates');
loading = false;
exporting = false;
exportingTemplate = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
@ -418,7 +422,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
selectedAdditionalServices: AdditionalServiceKey[] = [];
filterOperadora: OperadoraFilterMode = 'ALL';
filterContaEmpresa = '';
readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusMode }> = [
readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusFilterValue }> = [
{ label: 'Todos os status', value: '' },
{ label: 'Bloqueadas', value: 'ALL' },
{ label: 'Bloqueio Perda/Roubo', value: 'PERDA_ROUBO' },
{ label: 'Bloqueio por 120 dias', value: 'BLOQUEIO_120' },
@ -722,6 +727,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return (this.batchExcelPreview?.rows ?? []).slice(0, 8);
}
get blockedStatusSelectValue(): BlockedStatusFilterValue {
return this.filterStatus === 'BLOCKED' ? this.blockedStatusMode : '';
}
getBatchExcelRowErrorsTitle(row: BatchExcelPreviewRowDto | null | undefined): string {
const errors = row?.errors ?? [];
return errors
@ -1858,8 +1867,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
setBlockedStatusFilter(mode: BlockedStatusMode) {
const normalizedMode = mode ?? 'ALL';
setBlockedStatusFilter(mode: BlockedStatusFilterValue) {
const normalizedMode = mode ?? '';
if (normalizedMode === '') {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
return;
}
const sameSelection = this.filterStatus === 'BLOCKED' && this.blockedStatusMode === normalizedMode;
if (sameSelection) {
@ -1884,10 +1912,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
onBlockedStatusSelectChange(event: Event) {
const target = event.target;
const value = target instanceof HTMLSelectElement ? target.value : 'ALL';
this.setBlockedStatusFilter(value as BlockedStatusMode);
onBlockedStatusSelectChange(value: BlockedStatusFilterValue) {
this.setBlockedStatusFilter(value);
}
setAdditionalMode(mode: AdditionalMode) {
@ -2801,6 +2827,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportGeralTemplate();
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private async getRowsForExport(): Promise<LineRow[]> {
let lines = await this.fetchLinesForGrouping();

View File

@ -35,6 +35,10 @@
<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-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<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 *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button>

View File

@ -15,6 +15,7 @@ import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import { MuregModalsComponent } from '../../components/page-modals/mureg-modals/mureg-modals';
@ -109,6 +110,7 @@ export class Mureg implements AfterViewInit {
toastMessage = '';
loading = false;
exporting = false;
exportingTemplate = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ -118,7 +120,8 @@ export class Mureg implements AfterViewInit {
private cdr: ChangeDetectorRef,
private authService: AuthService,
private linesService: LinesService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'mureg');
@ -264,6 +267,20 @@ export class Mureg implements AfterViewInit {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportMuregTemplate();
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private async fetchAllRowsForExport(): Promise<MuregRow[]> {
const pageSize = 2000;
let page = 1;

View File

@ -29,6 +29,10 @@
<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-ghost btn-export-glass" type="button" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button *ngIf="canManageRecords" class="btn-primary" type="button" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i> Novo Parcelamento
</button>

View File

@ -6,6 +6,7 @@ import { finalize, Subscription, firstValueFrom, timeout } from 'rxjs';
import { ParcelamentosModalsComponent } from '../../components/page-modals/parcelamento-modals/parcelamentos-modals';
import { AuthService } from '../../services/auth.service';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import {
ParcelamentosService,
ParcelamentoListItem,
@ -68,6 +69,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
readonly vm = this;
loading = false;
exporting = false;
exportingTemplate = false;
errorMessage = '';
toastOpen = false;
toastMessage = '';
@ -160,7 +162,8 @@ export class Parcelamentos implements OnInit, OnDestroy {
constructor(
private parcelamentosService: ParcelamentosService,
private authService: AuthService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
ngOnInit(): void {
@ -335,6 +338,20 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportParcelamentosTemplate(this.resolveTemplateStartYear());
this.showToast('Modelo da página exportado.', 'success');
} catch {
this.showToast('Erro ao exportar o modelo da página.', 'danger');
} finally {
this.exportingTemplate = false;
}
}
onPageSizeChange(size: number): void {
this.pageSize = size;
this.page = 1;
@ -1159,6 +1176,19 @@ export class Parcelamentos implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
private resolveTemplateStartYear(): number {
const filteredYear = this.parseNumber(this.filters.anoRef);
if (filteredYear && filteredYear > 0) {
return filteredYear;
}
const listedYear = (this.items ?? [])
.map((item) => this.parseNumber(item.anoRef))
.find((year): year is number => year !== null && year > 0);
return listedYear ?? new Date().getFullYear();
}
private toNumber(value: any): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;

View File

@ -35,6 +35,10 @@
<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-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<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 *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Troca
</button>

View File

@ -14,6 +14,7 @@ import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { environment } from '../../../environments/environment';
import { TrocaNumeroModalsComponent } from '../../components/page-modals/troca-numero-modals/troca-numero-modals';
import {
@ -77,6 +78,7 @@ export class TrocaNumero implements AfterViewInit {
toastMessage = '';
loading = false;
exporting = false;
exportingTemplate = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ -85,7 +87,8 @@ export class TrocaNumero implements AfterViewInit {
private http: HttpClient,
private cdr: ChangeDetectorRef,
private authService: AuthService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'trocanumero');
@ -213,6 +216,20 @@ export class TrocaNumero implements AfterViewInit {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportTrocaNumeroTemplate();
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private async fetchAllRowsForExport(): Promise<TrocaRow[]> {
const pageSize = 2000;
let page = 1;

View File

@ -28,6 +28,10 @@
<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 class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<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 *ngIf="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button>

View File

@ -10,6 +10,7 @@ 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 { ImportPageTemplateService } from '../../services/import-page-template.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import { computeTotalPages } from '../../utils/pagination.util';
import { VigenciaModalsComponent } from '../../components/page-modals/vigencia-modals/vigencia-modals';
@ -37,6 +38,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
readonly vm = this;
loading = false;
exporting = false;
exportingTemplate = false;
errorMsg = '';
// Filtros
@ -119,7 +121,8 @@ export class VigenciaComponent implements OnInit, OnDestroy {
private linesService: LinesService,
private planAutoFill: PlanAutoFillService,
private route: ActivatedRoute,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private importPageTemplateService: ImportPageTemplateService
) {}
ngOnInit(): void {
@ -347,6 +350,20 @@ export class VigenciaComponent implements OnInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportVigenciaTemplate();
this.showToast('Modelo da página exportado.', 'success');
} catch {
this.showToast('Erro ao exportar o modelo da página.', 'danger');
} finally {
this.exportingTemplate = false;
}
}
private async fetchAllRowsForExport(): Promise<VigenciaRow[]> {
const pageSize = 500;
let page = 1;

View File

@ -0,0 +1,296 @@
import { Injectable } from '@angular/core';
import { TableExportColumn, TableExportService } from './table-export.service';
type TemplateCell = string | number | boolean | Date | null | undefined;
type TemplateRow = Record<string, TemplateCell>;
type FaturamentoTemplateMode = 'ALL' | 'PF' | 'PJ';
type DadosUsuariosTemplateMode = 'ALL' | 'PF' | 'PJ';
interface TemplateExportConfig {
fileNameBase: string;
sheetName: string;
headerRows: TemplateCell[][];
}
@Injectable({ providedIn: 'root' })
export class ImportPageTemplateService {
private readonly parcelamentoMonths = ['JAN', 'FEV', 'MAR', 'ABR', 'MAI', 'JUN', 'JUL', 'AGO', 'SET', 'OUT', 'NOV', 'DEZ'] as const;
constructor(private readonly tableExportService: TableExportService) {}
exportGeralTemplate(): Promise<void> {
return this.exportTemplate({
fileNameBase: 'modelo_geral',
sheetName: 'GERAL',
headerRows: [[
'ITEM',
'CONTA',
'LINHA',
'CHIP',
'CLIENTE',
'USUÁRIO',
'PLANO CONTRATO',
'FRAQUIA',
'VALOR DO PLANO R$',
'GESTÃO VOZ E DADOS R$',
'SKEELO',
'VIVO NEWS PLUS',
'VIVO TRAVEL MUNDO',
'VIVO SYNC',
'VIVO GESTÃO DISPOSITIVO',
'VALOR CONTRATO VIVO',
'FRANQUIA LINE',
'FRANQUIA GESTÃO',
'LOCAÇÃO AP.',
'VALOR CONTRATO LINE',
'DESCONTO',
'LUCRO',
'STATUS',
'DATA DO BLOQUEIO',
'SKIL',
'MODALIDADE',
'CEDENTE',
'SOLICITANTE',
'DATA DA ENTREGA OPERA.',
'DATA DA ENTREGA CLIENTE',
'VENC. DA CONTA',
'TIPO DE CHIP',
]],
});
}
exportMuregTemplate(): Promise<void> {
return this.exportTemplate({
fileNameBase: 'modelo_mureg',
sheetName: 'MUREG',
headerRows: [[
'ITEM',
'LINHA ANTIGA',
'LINHA NOVA',
'ICCID',
'DATA DA MUREG',
]],
});
}
exportFaturamentoTemplate(mode: FaturamentoTemplateMode = 'ALL'): Promise<void> {
const normalizedMode = mode === 'PF' || mode === 'PJ' ? mode : 'ALL';
const suffix = normalizedMode === 'ALL' ? 'faturamento' : `faturamento_${normalizedMode.toLowerCase()}`;
const sheetName = normalizedMode === 'ALL' ? 'FATURAMENTO' : `FATURAMENTO ${normalizedMode}`;
return this.exportTemplate({
fileNameBase: `modelo_${suffix}`,
sheetName,
headerRows: [
['', '', '', 'VIVO', 'VIVO', 'LINE', 'LINE', '', '', ''],
[
'ITEM',
'CLIENTE',
'QTD DE LINHAS',
'FRANQUIA',
'VALOR CONTRATO VIVO',
'FRANQUIA LINE',
'VALOR CONTRATO LINE',
'LUCRO',
'APARELHO',
'FORMA DE PAGAMENTO',
],
],
});
}
exportDadosUsuariosTemplate(mode: DadosUsuariosTemplateMode = 'ALL'): Promise<void> {
const normalizedMode = mode === 'PF' || mode === 'PJ' ? mode : 'ALL';
if (normalizedMode === 'PF') {
return this.exportTemplate({
fileNameBase: 'modelo_dados_pf',
sheetName: 'DADOS PF',
headerRows: [[
'ITEM',
'CLIENTE',
'NOME',
'LINHA',
'CPF',
'E-MAIL',
'CELULAR',
'TELEFONE FIXO',
'RG',
'ENDEREÇO',
'DATA DE NASCIMENTO',
]],
});
}
if (normalizedMode === 'PJ') {
return this.exportTemplate({
fileNameBase: 'modelo_dados_pj',
sheetName: 'DADOS PJ',
headerRows: [[
'ITEM',
'CLIENTE',
'RAZÃO SOCIAL',
'LINHA',
'CNPJ',
'E-MAIL',
'CELULAR',
'TELEFONE FIXO',
'ENDEREÇO',
]],
});
}
return this.exportTemplate({
fileNameBase: 'modelo_dados_usuarios',
sheetName: 'DADOS USUÁRIOS',
headerRows: [[
'ITEM',
'CLIENTE',
'NOME',
'RAZÃO SOCIAL',
'LINHA',
'CPF',
'CNPJ',
'E-MAIL',
'CELULAR',
'TELEFONE FIXO',
'RG',
'ENDEREÇO',
'DATA DE NASCIMENTO',
]],
});
}
exportVigenciaTemplate(): Promise<void> {
return this.exportTemplate({
fileNameBase: 'modelo_vigencia',
sheetName: 'VIGENCIA',
headerRows: [[
'ITEM',
'CONTA',
'LINHA',
'CLIENTE',
'USUÁRIO',
'PLANO CONTRATO',
'DT. DE EFETIVAÇÃO DO SERVIÇO',
'DT. DE TÉRMINO DA FIDELIZAÇÃO',
'TOTAL',
]],
});
}
exportTrocaNumeroTemplate(): Promise<void> {
return this.exportTemplate({
fileNameBase: 'modelo_troca_numero',
sheetName: 'TROCA NUMERO',
headerRows: [[
'ITEM',
'LINHA ANTIGA',
'LINHA NOVA',
'ICCID',
'DATA DA TROCA',
'MOTIVO',
'OBSERVAÇÃO',
]],
});
}
exportChipsVirgensTemplate(): Promise<void> {
return this.exportTemplate({
fileNameBase: 'modelo_chips_virgens',
sheetName: 'CHIPS VIRGENS',
headerRows: [[
'ITEM',
'NÚMERO DO CHIP',
'OBS',
]],
});
}
exportControleRecebidosTemplate(year?: number): Promise<void> {
const suffix = typeof year === 'number' && Number.isFinite(year) ? `_${year}` : '';
return this.exportTemplate({
fileNameBase: `modelo_controle_recebidos${suffix}`,
sheetName: typeof year === 'number' && Number.isFinite(year) ? `CONTROLE ${year}` : 'CONTROLE RECEBIDOS',
headerRows: [[
'ITEM',
'NOTA FISCAL',
'CHIP',
'SERIAL',
'CONTEÚDO DA NF',
'NÚMERO DA LINHA',
'VALOR UNIT.',
'VALOR DA NF',
'DATA DA NF',
'DATA DO RECEBIMENTO',
'QTD.',
]],
});
}
exportParcelamentosTemplate(startYear: number): Promise<void> {
const baseYear = Number.isFinite(startYear) ? Math.trunc(startYear) : new Date().getFullYear();
const nextYear = baseYear + 1;
const monthHeaders = [...this.parcelamentoMonths, ...this.parcelamentoMonths];
return this.exportTemplate({
fileNameBase: `modelo_parcelamentos_${baseYear}_${nextYear}`,
sheetName: 'PARCELAMENTOS',
headerRows: [
[
'',
'',
'',
'',
'',
'',
'',
'',
...Array.from({ length: 12 }, () => String(baseYear)),
...Array.from({ length: 12 }, () => String(nextYear)),
],
[
'ANO REF',
'ITEM',
'LINHA',
'CLIENTE',
'QT PARCELAS',
'VALOR CHEIO',
'DESCONTO',
'VALOR C/ DESCONTO',
...monthHeaders,
],
],
});
}
private async exportTemplate(config: TemplateExportConfig): Promise<void> {
const headerRows = config.headerRows?.filter((row) => Array.isArray(row) && row.length > 0) ?? [];
if (!headerRows.length) {
throw new Error('Nenhum cabeçalho configurado para o modelo.');
}
const lastHeaderRow = headerRows[headerRows.length - 1];
const columns = this.buildColumns(lastHeaderRow);
await this.tableExportService.exportAsXlsx<TemplateRow>({
fileName: `${config.fileNameBase}_${this.tableExportService.buildTimestamp()}`,
sheetName: config.sheetName,
columns,
rows: [],
headerRows,
excludeItemAndIdColumns: false,
});
}
private buildColumns(headers: TemplateCell[]): TableExportColumn<TemplateRow>[] {
return headers.map((header, index) => {
const key = `col_${index + 1}`;
return {
header: String(header ?? ''),
key,
value: (row) => row[key] ?? '',
};
});
}
}

View File

@ -19,9 +19,19 @@ export interface TableExportRequest<T> {
sheetName?: string;
columns: TableExportColumn<T>[];
rows: T[];
headerRows?: Array<Array<string | number | boolean | Date | null | undefined>>;
excludeItemAndIdColumns?: boolean;
templateBuffer?: ArrayBuffer | null;
}
type ExcelJsModule = typeof import('exceljs');
declare global {
interface Window {
ExcelJS?: ExcelJsModule;
}
}
type CellStyleSnapshot = {
font?: Partial<import('exceljs').Font>;
fill?: import('exceljs').Fill;
@ -38,43 +48,54 @@ type TemplateStyleSnapshot = {
@Injectable({ providedIn: 'root' })
export class TableExportService {
private readonly excelJsAssetPath = 'vendor/exceljs.min.js?v=20260312-1';
private readonly templatesApiBase = (() => {
const apiBase = buildApiBaseUrl(environment.apiUrl);
return `${apiBase}/templates`;
})();
private defaultTemplateBufferPromise: Promise<ArrayBuffer | null> | null = null;
private cachedDefaultTemplateStyle?: TemplateStyleSnapshot;
private excelJsPromise: Promise<ExcelJsModule> | null = null;
constructor(private readonly http: HttpClient) {}
async exportAsXlsx<T>(request: TableExportRequest<T>): Promise<void> {
const ExcelJS = await import('exceljs');
const excelJsModule = await this.getExcelJs();
const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer());
const templateStyle = await this.resolveTemplateStyle(ExcelJS, templateBuffer);
const workbook = new ExcelJS.Workbook();
const templateStyle = await this.resolveTemplateStyle(excelJsModule, templateBuffer);
const workbook = new excelJsModule.Workbook();
const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados'));
const rawColumns = request.columns ?? [];
const columns = rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header));
const excludeItemAndIdColumns = request.excludeItemAndIdColumns ?? true;
const columns = excludeItemAndIdColumns
? rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header))
: rawColumns;
const rows = request.rows ?? [];
const explicitHeaderRows = request.headerRows ?? [];
if (!columns.length) {
throw new Error('Nenhuma coluna exportavel apos remover ITEM/ID.');
}
const headerValues = columns.map((c) => c.header ?? '');
sheet.addRow(headerValues);
const headerRows = explicitHeaderRows.length
? explicitHeaderRows.map((row) => columns.map((_, columnIndex) => row[columnIndex] ?? ''))
: [columns.map((column) => column.header ?? '')];
headerRows.forEach((headerValues) => {
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 }];
this.applyHeaderStyle(sheet, columns.length, headerRows.length, templateStyle);
this.applyBodyStyle(sheet, columns, rows.length, headerRows.length, templateStyle);
this.applyColumnWidths(sheet, columns, rows, headerRows, templateStyle);
this.applyAutoFilter(sheet, columns.length, headerRows.length);
sheet.views = [{ state: 'frozen', ySplit: headerRows.length }];
const extensionSafeName = this.ensureXlsxExtension(request.fileName);
const buffer = await workbook.xlsx.writeBuffer();
@ -93,22 +114,25 @@ export class TableExportService {
private applyHeaderStyle(
sheet: import('exceljs').Worksheet,
columnCount: number,
headerRowCount: number,
templateStyle?: TemplateStyleSnapshot,
): void {
const headerRow = sheet.getRow(1);
headerRow.height = 24;
for (let rowIndex = 1; rowIndex <= headerRowCount; rowIndex += 1) {
const headerRow = sheet.getRow(rowIndex);
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();
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();
}
}
}
@ -116,11 +140,16 @@ export class TableExportService {
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
rowCount: number,
headerRowCount: number,
templateStyle?: TemplateStyleSnapshot,
): void {
for (let rowIndex = 2; rowIndex <= rowCount + 1; rowIndex += 1) {
const firstBodyRow = headerRowCount + 1;
const lastBodyRow = headerRowCount + rowCount;
for (let rowIndex = firstBodyRow; rowIndex <= lastBodyRow; rowIndex += 1) {
const row = sheet.getRow(rowIndex);
const isEven = (rowIndex - 1) % 2 === 0;
const bodyIndex = rowIndex - firstBodyRow;
const isEven = bodyIndex % 2 === 1;
const templateRowStyle = isEven
? (templateStyle?.bodyAltStyle ?? templateStyle?.bodyStyle)
: (templateStyle?.bodyStyle ?? templateStyle?.bodyAltStyle);
@ -154,6 +183,7 @@ export class TableExportService {
sheet: import('exceljs').Worksheet,
columns: TableExportColumn<T>[],
rows: T[],
headerRows: Array<Array<string | number | boolean | Date | null | undefined>>,
templateStyle?: TemplateStyleSnapshot,
): void {
columns.forEach((column, columnIndex) => {
@ -168,7 +198,11 @@ export class TableExportService {
return;
}
const headerLength = (column.header ?? '').length;
const explicitHeaderLength = headerRows.reduce((maxLength, headerRow) => {
const current = String(headerRow[columnIndex] ?? '').length;
return current > maxLength ? current : maxLength;
}, 0);
const headerLength = Math.max((column.header ?? '').length, explicitHeaderLength);
let maxLength = headerLength;
rows.forEach((row, rowIndex) => {
@ -182,11 +216,12 @@ export class TableExportService {
});
}
private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number): void {
if (columnCount <= 0) return;
private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number, headerRowCount: number): void {
if (columnCount <= 0 || headerRowCount <= 0) return;
const headerRow = headerRowCount;
sheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: columnCount },
from: { row: headerRow, column: 1 },
to: { row: headerRow, column: columnCount },
};
}
@ -357,8 +392,71 @@ export class TableExportService {
return value.toString().padStart(2, '0');
}
private async getExcelJs(): Promise<ExcelJsModule> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('Exportacao Excel disponivel apenas no navegador.');
}
if (window.ExcelJS) {
return window.ExcelJS;
}
if (this.excelJsPromise) {
return this.excelJsPromise;
}
this.excelJsPromise = new Promise<ExcelJsModule>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>('script[data-linegestao-exceljs="true"]');
const script = existingScript ?? document.createElement('script');
const finalizeSuccess = () => {
const excelJsModule = window.ExcelJS;
if (excelJsModule) {
script.setAttribute('data-load-state', 'loaded');
resolve(excelJsModule);
return;
}
script.setAttribute('data-load-state', 'error');
reject(new Error('ExcelJS foi carregado sem expor window.ExcelJS.'));
};
const finalizeError = () => {
script.setAttribute('data-load-state', 'error');
reject(new Error('Falha ao carregar a biblioteca ExcelJS.'));
};
if (existingScript?.getAttribute('data-load-state') === 'loaded') {
finalizeSuccess();
return;
}
if (existingScript?.getAttribute('data-load-state') === 'error') {
finalizeError();
return;
}
script.addEventListener('load', finalizeSuccess, { once: true });
script.addEventListener('error', finalizeError, { once: true });
if (!existingScript) {
script.src = new URL(this.excelJsAssetPath, document.baseURI).toString();
script.async = true;
script.setAttribute('data-linegestao-exceljs', 'true');
script.setAttribute('data-load-state', 'loading');
document.head.appendChild(script);
}
});
try {
return await this.excelJsPromise;
} catch (error) {
this.excelJsPromise = null;
throw error;
}
}
private async extractTemplateStyle(
excelJsModule: typeof import('exceljs'),
excelJsModule: ExcelJsModule,
templateBuffer: ArrayBuffer | null,
): Promise<TemplateStyleSnapshot | undefined> {
if (!templateBuffer) return undefined;
@ -387,7 +485,7 @@ export class TableExportService {
}
private async resolveTemplateStyle(
excelJsModule: typeof import('exceljs'),
excelJsModule: ExcelJsModule,
templateBuffer: ArrayBuffer | null,
): Promise<TemplateStyleSnapshot | undefined> {
if (templateBuffer) {