2026-10-16 10:24:39 -05:00
|
|
|
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 { map } from 'rxjs';
|
|
|
|
|
import { API_URL, apiErrorMessage } from '../../core/api';
|
|
|
|
|
import { SlMasterApi } from '../../core/sl/sl-master';
|
2026-10-16 12:31:27 -05:00
|
|
|
import { RowsTable } from '../../shared/grid/rows-table';
|
2026-10-16 10:24:39 -05:00
|
|
|
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';
|
2026-10-19 09:06:12 -05:00
|
|
|
import { ChartTreePanel } from './finanzas/chart-tree.panel';
|
2026-10-16 10:24:39 -05:00
|
|
|
import { MasterField } from './master-field';
|
|
|
|
|
import {
|
2026-10-16 12:31:27 -05:00
|
|
|
type CollectionRow,
|
2026-10-16 10:24:39 -05:00
|
|
|
type MasterNavTarget,
|
2026-10-16 12:31:27 -05:00
|
|
|
collectionPayload,
|
|
|
|
|
collectionRowsOf,
|
|
|
|
|
collectionsCreatePayload,
|
2026-10-16 10:24:39 -05:00
|
|
|
createMasterPayload,
|
2026-10-16 12:31:27 -05:00
|
|
|
masterCollectionsOf,
|
2026-10-16 10:24:39 -05:00
|
|
|
masterKeysOf,
|
|
|
|
|
masterRequiredOf,
|
|
|
|
|
pickMaster,
|
|
|
|
|
validateMaster,
|
|
|
|
|
} from './master-model';
|
2026-10-16 12:31:27 -05:00
|
|
|
import type { MasterCollection, MasterScreen } from './master-types';
|
2026-10-16 10:24:39 -05:00
|
|
|
|
|
|
|
|
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Formulario de un maestro (cliente, proveedor, artículo): `nuevo` (vacío) o `:clave` (ver / editar).
|
|
|
|
|
* Los campos y pestañas los declara la pantalla (`MasterLayout`); aquí solo se cargan, se editan y se
|
|
|
|
|
* guardan. Al modificar se envía el `odata.etag` leído (`If-Match`): si otro usuario cambió el registro,
|
|
|
|
|
* SAP responde 412 y se avisa sin pisarlo.
|
|
|
|
|
*/
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'sbo-master-form-page',
|
|
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
2026-10-19 09:06:12 -05:00
|
|
|
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
2026-10-16 10:24:39 -05:00
|
|
|
templateUrl: './master-form.page.html',
|
2026-10-19 09:06:12 -05:00
|
|
|
styles: `
|
|
|
|
|
.addrow { margin: 6px 0 0; }
|
|
|
|
|
.split.two { display: grid; grid-template-columns: minmax(380px, 460px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
|
|
|
|
.split.two .sbo-hdr { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
|
|
|
|
.left { min-width: 0; }
|
|
|
|
|
`,
|
2026-10-16 10:24:39 -05:00
|
|
|
})
|
|
|
|
|
export class MasterFormPage {
|
|
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
|
|
|
private readonly router = inject(Router);
|
|
|
|
|
private readonly api = inject(SlMasterApi);
|
|
|
|
|
|
|
|
|
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
|
|
|
|
protected readonly screen = this.route.snapshot.data['screen'] as MasterScreen;
|
|
|
|
|
protected readonly layout = this.screen.layout;
|
|
|
|
|
private readonly keys = masterKeysOf(this.layout);
|
|
|
|
|
private readonly required = masterRequiredOf(this.layout);
|
2026-10-16 12:31:27 -05:00
|
|
|
private readonly collections = masterCollectionsOf(this.layout);
|
2026-10-16 10:24:39 -05:00
|
|
|
|
|
|
|
|
protected readonly key = toSignal(this.route.paramMap.pipe(map((p) => p.get('key'))), {
|
|
|
|
|
initialValue: this.route.snapshot.paramMap.get('key'),
|
|
|
|
|
});
|
|
|
|
|
protected readonly isNew = computed(() => this.key() === null);
|
|
|
|
|
|
|
|
|
|
// --- datos ---------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
private readonly resource = httpResource<DocRecord>(() => {
|
|
|
|
|
const key = this.key();
|
|
|
|
|
return key === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${encodeURIComponent(key)}`;
|
|
|
|
|
});
|
|
|
|
|
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 registro no existe.' : apiErrorMessage(err)) : '';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Valores editables; se reinician al cargar o recargar el registro. */
|
|
|
|
|
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
|
|
|
|
|
private pick(doc: DocRecord | undefined): DocRecord {
|
|
|
|
|
return pickMaster(doc, this.keys, this.layout.newHeader());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 12:31:27 -05:00
|
|
|
/** 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] : [];
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 10:24:39 -05:00
|
|
|
// --- estado --------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
protected readonly saving = signal(false);
|
|
|
|
|
protected readonly conflict = 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: `${this.screen.singular} ${created} creado.` } : null;
|
|
|
|
|
})(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
protected readonly dirty = computed(() => {
|
|
|
|
|
const d = this.doc();
|
|
|
|
|
if (!d) return false;
|
2026-10-16 12:31:27 -05:00
|
|
|
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0 || Object.keys(this.tableChanges()).length > 0;
|
2026-10-16 10:24:39 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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.isNew()) return { kind: 'info', text: 'Indique el código y el nombre.' };
|
|
|
|
|
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
|
|
|
|
});
|
|
|
|
|
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
|
|
|
|
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
|
|
|
|
|
const d = this.doc();
|
|
|
|
|
if (!d) return undefined;
|
|
|
|
|
return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' };
|
|
|
|
|
});
|
|
|
|
|
protected readonly windowTitle = computed(() => {
|
2026-10-19 09:46:46 -05:00
|
|
|
const d = this.doc();
|
|
|
|
|
const code = (this.screen.displayField && text(d?.[this.screen.displayField]).trim()) || d?.[this.screen.keyField];
|
2026-10-16 10:24:39 -05:00
|
|
|
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- edición -------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
protected setValue(key: string, value: string | number): void {
|
|
|
|
|
this.header.update((h) => ({ ...h, [key]: value }));
|
|
|
|
|
this.message.set(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Clave de SAP de un campo del formulario (los de un maestro siempre la tienen). */
|
|
|
|
|
protected keyOf(f: HeaderField): string {
|
|
|
|
|
return 'key' in f ? f.key : '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 11:14:52 -05:00
|
|
|
private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []);
|
|
|
|
|
|
|
|
|
|
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
|
|
|
|
|
protected locked(key: string): boolean {
|
|
|
|
|
return this.lockedKeys.has(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 12:31:27 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 09:06:12 -05:00
|
|
|
/** Cuenta que se resalta en el árbol del plan de cuentas: la abierta o, si es nueva, su cuenta padre (que fija el grupo). */
|
2026-10-16 14:46:07 -05:00
|
|
|
protected groupCode(): string {
|
2026-10-19 09:06:12 -05:00
|
|
|
return this.key() ?? text(this.header()['FatherAccountKey']);
|
2026-10-16 14:46:07 -05:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 10:24:39 -05:00
|
|
|
protected value(key: string): unknown {
|
|
|
|
|
return this.header()[key];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async go(target: MasterNavTarget): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter);
|
|
|
|
|
if (next === null) {
|
|
|
|
|
this.message.set({ kind: 'warn', text: 'No hay más registros en esa dirección.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.message.set(null);
|
|
|
|
|
await this.router.navigate(['..', next], { relativeTo: this.route });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async save(): Promise<void> {
|
|
|
|
|
if (this.saving()) return;
|
|
|
|
|
const errors = validateMaster(this.header(), this.required);
|
|
|
|
|
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) {
|
2026-10-16 12:31:27 -05:00
|
|
|
const body = { ...createMasterPayload(this.header(), this.layout.fixed), ...collectionsCreatePayload(this.layout, this.tables()) };
|
|
|
|
|
const created = await this.api.create(this.screen.resource, body);
|
2026-10-16 10:24:39 -05:00
|
|
|
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(), [], []);
|
2026-10-16 12:31:27 -05:00
|
|
|
const changes = this.tableChanges();
|
|
|
|
|
Object.assign(body, changes);
|
2026-10-16 10:24:39 -05:00
|
|
|
if (!Object.keys(body).length) {
|
|
|
|
|
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-16 12:31:27 -05:00
|
|
|
// 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);
|
2026-10-16 10:24:39 -05:00
|
|
|
this.message.set({ kind: 'ok', text: `${this.screen.singular} actualizado.` });
|
|
|
|
|
this.resource.reload();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e instanceof HttpErrorResponse && e.status === 412) {
|
|
|
|
|
this.conflict.set(true);
|
|
|
|
|
this.message.set({ kind: 'warn', text: 'Otro usuario modificó este registro. 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.resource.reload();
|
|
|
|
|
}
|
|
|
|
|
}
|