line-gestao-frontend/src/app/pages/faturamento/faturamento.ts

608 lines
16 KiB
TypeScript

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<HTMLElement>;
@ViewChild('compareModal', { static: false }) compareModal!: ElementRef<HTMLElement>;
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<string, BillingItem[]>();
// 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<HTMLElement>('[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<BillingItem[]> {
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<string>();
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<string>();
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();
}
}