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'; 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 { displayCode } from './finanzas/chart-tree.model'; import { ChartTreePanel } from './finanzas/chart-tree.panel'; import { MasterField } from './master-field'; import { type CollectionRow, type MasterNavTarget, collectionPayload, collectionRowsOf, collectionsCreatePayload, createMasterPayload, masterCollectionsOf, masterKeysOf, masterRequiredOf, pickMaster, validateMaster, } from './master-model'; import type { MasterCollection, MasterScreen } from './master-types'; 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, imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar], templateUrl: './master-form.page.html', 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; } `, }) 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); 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'), }); protected readonly isNew = computed(() => this.key() === null); /** SAP no crea este registro desde aquí (los lotes): sin botón «Nuevo» y `/nuevo` vuelve a la lista. */ protected readonly creatable = this.screen.creatable !== false; constructor() { if (!this.creatable && this.isNew()) void this.router.navigate(['..'], { relativeTo: this.route }); } // --- datos --------------------------------------------------------------------------------- private readonly resource = httpResource(() => { 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(() => this.pick(this.doc())); private pick(doc: DocRecord | undefined): DocRecord { 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>(() => 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 { 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); protected readonly conflict = signal(false); private readonly message = signal( ((): 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; return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0 || Object.keys(this.tableChanges()).length > 0; }); protected readonly status = computed(() => { 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(() => { if (this.isNew()) return { label: 'Nuevo', tone: 'info' }; const d = this.doc(); if (!d) return undefined; if (this.screen.badge) return this.screen.badge(d); return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' }; }); protected readonly windowTitle = computed(() => { const d = this.doc(); if (this.screen.titleField && d) return `${this.screen.singular} · ${text(d[this.screen.titleField])}`; const code = this.screen.displayField && d ? displayCode({ Code: text(d[this.screen.keyField]), FormatCode: text(d[this.screen.displayField]) }) : d?.[this.screen.keyField]; 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 : ''; } 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); } 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; } /** Cuenta que se resalta en el árbol del plan de cuentas: la abierta o, si es nueva, su cuenta padre (que fija el grupo). */ protected groupCode(): string { return this.key() ?? text(this.header()['FatherAccountKey']); } protected value(key: string): unknown { // el código de una cuenta ya creada se ve con su formato (el `_SYS…` interno no es lo que el usuario conoce) if (this.screen.displayField && key === this.screen.keyField && !this.isNew()) { const d = this.doc(); return displayCode({ Code: text(d?.[this.screen.keyField]), FormatCode: text(d?.[this.screen.displayField]) }); } return this.header()[key]; } protected async go(target: MasterNavTarget): Promise { try { const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter, this.screen.keyType === 'number'); 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 { 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) { 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; } // 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) { 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(); } }