243 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|