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": "**/*", "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

@ -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 { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subscription } from 'rxjs';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
@Component({ @Component({
selector: 'app-select', 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() options: any[] = [];
@Input() placeholder = 'Selecione uma opção'; @Input() placeholder = 'Selecione uma opção';
@Input() labelKey = 'label'; @Input() labelKey = 'label';
@ -29,11 +33,22 @@ export class CustomSelectComponent implements ControlValueAccessor {
isOpen = false; isOpen = false;
value: any = null; value: any = null;
searchTerm = ''; searchTerm = '';
private readonly dropdownId = `custom-select-${CustomSelectComponent.nextDropdownId++}`;
private readonly dropdownSyncSub: Subscription;
private onChange: (value: any) => void = () => {}; private onChange: (value: any) => void = () => {};
private onTouched: () => 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 { writeValue(value: any): void {
this.value = value; this.value = value;
@ -49,7 +64,7 @@ export class CustomSelectComponent implements ControlValueAccessor {
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled;
if (this.disabled) this.isOpen = false; if (this.disabled) this.close();
} }
get displayLabel(): string { get displayLabel(): string {
@ -65,13 +80,21 @@ export class CustomSelectComponent implements ControlValueAccessor {
toggle(): void { toggle(): void {
if (this.disabled) return; if (this.disabled) return;
this.isOpen = !this.isOpen; if (this.isOpen) {
if (!this.isOpen) this.searchTerm = ''; this.close();
return;
} }
close(): void { this.dropdownCoordinator.requestOpen(this.dropdownId);
this.isOpen = true;
}
close(notifyCoordinator = true): void {
this.isOpen = false; this.isOpen = false;
this.searchTerm = ''; this.searchTerm = '';
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.dropdownId);
}
} }
selectOption(option: any): void { selectOption(option: any): void {
@ -148,4 +171,9 @@ export class CustomSelectComponent implements ControlValueAccessor {
onEsc(): void { onEsc(): void {
if (this.isOpen) this.close(); 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"><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,28 +51,41 @@
</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>
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" /> <div class="header-actions-secondary">
<input #batchExcelInput type="file" class="d-none" accept=".xlsx" (change)="onBatchExcelSelected($event)" /> <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 <button
type="button" type="button"
class="btn btn-brand btn-sm align-self-start" class="btn btn-brand btn-sm header-action-btn"
*ngIf="canManageLines" *ngIf="canManageLines"
(click)="onCadastrarLinha()" (click)="onCadastrarLinha()"
[disabled]="loading"> [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Novo Cliente <i class="bi bi-plus-circle me-1"></i> Novo Cliente
</button> </button>
</div> </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)" />
</div>
</div> </div>
<div class="filters-stack mt-4" data-animate> <div class="filters-stack mt-4" data-animate>
@ -95,27 +108,21 @@
<i class="bi bi-box-seam me-1"></i> Estoque <i class="bi bi-box-seam me-1"></i> Estoque
</button> </button>
</ng-container> </ng-container>
<button type="button" class="filter-tab" [class.active]="filterStatus === 'BLOCKED'" (click)="toggleBlockedFilter()" [disabled]="loading"> <button type="button" class="filter-tab" [class.active]="filterStatus === 'ACTIVE'" (click)="toggleActiveFilter()" [disabled]="loading">
<i class="bi bi-slash-circle me-1"></i> Bloqueadas <i class="bi bi-check2-circle me-1"></i> Ativos
</button> </button>
<ng-container *ngIf="filterStatus === 'BLOCKED'"> <div class="filter-blocked-select-box">
<button <app-select
type="button" class="select-glass"
class="filter-tab" size="sm"
[class.active]="blockedStatusMode === 'PERDA_ROUBO'" [options]="blockedStatusFilterOptions"
(click)="setBlockedStatusMode('PERDA_ROUBO')" labelKey="label"
[disabled]="loading"> valueKey="value"
Perda/Roubo [ngModel]="blockedStatusSelectValue"
</button> (ngModelChange)="onBlockedStatusSelectChange($event)"
<button [disabled]="loading"
type="button" ></app-select>
class="filter-tab" </div>
[class.active]="blockedStatusMode === 'BLOQUEIO_120'"
(click)="setBlockedStatusMode('BLOQUEIO_120')"
[disabled]="loading">
120 dias
</button>
</ng-container>
</div> </div>
</div> </div>
@ -344,16 +351,7 @@
</button> </button>
</div> </div>
<div class="page-size d-flex align-items-center gap-2"> <div class="controls-right">
<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 class="batch-status-tools" *ngIf="canManageLines"> <div class="batch-status-tools" *ngIf="canManageLines">
<span class="batch-status-count"> <span class="batch-status-count">
Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong> Selecionadas: <strong>{{ batchStatusSelectionCount }}</strong>
@ -373,6 +371,17 @@
<i class="bi bi-unlock me-1"></i> Desbloquear em lote <i class="bi bi-unlock me-1"></i> Desbloquear em lote
</button> </button>
</div> </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>
</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; } }
@ -143,13 +238,45 @@
.filters-row-top { .filters-row-top {
justify-content: center; justify-content: center;
z-index: 60;
} }
.filters-row-bottom { .filters-row-bottom {
justify-content: center; 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; } .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); } } .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 */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .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; } } } .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 { .batch-status-tools {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: auto;
@media (max-width: 900px) { @media (max-width: 900px) {
margin-left: 0;
width: 100%; width: 100%;
} }
} }

View File

@ -1,9 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpParams } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { Geral } from './geral'; import { Geral } from './geral';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
describe('Geral', () => { describe('Geral', () => {
let component: Geral; let component: Geral;
@ -105,4 +107,85 @@ describe('Geral', () => {
expect(filtered.length).toBe(1); expect(filtered.length).toBe(1);
expect(filtered[0].conta).toBe('460161507'); 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 { 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,
@ -31,6 +32,7 @@ import {
type MveAuditIssue, type MveAuditIssue,
type MveAuditRun, type MveAuditRun,
} from '../../services/mve-audit.service'; } from '../../services/mve-audit.service';
import { DropdownCoordinatorService } from '../../services/dropdown-coordinator.service';
import { firstValueFrom, Subscription, filter } from 'rxjs'; import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@ -67,7 +69,8 @@ type CreateEntryMode = 'SINGLE' | 'BATCH';
type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; 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'; 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 {
@ -121,7 +124,7 @@ interface ApiLineList {
interface SmartSearchTargetResolution { interface SmartSearchTargetResolution {
client: string; client: string;
skilFilter: SkilFilterMode; skilFilter: SkilFilterMode;
statusFilter: 'ALL' | 'BLOCKED'; statusFilter: 'ALL' | 'ACTIVE' | 'BLOCKED';
blockedStatusMode: BlockedStatusMode; blockedStatusMode: BlockedStatusMode;
requiresFilterAdjustment: boolean; requiresFilterAdjustment: boolean;
} }
@ -361,9 +364,14 @@ interface MveApplySelectionSummary {
styleUrls: ['./geral.scss'] styleUrls: ['./geral.scss']
}) })
export class Geral implements OnInit, AfterViewInit, OnDestroy { export class Geral implements OnInit, AfterViewInit, OnDestroy {
private static nextFilterDropdownScopeId = 0;
readonly vm = this; readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE; readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = ''; 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('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>; @ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ -385,13 +393,16 @@ 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 mveAuditService: MveAuditService private importPageTemplateService: ImportPageTemplateService,
private mveAuditService: MveAuditService,
private dropdownCoordinator: DropdownCoordinatorService
) {} ) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines'); private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
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;
@ -405,12 +416,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
searchTerm = ''; searchTerm = '';
filterSkil: SkilFilterMode = 'ALL'; filterSkil: SkilFilterMode = 'ALL';
filterStatus: 'ALL' | 'BLOCKED' = 'ALL'; filterStatus: 'ALL' | 'ACTIVE' | 'BLOCKED' = 'ALL';
blockedStatusMode: BlockedStatusMode = 'ALL'; blockedStatusMode: BlockedStatusMode = 'ALL';
additionalMode: AdditionalMode = 'ALL'; additionalMode: AdditionalMode = 'ALL';
selectedAdditionalServices: AdditionalServiceKey[] = []; selectedAdditionalServices: AdditionalServiceKey[] = [];
filterOperadora: OperadoraFilterMode = 'ALL'; filterOperadora: OperadoraFilterMode = 'ALL';
filterContaEmpresa = ''; 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 }> = [ readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [
{ key: 'gvd', label: 'Gestão Voz e Dados' }, { key: 'gvd', label: 'Gestão Voz e Dados' },
{ key: 'skeelo', label: 'Skeelo' }, { key: 'skeelo', label: 'Skeelo' },
@ -509,6 +527,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private editingId: string | null = null; private editingId: string | null = null;
private searchTimer: any = null; private searchTimer: any = null;
private navigationSub?: Subscription; private navigationSub?: Subscription;
private dropdownSyncSub?: Subscription;
private keepPageOnNextGroupsLoad = false; private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null; private searchResolvedClient: string | null = null;
@ -525,7 +544,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
kpiAtivas = 0; kpiAtivas = 0;
kpiBloqueadas = 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']; readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA'];
planOptions = [ planOptions = [
@ -708,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
@ -831,7 +854,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
get hasClientSideFiltersApplied(): boolean { get hasClientSideFiltersApplied(): boolean {
return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied; return this.hasAdditionalFiltersApplied || this.filterStatus !== 'ALL' || this.hasOperadoraEmpresaFiltersApplied;
} }
get additionalModeLabel(): string { get additionalModeLabel(): string {
@ -968,12 +991,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false; let changed = false;
if (this.showClientMenu && !insideClient) { if (this.showClientMenu && !insideClient) {
this.showClientMenu = false; this.closeClientDropdown();
changed = true; changed = true;
} }
if (this.showAdditionalMenu && !insideAdditional) { if (this.showAdditionalMenu && !insideAdditional) {
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
changed = true; changed = true;
} }
@ -999,12 +1022,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
let changed = false; let changed = false;
if (this.showClientMenu) { if (this.showClientMenu) {
this.showClientMenu = false; this.closeClientDropdown();
changed = true; changed = true;
} }
if (this.showAdditionalMenu) { if (this.showAdditionalMenu) {
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
changed = true; changed = true;
} }
@ -1018,6 +1041,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer); if (this.searchTimer) clearTimeout(this.searchTimer);
this.navigationSub?.unsubscribe(); this.navigationSub?.unsubscribe();
this.dropdownSyncSub?.unsubscribe();
} }
ngOnInit(): void { ngOnInit(): void {
@ -1035,6 +1059,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.selectedAdditionalServices = []; this.selectedAdditionalServices = [];
this.selectedClients = []; 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() { async ngAfterViewInit() {
@ -1149,10 +1183,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return null; return null;
} }
private parseQueryStatusFilter(value: unknown): 'ALL' | 'BLOCKED' | null { private parseQueryStatusFilter(value: unknown): 'ALL' | 'ACTIVE' | 'BLOCKED' | null {
const token = this.normalizeFilterToken(value); const token = this.normalizeFilterToken(value);
if (!token) return null; if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL'; if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (
token === 'ACTIVE' ||
token === 'ATIVAS' ||
token === 'ATIVOS' ||
token === 'ATIVA' ||
token === 'ATIVO'
) {
return 'ACTIVE';
}
if ( if (
token === 'BLOCKED' || token === 'BLOCKED' ||
token === 'BLOQUEADAS' || token === 'BLOQUEADAS' ||
@ -1186,6 +1229,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
) { ) {
return 'BLOQUEIO_120'; return 'BLOQUEIO_120';
} }
if (
token === 'PREATIVACAO' ||
token === 'PREATIV' ||
token === 'BLOQUEIOPREATIVACAO'
) {
return 'PRE_ATIVACAO';
}
return null; return null;
} }
@ -1396,7 +1446,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
refreshData(opts?: { keepCurrentPage?: boolean }) { refreshData(opts?: { keepCurrentPage?: boolean }) {
const keepCurrentPage = !!opts?.keepCurrentPage; const keepCurrentPage = !!opts?.keepCurrentPage;
this.keepPageOnNextGroupsLoad = keepCurrentPage; this.keepPageOnNextGroupsLoad = keepCurrentPage;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED')) { if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL')) {
this.page = 1; this.page = 1;
} }
this.searchResolvedClient = null; this.searchResolvedClient = null;
@ -1422,9 +1472,18 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return String(value ?? '').replace(/\D/g, ''); return String(value ?? '').replace(/\D/g, '');
} }
private resolveSkilFilterFromLine(skil: unknown, client: unknown): SkilFilterMode { private isReservaValue(value: unknown): boolean {
if (this.isStockClientName(client)) return 'ESTOQUE'; return this.normalizeFilterToken(value) === 'RESERVA';
const parsed = this.parseQuerySkilFilter(skil); }
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'; return parsed ?? 'ALL';
} }
@ -1482,6 +1541,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!options?.ignoreCurrentFilters) { if (!options?.ignoreCurrentFilters) {
params = this.applyBaseFilters(params); params = this.applyBaseFilters(params);
this.selectedClients.forEach((c) => (params = params.append('client', c))); this.selectedClients.forEach((c) => (params = params.append('client', c)));
} else if (!options?.skilFilter) {
params = params.set('includeAssignedReservaInAll', 'true');
} else if (options?.skilFilter === 'PF') { } else if (options?.skilFilter === 'PF') {
params = params.set('skil', 'PESSOA FÍSICA'); params = params.set('skil', 'PESSOA FÍSICA');
} else if (options?.skilFilter === 'PJ') { } else if (options?.skilFilter === 'PJ') {
@ -1514,14 +1575,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
): SmartSearchTargetResolution | null { ): SmartSearchTargetResolution | null {
if (!line) return 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 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); const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter);
return { return {
client, client,
skilFilter, skilFilter,
statusFilter: blockedStatusMode === 'ALL' ? 'ALL' : 'BLOCKED', statusFilter,
blockedStatusMode, blockedStatusMode,
requiresFilterAdjustment requiresFilterAdjustment
}; };
@ -1780,13 +1846,58 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData(); this.refreshData();
} }
toggleBlockedFilter() { toggleActiveFilter() {
if (this.filterStatus === 'BLOCKED') { if (this.filterStatus === 'ACTIVE') {
this.filterStatus = 'ALL';
} else {
this.filterStatus = 'ACTIVE';
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();
}
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.filterStatus = 'ALL';
this.blockedStatusMode = 'ALL'; this.blockedStatusMode = 'ALL';
} else { } else {
this.filterStatus = 'BLOCKED'; this.filterStatus = 'BLOCKED';
this.blockedStatusMode = normalizedMode;
} }
this.expandedGroup = null; this.expandedGroup = null;
this.groupLines = []; this.groupLines = [];
this.searchResolvedClient = null; this.searchResolvedClient = null;
@ -1801,24 +1912,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData(); this.refreshData();
} }
setBlockedStatusMode(mode: Exclude<BlockedStatusMode, 'ALL'>) { onBlockedStatusSelectChange(value: BlockedStatusFilterValue) {
if (this.filterStatus !== 'BLOCKED') { this.setBlockedStatusFilter(value);
this.filterStatus = 'BLOCKED';
}
this.blockedStatusMode = this.blockedStatusMode === mode ? 'ALL' : mode;
this.expandedGroup = null;
this.groupLines = [];
this.searchResolvedClient = null;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
if (!this.isClientRestricted) {
this.loadClients();
}
this.refreshData();
} }
setAdditionalMode(mode: AdditionalMode) { setAdditionalMode(mode: AdditionalMode) {
@ -1902,11 +1997,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
next = next.set('skil', 'RESERVA'); next = next.set('skil', 'RESERVA');
const reservaMode = this.getReservaModeForApi(); const reservaMode = this.getReservaModeForApi();
if (reservaMode) next = next.set('reservaMode', reservaMode); if (reservaMode) next = next.set('reservaMode', reservaMode);
} else {
next = next.set('includeAssignedReservaInAll', 'true');
} }
if (this.filterStatus === 'BLOCKED') { if (this.filterStatus === 'BLOCKED') {
next = next.set('statusMode', 'blocked'); next = next.set('statusMode', 'blocked');
if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo'); 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 === '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'); 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('BLOQUE') ||
normalized.includes('PERDA') || normalized.includes('PERDA') ||
normalized.includes('ROUBO') || normalized.includes('ROUBO') ||
normalized.includes('FURTO'); normalized.includes('FURTO') ||
normalized.includes('PREATIV');
if (!hasBlockedToken) return null; if (!hasBlockedToken) return null;
if (normalized.includes('120')) return 'BLOQUEIO_120'; if (normalized.includes('120')) return 'BLOQUEIO_120';
if (normalized.includes('PREATIV')) return 'PRE_ATIVACAO';
if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) { if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) {
return 'PERDA_ROUBO'; return 'PERDA_ROUBO';
} }
return 'PERDA_ROUBO'; return 'PRE_ATIVACAO';
} }
private isBlockedStatus(status: unknown): boolean { private isBlockedStatus(status: unknown): boolean {
return this.resolveBlockedStatusMode(status) !== null; 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 { private matchesBlockedStatusMode(status: unknown): boolean {
const mode = this.resolveBlockedStatusMode(status); const mode = this.resolveBlockedStatusMode(status);
if (!mode) return false; if (!mode) return false;
@ -1979,6 +2084,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) { if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) {
return false; return false;
} }
if (this.filterStatus === 'ACTIVE' && !this.isActiveStatus(line?.status ?? '')) {
return false;
}
if (!this.matchesOperadoraContaEmpresaFilters(line)) { if (!this.matchesOperadoraContaEmpresaFilters(line)) {
return false; return false;
@ -2192,7 +2300,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const keepCurrentPage = this.keepPageOnNextGroupsLoad; const keepCurrentPage = this.keepPageOnNextGroupsLoad;
this.keepPageOnNextGroupsLoad = false; this.keepPageOnNextGroupsLoad = false;
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) { if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus !== 'ALL') && !hasSelection && !hasResolved) {
this.page = 1; this.page = 1;
} }
@ -2480,27 +2588,43 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleClientMenu() { toggleClientMenu() {
if (this.isClientRestricted) return; if (this.isClientRestricted) return;
if (!this.showClientMenu) this.showAdditionalMenu = false; if (this.showClientMenu) {
this.showClientMenu = !this.showClientMenu; this.closeClientDropdown();
return;
}
this.dropdownCoordinator.requestOpen(this.clientDropdownId);
this.showClientMenu = true;
} }
toggleAdditionalMenu() { toggleAdditionalMenu() {
if (this.isClientRestricted) return; if (this.isClientRestricted) return;
if (!this.showAdditionalMenu) this.showClientMenu = false; if (this.showAdditionalMenu) {
this.showAdditionalMenu = !this.showAdditionalMenu; this.closeAdditionalDropdown();
return;
} }
closeClientDropdown() { this.dropdownCoordinator.requestOpen(this.additionalDropdownId);
this.showAdditionalMenu = true;
}
closeClientDropdown(notifyCoordinator = true) {
this.showClientMenu = false; this.showClientMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.clientDropdownId);
}
} }
closeAdditionalDropdown() { closeAdditionalDropdown(notifyCoordinator = true) {
this.showAdditionalMenu = false; this.showAdditionalMenu = false;
if (notifyCoordinator) {
this.dropdownCoordinator.clear(this.additionalDropdownId);
}
} }
closeFilterDropdowns() { closeFilterDropdowns() {
this.showClientMenu = false; this.closeClientDropdown();
this.showAdditionalMenu = false; this.closeAdditionalDropdown();
} }
selectClient(client: string | null) { 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[]> { private async getRowsForExport(): Promise<LineRow[]> {
let lines = await this.fetchLinesForGrouping(); 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 if (this.filterSkil === 'ESTOQUE') parts.push('estoque');
else parts.push('todas'); 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'); if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo');
else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120'); 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'); else parts.push('bloqueadas');
} }
@ -5166,12 +5307,6 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return v || '-'; 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 { private toEditModel(d: ApiLineDetail): any {
return { return {
...d, ...d,

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

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

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,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; 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
? explicitHeaderRows.map((row) => columns.map((_, columnIndex) => row[columnIndex] ?? ''))
: [columns.map((column) => column.header ?? '')];
headerRows.forEach((headerValues) => {
sheet.addRow(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,9 +114,11 @@ 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) {
const headerRow = sheet.getRow(rowIndex);
headerRow.height = 24; headerRow.height = 24;
for (let col = 1; col <= columnCount; col += 1) { for (let col = 1; col <= columnCount; col += 1) {
@ -111,16 +134,22 @@ export class TableExportService {
cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder(); cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder();
} }
} }
}
private applyBodyStyle<T>( private applyBodyStyle<T>(
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) {