refactor(web): el formulario base recibe de cada pantalla los campos de cabecera, pie y línea
Todas las pantallas declaran su DocumentLayout (catálogo FIELDS): ventas y compras dejan de tener la cabecera escrita en el HTML; el folio solo lo llevan los documentos tributarios. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c734dcbd0a
commit
8b0b8afc87
9 files changed
+320
-257
No files matched your search
@@ -1,39 +1,79 @@
|
||||
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
|
||||
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
|
||||
import type { DocRecord, LinesSpec } from './document-model';
|
||||
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
|
||||
|
||||
export interface HeaderField {
|
||||
key: string;
|
||||
label: string;
|
||||
/** `warehouse` abre la lista de almacenes. */
|
||||
type: 'text' | 'date' | 'warehouse';
|
||||
required?: boolean;
|
||||
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
|
||||
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
|
||||
|
||||
/**
|
||||
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
|
||||
* de campos y el formulario base los dibuja: no hay HTML por documento.
|
||||
*/
|
||||
export type HeaderField =
|
||||
| { type: 'text'; key: string; label: string; readonly?: boolean }
|
||||
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
|
||||
| { type: 'number'; key: string; label: string; decimals?: number }
|
||||
| { type: 'textarea'; key: string; label: string }
|
||||
/** Abre la lista de almacenes. */
|
||||
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
|
||||
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
|
||||
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
|
||||
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
|
||||
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
|
||||
| { type: 'computed'; label: string; source: ComputedSource };
|
||||
|
||||
export interface FieldColumns {
|
||||
/** Dos columnas como máximo (regla del proyecto base). */
|
||||
left: readonly HeaderField[];
|
||||
right: readonly HeaderField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
|
||||
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
|
||||
* Formulario de un documento: qué campos de cabecera y de pie, qué columnas de línea y cómo se
|
||||
* guarda. El formulario base lo dibuja sin saber de qué documento se trata.
|
||||
*/
|
||||
export interface DocumentLayout {
|
||||
lines: LinesSpec;
|
||||
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
|
||||
left: readonly HeaderField[];
|
||||
right: readonly HeaderField[];
|
||||
header: FieldColumns;
|
||||
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
|
||||
footer: FieldColumns;
|
||||
columns: readonly GridColumn[];
|
||||
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
|
||||
newHeader: () => DocRecord;
|
||||
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
|
||||
newLine: (header: DocRecord) => GridLine;
|
||||
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
|
||||
recalc?: (line: GridLine, changedKey: string) => GridLine;
|
||||
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
|
||||
immutable?: boolean;
|
||||
/** Ajustes a la cabecera antes de crear. */
|
||||
beforeCreate?: (header: DocRecord) => DocRecord;
|
||||
requiredLine: readonly RequiredField[];
|
||||
requiredLine: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
// --- catálogo de campos ---------------------------------------------------------------------
|
||||
|
||||
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
|
||||
export const FIELDS = {
|
||||
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
|
||||
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
|
||||
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
|
||||
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
|
||||
comments: { type: 'textarea', key: 'Comments', label: 'Comentarios' },
|
||||
numAtCard: { type: 'text', key: 'NumAtCard', label: 'Nº referencia' },
|
||||
folio: { type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' },
|
||||
fromWarehouse: { type: 'warehouse', key: 'FromWarehouse', label: 'Almacén origen', required: true, missing: 'el almacén origen' },
|
||||
toWarehouse: { type: 'warehouse', key: 'ToWarehouse', label: 'Almacén destino', required: true, missing: 'el almacén destino' },
|
||||
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
|
||||
} as const satisfies Record<string, HeaderField>;
|
||||
|
||||
const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
|
||||
|
||||
// --- columnas de línea compartidas ----------------------------------------------------------
|
||||
|
||||
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
||||
|
||||
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
||||
@@ -50,11 +90,55 @@ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
|
||||
|
||||
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
||||
|
||||
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
|
||||
|
||||
/**
|
||||
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
|
||||
* comentarios, descuento y totales. `left` y `right` son los campos que esa pantalla agrega a la
|
||||
* cabecera (por ejemplo `FIELDS.folio` en las facturas); sin ellos, la cabecera base.
|
||||
*/
|
||||
export function marketingLayout(opts: {
|
||||
partnerLabel: string;
|
||||
taxResource: string;
|
||||
left?: readonly HeaderField[];
|
||||
right?: readonly HeaderField[];
|
||||
}): DocumentLayout {
|
||||
return {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'], base: true },
|
||||
header: {
|
||||
left: [
|
||||
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
|
||||
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
|
||||
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
|
||||
FIELDS.numAtCard,
|
||||
...(opts.left ?? []),
|
||||
],
|
||||
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
|
||||
},
|
||||
footer: {
|
||||
left: [FIELDS.comments],
|
||||
right: [
|
||||
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
||||
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
||||
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
||||
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
||||
],
|
||||
},
|
||||
columns: lineColumnsFor(opts.taxResource),
|
||||
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
|
||||
newLine: newDocumentLine,
|
||||
recalc: recalcLine,
|
||||
requiredLine: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- documentos sin socio ni totales (inventario y solicitudes de compra) -------------------
|
||||
|
||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||
const goods: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
||||
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
right: [],
|
||||
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
|
||||
footer: commentsOnly,
|
||||
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
|
||||
newHeader: () => ({ DocDate: today() }),
|
||||
newLine: () => ({ ...blankLine(), Price: null }),
|
||||
@@ -65,11 +149,8 @@ const goods: DocumentLayout = {
|
||||
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
|
||||
const transfer = (immutable: boolean): DocumentLayout => ({
|
||||
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
|
||||
left: [
|
||||
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
|
||||
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
|
||||
],
|
||||
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
|
||||
footer: commentsOnly,
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
@@ -99,11 +180,8 @@ const transfer = (immutable: boolean): DocumentLayout => ({
|
||||
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
||||
const purchaseRequest: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
||||
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
|
||||
right: [
|
||||
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
|
||||
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
|
||||
],
|
||||
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
|
||||
footer: commentsOnly,
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
@@ -143,9 +221,34 @@ export const LAYOUTS = {
|
||||
purchaseRequest,
|
||||
} as const;
|
||||
|
||||
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
|
||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
|
||||
...layout.left.map((f) => f.key),
|
||||
...layout.right.map((f) => f.key),
|
||||
'Comments',
|
||||
// --- consultas sobre un layout --------------------------------------------------------------
|
||||
|
||||
const allFields = (layout: DocumentLayout): HeaderField[] => [
|
||||
...layout.header.left,
|
||||
...layout.header.right,
|
||||
...layout.footer.left,
|
||||
...layout.footer.right,
|
||||
];
|
||||
|
||||
/** Campos de cabecera que se editan (y se envían a SAP). */
|
||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
|
||||
allFields(layout).flatMap((f) => {
|
||||
switch (f.type) {
|
||||
case 'computed':
|
||||
return [];
|
||||
case 'folio':
|
||||
return [f.prefixKey, f.numberKey];
|
||||
default:
|
||||
return [f.key];
|
||||
}
|
||||
});
|
||||
|
||||
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
|
||||
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
|
||||
allFields(layout).flatMap((f) =>
|
||||
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner') && f.required
|
||||
? [{ key: f.key, label: f.missing ?? f.label }]
|
||||
: [],
|
||||
);
|
||||
|
||||
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|
||||
Reference in new issue
Block a user