1094 lines
34 KiB
TypeScript
1094 lines
34 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, timeout } from 'rxjs';
|
|
import { AuthService } from '../../services/auth.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[] };
|
|
|
|
@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;
|
|
errorMessage = '';
|
|
|
|
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;
|
|
|
|
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
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.syncPermissions();
|
|
this.load();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.cancelDetailRequest();
|
|
}
|
|
|
|
@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');
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 {
|
|
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.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 {
|
|
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.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 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 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),
|
|
};
|
|
}),
|
|
}));
|
|
}
|
|
|
|
}
|