import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { environment } from '../../../environments/environment'; import { finalize, Subscription, firstValueFrom, timeout } from 'rxjs'; import { AuthService } from '../../services/auth.service'; import { TableExportService } from '../../services/table-export.service'; import { ParcelamentosService, ParcelamentoListItem, ParcelamentoDetail, ParcelamentoDetailResponse, ParcelamentoParcela, ParcelamentoAnnualRow, ParcelamentoAnnualMonth, ParcelamentoUpsertRequest, ParcelamentoMonthInput, } from '../../services/parcelamentos.service'; import { ParcelamentosKpisComponent, ParcelamentoKpi, } from './components/parcelamentos-kpis/parcelamentos-kpis'; import { ParcelamentosFiltersComponent, ParcelamentosFiltersModel, FilterChip, } from './components/parcelamentos-filters/parcelamentos-filters'; import { ParcelamentosTableComponent, ParcelamentoSegment, ParcelamentoViewItem, } from './components/parcelamentos-table/parcelamentos-table'; import { ParcelamentoCreateModalComponent, ParcelamentoCreateModel, } from './components/parcelamento-create-modal/parcelamento-create-modal'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; type MonthOption = { value: number; label: string }; type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados'; type AnnualMonthValue = { month: number; label: string; value: number | null }; type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] }; type ParcelamentoExportRow = ParcelamentoViewItem & Partial; @Component({ selector: 'app-parcelamentos', standalone: true, imports: [ CommonModule, FormsModule, ParcelamentosKpisComponent, ParcelamentosFiltersComponent, ParcelamentosTableComponent, ParcelamentoCreateModalComponent, ], templateUrl: './parcelamentos.html', styleUrls: ['./parcelamentos.scss'], }) export class Parcelamentos implements OnInit, OnDestroy { loading = false; exporting = false; errorMessage = ''; toastOpen = false; toastMessage = ''; toastType: 'success' | 'danger' = 'success'; private toastTimer: ReturnType | null = null; debugMode = !environment.production; items: ParcelamentoListItem[] = []; total = 0; page = 1; pageSize = 10; pageSizeOptions = [10, 20, 50, 100]; filters: ParcelamentosFiltersModel = { anoRef: '', linha: '', cliente: '', competenciaAno: '', competenciaMes: '', search: '', }; activeSegment: ParcelamentoSegment = 'todos'; segmentCounts: Record = { todos: 0, ativos: 0, futuros: 0, finalizados: 0, }; viewItems: ParcelamentoViewItem[] = []; kpiCards: ParcelamentoKpi[] = []; activeChips: FilterChip[] = []; isSysAdmin = false; isGestor = false; isFinanceiro = false; get canManageRecords(): boolean { return this.isSysAdmin || this.isGestor; } detailOpen = false; detailLoading = false; detailError = ''; selectedDetail: ParcelamentoDetail | null = null; annualRows: AnnualRow[] = []; readonly annualMonthHeaders = this.buildAnnualMonthHeaders(); private detailRequestSub?: Subscription; private detailRequestToken = 0; private detailGuardTimer?: ReturnType; debugYearGroups: { year: number; months: Array<{ label: string; value: string }> }[] = []; createOpen = false; createSaving = false; createError = ''; createModel: ParcelamentoCreateModel = this.buildCreateModel(); editOpen = false; editLoading = false; editSaving = false; editError = ''; editModel: ParcelamentoCreateModel | null = null; editId: string | null = null; deleteOpen = false; deleteLoading = false; deleteError = ''; deleteTarget: ParcelamentoViewItem | null = null; readonly monthOptions: MonthOption[] = [ { value: 1, label: '01 - Janeiro' }, { value: 2, label: '02 - Fevereiro' }, { value: 3, label: '03 - Marco' }, { value: 4, label: '04 - Abril' }, { value: 5, label: '05 - Maio' }, { value: 6, label: '06 - Junho' }, { value: 7, label: '07 - Julho' }, { value: 8, label: '08 - Agosto' }, { value: 9, label: '09 - Setembro' }, { value: 10, label: '10 - Outubro' }, { value: 11, label: '11 - Novembro' }, { value: 12, label: '12 - Dezembro' }, ]; constructor( private parcelamentosService: ParcelamentosService, private authService: AuthService, private tableExportService: TableExportService ) {} ngOnInit(): void { this.syncPermissions(); this.load(); } ngOnDestroy(): void { this.cancelDetailRequest(); if (this.toastTimer) clearTimeout(this.toastTimer); } @HostListener('document:keydown.escape') onEscape(): void { if (this.detailOpen) this.closeDetails(); if (this.createOpen) this.closeCreateModal(); if (this.editOpen) this.closeEditModal(); if (this.deleteOpen) this.cancelDelete(); } private syncPermissions(): void { this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isGestor = this.authService.hasRole('gestor'); this.isFinanceiro = this.authService.hasRole('financeiro'); } get totalPages(): number { return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); } get pageNumbers(): number[] { 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; } get pageStart(): number { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } get pageEnd(): number { return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); } get competenciaInvalid(): boolean { const ano = this.parseNumber(this.filters.competenciaAno); const mes = this.parseNumber(this.filters.competenciaMes); const hasAno = ano !== null; const hasMes = mes !== null; return (hasAno || hasMes) && !(hasAno && hasMes); } load(): void { this.loading = true; this.errorMessage = ''; const anoRef = this.parseNumber(this.filters.anoRef); const competenciaAno = this.parseNumber(this.filters.competenciaAno); const competenciaMes = this.parseNumber(this.filters.competenciaMes); const sendCompetencia = competenciaAno !== null && competenciaMes !== null; this.parcelamentosService .list({ anoRef: anoRef ?? undefined, linha: this.filters.linha?.trim() || undefined, cliente: this.filters.cliente?.trim() || undefined, competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined, competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined, page: this.page, pageSize: this.pageSize, }) .subscribe({ next: (res) => { try { const anyRes: any = res ?? {}; const items = Array.isArray(anyRes.items) ? anyRes.items.filter(Boolean) : Array.isArray(anyRes.Items) ? anyRes.Items.filter(Boolean) : []; this.items = items; this.total = typeof anyRes.total === 'number' ? anyRes.total : (typeof anyRes.Total === 'number' ? anyRes.Total : 0); this.loading = false; this.updateDerived(); } catch (e) { console.error('Erro ao processar parcelamentos', e); this.items = []; this.total = 0; this.loading = false; this.errorMessage = 'Erro ao processar parcelamentos.'; this.updateDerivedSafe(); } }, error: () => { this.items = []; this.total = 0; this.loading = false; this.errorMessage = 'Erro ao carregar parcelamentos.'; this.updateDerivedSafe(); }, }); } applyFilters(): void { if (this.competenciaInvalid) { this.errorMessage = 'Informe ano e mes para filtrar competencia.'; return; } this.page = 1; this.load(); } clearFilters(): void { this.filters = { anoRef: '', linha: '', cliente: '', competenciaAno: '', competenciaMes: '', search: '', }; this.page = 1; this.errorMessage = ''; this.load(); } refresh(): void { this.load(); } async onExport(): Promise { if (this.exporting) return; this.exporting = true; try { const baseRows = await this.fetchAllItemsForExport(); const rows = await this.fetchDetailedItemsForExport(baseRows); if (!rows.length) { this.showToast('Nenhum registro encontrado para exportar.', 'danger'); return; } const timestamp = this.tableExportService.buildTimestamp(); await this.tableExportService.exportAsXlsx({ fileName: `parcelamentos_${this.activeSegment}_${timestamp}`, sheetName: 'Parcelamentos', rows, columns: [ { header: 'ID', value: (row) => row.id ?? '' }, { header: 'Ano Ref', type: 'number', value: (row) => this.toNumber(row.anoRef) ?? 0 }, { header: 'Item', type: 'number', value: (row) => this.toNumber(row.item) ?? 0 }, { header: 'Linha', value: (row) => row.linha ?? '' }, { header: 'Cliente', value: (row) => row.cliente ?? '' }, { header: 'Status', value: (row) => row.statusLabel }, { header: 'Parcela Atual', type: 'number', value: (row) => this.toNumber(row.parcelaAtual) ?? 0 }, { header: 'Total Parcelas', type: 'number', value: (row) => this.toNumber(row.totalParcelas) ?? 0 }, { header: 'Qt Parcelas', value: (row) => row.qtParcelas ?? '' }, { header: 'Valor Cheio', type: 'currency', value: (row) => this.toNumber(row.valorCheio) ?? 0 }, { header: 'Desconto', type: 'currency', value: (row) => this.toNumber(row.desconto) ?? 0 }, { header: 'Valor c/ Desconto', type: 'currency', value: (row) => this.toNumber(row.valorComDesconto) ?? 0 }, { header: 'Valor Parcela', type: 'currency', value: (row) => this.toNumber(row.valorParcela) ?? 0 }, { header: 'Parcelas Mensais', value: (row) => this.stringifyParcelasMensais(row.parcelasMensais) }, { header: 'Detalhamento Anual', value: (row) => this.stringifyAnnualRows(row.annualRows) }, ], }); this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); } catch { this.showToast('Erro ao exportar planilha.', 'danger'); } finally { this.exporting = false; } } onPageSizeChange(size: number): void { this.pageSize = size; this.page = 1; this.load(); } goToPage(p: number): void { this.page = Math.max(1, Math.min(this.totalPages, p)); this.load(); } setSegment(segment: ParcelamentoSegment): void { this.activeSegment = segment; this.updateDerivedSafe(); } onSearchChange(term: string): void { this.filters.search = term; this.updateDerivedSafe(); } openDetails(item: ParcelamentoListItem): void { const id = this.getItemId(item); if (!id) { this.detailOpen = true; this.detailLoading = false; this.detailError = 'Registro sem identificador para carregar detalhes.'; this.selectedDetail = null; this.annualRows = []; return; } this.cancelDetailRequest(); const currentToken = ++this.detailRequestToken; this.detailOpen = true; this.detailLoading = true; this.detailError = ''; this.selectedDetail = null; this.startDetailGuard(currentToken, item); this.detailRequestSub = this.parcelamentosService .getById(id) .pipe( timeout(15000), finalize(() => { if (!this.isCurrentDetailRequest(currentToken)) return; this.clearDetailGuard(); this.detailLoading = false; }) ) .subscribe({ next: (res) => { if (!this.isCurrentDetailRequest(currentToken)) return; try { this.selectedDetail = this.normalizeDetail(res); this.prepareAnnual(this.selectedDetail); this.debugYearGroups = this.buildDebugYearGroups(this.selectedDetail); } catch { this.applyDetailFallback(item); } this.detailLoading = false; }, error: () => { if (!this.isCurrentDetailRequest(currentToken)) return; this.applyDetailFallback(item); this.detailLoading = false; }, }); } closeDetails(): void { this.cancelDetailRequest(); this.detailOpen = false; this.detailLoading = false; this.detailError = ''; this.selectedDetail = null; this.debugYearGroups = []; this.annualRows = []; } openCreateModal(): void { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); return; } this.createModel = this.buildCreateModel(); this.createError = ''; this.createOpen = true; } closeCreateModal(): void { this.createOpen = false; this.createSaving = false; this.createError = ''; } saveNewParcelamento(model: ParcelamentoCreateModel): void { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); return; } if (this.createSaving) return; this.createSaving = true; this.createError = ''; const payload = this.buildUpsertPayload(model); this.parcelamentosService.create(payload) .pipe(finalize(() => (this.createSaving = false))) .subscribe({ next: () => { this.createOpen = false; this.load(); }, error: () => { this.createError = 'Erro ao salvar parcelamento.'; }, }); } openEdit(item: ParcelamentoListItem): void { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); return; } const id = this.getItemId(item); if (!id) return; this.editOpen = true; this.editLoading = true; this.editError = ''; this.editModel = this.buildCreateModel(); this.editId = id; this.parcelamentosService .getById(id) .pipe( timeout(15000), finalize(() => (this.editLoading = false)) ) .subscribe({ next: (res) => { const detail = this.normalizeDetail(res); this.editModel = this.buildEditModel(detail); }, error: () => { this.editError = 'Erro ao carregar dados para editar.'; }, }); } closeEditModal(): void { this.editOpen = false; this.editLoading = false; this.editSaving = false; this.editError = ''; this.editModel = null; this.editId = null; } saveEditParcelamento(model: ParcelamentoCreateModel): void { if (!this.canManageRecords) { this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); return; } if (this.editSaving || !this.editModel || !this.editId) return; this.editSaving = true; this.editError = ''; const payload = this.buildUpsertPayload(model); this.parcelamentosService .update(this.editId, payload) .pipe(finalize(() => (this.editSaving = false))) .subscribe({ next: () => { this.editOpen = false; this.load(); }, error: () => { this.editError = 'Erro ao atualizar parcelamento.'; }, }); } openDelete(item: ParcelamentoViewItem): void { if (!this.isSysAdmin) return; this.deleteTarget = item; this.deleteError = ''; this.deleteOpen = true; } cancelDelete(): void { this.deleteOpen = false; this.deleteLoading = false; this.deleteError = ''; this.deleteTarget = null; } async confirmDelete(): Promise { if (!this.deleteTarget || this.deleteLoading) return; if (!(await confirmDeletionWithTyping('este parcelamento'))) return; const id = this.getItemId(this.deleteTarget); if (!id) return; this.deleteLoading = true; this.deleteError = ''; this.parcelamentosService .delete(id) .pipe(finalize(() => (this.deleteLoading = false))) .subscribe({ next: () => { this.cancelDelete(); this.load(); }, error: () => { this.deleteError = 'Erro ao excluir parcelamento.'; }, }); } displayQtParcelas(item: ParcelamentoListItem): string { const atual = this.toNumber(item.parcelaAtual); const total = this.toNumber(item.totalParcelas); if (atual !== null && total !== null) return `${atual}/${total}`; const raw = (item.qtParcelas ?? '').toString().trim(); if (raw) return raw; return '-'; } formatMoney(value: any): string { const n = this.toNumber(value); if (n === null) return '-'; return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); } formatNumber(value: any): string { const n = this.toNumber(value); if (n === null) return '-'; return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(n); } formatCompetencia(value: any): string { const date = this.parseCompetenciaDate(value); if (!date) return value ? String(value) : '-'; const label = new Intl.DateTimeFormat('pt-BR', { month: 'long' }).format(date); const cap = label.charAt(0).toUpperCase() + label.slice(1); return `${cap}/${date.getFullYear()}`; } formatCompetenciaShort(value: any): string { const date = this.parseCompetenciaDate(value); if (!date) return value ? String(value) : '-'; let label = new Intl.DateTimeFormat('pt-BR', { month: 'short' }).format(date); label = label.replace('.', ''); const cap = label.charAt(0).toUpperCase() + label.slice(1); return `${cap}/${date.getFullYear()}`; } private buildAnnualMonthHeaders(): Array<{ month: number; label: string }> { return Array.from({ length: 12 }, (_, idx) => { const date = new Date(2000, idx, 1); let label = new Intl.DateTimeFormat('pt-BR', { month: 'short' }).format(date); label = label.replace('.', ''); label = label.charAt(0).toUpperCase() + label.slice(1); return { month: idx + 1, label }; }); } get detailStatus(): string { if (!this.selectedDetail) return '-'; const atual = this.toNumber(this.selectedDetail.parcelaAtual); const total = this.toNumber(this.selectedDetail.totalParcelas); if (atual !== null && total !== null) return `${atual}/${total}`; const raw = (this.selectedDetail.qtParcelas ?? '').toString().trim(); return raw || '-'; } private parseCompetenciaDate(value: any): Date | null { if (!value) return null; const raw = String(value); const match = raw.match(/^(\d{4})-(\d{2})/); if (match) { const year = Number(match[1]); const month = Number(match[2]); return new Date(year, month - 1, 1); } const d = new Date(raw); if (Number.isNaN(d.getTime())) return null; return d; } private updateDerived(): void { const base = (this.items || []).map((item) => this.toViewItem(item)); const searched = this.applySearch(base, this.filters.search); this.segmentCounts = { todos: searched.length, ativos: searched.filter((i) => i.status === 'ativos').length, futuros: searched.filter((i) => i.status === 'futuros').length, finalizados: searched.filter((i) => i.status === 'finalizados').length, }; this.viewItems = this.activeSegment === 'todos' ? searched : searched.filter((i) => i.status === this.activeSegment); this.kpiCards = this.buildKpis(searched); this.activeChips = this.buildActiveChips(); } private updateDerivedSafe(): void { try { this.updateDerived(); } catch (e) { console.error('Erro ao atualizar parcelamentos', e); this.viewItems = []; this.kpiCards = []; this.activeChips = []; this.segmentCounts = { todos: 0, ativos: 0, futuros: 0, finalizados: 0, }; } } private buildKpis(list: ParcelamentoViewItem[]): ParcelamentoKpi[] { const totalContratado = list.reduce((sum, item) => sum + (item.valorComDescontoNumber ?? item.valorCheioNumber ?? 0), 0); const totalCheio = list.reduce((sum, item) => sum + (item.valorCheioNumber ?? 0), 0); const totalDesconto = list.reduce((sum, item) => sum + (item.descontoNumber ?? 0), 0); const parcelasEmAberto = list.reduce((sum, item) => { const total = this.toNumber(item.totalParcelas); const atual = this.toNumber(item.parcelaAtual); if (total === null || atual === null) return sum; return sum + Math.max(0, total - atual); }, 0); const competenciaAno = this.parseNumber(this.filters.competenciaAno); const competenciaMes = this.parseNumber(this.filters.competenciaMes); const totalMensalEstimado = competenciaAno !== null && competenciaMes !== null ? list.reduce((sum, item) => sum + (item.valorParcela ?? 0), 0) : null; const anoRef = this.parseNumber(this.filters.anoRef); const totalAnual = anoRef !== null || competenciaAno !== null ? totalContratado : null; return [ { label: 'Total mensal (estimado)', value: totalMensalEstimado !== null ? this.formatMoney(totalMensalEstimado) : '-', hint: competenciaAno && competenciaMes ? 'Baseado nas parcelas da lista' : 'Selecione competencia', tone: 'brand', }, { label: 'Total anual (ano selecionado)', value: totalAnual !== null ? this.formatMoney(totalAnual) : '-', hint: anoRef || competenciaAno ? 'Baseado nos contratos filtrados' : 'Selecione ano', }, { label: 'Parcelamentos ativos', value: this.formatNumber(this.segmentCounts.ativos), hint: 'Status atual', tone: 'success', }, { label: 'Parcelas em aberto', value: this.formatNumber(parcelasEmAberto), hint: 'Estimado por parcela atual', }, { label: 'Valor total contratado', value: this.formatMoney(totalContratado || totalCheio), hint: totalDesconto ? `Desconto total: ${this.formatMoney(totalDesconto)}` : undefined, }, ]; } private buildActiveChips(): FilterChip[] { const chips: FilterChip[] = []; if (this.filters.anoRef) chips.push({ label: 'AnoRef', value: this.filters.anoRef }); if (this.filters.linha) chips.push({ label: 'Linha', value: this.filters.linha }); if (this.filters.cliente) chips.push({ label: 'Cliente', value: this.filters.cliente }); const ano = this.filters.competenciaAno; const mes = this.filters.competenciaMes; if (ano && mes) { chips.push({ label: 'Competencia', value: `${String(mes).padStart(2, '0')}/${ano}` }); } if (this.filters.search) chips.push({ label: 'Busca', value: this.filters.search }); return chips; } private toViewItem(item: ParcelamentoListItem): ParcelamentoViewItem { const id = this.getItemId(item) ?? ''; const status = this.resolveStatus(item); const valorCheio = this.toNumber(item.valorCheio); const desconto = this.toNumber(item.desconto); const valorComDesconto = this.toNumber(item.valorComDesconto); return { ...item, id, status, statusLabel: this.statusLabel(status), progressLabel: this.displayQtParcelas(item), valorParcela: this.computeParcelaValue(item), valorCheioNumber: valorCheio, descontoNumber: desconto, valorComDescontoNumber: valorComDesconto ?? (valorCheio !== null && desconto !== null ? Math.max(0, valorCheio - desconto) : null), }; } private applySearch(list: ParcelamentoViewItem[], term: string): ParcelamentoViewItem[] { const search = this.normalizeText(term); if (!search) return list; return list.filter((item) => { const payload = [ item.anoRef, item.item, item.linha, item.cliente, item.qtParcelas, ] .map((v) => (v ?? '').toString()) .join(' '); return this.normalizeText(payload).includes(search); }); } private async fetchAllItemsForExport(): Promise { const anoRef = this.parseNumber(this.filters.anoRef); const competenciaAno = this.parseNumber(this.filters.competenciaAno); const competenciaMes = this.parseNumber(this.filters.competenciaMes); const sendCompetencia = competenciaAno !== null && competenciaMes !== null; const pageSize = 500; let page = 1; let expectedTotal = 0; const allItems: ParcelamentoListItem[] = []; while (page <= 500) { const response = await firstValueFrom( this.parcelamentosService.list({ anoRef: anoRef ?? undefined, linha: this.filters.linha?.trim() || undefined, cliente: this.filters.cliente?.trim() || undefined, competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined, competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined, page, pageSize, }) ); const normalized = this.normalizeListResponse(response); allItems.push(...normalized.items); expectedTotal = normalized.total; if (normalized.items.length === 0) break; if (normalized.items.length < pageSize) break; if (expectedTotal > 0 && allItems.length >= expectedTotal) break; page += 1; } const base = allItems.map((item) => this.toViewItem(item)); const searched = this.applySearch(base, this.filters.search); return this.activeSegment === 'todos' ? searched : searched.filter((item) => item.status === this.activeSegment); } private async fetchDetailedItemsForExport(rows: ParcelamentoViewItem[]): Promise { if (!rows.length) return []; const detailedRows: ParcelamentoExportRow[] = []; const chunkSize = 10; for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); const resolved = await Promise.all( chunk.map(async (row) => { const id = this.getItemId(row); if (!id) return row; try { const detailRes = await firstValueFrom(this.parcelamentosService.getById(id)); const detail = this.normalizeDetail(detailRes); return { ...row, ...detail, }; } catch { return row; } }) ); detailedRows.push(...resolved); } return detailedRows; } private stringifyParcelasMensais(parcelas?: ParcelamentoParcela[] | null): string { if (!parcelas?.length) return ''; return parcelas .map((parcela) => { const competencia = (parcela.competencia ?? '').toString().trim(); const valor = this.toNumber(parcela.valor); const valorFmt = valor === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor); return `${competencia || '-'}: ${valorFmt}`; }) .join(' | '); } private stringifyAnnualRows(rows?: ParcelamentoAnnualRow[] | null): string { if (!rows?.length) return ''; return rows .map((row) => { const year = this.parseNumber(row.year); const total = this.toNumber(row.total); const totalFmt = total === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(total); const months = (row.months ?? []) .map((month) => { const monthNum = this.parseNumber(month.month); const monthValue = this.toNumber(month.valor); const monthLabel = monthNum ? String(monthNum).padStart(2, '0') : '--'; const monthFmt = monthValue === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(monthValue); return `${monthLabel}:${monthFmt}`; }) .join(', '); return `${year ?? '----'} (Total ${totalFmt})${months ? ` [${months}]` : ''}`; }) .join(' | '); } private normalizeListResponse(response: any): { items: ParcelamentoListItem[]; total: number } { const anyRes: any = response ?? {}; const items = Array.isArray(anyRes.items) ? anyRes.items.filter(Boolean) : Array.isArray(anyRes.Items) ? anyRes.Items.filter(Boolean) : []; const total = typeof anyRes.total === 'number' ? anyRes.total : (typeof anyRes.Total === 'number' ? anyRes.Total : 0); return { items, total }; } private resolveStatus(item: ParcelamentoListItem): ParcelamentoStatus { const total = this.toNumber(item.totalParcelas); const atual = this.toNumber(item.parcelaAtual); if (total !== null && atual !== null) { if (atual >= total) return 'finalizados'; if (atual <= 0) return 'futuros'; return 'ativos'; } const parsed = this.parseQtParcelas(item.qtParcelas); if (parsed) { if (parsed.atual >= parsed.total) return 'finalizados'; return 'ativos'; } return 'ativos'; } private statusLabel(status: ParcelamentoStatus): string { if (status === 'finalizados') return 'Finalizado'; if (status === 'futuros') return 'Futuro'; return 'Ativo'; } private computeParcelaValue(item: ParcelamentoListItem): number | null { const totalParcelas = this.toNumber(item.totalParcelas) ?? this.parseQtParcelas(item.qtParcelas)?.total ?? null; if (!totalParcelas) return null; const total = this.toNumber(item.valorComDesconto) ?? this.toNumber(item.valorCheio); if (total === null) return null; return total / totalParcelas; } private parseQtParcelas(value: any): { atual: number; total: number } | null { if (!value) return null; const raw = String(value); const parts = raw.split('/'); if (parts.length < 2) return null; const atualStr = this.onlyDigits(parts[0]); const totalStr = this.onlyDigits(parts[1]); if (!atualStr || !totalStr) return null; return { atual: Number(atualStr), total: Number(totalStr) }; } private prepareAnnual(detail: ParcelamentoDetail): void { this.annualRows = this.buildAnnualRows(detail); } private buildAnnualRows(detail: ParcelamentoDetail): AnnualRow[] { const fromApi = this.mapAnnualRows(detail.annualRows ?? []); if (fromApi.length) return fromApi; return this.buildAnnualRowsFromParcelas(detail.parcelasMensais ?? []); } private mapAnnualRows(rawRows?: ParcelamentoAnnualRow[] | null): AnnualRow[] { const rows = (rawRows ?? []) .filter((row): row is ParcelamentoAnnualRow => !!row) .map((row) => { const year = this.parseNumber(row.year); if (year === null) return null; const monthMap = new Map(); (row.months ?? []).forEach((m) => { const month = this.parseNumber(m.month); if (month === null) return; const value = this.toNumber(m.valor); monthMap.set(month, value); }); const months = this.annualMonthHeaders.map((h) => ({ month: h.month, label: h.label, value: monthMap.get(h.month) ?? null, })); const total = this.toNumber(row.total) ?? months.reduce((sum, m) => sum + (m.value ?? 0), 0); return { year, total, months }; }) .filter((row): row is AnnualRow => !!row) .sort((a, b) => a.year - b.year); return rows; } private buildAnnualRowsFromParcelas(parcelas: ParcelamentoParcela[]): AnnualRow[] { const map = new Map>(); (parcelas ?? []).forEach((p) => { const parsed = this.parseCompetenciaParts(p.competencia); if (!parsed) return; const value = this.toNumber(p.valor) ?? 0; if (!map.has(parsed.year)) map.set(parsed.year, new Map()); const monthMap = map.get(parsed.year)!; monthMap.set(parsed.month, (monthMap.get(parsed.month) ?? 0) + value); }); return Array.from(map.entries()) .sort((a, b) => a[0] - b[0]) .map(([year, monthMap]) => { const months = this.annualMonthHeaders.map((h) => ({ month: h.month, label: h.label, value: monthMap.get(h.month) ?? null, })); const total = months.reduce((sum, m) => sum + (m.value ?? 0), 0); return { year, total, months }; }); } private parseCompetenciaParts(value: any): { year: number; month: number } | null { const date = this.parseCompetenciaDate(value); if (!date) return null; return { year: date.getFullYear(), month: date.getMonth() + 1 }; } private normalizeDetail(res: ParcelamentoDetailResponse): ParcelamentoDetail { const payload = (res ?? {}) as any; const parcelasRaw = payload.parcelasMensais ?? payload.ParcelasMensais ?? payload.parcelas ?? payload.Parcelas ?? payload.monthValues ?? payload.MonthValues ?? []; const parcelasMensais = Array.isArray(parcelasRaw) ? parcelasRaw .filter((p) => !!p && typeof p === 'object') .map((p: any) => ({ competencia: (p.competencia ?? p.Competencia ?? '').toString(), valor: p.valor ?? p.Valor ?? null, })) .filter((p) => !!p.competencia) : []; const annualRowsRaw = payload.annualRows ?? payload.AnnualRows ?? payload.detalhamentoAnual ?? payload.DetalhamentoAnual ?? []; const annualRows: ParcelamentoAnnualRow[] = []; if (Array.isArray(annualRowsRaw)) { annualRowsRaw.forEach((row: any) => { if (!row || typeof row !== 'object') return; const year = this.parseNumber(row.year ?? row.Year); if (year === null) return; const monthsRaw = row.months ?? row.Months ?? []; const months: ParcelamentoAnnualMonth[] = []; if (Array.isArray(monthsRaw)) { monthsRaw.forEach((m: any) => { if (!m || typeof m !== 'object') return; const month = this.parseNumber(m.month ?? m.Month); if (month === null) return; const valor = (m.valor ?? m.Valor ?? m.value ?? m.Value ?? null) as number | string | null; months.push({ month, valor, }); }); } const total = (row.total ?? row.Total ?? null) as number | string | null; annualRows.push({ year, total, months, }); }); } return { id: payload.id ?? payload.Id ?? '', anoRef: payload.anoRef ?? payload.AnoRef ?? null, item: payload.item ?? payload.Item ?? null, linha: payload.linha ?? payload.Linha ?? null, cliente: payload.cliente ?? payload.Cliente ?? null, qtParcelas: payload.qtParcelas ?? payload.QtParcelas ?? null, parcelaAtual: payload.parcelaAtual ?? payload.ParcelaAtual ?? null, totalParcelas: payload.totalParcelas ?? payload.TotalParcelas ?? null, valorCheio: payload.valorCheio ?? payload.ValorCheio ?? null, desconto: payload.desconto ?? payload.Desconto ?? null, valorComDesconto: payload.valorComDesconto ?? payload.ValorComDesconto ?? null, parcelasMensais, annualRows, }; } private buildCreateModel(): ParcelamentoCreateModel { const now = new Date(); return { anoRef: now.getFullYear(), linha: '', cliente: '', item: null, qtParcelas: '', parcelaAtual: null, totalParcelas: 12, valorCheio: '', desconto: '', valorComDesconto: '', competenciaAno: now.getFullYear(), competenciaMes: now.getMonth() + 1, monthValues: [], }; } private buildEditModel(detail: ParcelamentoDetail): ParcelamentoCreateModel { const parcelas = (detail.parcelasMensais ?? []) .filter((p) => !!p && !!p.competencia) .map((p) => ({ competencia: p.competencia, valor: this.normalizeInputValue(p.valor), })) .sort((a, b) => a.competencia.localeCompare(b.competencia)); const firstCompetencia = parcelas.length ? parcelas[0].competencia : ''; const compParts = firstCompetencia.match(/^(\d{4})-(\d{2})/); const competenciaAno = compParts ? Number(compParts[1]) : null; const competenciaMes = compParts ? Number(compParts[2]) : null; return { anoRef: detail.anoRef ?? null, linha: detail.linha ?? '', cliente: detail.cliente ?? '', item: this.toNumber(detail.item) ?? null, qtParcelas: detail.qtParcelas ?? '', parcelaAtual: this.toNumber(detail.parcelaAtual), totalParcelas: this.toNumber(detail.totalParcelas), valorCheio: this.normalizeInputValue(detail.valorCheio), desconto: this.normalizeInputValue(detail.desconto), valorComDesconto: this.normalizeInputValue(detail.valorComDesconto), competenciaAno, competenciaMes, monthValues: parcelas, }; } private buildUpsertPayload(model: ParcelamentoCreateModel): ParcelamentoUpsertRequest { const monthValues: ParcelamentoMonthInput[] = (model.monthValues ?? []) .filter((m) => !!m && !!m.competencia) .map((m) => ({ competencia: m.competencia, valor: this.toNumber(m.valor), })); return { anoRef: this.toNumber(model.anoRef), item: this.toNumber(model.item), linha: model.linha?.trim() || null, cliente: model.cliente?.trim() || null, qtParcelas: model.qtParcelas?.trim() || null, parcelaAtual: this.toNumber(model.parcelaAtual), totalParcelas: this.toNumber(model.totalParcelas), valorCheio: this.toNumber(model.valorCheio), desconto: this.toNumber(model.desconto), valorComDesconto: this.toNumber(model.valorComDesconto), monthValues, }; } private normalizeInputValue(value: any): string { const n = this.toNumber(value); if (n === null) return ''; return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); } private parseNumber(value: any): number | null { if (value === null || value === undefined || value === '') return null; const n = Number(value); return Number.isNaN(n) ? null : n; } private toNumber(value: any): number | null { if (value === null || value === undefined) return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; const raw = String(value).trim(); if (!raw) return null; let cleaned = raw.replace(/[^\d,.-]/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } const n = Number(cleaned); return Number.isNaN(n) ? null : n; } private showToast(message: string, type: 'success' | 'danger'): void { this.toastMessage = message; this.toastType = type; this.toastOpen = true; if (this.toastTimer) clearTimeout(this.toastTimer); this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000); } private normalizeText(value: any): string { return (value ?? '') .toString() .trim() .toUpperCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); } private onlyDigits(value: string): string { let out = ''; for (const ch of value ?? '') { if (ch >= '0' && ch <= '9') out += ch; } return out; } private getItemId(item: ParcelamentoListItem | null | undefined): string | null { if (!item) return null; const raw = (item as any).id ?? (item as any).Id ?? (item as any).parcelamentoId ?? (item as any).ParcelamentoId; if (raw === null || raw === undefined) return null; const value = String(raw).trim(); return value ? value : null; } private cancelDetailRequest(): void { this.clearDetailGuard(); this.detailRequestToken++; if (this.detailRequestSub) { this.detailRequestSub.unsubscribe(); this.detailRequestSub = undefined; } } private isCurrentDetailRequest(token: number): boolean { return token === this.detailRequestToken; } private startDetailGuard(token: number, item: ParcelamentoListItem): void { this.clearDetailGuard(); this.detailGuardTimer = setTimeout(() => { if (!this.isCurrentDetailRequest(token)) return; this.cancelDetailRequest(); this.applyDetailFallback(item); this.detailLoading = false; }, 20000); } private clearDetailGuard(): void { if (this.detailGuardTimer) { clearTimeout(this.detailGuardTimer); this.detailGuardTimer = undefined; } } private applyDetailFallback(item: ParcelamentoListItem): void { this.detailError = ''; this.selectedDetail = this.buildFallbackDetail(item); this.prepareAnnual(this.selectedDetail); this.debugYearGroups = this.buildDebugYearGroups(this.selectedDetail); this.detailLoading = false; } private buildFallbackDetail(item: ParcelamentoListItem): ParcelamentoDetail { return { id: this.getItemId(item) ?? '', anoRef: item.anoRef ?? null, item: item.item ?? null, linha: item.linha ?? null, cliente: item.cliente ?? null, qtParcelas: item.qtParcelas ?? null, parcelaAtual: item.parcelaAtual ?? null, totalParcelas: item.totalParcelas ?? null, valorCheio: item.valorCheio ?? null, desconto: item.desconto ?? null, valorComDesconto: item.valorComDesconto ?? null, parcelasMensais: [], }; } private buildDebugYearGroups(detail: ParcelamentoDetail | null): { year: number; months: Array<{ label: string; value: string }> }[] { if (!detail) return []; const parcels = (detail.parcelasMensais ?? []).filter( (p): p is ParcelamentoParcela => !!p && typeof p === 'object' ); const map = new Map>(); parcels.forEach((p) => { const parsed = this.parseCompetenciaParts(p.competencia); if (!parsed) return; if (!map.has(parsed.year)) map.set(parsed.year, new Map()); map.get(parsed.year)!.set(parsed.month, p); }); return Array.from(map.entries()) .sort((a, b) => a[0] - b[0]) .map(([year, monthMap]) => ({ year, months: Array.from({ length: 12 }, (_, idx) => { const month = idx + 1; const item = monthMap.get(month); return { label: `${month.toString().padStart(2, '0')}/${year}`, value: this.formatMoney(item?.valor), }; }), })); } }