Ajusta exportacoes e modelos das telas

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

View File

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

View File

@ -43,6 +43,14 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button
class="btn btn-glass btn-sm"
(click)="onExportTemplate()"
[disabled]="activeLoading || exportingTemplate"
>
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button <button
*ngIf="isSysAdmin && activeTab === 'chips'" *ngIf="isSysAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm" class="btn btn-brand btn-sm"

View File

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

View File

@ -36,6 +36,10 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button *ngIf="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()"> <button *ngIf="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário <i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button> </button>

View File

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

View File

@ -38,6 +38,10 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -111,12 +111,107 @@
/* 3. HEADER, FILTROS E KPIs */ /* 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; } .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); } } .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; } .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; } .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; } .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 */ /* 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; } } .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-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-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; flex: 0 0 auto; white-space: nowrap; line-height: 1.1; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.filter-blocked-select-box { width: 196px; flex: 0 0 auto; position: relative; z-index: 80; } .filter-blocked-select-box { width: 196px; flex: 0 0 auto; position: relative; z-index: 80; }
.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) { @media (max-width: 1366px) {
.filter-tabs { .filter-tabs {

View File

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

View File

@ -35,6 +35,10 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading"> <button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg <i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button> </button>

View File

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

View File

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

View File

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

View File

@ -35,6 +35,10 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading"> <button *ngIf="canManageRecords" type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Troca <i class="bi bi-plus-circle me-1"></i> Nova Troca
</button> </button>

View File

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

View File

@ -28,6 +28,10 @@
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span> <span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span> <span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button> </button>
<button class="btn btn-glass btn-sm" (click)="onExportTemplate()" [disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button *ngIf="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()"> <button *ngIf="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência <i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button> </button>

View File

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

View File

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

View File

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