Files
basic-erp/apps/web/src/app/features/finance/journal-form.page.ts
T

243 lines
12 KiB
TypeScript

import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, SboField, SelectField, TextField, TextInput } from '../../shared/form';
import type { DetailColumn } from '../../shared/grid/grid.types';
import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups';
import type { NavTarget } from '../documents/document-model';
import { isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model';
type Row = Record<string, unknown>;
/** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */
const OBJECT_TYPE = 30;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
const today = (): string => new Date().toISOString().slice(0, 10);
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/**
* Formulario de un asiento contable: `nuevo` (vacío) o `:jdtNum`. Cada línea lleva una cuenta y el debe o el
* haber; el asiento solo se crea si cuadra. Un asiento contabilizado no se modifica: se consulta y, si es manual,
* se cancela (SAP genera el asiento de reverso).
*/
@Component({
selector: 'sbo-journal-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LookupField, RouterLink, RowsTable, SboField, SboWindow, SelectField, TextField, TextInput, Toolbar],
templateUrl: './journal-form.page.html',
styleUrl: './journal-form.page.css',
})
export class JournalFormPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi);
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly jdtNum = toSignal(this.route.paramMap.pipe(map((p) => (p.has('jdtNum') ? Number(p.get('jdtNum')) : null))), {
initialValue: this.route.snapshot.paramMap.has('jdtNum') ? Number(this.route.snapshot.paramMap.get('jdtNum')) : null,
});
protected readonly isNew = computed(() => this.jdtNum() === null);
protected readonly saved = computed(() => !this.isNew());
// --- datos ---------------------------------------------------------------------------------
private readonly resource = httpResource<Row>(() => {
const id = this.jdtNum();
return id === null ? undefined : `${API_URL}/sl/JournalEntries/${id}`;
});
protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined));
protected readonly loading = computed(() => this.resource.isLoading());
protected readonly loadError = computed(() => {
const err = this.resource.error();
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El asiento no existe.' : apiErrorMessage(err)) : '';
});
protected readonly manual = computed(() => isManualJournal(this.doc()));
// --- el borrador (o el asiento guardado, mostrado) -----------------------------------------
protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today()));
protected readonly dueDate = linkedSignal(() => dateOnly(this.doc()?.['DueDate']));
protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate']));
protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null));
protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo']));
protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference']));
protected readonly reference2 = linkedSignal(() => text(this.doc()?.['Reference2']));
protected readonly reference3 = linkedSignal(() => text(this.doc()?.['Reference3']));
protected readonly indicator = linkedSignal(() => text(this.doc()?.['Indicator']));
protected readonly folioPrefix = linkedSignal(() => text(this.doc()?.['FolioPrefixString']));
protected readonly folioNumber = linkedSignal(() => text(this.doc()?.['FolioNumber']));
protected readonly indicatorLookup = INDICATOR_LOOKUP;
protected readonly lines = linkedSignal<Row[]>(() => {
const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l }));
return this.doc() ? rows : [{}, {}];
});
protected readonly saving = signal(false);
protected readonly confirmCancel = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
return created ? { kind: 'ok', text: `Asiento ${created} creado.` } : null;
})(),
);
protected readonly totals = computed(() => journalTotals(this.lines()));
protected readonly fmt = (n: number): string => formatNumber(n, 2);
// --- series de numeración --------------------------------------------------------------------
private readonly seriesResource = httpResource<SlListResponse<Row>>(() =>
this.isNew() ? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(OBJECT_TYPE) } } } : undefined,
);
protected readonly seriesOptions = computed(() => {
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
return rows.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
});
// --- vista -----------------------------------------------------------------------------------
protected readonly columns: readonly DetailColumn[] = [
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
{ key: 'ShortName', title: 'Cuenta / socio', type: 'lookup', lookup: ACCOUNT_LOOKUP, width: '170px' },
{ key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' },
{ key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' },
{ key: 'DueDate', title: 'Vencimiento', type: 'date', width: '120px' },
{ key: 'Reference1', title: 'Ref. 1', type: 'text', width: '110px' },
{ key: 'Reference2', title: 'Ref. 2', type: 'text', width: '110px' },
{ key: 'AdditionalReference', title: 'Ref. 3', type: 'text', width: '110px' },
{ key: 'LineMemo', title: 'Comentarios', type: 'text', width: '220px' },
...([1, 2, 3, 4, 5] as const).map((n): DetailColumn => ({
key: n === 1 ? 'CostingCode' : `CostingCode${n}`,
title: `Dim. ${n}`,
type: 'lookup',
lookup: dimensionLookup(n),
width: '90px',
})),
{ key: 'ProjectCode', title: 'Proyecto', type: 'lookup', lookup: PROJECT_LOOKUP, width: '100px' },
];
protected readonly status = computed<StatusMessage>(() => {
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
if (this.saved()) return { kind: 'info', text: this.manual() ? 'Asiento contabilizado: no se modifica; se puede cancelar.' : `Asiento generado por: ${journalOrigin(this.doc()?.['OriginalJournal'])}. Se cancela desde su documento.` };
const t = this.totals();
return t.difference !== 0 ? { kind: 'warn', text: `Falta ${this.fmt(Math.abs(t.difference))} para que el asiento cuadre.` } : { kind: 'info', text: 'Indique las cuentas, con el debe o el haber de cada línea.' };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
return this.doc() ? { label: this.manual() ? 'Manual' : 'De documento', tone: this.manual() ? 'ok' : 'muted' } : undefined;
});
protected readonly windowTitle = computed(() => {
const n = this.doc()?.['JdtNum'];
return n === undefined ? this.title : `${this.title} · ${text(n)}`;
});
// --- edición y acciones ----------------------------------------------------------------------
protected setLines(rows: Row[]): void {
this.lines.set(rows);
this.message.set(null);
}
protected addLine(): void {
this.setLines([...this.lines(), {}]);
}
/** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */
protected balance(): void {
const t = this.totals();
if (t.difference === 0) return;
const rows = [...this.lines()];
const last = rows.length - 1;
const row = { ...rows[last] };
if (t.difference > 0) {
// sobra debe: la línea toma el faltante en el haber
row['Credit'] = (Number(row['Credit']) || 0) + t.difference;
row['Debit'] = null;
} else {
row['Debit'] = (Number(row['Debit']) || 0) - t.difference;
row['Credit'] = null;
}
rows[last] = row;
this.setLines(rows);
}
protected set(key: 'memo' | 'reference' | 'reference2' | 'reference3' | 'folioPrefix' | 'folioNumber', value: string): void {
this[key].set(value);
this.message.set(null);
}
protected async go(target: NavTarget): Promise<void> {
try {
const entry = await this.api.neighbour('JournalEntries', target, this.jdtNum(), undefined, 'JdtNum');
if (entry === null) {
this.message.set({ kind: 'warn', text: 'No hay más asientos en esa dirección.' });
return;
}
this.message.set(null);
await this.router.navigate(['..', entry], { relativeTo: this.route });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
protected async save(): Promise<void> {
if (this.saving() || this.saved()) return;
const draft = {
referenceDate: this.referenceDate(),
dueDate: this.dueDate(),
taxDate: this.taxDate(),
series: this.series(),
memo: this.memo(),
reference: this.reference(),
reference2: this.reference2(),
reference3: this.reference3(),
indicator: this.indicator(),
folioPrefix: this.folioPrefix(),
folioNumber: this.folioNumber(),
lines: this.lines(),
};
const errors = validateJournal(draft);
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
}
this.saving.set(true);
try {
const created = await this.api.create('JournalEntries', journalPayload(draft));
await this.router.navigate(['..', created['JdtNum']], { relativeTo: this.route, state: { created: text(created['JdtNum']) } });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
protected async cancelEntry(): Promise<void> {
const id = this.jdtNum();
if (id === null || this.saving()) return;
this.saving.set(true);
this.confirmCancel.set(false);
try {
await this.api.action('JournalEntries', id, 'Cancel');
this.message.set({ kind: 'ok', text: 'Asiento cancelado: SAP generó el asiento de reverso.' });
this.resource.reload();
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.saving.set(false);
}
}
}