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

1302 lines
42 KiB
TypeScript

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<ParcelamentoDetail>;
@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<typeof setTimeout> | 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<ParcelamentoSegment, number> = {
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<typeof setTimeout>;
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<void> {
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<ParcelamentoExportRow>({
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<void> {
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<ParcelamentoViewItem[]> {
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<ParcelamentoExportRow[]> {
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<number, number | null>();
(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<number, Map<number, number>>();
(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<number, Map<number, ParcelamentoParcela>>();
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),
};
}),
}));
}
}