Files
basic-erp/apps/web/src/app/features/masters/master-form.page.ts
T

179 lines
7.5 KiB
TypeScript
Raw Normal View History

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 { 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 MasterNavTarget,
createMasterPayload,
masterKeysOf,
masterRequiredOf,
pickMaster,
validateMaster,
} from './master-model';
import type { 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: [Icon, MasterField, RouterLink, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
})
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);
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());
}
// --- 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;
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0;
});
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(() => {
const code = this.doc()?.[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 : '';
}
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) {
const created = await this.api.create(this.screen.resource, createMasterPayload(this.header(), this.layout.fixed));
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(), [], []);
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);
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();
}
}