@asteby/metacore-runtime-react 49.1.0 → 49.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/action-modal-dispatcher.d.ts +1 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +34 -5
- package/dist/business/document-editor-model.d.ts +27 -1
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +104 -7
- package/dist/business/document-editor-panels.d.ts +18 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +9 -0
- package/dist/business/document-editor.d.ts +7 -1
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +106 -41
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +8 -3
- package/dist/dynamic-row-actions.d.ts +6 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/record-mutation-events.d.ts +12 -0
- package/dist/record-mutation-events.d.ts.map +1 -1
- package/dist/record-mutation-events.js +18 -0
- package/package.json +1 -1
- package/src/__tests__/document-editor-model.test.ts +27 -0
- package/src/__tests__/document-editor.test.tsx +60 -0
- package/src/__tests__/invoice-document-editor.test.tsx +267 -0
- package/src/__tests__/r5-self-label-settled.test.ts +57 -0
- package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
- package/src/__tests__/row-action-link-navigation.test.ts +10 -6
- package/src/action-modal-dispatcher.tsx +34 -5
- package/src/business/document-editor-model.ts +118 -7
- package/src/business/document-editor-panels.tsx +49 -0
- package/src/business/document-editor.tsx +288 -177
- package/src/document-form-dialog.tsx +13 -4
- package/src/dynamic-row-actions.tsx +12 -4
- package/src/index.ts +2 -0
- package/src/record-mutation-events.ts +23 -0
|
@@ -54,7 +54,9 @@ function declaredDefault(f: ActionFieldDef): unknown {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
|
|
57
|
-
* - campos de extensión (`fiscal_data.*`) que aportan otros addons
|
|
57
|
+
* - campos de extensión (`fiscal_data.*`) que aportan otros addons (los que
|
|
58
|
+
* el propio tipo declara obligatorios, p. ej. método y forma de pago de la
|
|
59
|
+
* factura, se marcan esenciales en splitEditorFields);
|
|
58
60
|
* - `section: "fiscal" | "advanced"`;
|
|
59
61
|
* - catálogos opcionales que ya traen un default (método, serie, relación…):
|
|
60
62
|
* el usuario no necesita tocarlos para guardar.
|
|
@@ -84,7 +86,10 @@ export function splitEditorFields(
|
|
|
84
86
|
fields: readonly ActionFieldDef[],
|
|
85
87
|
opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
|
|
86
88
|
): EditorFieldGroups {
|
|
87
|
-
|
|
89
|
+
// Lo obligatorio que el tipo declara siempre está a la vista, aunque sea de
|
|
90
|
+
// extensión (`fiscal_data.metodo_pago`): es lo que el usuario debe decidir.
|
|
91
|
+
// Las extensiones que llegan del metadata (no declaradas) siguen plegadas.
|
|
92
|
+
const essentialKeys = new Set([...(opts.essentialKeys ?? []), ...fields.filter((f) => f.required).map((f) => f.key)])
|
|
88
93
|
const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
|
|
89
94
|
const seen = new Set<string>()
|
|
90
95
|
for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
|
|
@@ -200,7 +205,7 @@ export function creditStatus(
|
|
|
200
205
|
export function linesFromSource(
|
|
201
206
|
rows: unknown,
|
|
202
207
|
map: Record<string, string> = {},
|
|
203
|
-
opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
|
|
208
|
+
opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode']; defaultTaxRate?: number } = {},
|
|
204
209
|
): LineItem[] {
|
|
205
210
|
if (!Array.isArray(rows)) return []
|
|
206
211
|
return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
|
|
@@ -212,13 +217,18 @@ export function linesFromSource(
|
|
|
212
217
|
if (!hasValue(rate)) {
|
|
213
218
|
const sub = toAmount(get('subtotal'))
|
|
214
219
|
const tax = toAmount(get('tax_amount'))
|
|
215
|
-
|
|
220
|
+
// Un origen sin dato de impuesto (p. ej. una OT de taller: solo
|
|
221
|
+
// cantidad y precio) toma la tasa de la org, como un renglón nuevo.
|
|
222
|
+
rate = hasValue(get('tax_amount'))
|
|
223
|
+
? sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
|
|
224
|
+
: opts.defaultTaxRate ?? 0
|
|
216
225
|
}
|
|
217
226
|
const r2 = toAmount(rate)
|
|
227
|
+
const product = get('product_id')
|
|
218
228
|
const line = makeLine({
|
|
219
|
-
product_id:
|
|
229
|
+
product_id: refValue(product),
|
|
220
230
|
sku: get('sku') ?? undefined,
|
|
221
|
-
description: String(get('description') ?? get('product_name') ?? ''),
|
|
231
|
+
description: String(get('description') ?? get('product_name') ?? refLabel(product) ?? refLabel(r.product) ?? ''),
|
|
222
232
|
quantity: qty,
|
|
223
233
|
unit_price: toAmount(get('unit_price')),
|
|
224
234
|
discount: toAmount(get('discount') ?? 0),
|
|
@@ -236,11 +246,40 @@ export function linesFromSource(
|
|
|
236
246
|
})
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/** Id de una celda de relación (`{value,label}`, `{id,name}` o el id plano). */
|
|
250
|
+
function refValue(v: unknown): string | undefined {
|
|
251
|
+
if (v == null || v === '') return undefined
|
|
252
|
+
if (typeof v === 'object') {
|
|
253
|
+
const o = v as Record<string, unknown>
|
|
254
|
+
const id = o.value ?? o.id
|
|
255
|
+
return id == null || id === '' ? undefined : String(id)
|
|
256
|
+
}
|
|
257
|
+
return String(v)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Nombre de una celda de relación ya resuelta por el host (`{label}`/`{name}`).
|
|
262
|
+
* Un renglón de origen sin descripción propia (InvoiceItem solo lleva el
|
|
263
|
+
* producto) se nombra con su producto en vez de entrar en blanco.
|
|
264
|
+
*/
|
|
265
|
+
function refLabel(v: unknown): string | undefined {
|
|
266
|
+
if (!v || typeof v !== 'object') return undefined
|
|
267
|
+
const o = v as Record<string, unknown>
|
|
268
|
+
const l = o.label ?? o.name
|
|
269
|
+
return typeof l === 'string' && l.trim() ? l : undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
function isFullyConsumed(raw: unknown): boolean {
|
|
240
273
|
const r = (raw ?? {}) as Record<string, any>
|
|
241
274
|
return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
|
|
242
275
|
}
|
|
243
276
|
|
|
277
|
+
/** El editor ocupa toda la pantalla: documentos comerciales con renglones (factura, cotización, pedido, OC). */
|
|
278
|
+
export function isFullscreenEditor(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): boolean {
|
|
279
|
+
const kind = editorLinesConfig(type, forms)?.kind ?? 'sale'
|
|
280
|
+
return !!type.lines && (kind === 'sale' || kind === 'purchase')
|
|
281
|
+
}
|
|
282
|
+
|
|
244
283
|
/** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
|
|
245
284
|
export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
|
|
246
285
|
if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
|
|
@@ -253,6 +292,74 @@ export function sourceTracksRemaining(src: Pick<DocumentFormSource, 'line_link_f
|
|
|
253
292
|
return !!(src.line_link_field || src.remaining_qty_field || src.remaining_endpoint)
|
|
254
293
|
}
|
|
255
294
|
|
|
295
|
+
/** Opción ya resuelta de un selector de la cabecera que el editor llenó por código. */
|
|
296
|
+
export interface SeedLabel {
|
|
297
|
+
value: string
|
|
298
|
+
label: string
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Columnas con las que se nombra un documento (folio antes que nombre). */
|
|
302
|
+
const DOCUMENT_LABEL_COLUMNS = ['number', 'folio', 'order_number', 'name', 'title', 'code']
|
|
303
|
+
|
|
304
|
+
function declaredOptionLabel(f: ActionFieldDef | undefined): string | undefined {
|
|
305
|
+
const opts = (f as { options?: unknown } | undefined)?.options
|
|
306
|
+
const l = opts && !Array.isArray(opts) && typeof opts === 'object' ? (opts as { label?: unknown }).label : undefined
|
|
307
|
+
return typeof l === 'string' && l ? l : undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Nombre visible de un registro: la columna declarada por el campo, luego folio/nombre. */
|
|
311
|
+
export function recordLabel(rec: Record<string, any> | null | undefined, preferred?: string): string | undefined {
|
|
312
|
+
if (!rec) return undefined
|
|
313
|
+
for (const c of preferred ? [preferred, ...DOCUMENT_LABEL_COLUMNS] : DOCUMENT_LABEL_COLUMNS) {
|
|
314
|
+
const v = rec[c]
|
|
315
|
+
if ((typeof v === 'string' && v.trim()) || typeof v === 'number') return String(v)
|
|
316
|
+
}
|
|
317
|
+
return undefined
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Etiquetas de lo que «Cargar desde…» escribe en la cabecera, para que el
|
|
322
|
+
* selector muestre el nombre y no el UUID sin abrirse:
|
|
323
|
+
* - `header` (`customer_id ← customer_id`): el objeto hermano `{value,label}`
|
|
324
|
+
* que el host sirve junto a la FK (`customer`);
|
|
325
|
+
* - `link_field`: el propio documento origen, con su folio.
|
|
326
|
+
*/
|
|
327
|
+
export function sourceHeaderSeeds(
|
|
328
|
+
src: Pick<DocumentFormSource, 'header' | 'link_field'>,
|
|
329
|
+
rec: Record<string, any>,
|
|
330
|
+
id: string,
|
|
331
|
+
fields: readonly ActionFieldDef[] = [],
|
|
332
|
+
): Record<string, SeedLabel> {
|
|
333
|
+
const out: Record<string, SeedLabel> = {}
|
|
334
|
+
for (const [to, from] of Object.entries(src.header ?? {})) {
|
|
335
|
+
if (!from.endsWith('_id')) continue
|
|
336
|
+
const v = refValue(rec[from])
|
|
337
|
+
const label = refLabel(rec[from]) ?? refLabel(rec[from.slice(0, -3)])
|
|
338
|
+
if (v && label) out[to] = { value: v, label }
|
|
339
|
+
}
|
|
340
|
+
if (src.link_field) {
|
|
341
|
+
const label = recordLabel(rec, declaredOptionLabel(fields.find((f) => f.key === src.link_field)))
|
|
342
|
+
if (label) out[src.link_field] = { value: id, label }
|
|
343
|
+
}
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Registro para `renderField` con las etiquetas sembradas como objeto hermano
|
|
349
|
+
* (`customer_id` → `customer: {value,label}`), el mismo contrato que sirve el
|
|
350
|
+
* host en una fila. Conserva el registro desde el que se abrió.
|
|
351
|
+
*/
|
|
352
|
+
export function seedRecord(
|
|
353
|
+
seeds: Record<string, SeedLabel>,
|
|
354
|
+
record?: Record<string, any>,
|
|
355
|
+
): Record<string, any> | undefined {
|
|
356
|
+
const keys = Object.keys(seeds).filter((k) => k.endsWith('_id'))
|
|
357
|
+
if (keys.length === 0) return record
|
|
358
|
+
const out: Record<string, any> = { ...(record ?? {}) }
|
|
359
|
+
for (const k of keys) out[k.slice(0, -3)] = seeds[k]
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
256
363
|
export interface EditorIssue {
|
|
257
364
|
field?: string
|
|
258
365
|
severity: 'error' | 'warning'
|
|
@@ -267,7 +374,11 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
|
|
|
267
374
|
issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
|
|
268
375
|
}
|
|
269
376
|
items.forEach((l, i) => {
|
|
270
|
-
|
|
377
|
+
// Un renglón con producto se describe con él (el servidor lo nombra
|
|
378
|
+
// desde el catálogo): solo un renglón libre exige texto, como en el
|
|
379
|
+
// editor de renglones (validateLineItems).
|
|
380
|
+
if (!l.description.trim() && !l.product_id)
|
|
381
|
+
issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: elige un producto o escribe la descripción.` })
|
|
271
382
|
if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
|
|
272
383
|
if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
|
|
273
384
|
issues.push({
|
|
@@ -7,6 +7,7 @@ import { Badge } from '@asteby/metacore-ui/primitives'
|
|
|
7
7
|
import { AlertTriangle, ChevronDown, XCircle } from 'lucide-react'
|
|
8
8
|
import type { CreditStatus, EditorIssue } from './document-editor-model'
|
|
9
9
|
import type { Formatter } from './format'
|
|
10
|
+
import { computeLine, type LineItem } from './line-items'
|
|
10
11
|
|
|
11
12
|
/** Bloque con título discreto y espaciado generoso (sin bordes pesados). */
|
|
12
13
|
export function EditorSection({ title, hint, children, slot }: { title?: ReactNode; hint?: ReactNode; children: ReactNode; slot?: string }) {
|
|
@@ -162,3 +163,51 @@ export function PreviewPanel({ preview }: { preview: PreviewContent | null }) {
|
|
|
162
163
|
if (preview.xml) return <pre className="max-h-96 overflow-auto rounded bg-muted p-3 text-[11px] leading-tight" data-slot="preview-xml">{preview.xml}</pre>
|
|
163
164
|
return null
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
export interface DraftPreviewProps {
|
|
168
|
+
title: string
|
|
169
|
+
party?: string
|
|
170
|
+
/** Datos del encabezado ya con su etiqueta y valor legibles. */
|
|
171
|
+
header: Array<{ label: string; value: string }>
|
|
172
|
+
lines: LineItem[]
|
|
173
|
+
totals: TotalsRow[]
|
|
174
|
+
fmt: Formatter
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Vista previa local del documento (sin servidor ni borrador): cómo queda con
|
|
179
|
+
* lo capturado. Para tipos sin acción `preview`; la del servidor (PDF/XML) gana.
|
|
180
|
+
*/
|
|
181
|
+
export function DraftPreview({ title, party, header, lines, totals, fmt }: DraftPreviewProps) {
|
|
182
|
+
const items = lines.filter((l) => l.kind === 'item')
|
|
183
|
+
return (
|
|
184
|
+
<article className="space-y-3 rounded border bg-background p-4 text-xs" data-slot="preview-draft">
|
|
185
|
+
<header className="flex flex-wrap items-baseline justify-between gap-2 border-b pb-2">
|
|
186
|
+
<span className="text-sm font-semibold">{title}</span>
|
|
187
|
+
{party && <span className="font-medium">{party}</span>}
|
|
188
|
+
</header>
|
|
189
|
+
{header.length > 0 && (
|
|
190
|
+
<dl className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
191
|
+
{header.map((h) => (
|
|
192
|
+
<div key={h.label} className="min-w-0">
|
|
193
|
+
<dt className="text-muted-foreground">{h.label}</dt>
|
|
194
|
+
<dd className="truncate">{h.value}</dd>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</dl>
|
|
198
|
+
)}
|
|
199
|
+
<table className="w-full tabular-nums">
|
|
200
|
+
<tbody>
|
|
201
|
+
{items.map((l) => (
|
|
202
|
+
<tr key={l.key} className="border-t align-top">
|
|
203
|
+
<td className="py-1 pr-2">{l.description}</td>
|
|
204
|
+
<td className="py-1 pr-2 text-right">{l.quantity}</td>
|
|
205
|
+
<td className="py-1 text-right">{fmt.money(computeLine(l).net)}</td>
|
|
206
|
+
</tr>
|
|
207
|
+
))}
|
|
208
|
+
</tbody>
|
|
209
|
+
</table>
|
|
210
|
+
<TotalsPanel rows={totals} />
|
|
211
|
+
</article>
|
|
212
|
+
)
|
|
213
|
+
}
|