import { HttpErrorResponse, httpResource } from '@angular/common/http'; import { NgTemplateOutlet } from '@angular/common'; 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, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form'; import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types'; import { LinesGrid } from '../../shared/grid/lines-grid'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window'; import { ValueHelp } from '../../shared/value-help/value-help'; import type { ListRow } from '../../shared/list/list.types'; import { type DocRecord, type NavTarget, computeTotals, freightOf, freightPayload, createPayload, isBlankLine, isDocumentReadOnly, taxRateMap, updatePayload, validateLayout, } from './document-model'; import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, requiredHeaderOf } from './document-layouts'; import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy'; import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens'; import { OBJECT_TYPES } from './document-objects'; import { ITEMS_MULTI_ADD } from './line-columns'; const today = (): string => new Date().toISOString().slice(0, 10); const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']); /** 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) : ''); const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => { if (!doc) return blank; return Object.fromEntries( keys.map((k) => [ k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : k === 'FolioNumber' ? text(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')), ]), ); }; const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); /** El folio se edita como texto; SAP lo espera como número. */ const folioAsNumber = (h: DocRecord): DocRecord => { const folio = h['FolioNumber']; if (typeof folio !== 'string') return h; return { ...h, FolioNumber: folio.trim() === '' ? null : Number(folio) }; }; /** * 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: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], 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 partner = this.screen.partner; /** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */ protected readonly layout = this.screen.layout; private readonly lineSpec = this.layout.lines; private readonly headerKeys: readonly string[] = headerKeysOf(this.layout); private readonly hasFolio = hasFolio(this.layout); private readonly blankHeader: DocRecord = this.layout.newHeader(); private readonly hasFreight = hasFreight(this.layout); private pick(doc: DocRecord | undefined): DocRecord { const header = pickHeader(doc, this.headerKeys, this.blankHeader); if (!doc || !this.hasFreight) return header; const freight = freightOf(doc); return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount }; } private docLines(doc: DocRecord | undefined): GridLine[] { return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? []; } 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(() => { const id = this.docEntry(); return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`; }); private readonly taxResource = httpResource>(() => this.partner ? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } } : undefined, ); /** Gastos adicionales de SAP (el flete se registra con uno de ellos). */ private readonly expensesResource = httpResource>(() => this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined, ); protected readonly expenseOptions = computed(() => (this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({ value: String(e['ExpensCode']), label: text(e['Name']) || String(e['ExpensCode']), })), ); /** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */ protected readonly defaultExpense = computed(() => { const options = this.expenseOptions(); return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? ''; }); protected readonly freightEditable = computed(() => freightOf(this.doc()).editable); /** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */ private readonly hasSeries = hasSeries(this.layout); private readonly objectType = OBJECT_TYPES[this.screen.path]; private readonly seriesResource = httpResource>(() => this.hasSeries && this.objectType ? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(this.objectType) } }, } : undefined, ); protected readonly seriesValue = computed(() => Number(this.header()['Series']) || null); /** Series elegibles: del subtipo de la pantalla y sin bloquear; la del documento abierto se incluye siempre. */ protected readonly seriesOptions = computed(() => { const subType = this.layout.seriesSubType ?? '--'; const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : []; const options = rows .filter((r) => r['DocumentSubType'] === subType && r['Locked'] !== 'tYES') .map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) })); const current = Number(this.doc()?.['Series']); if (current && !options.some((o) => o.value === current)) { const known = rows.find((r) => Number(r['Series']) === current); options.unshift({ value: current, label: text(known?.['Name']) || `Serie ${current}` }); } return options; }); 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(() => this.pick(this.doc())); protected readonly lines = linkedSignal(() => this.docLines(this.doc()).map((l) => ({ ...l }))); protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName'])); /** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */ protected readonly partnerTaxId = linkedSignal(() => text(this.doc()?.['FederalTaxID'])); // --- estado -------------------------------------------------------------------------------- protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew())); protected readonly saving = signal(false); private readonly message = signal( ((): 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(() => { 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()) { const posted = this.layout.immutable && !isDocumentReadOnly(this.doc()); return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' }; } if (this.isNew()) { return { kind: 'info', text: this.partner ? `Indique el ${this.partner.label.toLowerCase()} y las líneas.` : 'Complete la cabecera y las líneas.' }; } return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' }; }); protected readonly badge = computed(() => { 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 { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec); 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), Number(this.header()['FreightAmount'] ?? 0)), ); protected readonly fmt = (n: number): string => formatNumber(n, 2); // --- copiar de / a ---------------------------------------------------------------------------- private readonly copyOrigin = (this.router.currentNavigation()?.extras.state as { copyFrom?: CopyOrigin } | undefined)?.copyFrom; protected readonly copyListFor = signal(null); /** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */ protected readonly copyToItems = computed(() => { const blocked = this.isNew() || this.readOnly() || this.dirty(); return copyTargets(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: blocked })); }); protected readonly copyFromItems = computed(() => copySources(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: !this.isNew() })), ); protected readonly copyListTitle = computed(() => menuLabel(this.copyListFor() ?? '')); protected readonly copyListScreen = computed(() => DOCUMENT_SCREENS.find((s) => s.path === this.copyListFor())); /** Documentos abiertos del origen elegido, del mismo socio si ya se indicó. */ protected readonly copyListFilter = computed(() => { const card = text(this.header()['CardCode']); return [`DocumentStatus eq 'bost_Open'`, `Cancelled eq 'tNO'`, ...(card ? [`CardCode eq '${card.replace(/'/g, "''")}'`] : [])].join(' and '); }); protected readonly copyListColumns = [ docNum, { key: 'CardCode', title: this.partner?.label ?? 'Socio', width: '120px', mono: true }, { key: 'CardName', title: 'Nombre' }, { key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' }, ] as const; protected copyTo(path: string): void { const docEntry = this.docEntry(); if (docEntry === null) return; const origin: CopyOrigin = { path: this.screen.path, docEntry }; void this.router.navigateByUrl(`${path}/nuevo`, { state: { copyFrom: origin } }); } protected copyFrom(path: string): void { this.copyListFor.set(path); } protected async copyPicked(rows: ListRow[]): Promise { const path = this.copyListFor(); this.copyListFor.set(null); const docEntry = rows[0]?.['DocEntry']; if (path && typeof docEntry === 'number') await this.loadCopy({ path, docEntry }); } /** Trae el documento origen y vuelca sus datos y líneas abiertas en el formulario. */ private async loadCopy(origin: CopyOrigin): Promise { const source = DOCUMENT_SCREENS.find((s) => s.path === origin.path); if (!source) return; try { const doc = await this.api.get(source.resource, origin.docEntry); const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path)); const card = text(this.header()['CardCode']); if (card && card !== text(copied.header['CardCode'])) { this.message.set({ kind: 'warn', text: `El documento es de ${text(copied.header['CardCode'])}; este es de ${card}.` }); return; } if (!copied.lines.length) { this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' }); return; } if (!card) { this.header.update((h) => ({ ...h, ...copied.header })); this.partnerName.set(text(doc['CardName'])); this.partnerTaxId.set(text(doc['FederalTaxID'])); } this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...copied.lines]); this.message.set({ kind: 'ok', text: `Copiado de ${menuLabel(origin.path).toLowerCase()} ${text(doc['DocNum'])}${copied.skipped ? ` (${copied.skipped} línea(s) cerradas no se copian)` : ''}. Revise y pulse Crear.`, }); } catch (e) { this.message.set({ kind: 'err', text: apiErrorMessage(e) }); } } // --- configuración de la vista -------------------------------------------------------------- protected readonly columns = this.layout.columns; private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0]; protected readonly newLine = () => this.layout.newLine(this.header()); /** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */ protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined; protected readonly multi: GridMultiAdd = { ...ITEMS_MULTI_ADD, lookup: this.itemColumn.lookup!.config, toLine: (item) => this.itemColumn.lookup!.apply(item, this.layout.newLine(this.header())), }; protected readonly warehouseLookup: VhLookup = { ...WAREHOUSE_LOOKUP, columns: this.layout.lookups?.warehouse ?? WAREHOUSE_LOOKUP.columns, }; protected readonly partnerLookup: VhLookup = { title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes', resource: 'BusinessPartners', baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`, orderby: 'CardCode', columns: this.layout.lookups?.partner ?? PARTNER_COLUMNS, extraFields: ['FederalTaxID'], }; protected str = text; /** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */ protected computedValue(source: ComputedSource): string { switch (source) { case 'docNum': return text(this.doc()?.['DocNum']); case 'partnerName': return this.partnerName(); case 'partnerTaxId': return this.partnerTaxId(); case 'subtotal': return this.fmt(this.totals().subtotal); case 'tax': return this.fmt(this.totals().tax); case 'total': return this.fmt(this.totals().total); } } constructor() { if (this.copyOrigin) void this.loadCopy(this.copyOrigin); } protected setHeader(key: string, value: unknown): void { this.header.update((h) => ({ ...h, [key]: value })); this.message.set(null); } /** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */ protected onPartnerText(code: string): void { if (code === text(this.header()['CardCode'])) return; this.setHeader('CardCode', code); this.partnerName.set(''); this.partnerTaxId.set(''); } protected notify(textMessage: string): void { this.message.set({ kind: 'warn', text: textMessage }); } protected onPartner(row: Record): void { this.partnerName.set(text(row['CardName'])); this.partnerTaxId.set(text(row['FederalTaxID'])); } protected setLines(lines: readonly GridLine[]): void { this.lines.set(lines); this.message.set(null); } // --- acciones ------------------------------------------------------------------------------ protected async go(target: NavTarget): Promise { try { const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry(), this.screen.baseFilter); 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 { if (this.saving() || this.readOnly()) return; const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine); if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.'); 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 header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header()); const body = this.hasFreight ? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body : createPayload(header, this.lines(), this.lineSpec); const created = await this.api.create(this.screen.resource, body); await this.router.navigate(['..', created['DocEntry']], { relativeTo: this.route, state: { created: text(created['DocNum']) }, }); return; } const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec); let body = folioAsNumber(update.body); let replaceLines = update.replaceLines; if (this.hasFreight) { const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0); body = freight.body; replaceLines = replaceLines || freight.replace; } 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(); } }