608 lines
16 KiB
TypeScript
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();
|
|
}
|
|
}
|