179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
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();
|
||
|
|
}
|
||
|
|
}
|