Compare commits

..

12 Commits

Author SHA1 Message Date
Eduardo Lopes 31999870e4 Merge branch 'dev' into producao
# Conflicts:
#	src/app/pages/geral/geral.html
#	src/app/pages/geral/geral.scss
#	src/app/pages/geral/geral.ts
2026-03-12 20:33:06 -03:00
Eduardo Lopes ccea2fe13b Ajusta exportacoes e modelos das telas 2026-03-12 20:28:32 -03:00
Eduardo Lopes 469a2616a5 Revert "Merge branch 'dev' into producao"
This reverts commit 5443acd33a, reversing
changes made to 6803ce9337.
2026-03-12 16:29:07 -03:00
Eduardo Lopes 2bd044a3ab Revert "Merge branch 'dev' into producao"
This reverts commit baf4c02f7b, reversing
changes made to 5443acd33a.
2026-03-12 16:29:06 -03:00
Eduardo Lopes baf4c02f7b Merge branch 'dev' into producao 2026-03-12 16:19:30 -03:00
Eduardo Lopes af49f8265c Reduz carga inicial do dashboard 2026-03-12 16:19:07 -03:00
Eduardo Lopes 5443acd33a Merge branch 'dev' into producao 2026-03-12 16:08:37 -03:00
Eduardo Lopes d1ec70cd69 Ajusta filtros e layout da pagina geral 2026-03-12 16:08:17 -03:00
Eduardo Lopes 6803ce9337 Merge branch 'dev' into producao 2026-03-12 14:43:40 -03:00
Eduardo Lopes 9a635ac167 fix: fecha filtros simultaneos na geral 2026-03-12 14:42:24 -03:00
Eduardo Lopes bef6496640 Merge branch 'dev' into producao 2026-03-12 13:51:45 -03:00
Eduardo Lopes 0d7186ce59 feat: ajuste de filtros e alterações pagina mve 2026-03-12 10:50:13 -03:00
25 changed files with 1291 additions and 172 deletions

View File

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

View File

@ -0,0 +1,56 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from './custom-select';
@Component({
standalone: true,
imports: [FormsModule, CustomSelectComponent],
template: `
<app-select [options]="options" [(ngModel)]="firstValue"></app-select>
<app-select [options]="options" [(ngModel)]="secondValue"></app-select>
`,
})
class HostComponent {
options = [
{ label: 'Primeira', value: 'one' },
{ label: 'Segunda', value: 'two' },
];
firstValue = 'one';
secondValue = 'two';
}
describe('CustomSelectComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
it('should keep only one select panel open at a time', () => {
const selectComponents = fixture.debugElement
.queryAll(By.directive(CustomSelectComponent))
.map((debugEl) => debugEl.componentInstance as CustomSelectComponent);
const triggerButtons = fixture.nativeElement.querySelectorAll('.app-select-trigger') as NodeListOf<HTMLButtonElement>;
triggerButtons[0].click();
fixture.detectChanges();
expect(selectComponents[0].isOpen).toBeTrue();
expect(selectComponents[1].isOpen).toBeFalse();
triggerButtons[1].click();
fixture.detectChanges();
expect(selectComponents[0].isOpen).toBeFalse();
expect(selectComponents[1].isOpen).toBeTrue();
expect(fixture.nativeElement.querySelectorAll('.app-select-panel').length).toBe(1);
});
});

View File

@ -1,6 +1,8 @@
import { Component, ElementRef, HostListener, Input, forwardRef } from '@angular/core';
import { Component, ElementRef, HostListener, Input, OnDestroy, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subscription } from 'rxjs';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
@Component({
selector: 'app-select',
@ -16,7 +18,9 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
},
],
})
export class CustomSelectComponent implements ControlValueAccessor {
export class CustomSelectComponent implements ControlValueAccessor, OnDestroy {
private static nextDropdownId = 0;
@Input() options: any[] = [];
@Input() placeholder = 'Selecione uma opção';
@Input() labelKey = 'label';
@ -29,11 +33,22 @@ export class CustomSelectComponent implements ControlValueAccessor {
isOpen = false;
value: any = null;
searchTerm = '';
private readonly dropdownId = `custom-select-${CustomSelectComponent.nextDropdownId++}`;
private readonly dropdownSyncSub: Subscription;
private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => {};
constructor(private host: ElementRef<HTMLElement>) {}
constructor(
private host: ElementRef<HTMLElement>,
private dropdownCoordinator: DropdownCoordinatorService
) {
this.dropdownSyncSub = this.dropdownCoordinator.activeDropdownId$.subscribe((activeId) => {
if (this.isOpen && activeId !== this.dropdownId) {
this.close(false);
}
});
}
writeValue(value: any): void {
this.value = value;
@ -49,7 +64,7 @@ export class CustomSelectComponent implements ControlValueAccessor {
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) this.isOpen = false;
if (this.disabled) this.close();
}
get displayLabel(): string {
@ -65,13 +80,21 @@ export class CustomSelectComponent implements ControlValueAccessor {
toggle(): void {
if (this.disabled) return;
this.isOpen = !this.isOpen;
if (!this.isOpen) this.searchTerm = '';
if (this.isOpen) {
this.close();
return;
}
this.dropdownCoordinator.requestOpen(this.dropdownId);
this.isOpen = true;
}
close(): void {
close(notifyCoordinator = true): void {
this.isOpen = false;
this.searchTerm = '';
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.dropdownId);
}
}
selectOption(option: any): void {
@ -148,4 +171,9 @@ export class CustomSelectComponent implements ControlValueAccessor {
onEsc(): void {
if (this.isOpen) this.close();
}
ngOnDestroy(): void {
this.close();
this.dropdownSyncSub.unsubscribe();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,11 +31,11 @@
<small class="subtitle">Tabela de linhas e dados de telefonia</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end align-items-start" data-animate>
<div class="d-flex flex-column gap-2 align-items-start" *ngIf="isSysAdmin; else exportOnlyTpl">
<div class="header-actions" data-animate>
<div class="header-actions-stack" *ngIf="isSysAdmin; else exportOnlyTpl">
<button
type="button"
class="btn btn-glass btn-sm"
class="btn btn-glass btn-sm header-action-btn header-action-btn-wide"
(click)="onImportExcel()"
[disabled]="loading">
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
@ -43,7 +43,7 @@
<button
type="button"
class="btn btn-glass btn-sm align-self-center"
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(click)="onExport()"
[disabled]="loading || exporting">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
@ -51,27 +51,40 @@
</button>
</div>
<ng-template #exportOnlyTpl>
<div class="header-actions-stack header-actions-stack-single">
<button
type="button"
class="btn btn-glass btn-sm"
class="btn btn-glass btn-sm header-action-btn header-action-btn-export"
(click)="onExport()"
[disabled]="loading || exporting">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
</ng-template>
<div class="header-actions-secondary">
<button
type="button"
class="btn btn-glass btn-sm header-action-btn"
(click)="onExportTemplate()"
[disabled]="loading || exportingTemplate">
<span *ngIf="!exportingTemplate"><i class="bi bi-file-earmark-arrow-down me-1"></i> Exportar Modelo</span>
<span *ngIf="exportingTemplate"><span class="spinner-border spinner-border-sm me-2"></span> Gerando Modelo...</span>
</button>
<button
type="button"
class="btn btn-brand btn-sm header-action-btn"
*ngIf="canManageLines"
(click)="onCadastrarLinha()"
[disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
</button>
</div>
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
<input #batchExcelInput type="file" class="d-none" accept=".xlsx" (change)="onBatchExcelSelected($event)" />
<button
type="button"
class="btn btn-brand btn-sm align-self-start"
*ngIf="canManageLines"
(click)="onCadastrarLinha()"
[disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Novo Cliente
</button>
</div>
</div>
@ -95,27 +108,21 @@
<i class="bi bi-box-seam me-1"></i> Estoque
</button>
</ng-container>
<button type="button" class="filter-tab" [class.active]="filterStatus === 'BLOCKED'" (click)="toggleBlockedFilter()" [disabled]="loading">
<i class="bi bi-slash-circle me-1"></i> Bloqueadas
<button type="button" class="filter-tab" [class.active]="filterStatus === 'ACTIVE'" (click)="toggleActiveFilter()" [disabled]="loading">
<i class="bi bi-check2-circle me-1"></i> Ativos
</button>
<ng-container *ngIf="filterStatus === 'BLOCKED'">
<button
type="button"
class="filter-tab"
[class.active]="blockedStatusMode === 'PERDA_ROUBO'"
(click)="setBlockedStatusMode('PERDA_ROUBO')"
[disabled]="loading">
Perda/Roubo
</button>
<button
type="button"
class="filter-tab"
[class.active]="blockedStatusMode === 'BLOQUEIO_120'"
(click)="setBlockedStatusMode('BLOQUEIO_120')"
[disabled]="loading">
120 dias
</button>
</ng-container>
<div class="filter-blocked-select-box">
<app-select
class="select-glass"
size="sm"
[options]="blockedStatusFilterOptions"
labelKey="label"
valueKey="value"
[ngModel]="blockedStatusSelectValue"
(ngModelChange)="onBlockedStatusSelectChange($event)"
[disabled]="loading"
></app-select>
</div>
</div>
</div>
@ -344,34 +351,36 @@
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
</span>
<div class="select-wrapper">
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
<div class="controls-right">
<div class="batch-status-tools" *ngIf="canManageLines">
<span class="batch-status-count">
Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong>
</span>
<button
type="button"
class="btn btn-glass btn-sm"
(click)="openBatchStatusModal('BLOCK')"
[disabled]="!canOpenBatchStatusModal">
<i class="bi bi-lock me-1"></i> Bloquear em lote
</button>
<button
type="button"
class="btn btn-glass btn-sm"
(click)="openBatchStatusModal('UNBLOCK')"
[disabled]="!canOpenBatchStatusModal">
<i class="bi bi-unlock me-1"></i> Desbloquear em lote
</button>
</div>
</div>
<div class="batch-status-tools" *ngIf="canManageLines">
<span class="batch-status-count">
Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong>
</span>
<button
type="button"
class="btn btn-glass btn-sm"
(click)="openBatchStatusModal('BLOCK')"
[disabled]="!canOpenBatchStatusModal">
<i class="bi bi-lock me-1"></i> Bloquear em lote
</button>
<button
type="button"
class="btn btn-glass btn-sm"
(click)="openBatchStatusModal('UNBLOCK')"
[disabled]="!canOpenBatchStatusModal">
<i class="bi bi-unlock me-1"></i> Desbloquear em lote
</button>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
</span>
<div class="select-wrapper">
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
</div>
</div>

View File

@ -111,12 +111,107 @@
/* 3. HEADER, FILTROS E KPIs */
/* ========================================================== */
.geral-header { padding: 16px 24px; border-bottom: 1px solid rgba(17, 18, 20, 0.06); background: linear-gradient(180deg, rgba(227,61,207,0.06), rgba(255,255,255,0.2)); flex-shrink: 0; }
.header-row-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; @media (max-width: 768px) { grid-template-columns: 1fr; text-align: center; gap: 16px; .title-badge { justify-self: center; margin-bottom: 8px; } .header-actions { justify-self: center; } } }
.header-row-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 12px;
@media (max-width: 1366px) {
grid-template-columns: 1fr;
text-align: center;
gap: 16px;
.title-badge {
justify-self: center;
margin-bottom: 8px;
}
.header-title {
justify-self: center;
align-items: center;
text-align: center;
}
.header-actions {
justify-self: center;
}
}
}
.title-badge { justify-self: start; display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.78); border: 1px solid rgba(227, 61, 207, 0.22); backdrop-filter: blur(10px); color: var(--text); font-size: 13px; font-weight: 800; i { color: var(--brand); } }
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions { justify-self: end; }
.header-actions {
justify-self: end;
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
max-width: 100%;
}
.header-actions-stack,
.header-actions-secondary {
display: flex;
flex-direction: column;
gap: 8px;
}
.header-actions-stack {
align-items: stretch;
min-width: 220px;
}
.header-actions-stack-single {
min-width: auto;
}
.header-actions-secondary {
align-items: stretch;
}
.header-action-btn {
white-space: nowrap;
}
.header-action-btn-wide {
min-width: 220px;
}
.header-action-btn-export {
align-self: center;
min-width: 140px;
}
@media (max-width: 1366px) {
.header-actions {
justify-content: center;
align-items: stretch;
}
}
@media (max-width: 920px) {
.header-actions {
flex-direction: column;
align-items: center;
width: 100%;
}
.header-actions-stack,
.header-actions-secondary,
.header-actions-stack-single {
width: min(100%, 280px);
}
.header-action-btn,
.header-action-btn-wide,
.header-action-btn-export {
width: 100%;
min-width: 0;
}
}
/* Botões */
.btn-brand { background-color: var(--brand); border-color: var(--brand); color: #fff; font-weight: 900; border-radius: 12px; transition: transform 0.2s, box-shadow 0.2s; &:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); filter: brightness(1.05); } &:disabled { opacity: 0.7; cursor: not-allowed; transform: none; } }
@ -143,13 +238,45 @@
.filters-row-top {
justify-content: center;
z-index: 60;
}
.filters-row-bottom {
justify-content: center;
z-index: 40;
}
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); flex-wrap: wrap; justify-content: center; }
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; flex: 0 0 auto; white-space: nowrap; line-height: 1.1; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.filter-blocked-select-box { width: 196px; flex: 0 0 auto; position: relative; z-index: 80; }
@media (max-width: 1366px) {
.filter-tabs {
gap: 3px;
padding: 3px;
}
.filter-tab {
padding: 7px 12px;
font-size: 0.78rem;
gap: 5px;
}
.filter-blocked-select-box {
width: 184px;
}
}
@media (max-width: 1200px) {
.filter-tab {
padding: 6px 10px;
font-size: 0.74rem;
gap: 4px;
}
.filter-blocked-select-box {
width: 176px;
}
}
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); }
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.client-filter-wrap { position: relative; z-index: 40; }
.btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } }
@ -335,17 +462,32 @@
/* Controls */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.controls-right {
margin-left: auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
@media (max-width: 900px) {
width: 100%;
justify-content: space-between;
}
@media (max-width: 500px) {
gap: 8px;
}
}
.search-group { max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease; &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); } .input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; padding-right: 8px; display: flex; align-items: center; i { font-size: 1rem; } } .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } &:focus { outline: none; } } .btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; display: flex; align-items: center; cursor: pointer; transition: color 0.2s; &:hover { color: #dc3545; } i { font-size: 1rem; } } }
.page-size { margin-left: auto; @media (max-width: 500px) { margin-left: 0; width: 100%; justify-content: space-between; } }
.page-size { @media (max-width: 500px) { width: 100%; justify-content: space-between; } }
.batch-status-tools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
@media (max-width: 900px) {
margin-left: 0;
width: 100%;
}
}

View File

@ -1,9 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpParams } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { Geral } from './geral';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
describe('Geral', () => {
let component: Geral;
@ -105,4 +107,85 @@ describe('Geral', () => {
expect(filtered.length).toBe(1);
expect(filtered[0].conta).toBe('460161507');
});
it('should classify reserve-assigned line as RESERVA during smart search resolution', () => {
const skilFilter = (component as any).resolveSkilFilterFromLine({
cliente: 'AVANCO DISTRIBUIDORA',
usuario: 'RESERVA',
skil: 'PESSOA JURIDICA',
});
expect(skilFilter).toBe('RESERVA');
});
it('should classify stock line as ESTOQUE during smart search resolution', () => {
const skilFilter = (component as any).resolveSkilFilterFromLine({
cliente: 'ESTOQUE',
usuario: 'RESERVA',
skil: 'RESERVA',
});
expect(skilFilter).toBe('ESTOQUE');
});
it('should filter only active lines when ACTIVE status filter is selected', () => {
component.filterStatus = 'ACTIVE';
component.filterOperadora = 'ALL';
component.filterContaEmpresa = '';
component.additionalMode = 'ALL';
component.selectedAdditionalServices = [];
const filtered = (component as any).applyAdditionalFiltersClientSide([
{ id: '1', item: 1, conta: '1', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
{ id: '2', item: 2, conta: '2', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'BLOQUEIO 120 DIAS' },
]);
expect(filtered.length).toBe(1);
expect(filtered[0].status).toBe('ATIVO');
});
it('should classify active line as ACTIVE during smart search resolution', () => {
const target = (component as any).buildSmartSearchTarget({
id: '1',
item: 1,
conta: '1',
linha: '11911111111',
cliente: 'CLIENTE A',
usuario: 'USUARIO A',
vencConta: null,
status: 'ATIVO',
skil: 'PESSOA JURIDICA',
}, true);
expect(target?.statusFilter).toBe('ACTIVE');
});
it('should request assigned reserve lines in ALL filter only', () => {
component.filterSkil = 'ALL';
let params = (component as any).applyBaseFilters(new HttpParams());
expect(params.get('includeAssignedReservaInAll')).toBe('true');
component.filterSkil = 'RESERVA';
params = (component as any).applyBaseFilters(new HttpParams());
expect(params.get('includeAssignedReservaInAll')).toBeNull();
expect(params.get('skil')).toBe('RESERVA');
});
it('should close custom filter dropdowns when another filter dropdown opens', () => {
const dropdownCoordinator = TestBed.inject(DropdownCoordinatorService);
component.toggleAdditionalMenu();
expect(component.showAdditionalMenu).toBeTrue();
component.toggleClientMenu();
expect(component.showClientMenu).toBeTrue();
expect(component.showAdditionalMenu).toBeFalse();
dropdownCoordinator.requestOpen('external-filter');
expect(component.showClientMenu).toBeFalse();
expect(component.showAdditionalMenu).toBeFalse();
});
});

View File

@ -24,6 +24,7 @@ import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
import { TableExportService } from '../../services/table-export.service';
import { ImportPageTemplateService } from '../../services/import-page-template.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import {
MveAuditService,
@ -31,6 +32,7 @@ import {
type MveAuditIssue,
type MveAuditRun,
} from '../../services/mve-audit.service';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@ -67,7 +69,8 @@ type CreateEntryMode = 'SINGLE' | 'BATCH';
type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT';
type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM';
type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120' | 'PRE_ATIVACAO';
type BlockedStatusFilterValue = '' | BlockedStatusMode;
type SkilFilterMode = 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 'ESTOQUE';
interface LineRow {
@ -121,7 +124,7 @@ interface ApiLineList {
interface SmartSearchTargetResolution {
client: string;
skilFilter: SkilFilterMode;
statusFilter: 'ALL' | 'BLOCKED';
statusFilter: 'ALL' | 'ACTIVE' | 'BLOCKED';
blockedStatusMode: BlockedStatusMode;
requiresFilterAdjustment: boolean;
}
@ -361,9 +364,14 @@ interface MveApplySelectionSummary {
styleUrls: ['./geral.scss']
})
export class Geral implements OnInit, AfterViewInit, OnDestroy {
private static nextFilterDropdownScopeId = 0;
readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = '';
private readonly filterDropdownScopeId = Geral.nextFilterDropdownScopeId++;
private readonly clientDropdownId = `geral-client-filter-${this.filterDropdownScopeId}`;
private readonly additionalDropdownId = `geral-additional-filter-${this.filterDropdownScopeId}`;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ -385,13 +393,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService,
private mveAuditService: MveAuditService
private importPageTemplateService: ImportPageTemplateService,
private mveAuditService: MveAuditService,
private dropdownCoordinator: DropdownCoordinatorService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates');
loading = false;
exporting = false;
exportingTemplate = false;
isSysAdmin = false;
isGestor = false;
isFinanceiro = false;
@ -405,12 +416,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
searchTerm = '';
filterSkil: SkilFilterMode = 'ALL';
filterStatus: 'ALL' | 'BLOCKED' = 'ALL';
filterStatus: 'ALL' | 'ACTIVE' | 'BLOCKED' = 'ALL';
blockedStatusMode: BlockedStatusMode = 'ALL';
additionalMode: AdditionalMode = 'ALL';
selectedAdditionalServices: AdditionalServiceKey[] = [];
filterOperadora: OperadoraFilterMode = 'ALL';
filterContaEmpresa = '';
readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusFilterValue }> = [
{ label: 'Todos os status', value: '' },
{ label: 'Bloqueadas', value: 'ALL' },
{ label: 'Bloqueio Perda/Roubo', value: 'PERDA_ROUBO' },
{ label: 'Bloqueio por 120 dias', value: 'BLOQUEIO_120' },
{ label: 'Bloqueio de Pré Ativação', value: 'PRE_ATIVACAO' },
];
readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [
{ key: 'gvd', label: 'Gestão Voz e Dados' },
{ key: 'skeelo', label: 'Skeelo' },
@ -509,6 +527,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private editingId: string | null = null;
private searchTimer: any = null;
private navigationSub?: Subscription;
private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null;
@ -525,7 +544,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
kpiAtivas = 0;
kpiBloqueadas = 0;
readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS'];
readonly statusOptions = ['ATIVO', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS', 'BLOQUEIO DE PRÉ ATIVAÇÃO'];
readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA'];
planOptions = [
@ -708,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
@ -831,7 +854,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get hasClientSideFiltersApplied(): boolean {
return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied;
return this.hasAdditionalFiltersApplied || this.filterStatus !== 'ALL' || this.hasOperadoraEmpresaFiltersApplied;
}
get additionalModeLabel(): string {
@ -968,12 +991,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false;
if (this.showClientMenu && !insideClient) {
this.showClientMenu = false;
this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu && !insideAdditional) {
this.showAdditionalMenu = false;
this.closeAdditionalDropdown();
changed = true;
}
@ -999,12 +1022,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false;
if (this.showClientMenu) {
this.showClientMenu = false;
this.closeClientDropdown();
changed = true;
}
if (this.showAdditionalMenu) {
this.showAdditionalMenu = false;
this.closeAdditionalDropdown();
changed = true;
}
@ -1018,6 +1041,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.navigationSub?.unsubscribe();
this.dropdownSyncSub?.unsubscribe();
}
ngOnInit(): void {
@ -1035,6 +1059,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.selectedAdditionalServices = [];
this.selectedClients = [];
}
this.dropdownSyncSub = this.dropdownCoordinator.activeDropdownId$.subscribe((activeId) => {
if (activeId !== this.clientDropdownId) {
this.closeClientDropdown(false);
}
if (activeId !== this.additionalDropdownId) {
this.closeAdditionalDropdown(false);
}
});
}
async ngAfterViewInit() {
@ -1149,10 +1183,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return null;
}
private parseQueryStatusFilter(value: unknown): 'ALL' | 'BLOCKED' | null {
private parseQueryStatusFilter(value: unknown): 'ALL' | 'ACTIVE' | 'BLOCKED' | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (
token === 'ACTIVE' ||
token === 'ATIVAS' ||
token === 'ATIVOS' ||
token === 'ATIVA' ||
token === 'ATIVO'
) {
return 'ACTIVE';
}
if (
token === 'BLOCKED' ||
token === 'BLOQUEADAS' ||
@ -1186,6 +1229,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
) {
return 'BLOQUEIO_120';
}
if (
token === 'PREATIVACAO' ||
token === 'PREATIV' ||
token === 'BLOQUEIOPREATIVACAO'
) {
return 'PRE_ATIVACAO';
}
return null;
}
@ -1396,7 +1446,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
refreshData(opts?: { keepCurrentPage?: boolean }) {
const keepCurrentPage = !!opts?.keepCurrentPage;
this.keepPageOnNextGroupsLoad = keepCurrentPage;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED')) {
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL')) {
this.page = 1;
}
this.searchResolvedClient = null;
@ -1422,9 +1472,18 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return String(value ?? '').replace(/\D/g, '');
}
private resolveSkilFilterFromLine(skil: unknown, client: unknown): SkilFilterMode {
if (this.isStockClientName(client)) return 'ESTOQUE';
const parsed = this.parseQuerySkilFilter(skil);
private isReservaValue(value: unknown): boolean {
return this.normalizeFilterToken(value) === 'RESERVA';
}
private resolveSkilFilterFromLine(line: Pick<ApiLineList, 'skil' | 'cliente' | 'usuario'> | null | undefined): SkilFilterMode {
if (!line) return 'ALL';
if (this.isStockClientName(line.cliente)) return 'ESTOQUE';
if (this.isReservaValue(line.cliente) || this.isReservaValue(line.usuario) || this.isReservaValue(line.skil)) {
return 'RESERVA';
}
const parsed = this.parseQuerySkilFilter(line.skil);
return parsed ?? 'ALL';
}
@ -1482,6 +1541,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!options?.ignoreCurrentFilters) {
params = this.applyBaseFilters(params);
this.selectedClients.forEach((c) => (params = params.append('client', c)));
} else if (!options?.skilFilter) {
params = params.set('includeAssignedReservaInAll', 'true');
} else if (options?.skilFilter === 'PF') {
params = params.set('skil', 'PESSOA FÍSICA');
} else if (options?.skilFilter === 'PJ') {
@ -1514,14 +1575,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
): SmartSearchTargetResolution | null {
if (!line) return null;
const skilFilter = this.resolveSkilFilterFromLine(line?.skil, line?.cliente);
const skilFilter = this.resolveSkilFilterFromLine(line);
const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL';
const statusFilter = blockedStatusMode !== 'ALL'
? 'BLOCKED'
: this.isActiveStatus(line?.status ?? '')
? 'ACTIVE'
: 'ALL';
const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter);
return {
client,
skilFilter,
statusFilter: blockedStatusMode === 'ALL' ? 'ALL' : 'BLOCKED',
statusFilter,
blockedStatusMode,
requiresFilterAdjustment
};
@ -1780,12 +1846,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
toggleBlockedFilter() {
if (this.filterStatus === 'BLOCKED') {
toggleActiveFilter() {
if (this.filterStatus === 'ACTIVE') {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
} else {
this.filterStatus = 'BLOCKED';
this.filterStatus = 'ACTIVE';
this.blockedStatusMode = 'ALL';
}
this.expandedGroup = null;
this.groupLines = [];
@ -1801,12 +1867,37 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
setBlockedStatusMode(mode: Exclude<BlockedStatusMode, 'ALL'>) {
if (this.filterStatus !== 'BLOCKED') {
this.filterStatus = 'BLOCKED';
setBlockedStatusFilter(mode: BlockedStatusFilterValue) {
const normalizedMode = mode ?? '';
if (normalizedMode === '') {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
return;
}
const sameSelection = this.filterStatus === 'BLOCKED' && this.blockedStatusMode === normalizedMode;
if (sameSelection) {
this.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL';
} else {
this.filterStatus = 'BLOCKED';
this.blockedStatusMode = normalizedMode;
}
this.blockedStatusMode = this.blockedStatusMode === mode ? 'ALL' : mode;
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
@ -1821,6 +1912,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
onBlockedStatusSelectChange(value: BlockedStatusFilterValue) {
this.setBlockedStatusFilter(value);
}
setAdditionalMode(mode: AdditionalMode) {
if (this.isClientRestricted) return;
if (this.additionalMode === mode) return;
@ -1902,11 +1997,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
next = next.set('skil', 'RESERVA');
const reservaMode = this.getReservaModeForApi();
if (reservaMode) next = next.set('reservaMode', reservaMode);
} else {
next = next.set('includeAssignedReservaInAll', 'true');
}
if (this.filterStatus === 'BLOCKED') {
next = next.set('statusMode', 'blocked');
if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') next = next.set('statusSubtype', '120_dias');
else if (this.blockedStatusMode === 'PRE_ATIVACAO') next = next.set('statusSubtype', 'pre_ativacao');
}
if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with');
@ -1953,21 +2051,28 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
normalized.includes('BLOQUE') ||
normalized.includes('PERDA') ||
normalized.includes('ROUBO') ||
normalized.includes('FURTO');
normalized.includes('FURTO') ||
normalized.includes('PREATIV');
if (!hasBlockedToken) return null;
if (normalized.includes('120')) return 'BLOQUEIO_120';
if (normalized.includes('PREATIV')) return 'PRE_ATIVACAO';
if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) {
return 'PERDA_ROUBO';
}
return 'PERDA_ROUBO';
return 'PRE_ATIVACAO';
}
private isBlockedStatus(status: unknown): boolean {
return this.resolveBlockedStatusMode(status) !== null;
}
private isActiveStatus(status: unknown): boolean {
if (this.isBlockedStatus(status)) return false;
return this.normalizeFilterToken(status).includes('ATIVO');
}
private matchesBlockedStatusMode(status: unknown): boolean {
const mode = this.resolveBlockedStatusMode(status);
if (!mode) return false;
@ -1979,6 +2084,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) {
return false;
}
if (this.filterStatus === 'ACTIVE' && !this.isActiveStatus(line?.status ?? '')) {
return false;
}
if (!this.matchesOperadoraContaEmpresaFilters(line)) {
return false;
@ -2192,7 +2300,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const keepCurrentPage = this.keepPageOnNextGroupsLoad;
this.keepPageOnNextGroupsLoad = false;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) {
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL') && !hasSelection && !hasResolved) {
this.page = 1;
}
@ -2480,27 +2588,43 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleClientMenu() {
if (this.isClientRestricted) return;
if (!this.showClientMenu) this.showAdditionalMenu = false;
this.showClientMenu = !this.showClientMenu;
if (this.showClientMenu) {
this.closeClientDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.clientDropdownId);
this.showClientMenu = true;
}
toggleAdditionalMenu() {
if (this.isClientRestricted) return;
if (!this.showAdditionalMenu) this.showClientMenu = false;
this.showAdditionalMenu = !this.showAdditionalMenu;
if (this.showAdditionalMenu) {
this.closeAdditionalDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.additionalDropdownId);
this.showAdditionalMenu = true;
}
closeClientDropdown() {
closeClientDropdown(notifyCoordinator = true) {
this.showClientMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.clientDropdownId);
}
}
closeAdditionalDropdown() {
closeAdditionalDropdown(notifyCoordinator = true) {
this.showAdditionalMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.additionalDropdownId);
}
}
closeFilterDropdowns() {
this.showClientMenu = false;
this.showAdditionalMenu = false;
this.closeClientDropdown();
this.closeAdditionalDropdown();
}
selectClient(client: string | null) {
@ -2703,6 +2827,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
}
async onExportTemplate(): Promise<void> {
if (this.exportingTemplate) return;
this.exportingTemplate = true;
try {
await this.importPageTemplateService.exportGeralTemplate();
await this.showToast('Modelo da página exportado.');
} catch {
await this.showToast('Erro ao exportar o modelo da página.');
} finally {
this.exportingTemplate = false;
}
}
private async getRowsForExport(): Promise<LineRow[]> {
let lines = await this.fetchLinesForGrouping();
@ -2843,9 +2981,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
else if (this.filterSkil === 'ESTOQUE') parts.push('estoque');
else parts.push('todas');
if (this.filterStatus === 'BLOCKED') {
if (this.filterStatus === 'ACTIVE') {
parts.push('ativas');
} else if (this.filterStatus === 'BLOCKED') {
if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120');
else if (this.blockedStatusMode === 'PRE_ATIVACAO') parts.push('bloq-pre-ativacao');
else parts.push('bloqueadas');
}
@ -5166,12 +5307,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return v || '-';
}
private isActiveStatus(status: string | null | undefined): boolean {
const normalized = (status ?? '').toString().trim().toLowerCase();
if (!normalized) return false;
return normalized.includes('ativo');
}
private toEditModel(d: ApiLineDetail): any {
return {
...d,

View File

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

View File

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

View File

@ -463,11 +463,11 @@
}
thead th:nth-child(4) {
width: 24%;
width: 20%;
}
thead th:nth-child(5) {
width: 12%;
width: 16%;
}
thead th {
@ -508,6 +508,12 @@
text-align: center;
}
.cell-situation,
.cell-action {
padding-left: 12px;
padding-right: 12px;
}
.cell-compare {
text-align: left;
}
@ -540,12 +546,16 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
min-width: min(100%, 190px);
max-width: 100%;
padding: 10px 16px;
border-radius: 999px;
font-size: 11px;
font-size: 13px;
font-weight: 900;
line-height: 1;
line-height: 1.15;
letter-spacing: 0.02em;
border: 1px solid transparent;
text-align: center;
}
.issue-kind-badge {
@ -709,16 +719,17 @@
.situation-card {
display: grid;
gap: 12px;
padding: 14px;
width: min(100%, 240px);
min-height: 112px;
margin-inline: auto;
padding: 10px 8px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(248, 246, 250, 0.93));
box-shadow: 0 14px 28px rgba(24, 17, 33, 0.05);
background: transparent;
box-shadow: none;
border: 0;
align-content: center;
justify-items: center;
text-align: center;
&.is-applied {
background: linear-gradient(180deg, rgba(25, 135, 84, 0.06), rgba(255, 255, 255, 0.98));
}
}
.situation-top {
@ -731,23 +742,38 @@
.action-card {
display: grid;
gap: 10px;
width: min(100%, 188px);
min-height: 112px;
margin-inline: auto;
padding: 10px 8px;
gap: 12px;
align-content: center;
justify-items: center;
text-align: center;
border-radius: 18px;
border: 0;
background: transparent;
box-shadow: none;
}
.sync-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 12px;
max-width: 100%;
padding: 10px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 900;
line-height: 1.15;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.ready {
background: rgba(255, 178, 0, 0.16);
color: #8c6200;
font-size: 11px;
}
&.applied {
@ -763,6 +789,8 @@
.page-footer {
margin-top: 16px;
padding: 14px 24px 0;
border-top: 1px solid rgba(17, 18, 20, 0.06);
display: flex;
align-items: center;
justify-content: space-between;
@ -770,6 +798,35 @@
flex-wrap: wrap;
}
.pagination-modern .page-link {
color: #030faa;
font-weight: 900;
border-radius: 10px;
border: 1px solid rgba(17, 18, 20, 0.1);
background: rgba(255, 255, 255, 0.6);
margin: 0 2px;
transition: transform 160ms ease, border-color 160ms ease, color 160ms ease, background-color 160ms ease;
&:hover {
transform: translateY(-1px);
border-color: var(--brand);
color: var(--brand);
background: rgba(255, 255, 255, 0.92);
}
}
.pagination-modern .page-item.active .page-link {
background-color: #030faa;
border-color: #030faa;
color: #fff;
}
.pagination-modern .page-item.disabled .page-link {
color: rgba(24, 17, 33, 0.42);
background: rgba(255, 255, 255, 0.38);
border-color: rgba(17, 18, 20, 0.08);
}
.status-empty,
.empty-state {
padding: 42px 20px;
@ -908,6 +965,11 @@
padding: 7px 12px;
}
.situation-card,
.action-card {
width: min(100%, 220px);
}
.page-footer {
justify-content: center;
text-align: center;
@ -942,6 +1004,11 @@
font-size: 11px;
}
.issue-kind-badge {
font-size: 11px;
padding: 8px 12px;
}
.page-footer .pagination {
justify-content: center;
flex-wrap: wrap;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class DropdownCoordinatorService {
private readonly activeDropdownIdSubject = new BehaviorSubject<string | null>(null);
readonly activeDropdownId$ = this.activeDropdownIdSubject.asObservable();
get activeDropdownId(): string | null {
return this.activeDropdownIdSubject.value;
}
requestOpen(id: string): void {
if (!id || this.activeDropdownIdSubject.value === id) return;
this.activeDropdownIdSubject.next(id);
}
clear(id?: string): void {
if (id && this.activeDropdownIdSubject.value !== id) return;
if (this.activeDropdownIdSubject.value === null) return;
this.activeDropdownIdSubject.next(null);
}
}

View File

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

View File

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