From ccea2fe13bd0a6025858e5afdf2a52df558acdc5 Mon Sep 17 00:00:00 2001 From: Eduardo Lopes Date: Thu, 12 Mar 2026 20:28:32 -0300 Subject: [PATCH] Ajusta exportacoes e modelos das telas --- angular.json | 10 + .../chips-controle-recebidos.html | 8 + .../chips-controle-recebidos.ts | 25 +- .../pages/dados-usuarios/dados-usuarios.html | 4 + .../pages/dados-usuarios/dados-usuarios.ts | 19 +- src/app/pages/faturamento/faturamento.html | 4 + src/app/pages/faturamento/faturamento.ts | 19 +- src/app/pages/geral/geral.html | 60 ++-- src/app/pages/geral/geral.scss | 142 ++++++--- src/app/pages/geral/geral.ts | 54 +++- src/app/pages/mureg/mureg.html | 4 + src/app/pages/mureg/mureg.ts | 19 +- .../pages/parcelamentos/parcelamentos.html | 4 + src/app/pages/parcelamentos/parcelamentos.ts | 32 +- src/app/pages/troca-numero/troca-numero.html | 4 + src/app/pages/troca-numero/troca-numero.ts | 19 +- src/app/pages/vigencia/vigencia.html | 4 + src/app/pages/vigencia/vigencia.ts | 19 +- .../services/import-page-template.service.ts | 296 ++++++++++++++++++ src/app/services/table-export.service.ts | 164 ++++++++-- 20 files changed, 794 insertions(+), 116 deletions(-) create mode 100644 src/app/services/import-page-template.service.ts 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 796667f..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 -
-
+
+
+
+
+
+ + + +
+ - -
@@ -99,17 +112,16 @@ Ativos
- - + >
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 6519cc5..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; } } @@ -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 { diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 3ccfba5..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, @@ -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 { + 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(); 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) {