feat(web): tablas en los maestros (direcciones, personas de contacto, precios por lista, stock por almacén)

sbo-rows-table reutilizable (también en la ventana de lotes). Direcciones y contactos se envían
completos con reemplazo; los precios solo por filas cambiadas. Probado contra SBODemoCL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:31:27 -05:00
1 parent e853b1b76c
commit 4e9d6a757d
14 files changed
+679 -96

No files matched your search

@@ -1,7 +1,7 @@
import { escapeODataString } from '../../core/sl/odata';
import { type HeaderField, keysOfFields, requiredOfFields } from '../documents/document-layouts';
import { type DocRecord, compact } from '../documents/document-model';
import type { MasterLayout } from './master-types';
import type { MasterCollection, MasterLayout } from './master-types';
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
@@ -58,3 +58,61 @@ export function masterNavigationQuery(
return current === null ? { $orderby: asc } : { $filter: `${keyField} gt ${quoted}`, $orderby: asc };
}
}
// --- tablas (colecciones) ----------------------------------------------------------------
export type CollectionRow = Record<string, unknown>;
/** Las tablas que declara el formulario, de todas sus pestañas. */
export const masterCollectionsOf = (layout: MasterLayout): MasterCollection[] =>
layout.tabs.flatMap((t) => (t.collection ? [t.collection] : []));
/** Copia de las filas que trae SAP de una colección (sobre ellas se aplican los cambios). */
export const collectionRowsOf = (doc: DocRecord | undefined, c: MasterCollection): CollectionRow[] =>
((doc?.[c.key] as CollectionRow[] | undefined) ?? []).map((r) => ({ ...r }));
const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === ''));
/** Una fila vacía: ninguno de los campos que la identifican tiene valor. */
export const isBlankRow = (c: MasterCollection, row: CollectionRow): boolean => !c.identity.some((k) => has(row[k]));
const editableKeys = (c: MasterCollection): string[] => c.columns.filter((col) => !col.readonly).map((col) => col.key);
/** Dos valores de una celda son iguales (vacío = vacío; un número escrito como texto = el número). */
function same(a: unknown, b: unknown): boolean {
if (!has(a) && !has(b)) return true;
if (!has(a) || !has(b)) return false;
return a === b || (Number.isFinite(Number(a)) && Number(a) === Number(b));
}
/**
* Lo que se envía a SAP de una tabla tras editarla, o `null` si no cambió. En `replace` la colección completa
* (las filas leídas con los cambios y las nuevas, sin las vacías); en `merge` solo las filas cambiadas con su
* `rowKey` y los campos que cambiaron.
*/
export function collectionPayload(c: MasterCollection, original: readonly CollectionRow[], current: readonly CollectionRow[]): CollectionRow[] | null {
if (c.readonly) return null;
const keys = editableKeys(c);
const rows = current.filter((r) => !isBlankRow(c, r));
if (c.mode === 'merge') {
const rk = c.rowKey ?? '';
const changed = rows.flatMap((r) => {
const before = original.find((o) => o[rk] === r[rk]);
const diff = keys.filter((k) => !same(before?.[k], r[k]));
return before && diff.length ? [{ [rk]: r[rk], ...Object.fromEntries(diff.map((k) => [k, has(r[k]) ? r[k] : null])) }] : [];
});
return changed.length ? changed : null;
}
const unchanged = rows.length === original.length && rows.every((r, i) => keys.every((k) => same(original[i]?.[k], r[k])));
return unchanged ? null : rows;
}
/** Tablas con filas para enviar al crear el registro (las vacías no). */
export function collectionsCreatePayload(layout: MasterLayout, current: Readonly<Record<string, readonly CollectionRow[]>>): DocRecord {
const body: DocRecord = {};
for (const c of masterCollectionsOf(layout)) {
if (c.readonly) continue;
const rows = (current[c.key] ?? []).filter((r) => !isBlankRow(c, r)).map((r) => compact(r));
if (rows.length) body[c.key] = rows;
}
return body;
}