refactor(web): el formulario base recibe de cada pantalla los campos de cabecera, pie y línea
Todas las pantallas declaran su DocumentLayout (catálogo FIELDS): ventas y compras dejan de tener la cabecera escrita en el HTML; el folio solo lo llevan los documentos tributarios. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c734dcbd0a
commit
8b0b8afc87
9 files changed
+320
-257
No files matched your search
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angul
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentFormPage } from './document-form.page';
|
||||
import { hasFolio } from './document-layouts';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
|
||||
const invoice = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -63,7 +64,7 @@ describe('folio y RUT en los formularios', () => {
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
|
||||
const withFolio = DOCUMENT_SCREENS.filter((s) => s.folio).map((s) => s.resource);
|
||||
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.resource);
|
||||
expect(withFolio.sort()).toEqual(
|
||||
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
|
||||
);
|
||||
|
||||
@@ -9,136 +9,44 @@
|
||||
</sbo-toolbar>
|
||||
|
||||
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
||||
@if (partner) {
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
<sbo-lookup-field
|
||||
[label]="partner.label"
|
||||
required
|
||||
[readonly]="readOnly() || !isNew()"
|
||||
[value]="str(header()['CardCode'])"
|
||||
(valueChange)="onPartnerText($event)"
|
||||
[lookup]="partnerLookup"
|
||||
resolveOnLeave
|
||||
(picked)="onPartner($event)"
|
||||
/>
|
||||
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
|
||||
<sbo-text-field label="RUT" readonly [value]="partnerTaxId()" />
|
||||
<sbo-text-field
|
||||
label="Nº referencia"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['NumAtCard'])"
|
||||
(valueChange)="setHeader('NumAtCard', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
|
||||
<sbo-date-field
|
||||
label="Fecha de contabilización"
|
||||
required
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['DocDate'])"
|
||||
(valueChange)="setHeader('DocDate', $event)"
|
||||
/>
|
||||
<sbo-date-field
|
||||
label="Fecha de vencimiento"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['DocDueDate'])"
|
||||
(valueChange)="setHeader('DocDueDate', $event)"
|
||||
/>
|
||||
@if (folio) {
|
||||
<sbo-field label="Folio">
|
||||
<sbo-input
|
||||
width="90px"
|
||||
[maxlength]="10"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['FolioPrefixString'])"
|
||||
(valueChange)="setHeader('FolioPrefixString', $event)"
|
||||
/>
|
||||
<sbo-input
|
||||
ariaLabel="Número de folio"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['FolioNumber'])"
|
||||
(valueChange)="setHeader('FolioNumber', $event)"
|
||||
/>
|
||||
</sbo-field>
|
||||
}
|
||||
</div>
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@for (f of layout.header.left; track $index) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<sbo-lines-grid
|
||||
[columns]="columns"
|
||||
[lines]="lines()"
|
||||
(linesChange)="setLines($event)"
|
||||
(notice)="notify($event)"
|
||||
[readonly]="readOnly()"
|
||||
[newLine]="newLine"
|
||||
[recalc]="recalc"
|
||||
[multiAdd]="multi"
|
||||
/>
|
||||
|
||||
<div class="sbo-hdr" style="margin-top: 14px">
|
||||
<div class="sbo-col">
|
||||
<sbo-textarea-field
|
||||
label="Comentarios"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['Comments'])"
|
||||
(valueChange)="setHeader('Comments', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Total antes del descuento" readonly [value]="fmt(totals().subtotal)" />
|
||||
<sbo-number-field
|
||||
label="Descuento %"
|
||||
[decimals]="2"
|
||||
[readonly]="readOnly()"
|
||||
[value]="+str(header()['DiscountPercent'])"
|
||||
(valueChange)="setHeader('DiscountPercent', $event ?? 0)"
|
||||
/>
|
||||
<sbo-text-field label="Impuesto" readonly [value]="fmt(totals().tax)" />
|
||||
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
@for (f of layout.header.right; track $index) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
} @else if (layout; as lay) {
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@for (f of lay.left; track f.key) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
|
||||
@for (f of lay.right; track f.key) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sbo-lines-grid
|
||||
[columns]="columns"
|
||||
[lines]="lines()"
|
||||
(linesChange)="setLines($event)"
|
||||
(notice)="notify($event)"
|
||||
[readonly]="readOnly()"
|
||||
[newLine]="newLine"
|
||||
[recalc]="recalc"
|
||||
[multiAdd]="multi"
|
||||
/>
|
||||
|
||||
<div class="sbo-hdr" style="margin-top: 14px">
|
||||
<div class="sbo-col">
|
||||
@for (f of layout.footer.left; track $index) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<sbo-lines-grid
|
||||
[columns]="columns"
|
||||
[lines]="lines()"
|
||||
(linesChange)="setLines($event)"
|
||||
(notice)="notify($event)"
|
||||
[readonly]="readOnly()"
|
||||
[newLine]="newLine"
|
||||
[multiAdd]="multi"
|
||||
/>
|
||||
|
||||
<div class="sbo-hdr" style="margin-top: 14px">
|
||||
<div class="sbo-col">
|
||||
<sbo-textarea-field
|
||||
label="Comentarios"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['Comments'])"
|
||||
(valueChange)="setHeader('Comments', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
@for (f of layout.footer.right; track $index) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if ((partner || layout) && !readOnly()) {
|
||||
@if (!readOnly()) {
|
||||
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
|
||||
{{ isNew() ? 'Crear' : 'Actualizar' }}
|
||||
</button>
|
||||
@@ -171,13 +79,16 @@
|
||||
|
||||
<ng-template #field let-f>
|
||||
@switch (f.type) {
|
||||
@case ('date') {
|
||||
<sbo-date-field
|
||||
@case ('partner') {
|
||||
<sbo-lookup-field
|
||||
[label]="f.label"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly()"
|
||||
[readonly]="readOnly() || !isNew()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
(valueChange)="onPartnerText($event)"
|
||||
[lookup]="partnerLookup"
|
||||
resolveOnLeave
|
||||
(picked)="onPartner($event)"
|
||||
/>
|
||||
}
|
||||
@case ('warehouse') {
|
||||
@@ -191,13 +102,59 @@
|
||||
resolveOnLeave
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<sbo-text-field
|
||||
@case ('date') {
|
||||
<sbo-date-field
|
||||
[label]="f.label"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('number') {
|
||||
<sbo-number-field
|
||||
[label]="f.label"
|
||||
[decimals]="f.decimals"
|
||||
[readonly]="readOnly()"
|
||||
[value]="+str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event ?? 0)"
|
||||
/>
|
||||
}
|
||||
@case ('textarea') {
|
||||
<sbo-textarea-field
|
||||
[label]="f.label"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('folio') {
|
||||
<sbo-field [label]="f.label">
|
||||
<sbo-input
|
||||
width="90px"
|
||||
[maxlength]="10"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.prefixKey])"
|
||||
(valueChange)="setHeader(f.prefixKey, $event)"
|
||||
/>
|
||||
<sbo-input
|
||||
ariaLabel="Número de folio"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.numberKey])"
|
||||
(valueChange)="setHeader(f.numberKey, $event)"
|
||||
/>
|
||||
</sbo-field>
|
||||
}
|
||||
@case ('computed') {
|
||||
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
|
||||
}
|
||||
@default {
|
||||
<sbo-text-field
|
||||
[label]="f.label"
|
||||
[readonly]="readOnly() || !!f.readonly"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
@@ -19,24 +19,17 @@ import {
|
||||
type DocRecord,
|
||||
type NavTarget,
|
||||
computeTotals,
|
||||
MARKETING_LINES,
|
||||
createPayload,
|
||||
isBlankLine,
|
||||
isDocumentReadOnly,
|
||||
taxRateMap,
|
||||
updatePayload,
|
||||
validateDocument,
|
||||
validateLayout,
|
||||
} from './document-model';
|
||||
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
|
||||
import { type ComputedSource, WAREHOUSE_LOOKUP, hasFolio, 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 { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
|
||||
|
||||
/** 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;
|
||||
/** Folio tributario de los documentos que lo llevan. */
|
||||
const FOLIO_KEYS = ['FolioPrefixString', 'FolioNumber'] as const;
|
||||
import { ITEMS_MULTI_ADD } from './line-columns';
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
|
||||
@@ -84,18 +77,12 @@ export class DocumentFormPage {
|
||||
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;
|
||||
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
|
||||
/** 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 ?? MARKETING_LINES;
|
||||
private readonly headerKeys: readonly string[] = this.layout
|
||||
? headerKeysOf(this.layout)
|
||||
: this.screen.folio
|
||||
? [...HEADER_KEYS, ...FOLIO_KEYS]
|
||||
: HEADER_KEYS;
|
||||
protected readonly folio = !!this.screen.folio;
|
||||
private readonly blankHeader: DocRecord = this.layout
|
||||
? this.layout.newHeader()
|
||||
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
||||
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 pick(doc: DocRecord | undefined): DocRecord {
|
||||
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
||||
}
|
||||
@@ -136,7 +123,7 @@ export class DocumentFormPage {
|
||||
|
||||
// --- estado --------------------------------------------------------------------------------
|
||||
|
||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
|
||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew()));
|
||||
protected readonly saving = signal(false);
|
||||
private readonly message = signal<StatusMessage | null>(
|
||||
((): StatusMessage | null => {
|
||||
@@ -152,11 +139,11 @@ export class DocumentFormPage {
|
||||
const message = this.message();
|
||||
if (message) return message;
|
||||
if (this.readOnly()) {
|
||||
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
|
||||
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.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
|
||||
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.' : '' };
|
||||
});
|
||||
@@ -265,12 +252,13 @@ export class DocumentFormPage {
|
||||
|
||||
// --- configuración de la vista --------------------------------------------------------------
|
||||
|
||||
protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
|
||||
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
|
||||
protected readonly recalc = this.layout ? undefined : recalcLine;
|
||||
protected readonly multi: GridMultiAdd = this.layout
|
||||
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
|
||||
: ITEMS_MULTI_ADD;
|
||||
protected readonly columns = this.layout.columns;
|
||||
protected readonly newLine = () => this.layout.newLine(this.header());
|
||||
protected readonly recalc = this.layout.recalc;
|
||||
protected readonly multi: GridMultiAdd = {
|
||||
...ITEMS_MULTI_ADD,
|
||||
toLine: (item) => this.layout.columns[0].lookup!.apply(item, this.layout.newLine(this.header())),
|
||||
};
|
||||
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
|
||||
protected readonly partnerLookup: VhLookup = {
|
||||
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
||||
@@ -286,6 +274,24 @@ export class DocumentFormPage {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -335,15 +341,8 @@ export class DocumentFormPage {
|
||||
|
||||
protected async save(): Promise<void> {
|
||||
if (this.saving() || this.readOnly()) return;
|
||||
const errors = this.layout
|
||||
? validateLayout(
|
||||
this.header(),
|
||||
this.lines(),
|
||||
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
|
||||
this.layout.requiredLine,
|
||||
)
|
||||
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
|
||||
if (this.folio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
|
||||
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;
|
||||
@@ -353,7 +352,7 @@ export class DocumentFormPage {
|
||||
try {
|
||||
const d = this.doc();
|
||||
if (!d) {
|
||||
const header = folioAsNumber(this.layout?.beforeCreate?.(this.header()) ?? this.header());
|
||||
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
|
||||
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
|
||||
await this.router.navigate(['..', created['DocEntry']], {
|
||||
relativeTo: this.route,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LAYOUTS, headerKeysOf } from './document-layouts';
|
||||
import { LAYOUTS, hasFolio, headerKeysOf } from './document-layouts';
|
||||
import { createPayload, updatePayload, validateLayout } from './document-model';
|
||||
import { DocumentFormPage } from './document-form.page';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
@@ -12,18 +12,33 @@ import { DOCUMENT_SCREENS } from './document-screens';
|
||||
describe('modelo de los formularios sin socio', () => {
|
||||
const transfer = LAYOUTS.transferRequest;
|
||||
|
||||
it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
|
||||
const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
|
||||
expect(withLayout.sort()).toEqual(
|
||||
it('cada pantalla tiene su formulario, declarado por ella', () => {
|
||||
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
|
||||
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
|
||||
const without = DOCUMENT_SCREENS.filter((s) => !s.partner).map((s) => s.resource);
|
||||
expect(withPartner).toHaveLength(12);
|
||||
expect(without.sort()).toEqual(
|
||||
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
||||
);
|
||||
expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
|
||||
for (const l of Object.values(LAYOUTS)) {
|
||||
for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
|
||||
for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
|
||||
it('cada formulario declara solo sus campos: la orden de venta no lleva folio y la factura sí', () => {
|
||||
const by = (r: string) => DOCUMENT_SCREENS.find((s) => s.resource === r)!.layout;
|
||||
expect(hasFolio(by('Orders'))).toBe(false);
|
||||
expect(hasFolio(by('PurchaseQuotations'))).toBe(false);
|
||||
for (const r of ['Invoices', 'PurchaseInvoices', 'CreditNotes', 'PurchaseCreditNotes', 'DeliveryNotes', 'PurchaseDeliveryNotes']) {
|
||||
expect(hasFolio(by(r))).toBe(true);
|
||||
}
|
||||
expect(headerKeysOf(by('Invoices'))).toEqual(expect.arrayContaining(['FolioPrefixString', 'FolioNumber']));
|
||||
expect(headerKeysOf(by('Orders'))).not.toContain('FolioNumber');
|
||||
});
|
||||
|
||||
it('la cabecera y el pie tienen como máximo dos columnas y no usan campos de usuario', () => {
|
||||
for (const s of DOCUMENT_SCREENS) {
|
||||
for (const k of headerKeysOf(s.layout)) expect(k.startsWith('U_')).toBe(false);
|
||||
for (const c of s.layout.columns) expect(c.key.startsWith('U_')).toBe(false);
|
||||
expect(Object.keys(s.layout.header)).toEqual(['left', 'right']);
|
||||
expect(Object.keys(s.layout.footer)).toEqual(['left', 'right']);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,39 +1,79 @@
|
||||
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
|
||||
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
|
||||
import type { DocRecord, LinesSpec } from './document-model';
|
||||
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
|
||||
|
||||
export interface HeaderField {
|
||||
key: string;
|
||||
label: string;
|
||||
/** `warehouse` abre la lista de almacenes. */
|
||||
type: 'text' | 'date' | 'warehouse';
|
||||
required?: boolean;
|
||||
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
|
||||
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
|
||||
|
||||
/**
|
||||
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
|
||||
* de campos y el formulario base los dibuja: no hay HTML por documento.
|
||||
*/
|
||||
export type HeaderField =
|
||||
| { type: 'text'; key: string; label: string; readonly?: boolean }
|
||||
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
|
||||
| { type: 'number'; key: string; label: string; decimals?: number }
|
||||
| { type: 'textarea'; key: string; label: string }
|
||||
/** Abre la lista de almacenes. */
|
||||
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
|
||||
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
|
||||
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
|
||||
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
|
||||
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
|
||||
| { type: 'computed'; label: string; source: ComputedSource };
|
||||
|
||||
export interface FieldColumns {
|
||||
/** Dos columnas como máximo (regla del proyecto base). */
|
||||
left: readonly HeaderField[];
|
||||
right: readonly HeaderField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
|
||||
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
|
||||
* Formulario de un documento: qué campos de cabecera y de pie, qué columnas de línea y cómo se
|
||||
* guarda. El formulario base lo dibuja sin saber de qué documento se trata.
|
||||
*/
|
||||
export interface DocumentLayout {
|
||||
lines: LinesSpec;
|
||||
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
|
||||
left: readonly HeaderField[];
|
||||
right: readonly HeaderField[];
|
||||
header: FieldColumns;
|
||||
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
|
||||
footer: FieldColumns;
|
||||
columns: readonly GridColumn[];
|
||||
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
|
||||
newHeader: () => DocRecord;
|
||||
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
|
||||
newLine: (header: DocRecord) => GridLine;
|
||||
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
|
||||
recalc?: (line: GridLine, changedKey: string) => GridLine;
|
||||
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
|
||||
immutable?: boolean;
|
||||
/** Ajustes a la cabecera antes de crear. */
|
||||
beforeCreate?: (header: DocRecord) => DocRecord;
|
||||
requiredLine: readonly RequiredField[];
|
||||
requiredLine: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
// --- catálogo de campos ---------------------------------------------------------------------
|
||||
|
||||
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
|
||||
export const FIELDS = {
|
||||
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
|
||||
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
|
||||
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
|
||||
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
|
||||
comments: { type: 'textarea', key: 'Comments', label: 'Comentarios' },
|
||||
numAtCard: { type: 'text', key: 'NumAtCard', label: 'Nº referencia' },
|
||||
folio: { type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' },
|
||||
fromWarehouse: { type: 'warehouse', key: 'FromWarehouse', label: 'Almacén origen', required: true, missing: 'el almacén origen' },
|
||||
toWarehouse: { type: 'warehouse', key: 'ToWarehouse', label: 'Almacén destino', required: true, missing: 'el almacén destino' },
|
||||
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
|
||||
} as const satisfies Record<string, HeaderField>;
|
||||
|
||||
const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
|
||||
|
||||
// --- columnas de línea compartidas ----------------------------------------------------------
|
||||
|
||||
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
||||
|
||||
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
||||
@@ -50,11 +90,55 @@ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
|
||||
|
||||
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
||||
|
||||
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
|
||||
|
||||
/**
|
||||
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
|
||||
* comentarios, descuento y totales. `left` y `right` son los campos que esa pantalla agrega a la
|
||||
* cabecera (por ejemplo `FIELDS.folio` en las facturas); sin ellos, la cabecera base.
|
||||
*/
|
||||
export function marketingLayout(opts: {
|
||||
partnerLabel: string;
|
||||
taxResource: string;
|
||||
left?: readonly HeaderField[];
|
||||
right?: readonly HeaderField[];
|
||||
}): DocumentLayout {
|
||||
return {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'], base: true },
|
||||
header: {
|
||||
left: [
|
||||
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
|
||||
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
|
||||
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
|
||||
FIELDS.numAtCard,
|
||||
...(opts.left ?? []),
|
||||
],
|
||||
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
|
||||
},
|
||||
footer: {
|
||||
left: [FIELDS.comments],
|
||||
right: [
|
||||
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
||||
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
||||
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
||||
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
||||
],
|
||||
},
|
||||
columns: lineColumnsFor(opts.taxResource),
|
||||
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
|
||||
newLine: newDocumentLine,
|
||||
recalc: recalcLine,
|
||||
requiredLine: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- documentos sin socio ni totales (inventario y solicitudes de compra) -------------------
|
||||
|
||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||
const goods: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
||||
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
right: [],
|
||||
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
|
||||
footer: commentsOnly,
|
||||
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
|
||||
newHeader: () => ({ DocDate: today() }),
|
||||
newLine: () => ({ ...blankLine(), Price: null }),
|
||||
@@ -65,11 +149,8 @@ const goods: DocumentLayout = {
|
||||
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
|
||||
const transfer = (immutable: boolean): DocumentLayout => ({
|
||||
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
|
||||
left: [
|
||||
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
|
||||
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
|
||||
],
|
||||
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
|
||||
footer: commentsOnly,
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
@@ -99,11 +180,8 @@ const transfer = (immutable: boolean): DocumentLayout => ({
|
||||
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
||||
const purchaseRequest: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
||||
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
|
||||
right: [
|
||||
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
|
||||
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
|
||||
],
|
||||
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
|
||||
footer: commentsOnly,
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
@@ -143,9 +221,34 @@ export const LAYOUTS = {
|
||||
purchaseRequest,
|
||||
} as const;
|
||||
|
||||
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
|
||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
|
||||
...layout.left.map((f) => f.key),
|
||||
...layout.right.map((f) => f.key),
|
||||
'Comments',
|
||||
// --- consultas sobre un layout --------------------------------------------------------------
|
||||
|
||||
const allFields = (layout: DocumentLayout): HeaderField[] => [
|
||||
...layout.header.left,
|
||||
...layout.header.right,
|
||||
...layout.footer.left,
|
||||
...layout.footer.right,
|
||||
];
|
||||
|
||||
/** Campos de cabecera que se editan (y se envían a SAP). */
|
||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
|
||||
allFields(layout).flatMap((f) => {
|
||||
switch (f.type) {
|
||||
case 'computed':
|
||||
return [];
|
||||
case 'folio':
|
||||
return [f.prefixKey, f.numberKey];
|
||||
default:
|
||||
return [f.key];
|
||||
}
|
||||
});
|
||||
|
||||
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
|
||||
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
|
||||
allFields(layout).flatMap((f) =>
|
||||
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner') && f.required
|
||||
? [{ key: f.key, label: f.missing ?? f.label }]
|
||||
: [],
|
||||
);
|
||||
|
||||
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
navigationQuery,
|
||||
taxRateMap,
|
||||
updatePayload,
|
||||
validateDocument,
|
||||
validateLayout,
|
||||
} from './document-model';
|
||||
|
||||
const line = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -52,15 +52,19 @@ describe('computeTotals', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDocument', () => {
|
||||
describe('validateLayout', () => {
|
||||
const header = { CardCode: 'C1', DocDate: '2026-01-05' };
|
||||
const required = [
|
||||
{ key: 'CardCode', label: 'el cliente' },
|
||||
{ key: 'DocDate', label: 'la fecha de contabilización' },
|
||||
];
|
||||
|
||||
it('documento correcto no tiene errores', () => {
|
||||
expect(validateDocument(header, [line()], 'Cliente')).toEqual([]);
|
||||
expect(validateLayout(header, [line()], required, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('exige socio, fecha y al menos una línea con artículo', () => {
|
||||
expect(validateDocument({}, [line({ ItemCode: '' })], 'Cliente')).toEqual([
|
||||
it('exige los campos obligatorios y al menos una línea con artículo', () => {
|
||||
expect(validateLayout({}, [line({ ItemCode: '' })], required, [])).toEqual([
|
||||
'Indique el cliente.',
|
||||
'Indique la fecha de contabilización.',
|
||||
'Agregue al menos una línea con artículo.',
|
||||
@@ -68,7 +72,7 @@ describe('validateDocument', () => {
|
||||
});
|
||||
|
||||
it('exige cantidad mayor que cero (numerando solo las líneas reales)', () => {
|
||||
expect(validateDocument(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], 'Cliente')).toEqual([
|
||||
expect(validateLayout(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], required, [])).toEqual([
|
||||
'Línea 1: la cantidad debe ser mayor que cero.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -97,19 +97,6 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
|
||||
});
|
||||
}
|
||||
|
||||
/** Errores que se pueden detectar sin preguntarle a SAP. Lista vacía = se puede guardar. */
|
||||
export function validateDocument(header: DocRecord, lines: readonly GridLine[], partnerLabel: string): string[] {
|
||||
const errors: string[] = [];
|
||||
if (isEmpty(header['CardCode'])) errors.push(`Indique el ${partnerLabel.toLowerCase()}.`);
|
||||
if (isEmpty(header['DocDate'])) errors.push('Indique la fecha de contabilización.');
|
||||
const real = lines.filter((l) => !isBlankLine(l));
|
||||
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
|
||||
real.forEach((l, i) => {
|
||||
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Un campo obligatorio y su nombre para el aviso. */
|
||||
export interface RequiredField {
|
||||
key: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
|
||||
import { formatNumber } from '../../core/format';
|
||||
import type { ListFilter } from '../../core/sl/list-filters';
|
||||
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
||||
import { type DocumentLayout, LAYOUTS } from './document-layouts';
|
||||
import { type DocumentLayout, FIELDS, LAYOUTS, type HeaderField, marketingLayout } from './document-layouts';
|
||||
|
||||
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||
export interface DocumentScreen {
|
||||
@@ -24,10 +24,8 @@ export interface DocumentScreen {
|
||||
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
||||
taxResource: string;
|
||||
};
|
||||
/** El formulario muestra el folio tributario (`FolioPrefixString`, `FolioNumber`) y el RUT del socio. */
|
||||
folio?: boolean;
|
||||
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
|
||||
layout?: DocumentLayout;
|
||||
/** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */
|
||||
layout: DocumentLayout;
|
||||
}
|
||||
|
||||
export const menuLabel = (path: string): string =>
|
||||
@@ -110,15 +108,26 @@ const currencyFilter: ListFilter = {
|
||||
labelField: 'Name',
|
||||
};
|
||||
|
||||
/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */
|
||||
interface MarketingExtras {
|
||||
columns?: readonly ListColumn[];
|
||||
headerLeft?: readonly HeaderField[];
|
||||
headerRight?: readonly HeaderField[];
|
||||
}
|
||||
|
||||
/** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */
|
||||
const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] };
|
||||
|
||||
/** Ventas y compras: documentos con socio de negocio y total. */
|
||||
const marketing = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
partner: string,
|
||||
extraColumns: readonly ListColumn[] = [],
|
||||
extras: MarketingExtras = {},
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }),
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
@@ -129,7 +138,7 @@ const marketing = (
|
||||
columns: [
|
||||
docNum,
|
||||
statusColumn,
|
||||
...extraColumns,
|
||||
...(extras.columns ?? []),
|
||||
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
||||
{ key: 'CardName', title: 'Nombre' },
|
||||
docDate,
|
||||
@@ -167,24 +176,14 @@ const inventory = (
|
||||
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
|
||||
});
|
||||
|
||||
/** Documentos tributarios: llevan folio y RUT. */
|
||||
const FOLIO_PATHS: ReadonlySet<string> = new Set([
|
||||
'/ventas/entrega',
|
||||
'/ventas/facturas',
|
||||
'/ventas/notas-credito',
|
||||
'/compras/recepcion',
|
||||
'/compras/facturas',
|
||||
'/compras/notas-credito',
|
||||
]);
|
||||
|
||||
const SCREENS: readonly DocumentScreen[] = [
|
||||
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
|
||||
// Ventas
|
||||
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
|
||||
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
|
||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente'),
|
||||
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', invoiceColumns),
|
||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
|
||||
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
|
||||
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente'),
|
||||
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
|
||||
// Compras
|
||||
{
|
||||
path: '/compras/solicitudes',
|
||||
@@ -204,15 +203,13 @@ const SCREENS: readonly DocumentScreen[] = [
|
||||
},
|
||||
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
|
||||
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
|
||||
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor'),
|
||||
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', invoiceColumns),
|
||||
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT),
|
||||
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
|
||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT),
|
||||
// Inventario
|
||||
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
|
||||
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
|
||||
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
|
||||
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
|
||||
];
|
||||
|
||||
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = SCREENS.map((s) => (FOLIO_PATHS.has(s.path) ? { ...s, folio: true } : s));
|
||||
Reference in new issue
Block a user