feat(web): maestros de clientes, proveedores y artículos (lista, formulario con pestañas, extensiones por cliente)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b759c7bce7
commit
9f003ee4bf
30 files changed
+1553
-23
No files matched your search
@@ -0,0 +1,179 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user