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
-
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) {