import { Component, ElementRef, ViewChild, Inject, PLATFORM_ID, AfterViewInit, ChangeDetectorRef, OnDestroy, HostListener } from '@angular/core'; import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { BillingService, BillingItem, BillingSortBy, SortDir, TipoCliente, TipoFiltro } from '../../services/billing'; interface BillingClientGroup { cliente: string; total: number; // registros linhas: number; // soma qtdLinhas totalVivo: number; totalLine: number; lucro: number; } @Component({ standalone: true, imports: [CommonModule, FormsModule, HttpClientModule], templateUrl: './faturamento.html', styleUrls: ['./faturamento.scss'] }) export class Faturamento implements AfterViewInit, OnDestroy { toastMessage = ''; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @ViewChild('detailModal', { static: false }) detailModal!: ElementRef; @ViewChild('compareModal', { static: false }) compareModal!: ElementRef; constructor( @Inject(PLATFORM_ID) private platformId: object, private billing: BillingService, private cdr: ChangeDetectorRef ) {} loading = false; // filtros searchTerm = ''; filterTipo: TipoFiltro = 'ALL'; clientsList: string[] = []; selectedClients: string[] = []; showClientMenu = false; clientSearchTerm = ''; // sort/paging sortBy: BillingSortBy = 'cliente'; sortDir: SortDir = 'asc'; // pagina por CLIENTES (grupos) page = 1; pageSize = 10; total = 0; // total de grupos // agrupamento clientGroups: BillingClientGroup[] = []; pagedClientGroups: BillingClientGroup[] = []; expandedGroup: string | null = null; groupRows: BillingItem[] = []; private rowsByClient = new Map(); // KPIs loadingKpis = false; kpiTotalClientes = 0; kpiTotalLinhas = 0; kpiTotalVivo = 0; kpiTotalLine = 0; kpiLucro = 0; // modals detailOpen = false; compareOpen = false; detailData: BillingItem | null = null; compareData: BillingItem | null = null; private searchTimer: any = null; // cache do ALL private allCache: BillingItem[] = []; private allCacheAt = 0; private allCacheTtlMs = 15000; // -------------------------- // Eventos globais // -------------------------- @HostListener('document:click', ['$event']) onDocumentClick(ev: MouseEvent) { if (!isPlatformBrowser(this.platformId)) return; if (this.anyModalOpen()) return; if (!this.showClientMenu) return; const target = ev.target as HTMLElement | null; if (!target) return; const inside = !!target.closest('.client-filter-wrap'); if (!inside) { this.showClientMenu = false; this.cdr.detectChanges(); } } @HostListener('document:keydown', ['$event']) onDocumentKeydown(ev: KeyboardEvent) { if (!isPlatformBrowser(this.platformId)) return; if (ev.key === 'Escape') { if (this.anyModalOpen()) { ev.preventDefault(); ev.stopPropagation(); this.closeAllModals(); return; } if (this.showClientMenu) { this.showClientMenu = false; ev.stopPropagation(); this.cdr.detectChanges(); } } } ngOnDestroy(): void { if (this.searchTimer) clearTimeout(this.searchTimer); } async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); setTimeout(() => { this.refreshData(true); }); } private initAnimations() { document.documentElement.classList.add('js-animate'); setTimeout(() => { const items = document.querySelectorAll('[data-animate]'); items.forEach((el) => el.classList.add('is-visible')); }, 100); } // -------------------------- // Helpers // -------------------------- private anyModalOpen(): boolean { return !!(this.detailOpen || this.compareOpen); } closeAllModals() { this.detailOpen = false; this.compareOpen = false; this.detailData = null; this.compareData = null; this.cdr.detectChanges(); } /** ✅ Evita usar Number(...) no template */ hasLucro(item: BillingItem | null): boolean { const n = Number((item as any)?.lucro ?? 0); return !Number.isNaN(n) && n !== 0; } /** ✅ Lê observação com/sem acento sem quebrar template */ getObservacao(item: BillingItem | null): string { const anyItem: any = item as any; const v = anyItem?.observacao ?? anyItem?.['observação'] ?? anyItem?.OBSERVACAO ?? anyItem?.['OBSERVAÇÃO']; const s = (v ?? '').toString().trim(); return s ? s : '—'; } private normalizeText(s: any): string { return (s ?? '') .toString() .trim() .toUpperCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); } private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean { if (filtro === 'ALL') return true; const t = this.normalizeText(itemTipo); if (filtro === 'PF') return t === 'PF' || t.includes('FISICA'); if (filtro === 'PJ') return t === 'PJ' || t.includes('JURIDICA'); return true; } formatMoney(v: any): string { const n = Number(v); if (v === null || v === undefined || Number.isNaN(n)) return '—'; return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); } formatFranquia(v: any): string { if (v === null || v === undefined) return '—'; if (typeof v === 'string') { const s = v.trim(); if (!s) return '—'; if (/[A-Z]/i.test(s)) return s; const n = Number(s.replace(',', '.')); if (Number.isNaN(n)) return s; return `${n.toLocaleString('pt-BR')} GB`; } const n = Number(v); if (Number.isNaN(n)) return '—'; return `${n.toLocaleString('pt-BR')} GB`; } // -------------------------- // Filtros / Clientes // -------------------------- toggleClientMenu() { this.showClientMenu = !this.showClientMenu; } closeClientDropdown() { this.showClientMenu = false; } isClientSelected(client: string): boolean { return this.selectedClients.includes(client); } get filteredClientsList(): string[] { const base = this.clientsList ?? []; if (!this.clientSearchTerm) return base; const s = this.clientSearchTerm.toLowerCase(); return base.filter((c) => (c ?? '').toLowerCase().includes(s)); } selectClient(client: string | null) { if (client === null) { this.selectedClients = []; } else { const idx = this.selectedClients.indexOf(client); if (idx >= 0) this.selectedClients.splice(idx, 1); else this.selectedClients.push(client); } this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); } removeClient(client: string, event: Event) { event.stopPropagation(); const idx = this.selectedClients.indexOf(client); if (idx >= 0) this.selectedClients.splice(idx, 1); this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); } clearClientSelection(event?: Event) { if (event) event.stopPropagation(); this.selectedClients = []; this.clientSearchTerm = ''; this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); } setFilter(type: 'ALL' | TipoCliente) { if (this.filterTipo === type) return; this.filterTipo = type; this.selectedClients = []; this.clientSearchTerm = ''; this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(true); } // -------------------------- // Search // -------------------------- onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); }, 250); } clearSearch() { this.searchTerm = ''; this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); } // -------------------------- // Data // -------------------------- refreshData(forceReloadAll = false) { this.loadAllAndApply(forceReloadAll); } private getAllItems(force = false): Promise { const now = Date.now(); if (!force && this.allCache.length > 0 && (now - this.allCacheAt) < this.allCacheTtlMs) { return Promise.resolve(this.allCache); } return new Promise((resolve) => { this.billing.getAll().subscribe({ next: (items) => { this.allCache = (items ?? []); this.allCacheAt = Date.now(); resolve(this.allCache); }, error: () => resolve(this.allCache ?? []) }); }); } private rebuildClientsList(baseTipo: BillingItem[]) { const set = new Set(); for (const r of baseTipo) { const c = (r.cliente ?? '').trim(); if (c) set.add(c); } this.clientsList = Array.from(set).sort((a, b) => a.localeCompare(b)); } private buildGroups(items: BillingItem[]) { this.rowsByClient.clear(); const safeClient = (c: any) => (String(c ?? '').trim() || 'SEM CLIENTE'); for (const r of (items ?? [])) { const key = safeClient(r.cliente); (r as any).cliente = key; const arr = this.rowsByClient.get(key) ?? []; arr.push(r); this.rowsByClient.set(key, arr); } const groups: BillingClientGroup[] = []; this.rowsByClient.forEach((arr, cliente) => { let linhas = 0; let totalVivo = 0; let totalLine = 0; let lucro = 0; for (const x of arr) { linhas += Number(x.qtdLinhas ?? 0) || 0; totalVivo += Number(x.valorContratoVivo ?? 0) || 0; totalLine += Number(x.valorContratoLine ?? 0) || 0; lucro += Number((x as any).lucro ?? 0) || 0; } groups.push({ cliente, total: arr.length, linhas, totalVivo: Number(totalVivo.toFixed(2)), totalLine: Number(totalLine.toFixed(2)), lucro: Number(lucro.toFixed(2)) }); }); groups.sort((a, b) => a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' })); this.clientGroups = groups; this.total = groups.length; this.applyGroupPagination(); } private applyGroupPagination() { const start = (this.page - 1) * this.pageSize; const end = start + this.pageSize; this.pagedClientGroups = (this.clientGroups ?? []).slice(start, end); if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) { this.expandedGroup = null; this.groupRows = []; } } private sortRows(arr: BillingItem[]): BillingItem[] { const dir = this.sortDir === 'asc' ? 1 : -1; const getVal = (r: BillingItem) => { switch (this.sortBy) { case 'tipo': return (r.tipo ?? '').toString(); case 'item': return r.item ?? 0; case 'cliente': return (r.cliente ?? '').toString(); case 'qtdlinhas': return r.qtdLinhas ?? 0; case 'franquiavivo': return r.franquiaVivo ?? 0; case 'valorcontratovivo': return r.valorContratoVivo ?? 0; case 'franquialine': return r.franquiaLine ?? 0; case 'valorcontratoline': return r.valorContratoLine ?? 0; case 'lucro': return (r as any).lucro ?? 0; case 'aparelho': return (r.aparelho ?? '').toString(); case 'formapagamento': return (r.formaPagamento ?? '').toString(); default: return (r.cliente ?? '').toString(); } }; return [...(arr ?? [])].sort((a, b) => { const va = getVal(a); const vb = getVal(b); if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; return String(va).localeCompare(String(vb), 'pt-BR', { sensitivity: 'base' }) * dir; }); } toggleGroup(cliente: string) { if (this.expandedGroup === cliente) { this.expandedGroup = null; this.groupRows = []; return; } this.expandedGroup = cliente; const rows = this.rowsByClient.get(cliente) ?? []; this.groupRows = this.sortRows(rows); this.cdr.detectChanges(); } private applyClientSide(allItems: BillingItem[]) { const baseTipo = (allItems ?? []).filter((r) => this.matchesTipo(r.tipo, this.filterTipo)); this.rebuildClientsList(baseTipo); let arr = [...baseTipo]; if (this.selectedClients.length > 0) { const set = new Set(this.selectedClients.map((x) => this.normalizeText(x))); arr = arr.filter((r) => set.has(this.normalizeText(r.cliente))); } const term = (this.searchTerm ?? '').trim().toLowerCase(); if (term) { arr = arr.filter((r) => { const cliente = (r.cliente ?? '').toLowerCase(); const aparelho = (r.aparelho ?? '').toLowerCase(); const forma = (r.formaPagamento ?? '').toLowerCase(); return cliente.includes(term) || aparelho.includes(term) || forma.includes(term); }); } // KPIs const unique = new Set(); let totalLinhas = 0; let totalVivo = 0; let totalLine = 0; let totalLucro = 0; for (const r of arr) { const c = (r.cliente ?? '').trim(); if (c) unique.add(c); totalLinhas += Number(r.qtdLinhas ?? 0) || 0; totalVivo += Number(r.valorContratoVivo ?? 0) || 0; totalLine += Number(r.valorContratoLine ?? 0) || 0; totalLucro += Number((r as any).lucro ?? 0) || 0; } this.kpiTotalClientes = unique.size; this.kpiTotalLinhas = totalLinhas; this.kpiTotalVivo = Number(totalVivo.toFixed(2)); this.kpiTotalLine = Number(totalLine.toFixed(2)); this.kpiLucro = Number(totalLucro.toFixed(2)); this.buildGroups(arr); } private async loadAllAndApply(forceReloadAll = false) { this.loading = true; this.loadingKpis = true; this.clientGroups = []; this.pagedClientGroups = []; this.rowsByClient.clear(); this.groupRows = []; try { const all = await this.getAllItems(forceReloadAll); this.applyClientSide(all); } finally { this.loading = false; this.loadingKpis = false; this.cdr.detectChanges(); } } // -------------------------- // Sort / Paging // -------------------------- setSort(key: BillingSortBy) { if (this.sortBy === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; else { this.sortBy = key; this.sortDir = 'asc'; } if (this.expandedGroup) { const rows = this.rowsByClient.get(this.expandedGroup) ?? []; this.groupRows = this.sortRows(rows); } this.cdr.detectChanges(); } onPageSizeChange() { this.page = 1; this.applyGroupPagination(); this.cdr.detectChanges(); } goToPage(p: number) { this.page = Math.max(1, Math.min(this.totalPages, p)); this.applyGroupPagination(); this.cdr.detectChanges(); } trackById(_: number, row: BillingItem) { return row.id; } trackByCliente(_: number, g: BillingClientGroup) { return g.cliente; } get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; } get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } get pageEnd() { return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); } get pageNumbers() { const total = this.totalPages; const current = this.page; const max = 5; let start = Math.max(1, current - 2); let end = Math.min(total, start + (max - 1)); start = Math.max(1, end - (max - 1)); const pages: number[] = []; for (let i = start; i <= end; i++) pages.push(i); return pages; } // -------------------------- // Modals // -------------------------- onDetalhes(r: BillingItem) { this.detailOpen = true; this.detailData = r; this.cdr.detectChanges(); } onComparativo(r: BillingItem) { this.compareOpen = true; this.compareData = r; this.cdr.detectChanges(); } }