Files
basic-erp/apps/web/src/app/features/documents/document-form.page.ts
T

394 lines
18 KiB
TypeScript

import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { NgTemplateOutlet } from '@angular/common';
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, NumberField, SboField, SelectField, TextField, TextInput, TextareaField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { ValueHelp } from '../../shared/value-help/value-help';
import type { ListRow } from '../../shared/list/list.types';
import {
type DocRecord,
type NavTarget,
computeTotals,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateLayout,
} from './document-model';
import { type ComputedSource, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD } from './line-columns';
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
if (!doc) return blank;
return Object.fromEntries(
keys.map((k) => [
k,
DATE_KEYS.has(k) ? dateOnly(doc[k]) : k === 'FolioNumber' ? text(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
]),
);
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/** El folio se edita como texto; SAP lo espera como número. */
const folioAsNumber = (h: DocRecord): DocRecord => {
const folio = h['FolioNumber'];
if (typeof folio !== 'string') return h;
return { ...h, FolioNumber: folio.trim() === '' ? null : Number(folio) };
};
/**
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
* Los cerrados y cancelados quedan en solo lectura. Al modificar se envía el `odata.etag` leído
* (`If-Match`): si otro usuario cambió el documento, SAP responde 412 y se avisa sin pisarlo.
*/
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SelectField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
`,
})
export class DocumentFormPage {
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 screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly partner = this.screen.partner;
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout.lines;
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
}
protected readonly docEntry = toSignal(
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
);
protected readonly isNew = computed(() => this.docEntry() === null);
// --- datos ---------------------------------------------------------------------------------
private readonly docResource = httpResource<DocRecord>(() => {
const id = this.docEntry();
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
});
private readonly taxResource = httpResource<SlListResponse<DocRecord>>(() =>
this.partner
? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } }
: undefined,
);
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
protected readonly loading = computed(() => this.docResource.isLoading());
protected readonly loadError = computed(() => {
const err = this.docResource.error();
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El documento no existe.' : apiErrorMessage(err)) : '';
});
/** Valores editables; se reinician al cargar o recargar el documento. */
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
protected readonly lines = linkedSignal<readonly GridLine[]>(() => this.docLines(this.doc()).map((l) => ({ ...l })));
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
/** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */
protected readonly partnerTaxId = linkedSignal(() => text(this.doc()?.['FederalTaxID']));
// --- estado --------------------------------------------------------------------------------
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew()));
protected readonly saving = 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: `Documento ${created} creado.` } : null;
})(),
);
protected readonly conflict = signal(false);
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.readOnly()) {
const posted = this.layout.immutable && !isDocumentReadOnly(this.doc());
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
}
if (this.isNew()) {
return { kind: 'info', text: this.partner ? `Indique el ${this.partner.label.toLowerCase()} y las líneas.` : 'Complete la cabecera y las líneas.' };
}
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
if (this.isNew()) return { label: 'Nueva', tone: 'info' };
const d = this.doc();
return d ? documentStatus(d) : undefined;
});
protected readonly windowTitle = computed(() => {
const num = this.doc()?.['DocNum'];
return num === undefined ? this.title : `${this.title} · ${text(num)}`;
});
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
return Object.keys(body).length > 0;
});
// --- totales -------------------------------------------------------------------------------
private readonly rates = computed(() =>
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
);
protected readonly totals = computed(() =>
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
// --- copiar de / a ----------------------------------------------------------------------------
private readonly copyOrigin = (this.router.currentNavigation()?.extras.state as { copyFrom?: CopyOrigin } | undefined)?.copyFrom;
protected readonly copyListFor = signal<string | null>(null);
/** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */
protected readonly copyToItems = computed<MenuItem[]>(() => {
const blocked = this.isNew() || this.readOnly() || this.dirty();
return copyTargets(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: blocked }));
});
protected readonly copyFromItems = computed<MenuItem[]>(() =>
copySources(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: !this.isNew() })),
);
protected readonly copyListTitle = computed(() => menuLabel(this.copyListFor() ?? ''));
protected readonly copyListScreen = computed(() => DOCUMENT_SCREENS.find((s) => s.path === this.copyListFor()));
/** Documentos abiertos del origen elegido, del mismo socio si ya se indicó. */
protected readonly copyListFilter = computed(() => {
const card = text(this.header()['CardCode']);
return [`DocumentStatus eq 'bost_Open'`, `Cancelled eq 'tNO'`, ...(card ? [`CardCode eq '${card.replace(/'/g, "''")}'`] : [])].join(' and ');
});
protected readonly copyListColumns = [
docNum,
{ key: 'CardCode', title: this.partner?.label ?? 'Socio', width: '120px', mono: true },
{ key: 'CardName', title: 'Nombre' },
{ key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' },
] as const;
protected copyTo(path: string): void {
const docEntry = this.docEntry();
if (docEntry === null) return;
const origin: CopyOrigin = { path: this.screen.path, docEntry };
void this.router.navigateByUrl(`${path}/nuevo`, { state: { copyFrom: origin } });
}
protected copyFrom(path: string): void {
this.copyListFor.set(path);
}
protected async copyPicked(rows: ListRow[]): Promise<void> {
const path = this.copyListFor();
this.copyListFor.set(null);
const docEntry = rows[0]?.['DocEntry'];
if (path && typeof docEntry === 'number') await this.loadCopy({ path, docEntry });
}
/** Trae el documento origen y vuelca sus datos y líneas abiertas en el formulario. */
private async loadCopy(origin: CopyOrigin): Promise<void> {
const source = DOCUMENT_SCREENS.find((s) => s.path === origin.path);
if (!source) return;
try {
const doc = await this.api.get(source.resource, origin.docEntry);
const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path));
const card = text(this.header()['CardCode']);
if (card && card !== text(copied.header['CardCode'])) {
this.message.set({ kind: 'warn', text: `El documento es de ${text(copied.header['CardCode'])}; este es de ${card}.` });
return;
}
if (!copied.lines.length) {
this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' });
return;
}
if (!card) {
this.header.update((h) => ({ ...h, ...copied.header }));
this.partnerName.set(text(doc['CardName']));
this.partnerTaxId.set(text(doc['FederalTaxID']));
}
this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...copied.lines]);
this.message.set({
kind: 'ok',
text: `Copiado de ${menuLabel(origin.path).toLowerCase()} ${text(doc['DocNum'])}${copied.skipped ? ` (${copied.skipped} línea(s) cerradas no se copian)` : ''}. Revise y pulse Crear.`,
});
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
// --- configuración de la vista --------------------------------------------------------------
protected readonly columns = this.layout.columns;
protected readonly newLine = () => this.layout.newLine(this.header());
protected readonly recalc = this.layout.recalc;
protected readonly multi: GridMultiAdd = {
...ITEMS_MULTI_ADD,
toLine: (item) => this.layout.columns[0].lookup!.apply(item, this.layout.newLine(this.header())),
};
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
resource: 'BusinessPartners',
baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
extraFields: ['FederalTaxID'],
};
protected str = text;
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
protected computedValue(source: ComputedSource): string {
switch (source) {
case 'docNum':
return text(this.doc()?.['DocNum']);
case 'partnerName':
return this.partnerName();
case 'partnerTaxId':
return this.partnerTaxId();
case 'subtotal':
return this.fmt(this.totals().subtotal);
case 'tax':
return this.fmt(this.totals().tax);
case 'total':
return this.fmt(this.totals().total);
}
}
constructor() {
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
}
protected setHeader(key: string, value: unknown): void {
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
}
/** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */
protected onPartnerText(code: string): void {
if (code === text(this.header()['CardCode'])) return;
this.setHeader('CardCode', code);
this.partnerName.set('');
this.partnerTaxId.set('');
}
protected notify(textMessage: string): void {
this.message.set({ kind: 'warn', text: textMessage });
}
protected onPartner(row: Record<string, unknown>): void {
this.partnerName.set(text(row['CardName']));
this.partnerTaxId.set(text(row['FederalTaxID']));
}
protected setLines(lines: readonly GridLine[]): void {
this.lines.set(lines);
this.message.set(null);
}
// --- acciones ------------------------------------------------------------------------------
protected async go(target: NavTarget): Promise<void> {
try {
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
if (entry === null) {
this.message.set({ kind: 'warn', text: 'No hay más documentos 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.readOnly()) return;
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
}
this.saving.set(true);
this.conflict.set(false);
try {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
state: { created: text(created['DocNum']) },
});
return;
}
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
const body = folioAsNumber(update.body);
const replaceLines = update.replaceLines;
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
}
await this.api.update(this.screen.resource, this.docEntry()!, body, text(d['odata.etag']) || undefined, replaceLines);
this.message.set({ kind: 'ok', text: 'Documento actualizado.' });
this.docResource.reload();
} catch (e) {
if (e instanceof HttpErrorResponse && e.status === 412) {
this.conflict.set(true);
this.message.set({
kind: 'warn',
text: 'Otro usuario modificó este documento. Recárguelo para ver sus cambios; los suyos no se guardaron.',
});
} else {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
} finally {
this.saving.set(false);
}
}
protected reload(): void {
this.conflict.set(false);
this.message.set(null);
this.docResource.reload();
}
}