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

@@ -5,19 +5,25 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { SlMasterApi } from '../../core/sl/sl-master';
import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
import type { HeaderField } from '../documents/document-layouts';
import { MasterField } from './master-field';
import {
type CollectionRow,
type MasterNavTarget,
collectionPayload,
collectionRowsOf,
collectionsCreatePayload,
createMasterPayload,
masterCollectionsOf,
masterKeysOf,
masterRequiredOf,
pickMaster,
validateMaster,
} from './master-model';
import type { MasterScreen } from './master-types';
import type { MasterCollection, MasterScreen } from './master-types';
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
@@ -30,8 +36,9 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Icon, MasterField, RouterLink, SboWindow, Tab, Tabs, Toolbar],
imports: [Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
styles: '.addrow { margin: 6px 0 0; }',
})
export class MasterFormPage {
private readonly route = inject(ActivatedRoute);
@@ -43,6 +50,7 @@ export class MasterFormPage {
protected readonly layout = this.screen.layout;
private readonly keys = masterKeysOf(this.layout);
private readonly required = masterRequiredOf(this.layout);
private readonly collections = masterCollectionsOf(this.layout);
protected readonly key = toSignal(this.route.paramMap.pipe(map((p) => p.get('key'))), {
initialValue: this.route.snapshot.paramMap.get('key'),
@@ -68,6 +76,22 @@ export class MasterFormPage {
return pickMaster(doc, this.keys, this.layout.newHeader());
}
/** Filas de las tablas (direcciones, contactos, precios…); se reinician al cargar o recargar el registro. */
protected readonly tables = linkedSignal<Record<string, CollectionRow[]>>(() =>
Object.fromEntries(this.collections.map((c) => [c.key, collectionRowsOf(this.doc(), c)])),
);
/** Lo que cambió en cada tabla (colección → filas a enviar). */
private tableChanges(): Record<string, CollectionRow[]> {
const d = this.doc();
return Object.fromEntries(
this.collections.flatMap((c) => {
const changed = collectionPayload(c, collectionRowsOf(d, c), this.tables()[c.key] ?? []);
return changed ? [[c.key, changed] as const] : [];
}),
);
}
// --- estado --------------------------------------------------------------------------------
protected readonly saving = signal(false);
@@ -82,7 +106,7 @@ export class MasterFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return false;
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0;
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0 || Object.keys(this.tableChanges()).length > 0;
});
protected readonly status = computed<StatusMessage>(() => {
@@ -123,6 +147,24 @@ export class MasterFormPage {
return this.lockedKeys.has(key);
}
protected rowsOf(c: MasterCollection): CollectionRow[] {
return this.tables()[c.key] ?? [];
}
protected setRows(c: MasterCollection, rows: CollectionRow[]): void {
this.tables.update((t) => ({ ...t, [c.key]: rows }));
this.message.set(null);
}
protected addRow(c: MasterCollection): void {
this.setRows(c, [...this.rowsOf(c), { ...(c.newRow?.() ?? {}) }]);
}
/** La tabla permite añadir y quitar filas. */
protected growable(c: MasterCollection): boolean {
return !c.readonly && c.mode === 'replace' && c.growable !== false;
}
protected value(key: string): unknown {
return this.header()[key];
}
@@ -153,17 +195,22 @@ export class MasterFormPage {
try {
const d = this.doc();
if (!d) {
const created = await this.api.create(this.screen.resource, createMasterPayload(this.header(), this.layout.fixed));
const body = { ...createMasterPayload(this.header(), this.layout.fixed), ...collectionsCreatePayload(this.layout, this.tables()) };
const created = await this.api.create(this.screen.resource, body);
const key = text(created[this.screen.keyField]);
await this.router.navigate(['..', key], { relativeTo: this.route, state: { created: key } });
return;
}
const { body } = updatePayload(this.pick(d), this.header(), [], []);
const changes = this.tableChanges();
Object.assign(body, changes);
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.key()!, body, text(d['odata.etag']) || undefined);
// Reemplazar la colección solo cuando se manda completa (direcciones, contactos), nunca en las que van por filas.
const replace = this.collections.some((c) => c.mode === 'replace' && c.key in changes);
await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined, replace);
this.message.set({ kind: 'ok', text: `${this.screen.singular} actualizado.` });
this.resource.reload();
} catch (e) {