diff --git a/angular.json b/angular.json index 8e66459..9a18802 100644 --- a/angular.json +++ b/angular.json @@ -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": [ diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html index 7e18bfd..900ad80 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html @@ -43,6 +43,14 @@ Exportar Exportando... + + diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index 325dade..2c61707 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -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 { + 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 { const pageSize = 500; let page = 1; diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 9b16bca..4fb4432 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -38,6 +38,10 @@ Exportar Exportando... + diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index 826d41c..b8d9b11 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -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 { + 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)); diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 0317570..3ee8341 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -31,11 +31,11 @@ Tabela de linhas e dados de telefonia -
-
+
+
+
+
+
+ + + +
+ - -
@@ -98,27 +111,18 @@ - - - - - +
+ +
@@ -347,34 +351,36 @@ -
- - Itens por pág: - - -
- +
+
+ + Selecionadas: {{ batchStatusSelectionCount }} + + +
-
-
- - Selecionadas: {{ batchStatusSelectionCount }} - - - +
+ + Itens por pág: + + +
+ +
+
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 2056380..1428bd4 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -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; } } @@ -143,13 +238,45 @@ .filters-row-top { justify-content: center; + z-index: 60; } .filters-row-bottom { justify-content: center; + z-index: 40; +} +.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; } + +@media (max-width: 1366px) { + .filter-tabs { + gap: 3px; + padding: 3px; + } + + .filter-tab { + padding: 7px 12px; + font-size: 0.78rem; + gap: 5px; + } + + .filter-blocked-select-box { + width: 184px; + } +} + +@media (max-width: 1200px) { + .filter-tab { + padding: 6px 10px; + font-size: 0.74rem; + gap: 4px; + } + + .filter-blocked-select-box { + width: 176px; + } } -.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); } -.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; &: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; } } .client-filter-wrap { position: relative; z-index: 40; } .btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } } @@ -335,17 +462,32 @@ /* Controls */ .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.controls-right { + margin-left: auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; + + @media (max-width: 900px) { + width: 100%; + justify-content: space-between; + } + + @media (max-width: 500px) { + gap: 8px; + } +} .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; } } +.page-size { @media (max-width: 500px) { 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%; } } diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 06efc75..85a787b 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -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, @@ -68,7 +69,8 @@ type CreateEntryMode = 'SINGLE' | 'BATCH'; 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'; +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,6 +422,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { selectedAdditionalServices: AdditionalServiceKey[] = []; filterOperadora: OperadoraFilterMode = 'ALL'; filterContaEmpresa = ''; + 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' }, + { label: 'Bloqueio de Pré Ativação', value: 'PRE_ATIVACAO' }, + ]; readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [ { key: 'gvd', label: 'Gestão Voz e Dados' }, { key: 'skeelo', label: 'Skeelo' }, @@ -533,7 +544,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { kpiAtivas = 0; kpiBloqueadas = 0; - readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS']; + readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS', 'BLOQUEIO DE PRÉ ATIVAÇÃO']; readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA']; planOptions = [ @@ -716,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 @@ -1214,6 +1229,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ) { return 'BLOQUEIO_120'; } + if ( + token === 'PREATIVACAO' || + token === 'PREATIV' || + token === 'BLOQUEIOPREATIVACAO' + ) { + return 'PRE_ATIVACAO'; + } return null; } @@ -1845,13 +1867,37 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } - toggleBlockedFilter() { - if (this.filterStatus === 'BLOCKED') { + 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) { this.filterStatus = 'ALL'; this.blockedStatusMode = 'ALL'; } else { this.filterStatus = 'BLOCKED'; + this.blockedStatusMode = normalizedMode; } + this.expandedGroup = null; this.groupLines = []; this.searchResolvedClient = null; @@ -1866,24 +1912,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } - setBlockedStatusMode(mode: Exclude) { - if (this.filterStatus !== 'BLOCKED') { - this.filterStatus = 'BLOCKED'; - } - - this.blockedStatusMode = this.blockedStatusMode === mode ? 'ALL' : mode; - this.expandedGroup = null; - this.groupLines = []; - this.searchResolvedClient = null; - this.selectedClients = []; - this.clientSearchTerm = ''; - this.page = 1; - - if (!this.isClientRestricted) { - this.loadClients(); - } - - this.refreshData(); + onBlockedStatusSelectChange(value: BlockedStatusFilterValue) { + this.setBlockedStatusFilter(value); } setAdditionalMode(mode: AdditionalMode) { @@ -1974,6 +2004,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { next = next.set('statusMode', 'blocked'); if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo'); else if (this.blockedStatusMode === 'BLOQUEIO_120') next = next.set('statusSubtype', '120_dias'); + else if (this.blockedStatusMode === 'PRE_ATIVACAO') next = next.set('statusSubtype', 'pre_ativacao'); } if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with'); @@ -2020,15 +2051,17 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { normalized.includes('BLOQUE') || normalized.includes('PERDA') || normalized.includes('ROUBO') || - normalized.includes('FURTO'); + normalized.includes('FURTO') || + normalized.includes('PREATIV'); if (!hasBlockedToken) return null; if (normalized.includes('120')) return 'BLOQUEIO_120'; + if (normalized.includes('PREATIV')) return 'PRE_ATIVACAO'; if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) { return 'PERDA_ROUBO'; } - return 'PERDA_ROUBO'; + return 'PRE_ATIVACAO'; } private isBlockedStatus(status: unknown): boolean { @@ -2794,6 +2827,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } } + async onExportTemplate(): Promise { + 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 { let lines = await this.fetchLinesForGrouping(); @@ -2939,6 +2986,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } else if (this.filterStatus === 'BLOCKED') { if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo'); else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120'); + else if (this.blockedStatusMode === 'PRE_ATIVACAO') parts.push('bloq-pre-ativacao'); else parts.push('bloqueadas'); } diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index ba4d93b..cb7bf5d 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -35,6 +35,10 @@ Exportar Exportando... + diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index aee7365..3ce28fd 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -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 { + 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 { const pageSize = 2000; let page = 1; diff --git a/src/app/pages/parcelamentos/parcelamentos.html b/src/app/pages/parcelamentos/parcelamentos.html index c12e0fa..99d9afe 100644 --- a/src/app/pages/parcelamentos/parcelamentos.html +++ b/src/app/pages/parcelamentos/parcelamentos.html @@ -29,6 +29,10 @@ Exportar Exportando... + diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts index 30ec4bc..cd432d7 100644 --- a/src/app/pages/parcelamentos/parcelamentos.ts +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -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 { + 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; diff --git a/src/app/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html index c8fa43f..f0caa57 100644 --- a/src/app/pages/troca-numero/troca-numero.html +++ b/src/app/pages/troca-numero/troca-numero.html @@ -35,6 +35,10 @@ Exportar Exportando... + diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts index 7264919..193b3a8 100644 --- a/src/app/pages/troca-numero/troca-numero.ts +++ b/src/app/pages/troca-numero/troca-numero.ts @@ -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 { + 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 { const pageSize = 2000; let page = 1; diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index fa2b8bf..7f5d669 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -28,6 +28,10 @@ Exportar Exportando... + diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index 0bcd584..433555e 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -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 { + 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 { const pageSize = 500; let page = 1; diff --git a/src/app/services/import-page-template.service.ts b/src/app/services/import-page-template.service.ts new file mode 100644 index 0000000..a9fa969 --- /dev/null +++ b/src/app/services/import-page-template.service.ts @@ -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; +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 { + 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 { + return this.exportTemplate({ + fileNameBase: 'modelo_mureg', + sheetName: 'MUREG', + headerRows: [[ + 'ITEM', + 'LINHA ANTIGA', + 'LINHA NOVA', + 'ICCID', + 'DATA DA MUREG', + ]], + }); + } + + exportFaturamentoTemplate(mode: FaturamentoTemplateMode = 'ALL'): Promise { + 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 { + 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 { + 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 { + 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 { + return this.exportTemplate({ + fileNameBase: 'modelo_chips_virgens', + sheetName: 'CHIPS VIRGENS', + headerRows: [[ + 'ITEM', + 'NÚMERO DO CHIP', + 'OBS', + ]], + }); + } + + exportControleRecebidosTemplate(year?: number): Promise { + 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 { + 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 { + 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({ + fileName: `${config.fileNameBase}_${this.tableExportService.buildTimestamp()}`, + sheetName: config.sheetName, + columns, + rows: [], + headerRows, + excludeItemAndIdColumns: false, + }); + } + + private buildColumns(headers: TemplateCell[]): TableExportColumn[] { + return headers.map((header, index) => { + const key = `col_${index + 1}`; + return { + header: String(header ?? ''), + key, + value: (row) => row[key] ?? '', + }; + }); + } +} diff --git a/src/app/services/table-export.service.ts b/src/app/services/table-export.service.ts index 8c9f72d..21c0165 100644 --- a/src/app/services/table-export.service.ts +++ b/src/app/services/table-export.service.ts @@ -19,9 +19,19 @@ export interface TableExportRequest { sheetName?: string; columns: TableExportColumn[]; rows: T[]; + headerRows?: Array>; + excludeItemAndIdColumns?: boolean; templateBuffer?: ArrayBuffer | null; } +type ExcelJsModule = typeof import('exceljs'); + +declare global { + interface Window { + ExcelJS?: ExcelJsModule; + } +} + type CellStyleSnapshot = { font?: Partial; 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 | null = null; private cachedDefaultTemplateStyle?: TemplateStyleSnapshot; + private excelJsPromise: Promise | null = null; constructor(private readonly http: HttpClient) {} async exportAsXlsx(request: TableExportRequest): Promise { - 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[], 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[], rows: T[], + headerRows: Array>, 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 { + 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((resolve, reject) => { + const existingScript = document.querySelector('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 { if (!templateBuffer) return undefined; @@ -387,7 +485,7 @@ export class TableExportService { } private async resolveTemplateStyle( - excelJsModule: typeof import('exceljs'), + excelJsModule: ExcelJsModule, templateBuffer: ArrayBuffer | null, ): Promise { if (templateBuffer) {