feat(web): formulario de documentos de ventas y compras (cargar, crear, actualizar con If-Match, navegar)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51982fd3ab
commit
d900a96740
9 files changed
+621
-11
No files matched your search
@@ -1,25 +1,246 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import type { DocumentScreen } from './document-screens';
|
||||
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 type { SlListResponse } from '@basic-erp/sap-types';
|
||||
import { map } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { formatNumber } from '../../core/format';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form';
|
||||
import type { GridLine } from '../../shared/grid/grid.types';
|
||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
import {
|
||||
type DocRecord,
|
||||
type NavTarget,
|
||||
computeTotals,
|
||||
createPayload,
|
||||
isBlankLine,
|
||||
isDocumentReadOnly,
|
||||
taxRateMap,
|
||||
updatePayload,
|
||||
validateDocument,
|
||||
} from './document-model';
|
||||
import { type DocumentScreen, documentStatus } from './document-screens';
|
||||
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
|
||||
|
||||
/** Formulario del documento: `nuevo` (vacío) o `:docEntry` (ver / editar). Se construye en el paso 9. */
|
||||
/** Campos de cabecera que se editan en el formulario (el resto lo calcula SAP). */
|
||||
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
|
||||
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
||||
return Object.fromEntries(HEADER_KEYS.map((k) => [k, doc[k] ?? (k === 'DiscountPercent' ? 0 : '')]));
|
||||
};
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
|
||||
/**
|
||||
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
|
||||
* Los cerrados y cancelados quedan en solo lectura. Al modificar se envía el `odata.etag` leído
|
||||
* (`If-Match`): si otro usuario cambió el documento, SAP responde 412 y se avisa sin pisarlo.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-document-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<h1>{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}</h1>
|
||||
<p>El formulario aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
|
||||
`,
|
||||
imports: [DateField, Icon, LinesGrid, LookupField, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar],
|
||||
templateUrl: './document-form.page.html',
|
||||
styles: `
|
||||
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||
p { margin: 0; color: var(--quiet); }
|
||||
a { color: var(--focus); }
|
||||
.totals { display: grid; gap: 2px; }
|
||||
`,
|
||||
})
|
||||
export class DocumentFormPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly api = inject(SlDocumentApi);
|
||||
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
||||
protected readonly docEntry = this.route.snapshot.paramMap.get('docEntry');
|
||||
protected readonly partner = this.screen.partner;
|
||||
protected readonly docEntry = toSignal(
|
||||
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
|
||||
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
|
||||
);
|
||||
protected readonly isNew = computed(() => this.docEntry() === null);
|
||||
|
||||
// --- datos ---------------------------------------------------------------------------------
|
||||
|
||||
private readonly docResource = httpResource<DocRecord>(() => {
|
||||
const id = this.docEntry();
|
||||
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
|
||||
});
|
||||
private readonly taxResource = httpResource<SlListResponse<DocRecord>>(() =>
|
||||
this.partner
|
||||
? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
|
||||
protected readonly loading = computed(() => this.docResource.isLoading());
|
||||
protected readonly loadError = computed(() => {
|
||||
const err = this.docResource.error();
|
||||
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El documento no existe.' : apiErrorMessage(err)) : '';
|
||||
});
|
||||
|
||||
/** Valores editables; se reinician al cargar o recargar el documento. */
|
||||
protected readonly header = linkedSignal<DocRecord>(() => pickHeader(this.doc()));
|
||||
protected readonly lines = linkedSignal<readonly GridLine[]>(() =>
|
||||
((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })),
|
||||
);
|
||||
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
|
||||
|
||||
// --- estado --------------------------------------------------------------------------------
|
||||
|
||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
|
||||
protected readonly saving = 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: `Documento ${created} creado.` } : null;
|
||||
})(),
|
||||
);
|
||||
protected readonly conflict = signal(false);
|
||||
|
||||
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.readOnly()) return { kind: 'info', text: 'Documento cerrado o cancelado: solo lectura.' };
|
||||
if (this.isNew()) return { kind: 'info', text: `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
|
||||
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
||||
});
|
||||
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
||||
if (this.isNew()) return { label: 'Nueva', tone: 'info' };
|
||||
const d = this.doc();
|
||||
return d ? documentStatus(d) : undefined;
|
||||
});
|
||||
protected readonly windowTitle = computed(() => {
|
||||
const num = this.doc()?.['DocNum'];
|
||||
return num === undefined ? this.title : `${this.title} · ${text(num)}`;
|
||||
});
|
||||
|
||||
protected readonly dirty = computed(() => {
|
||||
const d = this.doc();
|
||||
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
|
||||
const original = pickHeader(d);
|
||||
const { body } = updatePayload(original, this.header(), (d['DocumentLines'] as GridLine[]) ?? [], this.lines());
|
||||
return Object.keys(body).length > 0;
|
||||
});
|
||||
|
||||
// --- totales -------------------------------------------------------------------------------
|
||||
|
||||
private readonly rates = computed(() =>
|
||||
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
|
||||
);
|
||||
protected readonly totals = computed(() =>
|
||||
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)),
|
||||
);
|
||||
protected readonly fmt = (n: number): string => formatNumber(n, 2);
|
||||
|
||||
// --- configuración de la vista --------------------------------------------------------------
|
||||
|
||||
protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
|
||||
protected readonly newLine = newDocumentLine;
|
||||
protected readonly recalc = recalcLine;
|
||||
protected readonly multi = ITEMS_MULTI_ADD;
|
||||
protected readonly partnerLookup: VhLookup = {
|
||||
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
||||
resource: 'BusinessPartners',
|
||||
baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`,
|
||||
orderby: 'CardCode',
|
||||
columns: [
|
||||
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
|
||||
{ key: 'CardName', title: 'Nombre' },
|
||||
],
|
||||
};
|
||||
|
||||
protected str = text;
|
||||
|
||||
protected setHeader(key: string, value: unknown): void {
|
||||
this.header.update((h) => ({ ...h, [key]: value }));
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
protected onPartner(row: Record<string, unknown>): void {
|
||||
this.partnerName.set(text(row['CardName']));
|
||||
}
|
||||
|
||||
protected setLines(lines: readonly GridLine[]): void {
|
||||
this.lines.set(lines);
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
// --- acciones ------------------------------------------------------------------------------
|
||||
|
||||
protected async go(target: NavTarget): Promise<void> {
|
||||
try {
|
||||
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
|
||||
if (entry === null) {
|
||||
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
|
||||
return;
|
||||
}
|
||||
this.message.set(null);
|
||||
await this.router.navigate(['..', entry], { relativeTo: this.route });
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
}
|
||||
}
|
||||
|
||||
protected async save(): Promise<void> {
|
||||
if (this.saving() || this.readOnly()) return;
|
||||
const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
|
||||
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, createPayload(this.header(), this.lines()));
|
||||
await this.router.navigate(['..', created['DocEntry']], {
|
||||
relativeTo: this.route,
|
||||
state: { created: text(created['DocNum']) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { body, replaceLines } = updatePayload(
|
||||
pickHeader(d),
|
||||
this.header(),
|
||||
(d['DocumentLines'] as GridLine[]) ?? [],
|
||||
this.lines(),
|
||||
);
|
||||
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.docEntry()!, body, text(d['odata.etag']) || undefined, replaceLines);
|
||||
this.message.set({ kind: 'ok', text: 'Documento actualizado.' });
|
||||
this.docResource.reload();
|
||||
} catch (e) {
|
||||
if (e instanceof HttpErrorResponse && e.status === 412) {
|
||||
this.conflict.set(true);
|
||||
this.message.set({
|
||||
kind: 'warn',
|
||||
text: 'Otro usuario modificó este documento. 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.docResource.reload();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user