2026-10-13 22:00:54 -05:00
|
|
|
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';
|
2026-10-13 20:01:34 -05:00
|
|
|
|
2026-10-13 22:00:54 -05:00
|
|
|
/** 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);
|
2026-10-14 09:13:53 -05:00
|
|
|
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
|
|
|
|
|
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
2026-10-13 22:00:54 -05:00
|
|
|
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
|
|
|
|
|
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
2026-10-14 09:13:53 -05:00
|
|
|
return Object.fromEntries(
|
|
|
|
|
HEADER_KEYS.map((k) => [
|
|
|
|
|
k,
|
|
|
|
|
k === 'DocDate' || k === 'DocDueDate' ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-10-13 22:00:54 -05:00
|
|
|
};
|
|
|
|
|
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.
|
|
|
|
|
*/
|
2026-10-13 20:01:34 -05:00
|
|
|
@Component({
|
|
|
|
|
selector: 'sbo-document-form-page',
|
|
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
2026-10-13 22:00:54 -05:00
|
|
|
imports: [DateField, Icon, LinesGrid, LookupField, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar],
|
|
|
|
|
templateUrl: './document-form.page.html',
|
2026-10-13 20:01:34 -05:00
|
|
|
styles: `
|
|
|
|
|
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
|
|
|
|
p { margin: 0; color: var(--quiet); }
|
|
|
|
|
a { color: var(--focus); }
|
2026-10-13 22:00:54 -05:00
|
|
|
.totals { display: grid; gap: 2px; }
|
2026-10-13 20:01:34 -05:00
|
|
|
`,
|
|
|
|
|
})
|
|
|
|
|
export class DocumentFormPage {
|
|
|
|
|
private readonly route = inject(ActivatedRoute);
|
2026-10-13 22:00:54 -05:00
|
|
|
private readonly router = inject(Router);
|
|
|
|
|
private readonly api = inject(SlDocumentApi);
|
|
|
|
|
|
2026-10-13 20:01:34 -05:00
|
|
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
|
|
|
|
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
2026-10-13 22:00:54 -05:00
|
|
|
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();
|
|
|
|
|
}
|
2026-10-13 20:01:34 -05:00
|
|
|
}
|