@asteby/metacore-runtime-react 47.1.0 → 48.0.0
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 +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +445 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -7
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +43 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +78 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +31 -12
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +29 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +117 -39
- package/src/dynamic-kanban.tsx +34 -7
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/index.ts +21 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +208 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +101 -0
|
@@ -31,17 +31,21 @@ export function applyProductToLine(
|
|
|
31
31
|
const list = variant?.price ?? product.price
|
|
32
32
|
const cost = variant?.cost ?? product.cost
|
|
33
33
|
const catalog = source === 'cost' ? (cost ?? list) : list
|
|
34
|
+
// Nunca cantidad 0: un renglón vacío (o con la celda borrada) entra con 1.
|
|
35
|
+
const qty = opts.quantity ?? toAmount(line.quantity)
|
|
34
36
|
return {
|
|
35
37
|
...line,
|
|
36
38
|
kind: 'item',
|
|
37
39
|
product_id: variant?.id ?? product.id,
|
|
38
40
|
sku: variant?.sku ?? product.sku,
|
|
39
41
|
description: variant ? `${product.name} · ${variant.label}` : product.name,
|
|
40
|
-
quantity:
|
|
42
|
+
quantity: qty !== 0 ? qty : 1,
|
|
41
43
|
unit_price: priceFromProduct(product, variant, source),
|
|
42
44
|
catalog_price: catalog,
|
|
43
45
|
cost,
|
|
44
46
|
tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
|
|
47
|
+
unit: product.unit ?? line.unit,
|
|
48
|
+
extensions: product.extensions ? { ...(line.extensions ?? {}), ...product.extensions } : line.extensions,
|
|
45
49
|
available: availableStock(src, opts.warehouseId) ?? availableStock(product, opts.warehouseId),
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/business/index.ts
CHANGED
|
@@ -19,3 +19,8 @@ export * from './inspection-checklist'
|
|
|
19
19
|
export * from './vehicle-picker'
|
|
20
20
|
export * from './relate-documents'
|
|
21
21
|
export * from './print-send-dialog'
|
|
22
|
+
export * from './catalog-product-search'
|
|
23
|
+
export * from './document-editor-model'
|
|
24
|
+
export * from './document-editor-panels'
|
|
25
|
+
export * from './payment-allocator'
|
|
26
|
+
export * from './document-editor'
|
|
@@ -37,6 +37,13 @@ export interface LineItem {
|
|
|
37
37
|
dot?: string
|
|
38
38
|
/** Existencia disponible conocida (para la política de sobreventa). */
|
|
39
39
|
available?: number
|
|
40
|
+
/**
|
|
41
|
+
* Campos de extensión del renglón que aportan OTROS addons (p. ej.
|
|
42
|
+
* fiscal_mexico: `mx_clave_prod_serv`, `mx_clave_unidad`), copiados del
|
|
43
|
+
* producto al elegirlo. Viajan en `fiscal_data` (la bolsa de ModelExtension
|
|
44
|
+
* del host) — el SDK no conoce sus claves.
|
|
45
|
+
*/
|
|
46
|
+
extensions?: Record<string, string>
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/** Qué hacer si `quantity > available`. */
|
|
@@ -214,6 +221,7 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
|
|
|
214
221
|
const v = l[k]
|
|
215
222
|
if (typeof v === 'string' && v.trim()) out[k] = v.trim()
|
|
216
223
|
}
|
|
224
|
+
if (l.extensions && Object.keys(l.extensions).length > 0) out.fiscal_data = { ...l.extensions }
|
|
217
225
|
}
|
|
218
226
|
return out
|
|
219
227
|
})
|
|
@@ -231,7 +239,7 @@ export function parseLineItems(rows: unknown): LineItem[] {
|
|
|
231
239
|
product_id: str(o.product_id),
|
|
232
240
|
sku: str(o.sku),
|
|
233
241
|
description: typeof o.description === 'string' ? o.description : '',
|
|
234
|
-
quantity: o.quantity == null ? 1 : toAmount(o.quantity),
|
|
242
|
+
quantity: o.quantity == null || o.quantity === '' ? 1 : toAmount(o.quantity),
|
|
235
243
|
unit_price: toAmount(o.unit_price),
|
|
236
244
|
discount: toAmount(o.discount),
|
|
237
245
|
discount_kind: o.discount_kind === 'amount' ? 'amount' : undefined,
|
|
@@ -243,6 +251,12 @@ export function parseLineItems(rows: unknown): LineItem[] {
|
|
|
243
251
|
technician_id: str(o.technician_id),
|
|
244
252
|
lot: str(o.lot),
|
|
245
253
|
dot: str(o.dot),
|
|
254
|
+
extensions:
|
|
255
|
+
o.fiscal_data && typeof o.fiscal_data === 'object'
|
|
256
|
+
? (Object.fromEntries(
|
|
257
|
+
Object.entries(o.fiscal_data as Record<string, unknown>).filter(([, v]) => typeof v === 'string'),
|
|
258
|
+
) as Record<string, string>)
|
|
259
|
+
: undefined,
|
|
246
260
|
})
|
|
247
261
|
})
|
|
248
262
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// PaymentAllocator — UI del reparto de un pago entre los documentos abiertos de
|
|
2
|
+
// la contraparte (cobro multi-factura / REP, CxP «Aplicar pago»). Las reglas
|
|
3
|
+
// son las de primitives/allocation (allocatePayment / validateAllocation); aquí
|
|
4
|
+
// solo se pintan y se editan. Por defecto reparte solo, del más vencido al más
|
|
5
|
+
// reciente; al teclear un monto pasa a manual sin perder lo ya repartido.
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives'
|
|
8
|
+
import { Input } from '@asteby/metacore-ui'
|
|
9
|
+
import type { AllocationResult, AllocationStrategy, OpenDocument } from '../primitives/allocation'
|
|
10
|
+
import { manualFromAllocations, overdueDays } from './document-editor-model'
|
|
11
|
+
import { useFormatter } from './format'
|
|
12
|
+
|
|
13
|
+
export interface PaymentAllocatorValue {
|
|
14
|
+
strategy: AllocationStrategy
|
|
15
|
+
manual: Record<string, number>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaymentAllocatorProps {
|
|
19
|
+
documents: OpenDocument[]
|
|
20
|
+
/** Resultado de `allocatePayment(amount, documents, value.strategy, value.manual)`. */
|
|
21
|
+
result: AllocationResult
|
|
22
|
+
value: PaymentAllocatorValue
|
|
23
|
+
onChange: (next: PaymentAllocatorValue) => void
|
|
24
|
+
currency?: string
|
|
25
|
+
loading?: boolean
|
|
26
|
+
readOnly?: boolean
|
|
27
|
+
/** Hoy (para los días de atraso; inyectable en tests). */
|
|
28
|
+
today?: Date
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const num = (s: string): number => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0)
|
|
32
|
+
|
|
33
|
+
export function PaymentAllocator({ documents, result, value, onChange, currency, loading, readOnly, today }: PaymentAllocatorProps) {
|
|
34
|
+
const { t } = useTranslation()
|
|
35
|
+
const fmt = useFormatter({ currency })
|
|
36
|
+
const byDoc = new Map(result.allocations.map((a) => [a.document_id, a]))
|
|
37
|
+
|
|
38
|
+
if (loading) {
|
|
39
|
+
return <p className="text-sm text-muted-foreground">{t('paymentAllocator.loading', { defaultValue: 'Buscando documentos con saldo…' })}</p>
|
|
40
|
+
}
|
|
41
|
+
if (documents.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<p className="text-sm text-muted-foreground" data-slot="payment-allocator-empty">
|
|
44
|
+
{t('paymentAllocator.empty', { defaultValue: 'No hay documentos con saldo pendiente.' })}
|
|
45
|
+
</p>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const setAmount = (id: string, amount: number) =>
|
|
50
|
+
onChange({ strategy: 'manual', manual: { ...manualFromAllocations(result.allocations), [id]: amount } })
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="space-y-2" data-slot="payment-allocator">
|
|
54
|
+
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
|
55
|
+
<span>
|
|
56
|
+
{value.strategy === 'manual'
|
|
57
|
+
? t('paymentAllocator.manual', { defaultValue: 'Reparto manual' })
|
|
58
|
+
: t('paymentAllocator.auto', { defaultValue: 'Se aplica primero a lo más vencido' })}
|
|
59
|
+
</span>
|
|
60
|
+
{!readOnly && value.strategy === 'manual' && (
|
|
61
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => onChange({ strategy: 'oldest_due_first', manual: {} })}>
|
|
62
|
+
{t('paymentAllocator.autoapply', { defaultValue: 'Autoaplicar' })}
|
|
63
|
+
</Button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
<ul className="divide-y rounded-md border">
|
|
67
|
+
{documents.map((d) => {
|
|
68
|
+
const a = byDoc.get(d.id)
|
|
69
|
+
const late = overdueDays(d, today)
|
|
70
|
+
return (
|
|
71
|
+
<li key={d.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm" data-document={d.id}>
|
|
72
|
+
<div className="min-w-0 flex-1">
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<span className="font-medium">{d.number}</span>
|
|
75
|
+
{late > 0 && (
|
|
76
|
+
<Badge variant="secondary">{t('paymentAllocator.overdue', { defaultValue: 'Vencida · {{days}} d', days: late })}</Badge>
|
|
77
|
+
)}
|
|
78
|
+
{d.payment_method && <span className="text-xs text-muted-foreground">{d.payment_method}</span>}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="text-xs text-muted-foreground">
|
|
81
|
+
{t('paymentAllocator.balance', { defaultValue: 'Saldo' })} {fmt.money(d.balance, d.currency)}
|
|
82
|
+
{a && a.balance_after > 0 && (
|
|
83
|
+
<>
|
|
84
|
+
{' · '}
|
|
85
|
+
{t('paymentAllocator.installment', { defaultValue: 'Parcialidad {{n}}', n: a.installment })}
|
|
86
|
+
{' · '}
|
|
87
|
+
{t('paymentAllocator.remaining', { defaultValue: 'Queda' })} {fmt.money(a.balance_after, d.currency)}
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
<Input
|
|
93
|
+
type="number"
|
|
94
|
+
inputMode="decimal"
|
|
95
|
+
min={0}
|
|
96
|
+
step="0.01"
|
|
97
|
+
aria-label={t('paymentAllocator.apply_to', { defaultValue: 'Aplicar a {{number}}', number: d.number })}
|
|
98
|
+
className="h-9 w-32 text-right tabular-nums"
|
|
99
|
+
value={a ? String(a.amount) : ''}
|
|
100
|
+
placeholder="0.00"
|
|
101
|
+
disabled={readOnly}
|
|
102
|
+
onChange={(e) => setAmount(d.id, num(e.target.value))}
|
|
103
|
+
/>
|
|
104
|
+
</li>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</ul>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -80,6 +80,10 @@ export interface ProductResult {
|
|
|
80
80
|
stock?: StockByWarehouse[]
|
|
81
81
|
/** Paquetes automáticos: ids/nombres que se sugieren junto al producto. */
|
|
82
82
|
bundle_items?: Array<{ product_id: string; name: string; quantity: number }>
|
|
83
|
+
/** Unidad de venta del catálogo (pza, juego, kg…). */
|
|
84
|
+
unit?: string
|
|
85
|
+
/** Campos de extensión del producto (fiscal_data.*) que se copian al renglón. */
|
|
86
|
+
extensions?: Record<string, string>
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/** Existencia disponible: de la variante o del producto, en un almacén o en total. */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
11
11
|
// SDK stays transport- and host-agnostic.
|
|
12
12
|
import { emitRecordMutation } from '../record-mutation-events'
|
|
13
|
-
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
|
|
13
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
14
14
|
import { useTranslation } from 'react-i18next'
|
|
15
15
|
import type { ModelSchema } from './types'
|
|
16
16
|
|
|
@@ -69,13 +69,14 @@ import type { VisibleWhen } from '../types'
|
|
|
69
69
|
import { groupFieldsBySection, type FormLayout } from '../form-layout'
|
|
70
70
|
import { FieldSection, WizardProgress } from '../form-layout-ui'
|
|
71
71
|
import { AssistInterview } from '../assist-interview'
|
|
72
|
+
import { RecordPrefillBar } from '../record-prefill-bar'
|
|
72
73
|
import { FieldCell } from '../field-grid'
|
|
73
74
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
74
75
|
import { normalizeRefFieldsForSubmit } from './normalize-submit'
|
|
75
76
|
import { validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
|
|
76
77
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
77
78
|
import { IconPickerField } from '../icon-picker-field'
|
|
78
|
-
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
79
|
+
import { humanizeToken, localizeFieldLabel } from '../dynamic-columns-helpers'
|
|
79
80
|
import { formatDateCell } from '../dynamic-columns'
|
|
80
81
|
import {
|
|
81
82
|
ImageStack,
|
|
@@ -667,6 +668,17 @@ export function DynamicRecordDialog({
|
|
|
667
668
|
const isCreate = mode === 'create'
|
|
668
669
|
const isView = mode === 'view'
|
|
669
670
|
const isEditable = mode === 'create' || mode === 'edit'
|
|
671
|
+
// Claves que un ayudante de captura (RecordPrefillBar) puede escribir: sólo
|
|
672
|
+
// los campos que este formulario declara (incluye fiscal_data.* de las
|
|
673
|
+
// ModelExtension). Readonly fuera: un prefill no salta un candado.
|
|
674
|
+
const prefillAllowedKeys = useMemo(
|
|
675
|
+
() => new Set<string>((modalMeta?.fields ?? []).filter((f: any) => !f.readonly).map((f: any) => f.key)),
|
|
676
|
+
[modalMeta],
|
|
677
|
+
)
|
|
678
|
+
const applyPrefill = useCallback(
|
|
679
|
+
(patch: Record<string, any>) => setFormValues(prev => ({ ...prev, ...patch })),
|
|
680
|
+
[],
|
|
681
|
+
)
|
|
670
682
|
const config = MODE_CONFIG[mode]
|
|
671
683
|
|
|
672
684
|
// ── Fetch metadata + record when dialog opens ──────────────────────────
|
|
@@ -851,7 +863,7 @@ export function DynamicRecordDialog({
|
|
|
851
863
|
// with no matching form field).
|
|
852
864
|
const labelForKey = (key: string): string => {
|
|
853
865
|
const f = (modalMeta?.fields ?? []).find(x => x.key === key)
|
|
854
|
-
if (f?.label) return f.label
|
|
866
|
+
if (f?.label) return localizeFieldLabel(f.label, t)
|
|
855
867
|
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
856
868
|
}
|
|
857
869
|
|
|
@@ -903,7 +915,7 @@ export function DynamicRecordDialog({
|
|
|
903
915
|
}
|
|
904
916
|
if (bagHasErrors(bag)) {
|
|
905
917
|
const labels: Record<string, string> = {}
|
|
906
|
-
for (const f of visible) labels[f.key] = f.label
|
|
918
|
+
for (const f of visible) labels[f.key] = localizeFieldLabel(f.label, t)
|
|
907
919
|
const next = localizeFieldErrorMap(bag, t, { labels })
|
|
908
920
|
setFieldErrors(next)
|
|
909
921
|
const description = Object.entries(next)
|
|
@@ -1077,7 +1089,7 @@ export function DynamicRecordDialog({
|
|
|
1077
1089
|
const bag = validateValues(stepFields as ActionFieldDef[], formValues)
|
|
1078
1090
|
if (bagHasErrors(bag)) {
|
|
1079
1091
|
const labels: Record<string, string> = {}
|
|
1080
|
-
for (const f of stepFields) labels[f.key] = f.label
|
|
1092
|
+
for (const f of stepFields) labels[f.key] = localizeFieldLabel(f.label, t)
|
|
1081
1093
|
setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
|
|
1082
1094
|
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
1083
1095
|
return
|
|
@@ -1111,6 +1123,16 @@ export function DynamicRecordDialog({
|
|
|
1111
1123
|
two-column grid, unchanged. FieldCell gives each
|
|
1112
1124
|
cell `min-w-0` so a long select/input value can't
|
|
1113
1125
|
blow the two columns past the dialog width. */}
|
|
1126
|
+
{isEditable && (
|
|
1127
|
+
<RecordPrefillBar
|
|
1128
|
+
model={model}
|
|
1129
|
+
mode={isCreate ? 'create' : 'edit'}
|
|
1130
|
+
recordId={recordId}
|
|
1131
|
+
values={formValues}
|
|
1132
|
+
allowedKeys={prefillAllowedKeys}
|
|
1133
|
+
onApply={applyPrefill}
|
|
1134
|
+
/>
|
|
1135
|
+
)}
|
|
1114
1136
|
<form
|
|
1115
1137
|
id={formId}
|
|
1116
1138
|
// Stable Aby / guidance contract: unique id={formId}
|
|
@@ -1313,11 +1335,12 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
|
|
|
1313
1335
|
// the rich read-only renderer. `locked` forces the same disabled rendering on
|
|
1314
1336
|
// CREATE for a caller-specified field (see `lockedFields`).
|
|
1315
1337
|
const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked
|
|
1338
|
+
const { t } = useTranslation()
|
|
1316
1339
|
|
|
1317
1340
|
return (
|
|
1318
1341
|
<div className="flex flex-col gap-1.5" data-aby-field={field.key}>
|
|
1319
1342
|
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
1320
|
-
{field.label}
|
|
1343
|
+
{localizeFieldLabel(field.label, t)}
|
|
1321
1344
|
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
1322
1345
|
<span className="text-destructive ml-0.5">*</span>
|
|
1323
1346
|
)}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { Loader2 } from 'lucide-react'
|
|
24
24
|
import { toast } from 'sonner'
|
|
25
25
|
import { useApi } from './api-context'
|
|
26
|
+
import { useOrgTaxRate } from './org-runtime-context'
|
|
26
27
|
import { DynamicIcon } from './dynamic-icon'
|
|
27
28
|
import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
|
|
28
29
|
import { FormErrorBanner } from './business/feedback'
|
|
@@ -41,12 +42,13 @@ import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
|
|
|
41
42
|
import { validateValues, bagHasErrors } from './validator'
|
|
42
43
|
import { clearFieldErrorTree } from './field-validation-ui'
|
|
43
44
|
import { emitRecordMutation } from './record-mutation-events'
|
|
44
|
-
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
45
|
+
import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
|
|
46
|
+
import { createCatalogProductSearch } from './business/catalog-product-search'
|
|
47
|
+
import { DocumentEditor } from './business/document-editor'
|
|
48
|
+
import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
|
|
45
49
|
import type {
|
|
46
50
|
ActionFieldDef,
|
|
47
|
-
DocumentFormLines,
|
|
48
51
|
DocumentFormsManifest,
|
|
49
|
-
DocumentFormType,
|
|
50
52
|
TableMetadata,
|
|
51
53
|
} from './types'
|
|
52
54
|
|
|
@@ -62,10 +64,24 @@ export interface DocumentFormDialogProps {
|
|
|
62
64
|
initialType?: string
|
|
63
65
|
/** Se llama con el registro creado. */
|
|
64
66
|
onSaved?: (record?: unknown) => void
|
|
65
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Buscador de productos para el editor de renglones (mismo contrato que
|
|
69
|
+
* ProductPicker). Sin él se busca en el catálogo (`productModel`) vía /data.
|
|
70
|
+
*/
|
|
66
71
|
searchProducts?: DocumentLinesGridProps['search']
|
|
72
|
+
/** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
|
|
73
|
+
productModel?: string
|
|
74
|
+
/** Tasa de impuesto (0.16) si el producto no trae la suya. Default: la de la org (OrgRuntimeProvider). */
|
|
75
|
+
defaultTaxRate?: number
|
|
67
76
|
/** Moneda ISO para el editor de renglones (default: la de la org). */
|
|
68
77
|
currency?: string
|
|
78
|
+
/**
|
|
79
|
+
* Registro desde el que se abre (acción de fila «Crear NC», «Registrar
|
|
80
|
+
* cobro»…): siembra los campos con `default_from_record`.
|
|
81
|
+
*/
|
|
82
|
+
record?: Record<string, any>
|
|
83
|
+
/** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */
|
|
84
|
+
initialSource?: { key: string; id: string }
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
/** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
|
|
@@ -77,10 +93,25 @@ export function resolveDocumentForms(
|
|
|
77
93
|
return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
|
|
78
94
|
}
|
|
79
95
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Acota el alta guiada a la vista filtrada. Un nav que filtra por la columna
|
|
98
|
+
* discriminadora (`type_field`) lista UN tipo de documento: «Crear» debe abrir
|
|
99
|
+
* ese tipo directo y, si el manifest no trae formulario para él, no ofrecerse
|
|
100
|
+
* (retest r3: «Documentos fiscales → Facturas → Crear» abría el REP, el único
|
|
101
|
+
* tipo declarado). Sin manifest, sin `type_field` o sin filtro sobre él, todo
|
|
102
|
+
* queda igual.
|
|
103
|
+
*/
|
|
104
|
+
export function scopeDocumentFormsToFilter(
|
|
105
|
+
forms: DocumentFormsManifest | undefined,
|
|
106
|
+
filter?: Record<string, unknown> | null,
|
|
107
|
+
): { forms: DocumentFormsManifest | undefined; hideCreate: boolean } {
|
|
108
|
+
const field = forms?.type_field
|
|
109
|
+
if (!forms || !field || !filter || !(field in filter)) return { forms, hideCreate: false }
|
|
110
|
+
const want = scopeValueFromFilterToken(filter[field]).trim().toLowerCase()
|
|
111
|
+
if (want === '') return { forms, hideCreate: false }
|
|
112
|
+
const types = forms.types.filter((x) => String(x.value ?? x.key).trim().toLowerCase() === want)
|
|
113
|
+
if (types.length === 0) return { forms: undefined, hideCreate: true }
|
|
114
|
+
return { forms: types.length === forms.types.length ? forms : { ...forms, types }, hideCreate: false }
|
|
84
115
|
}
|
|
85
116
|
|
|
86
117
|
type Step = 'type' | 'fields' | 'lines'
|
|
@@ -94,10 +125,15 @@ export function DocumentFormDialog({
|
|
|
94
125
|
initialType,
|
|
95
126
|
onSaved,
|
|
96
127
|
searchProducts,
|
|
128
|
+
productModel,
|
|
129
|
+
defaultTaxRate,
|
|
97
130
|
currency,
|
|
131
|
+
record,
|
|
132
|
+
initialSource,
|
|
98
133
|
}: DocumentFormDialogProps) {
|
|
99
134
|
const { t, i18n } = useTranslation()
|
|
100
135
|
const api = useApi()
|
|
136
|
+
const orgTaxRate = useOrgTaxRate()
|
|
101
137
|
const types = forms.types
|
|
102
138
|
const single = types.length === 1 ? types[0] : undefined
|
|
103
139
|
|
|
@@ -111,7 +147,15 @@ export function DocumentFormDialog({
|
|
|
111
147
|
const [saving, setSaving] = useState(false)
|
|
112
148
|
|
|
113
149
|
const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
|
|
114
|
-
|
|
150
|
+
// Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
|
|
151
|
+
// y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
|
|
152
|
+
// Default: el catálogo de productos de la org.
|
|
153
|
+
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
154
|
+
const lineSearch = useMemo(
|
|
155
|
+
() => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
|
|
156
|
+
[searchProducts, api, productModel, taxRate],
|
|
157
|
+
)
|
|
158
|
+
const lineCfg = type ? editorLinesConfig(type, forms) : undefined
|
|
115
159
|
|
|
116
160
|
// Reset every time the dialog (re)opens.
|
|
117
161
|
useEffect(() => {
|
|
@@ -128,10 +172,10 @@ export function DocumentFormDialog({
|
|
|
128
172
|
// Seed field defaults for the chosen type.
|
|
129
173
|
useEffect(() => {
|
|
130
174
|
if (!type) return
|
|
131
|
-
setFormData({ ...buildFieldDefaults(type.fields,
|
|
175
|
+
setFormData({ ...buildFieldDefaults(type.fields, record), ...(type.defaults ?? {}) })
|
|
132
176
|
setFieldErrors({})
|
|
133
177
|
setFormError(undefined)
|
|
134
|
-
}, [type])
|
|
178
|
+
}, [type, record])
|
|
135
179
|
|
|
136
180
|
const updateField = (key: string, value: any) => {
|
|
137
181
|
setFormData((prev) => ({ ...prev, [key]: value }))
|
|
@@ -232,6 +276,41 @@ export function DocumentFormDialog({
|
|
|
232
276
|
? t('documentForm.step_data', { defaultValue: 'Datos' })
|
|
233
277
|
: tl(lineCfg?.title ?? 'Renglones')
|
|
234
278
|
|
|
279
|
+
// layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
|
|
280
|
+
// sigue el wizard de siempre (retrocompatible).
|
|
281
|
+
if (open && type && step !== 'type' && isEditorLayout(type)) {
|
|
282
|
+
return (
|
|
283
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
284
|
+
<DialogContent
|
|
285
|
+
className="flex max-h-[92dvh] flex-col overflow-hidden"
|
|
286
|
+
style={{ maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }}
|
|
287
|
+
data-slot="document-editor-dialog"
|
|
288
|
+
>
|
|
289
|
+
<DialogHeader className="shrink-0">
|
|
290
|
+
<DialogTitle>{tl(type.label)}</DialogTitle>
|
|
291
|
+
{type.description && <DialogDescription>{tl(type.description)}</DialogDescription>}
|
|
292
|
+
</DialogHeader>
|
|
293
|
+
<DocumentEditor
|
|
294
|
+
key={type.key}
|
|
295
|
+
model={model}
|
|
296
|
+
endpoint={endpoint}
|
|
297
|
+
forms={forms}
|
|
298
|
+
type={type}
|
|
299
|
+
record={record}
|
|
300
|
+
initialSource={initialSource}
|
|
301
|
+
searchProducts={lineSearch}
|
|
302
|
+
currency={currency}
|
|
303
|
+
onCancel={() => (canGoBackToTypes ? setStep('type') : onOpenChange(false))}
|
|
304
|
+
onSaved={(rec) => {
|
|
305
|
+
onSaved?.(rec)
|
|
306
|
+
onOpenChange(false)
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
</DialogContent>
|
|
310
|
+
</Dialog>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
235
314
|
return (
|
|
236
315
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
237
316
|
<DialogContent
|
|
@@ -311,8 +390,9 @@ export function DocumentFormDialog({
|
|
|
311
390
|
}}
|
|
312
391
|
columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
|
|
313
392
|
priceSource={lineCfg.price_source ?? 'sale'}
|
|
393
|
+
discountMode={lineCfg.discount_mode}
|
|
314
394
|
serverErrors={serverLineErrors}
|
|
315
|
-
search={
|
|
395
|
+
search={lineSearch}
|
|
316
396
|
currency={currency}
|
|
317
397
|
/>
|
|
318
398
|
)}
|
|
@@ -78,3 +78,25 @@ export function translateMetadataLabel(
|
|
|
78
78
|
const translated = translate(value, { defaultValue: fallback })
|
|
79
79
|
return translated && translated !== value ? translated : fallback
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
// A catalog key as addons write them: lowercase dotted segments
|
|
83
|
+
// ("fiscal_mexico.ext.customer.rfc_receptor"). Human labels have spaces,
|
|
84
|
+
// capitals or accents and never match.
|
|
85
|
+
const CATALOG_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Form-field label as the user should read it. The host usually serves labels
|
|
89
|
+
* already localized; when it could not (an extension field whose addon catalog
|
|
90
|
+
* the server did not resolve), the label arrives as the raw catalog key and the
|
|
91
|
+
* form printed `FISCAL_MEXICO.EXT.CUSTOMER.RFC_RECEPTOR`. Such a key is resolved
|
|
92
|
+
* with the client catalog and, failing that, humanized. Any other label is
|
|
93
|
+
* returned untouched.
|
|
94
|
+
*/
|
|
95
|
+
export function localizeFieldLabel(
|
|
96
|
+
label: string | undefined,
|
|
97
|
+
translate?: MetadataTranslator,
|
|
98
|
+
): string {
|
|
99
|
+
if (!label) return ''
|
|
100
|
+
if (!CATALOG_KEY_RE.test(label)) return label
|
|
101
|
+
return translateMetadataLabel(label, translate)
|
|
102
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -243,7 +243,16 @@ export function RowActionMenuItem({
|
|
|
243
243
|
* …) and fall back to `state` (purchases, inventory transfers, …). Empty string
|
|
244
244
|
* is treated as missing so a blank `status` does not hide a populated `state`.
|
|
245
245
|
*/
|
|
246
|
-
const rowLifecycleState = (row: any): unknown => {
|
|
246
|
+
const rowLifecycleState = (row: any, stageField?: string): unknown => {
|
|
247
|
+
// A model with a stage machine gates its actions on the stage column, the
|
|
248
|
+
// same field the kernel's checkRequiresState reads (dynamic/action.go).
|
|
249
|
+
// Without this, WorkOrder actions declared with stage keys
|
|
250
|
+
// (requiresState: ['reception', …]) were compared to `status` ('open') and
|
|
251
|
+
// hidden in every row, so an OT could never be advanced from the UI.
|
|
252
|
+
if (stageField) {
|
|
253
|
+
const stage = row?.[stageField]
|
|
254
|
+
if (stage !== undefined && stage !== null && stage !== '') return stage
|
|
255
|
+
}
|
|
247
256
|
const status = row?.status
|
|
248
257
|
if (status !== undefined && status !== null && status !== '') return status
|
|
249
258
|
const state = row?.state
|
|
@@ -264,10 +273,10 @@ const rowLifecycleState = (row: any): unknown => {
|
|
|
264
273
|
* - action without requiresState (or empty array) → always shown.
|
|
265
274
|
* - row with neither `status` nor `state` → all actions shown.
|
|
266
275
|
*/
|
|
267
|
-
export const isActionAllowedForRowState = (action: any, row: any): boolean => {
|
|
276
|
+
export const isActionAllowedForRowState = (action: any, row: any, stageField?: string): boolean => {
|
|
268
277
|
const requires: unknown = action?.requiresState ?? action?.requires_state
|
|
269
278
|
if (!Array.isArray(requires) || requires.length === 0) return true
|
|
270
|
-
const status = rowLifecycleState(row)
|
|
279
|
+
const status = rowLifecycleState(row, stageField)
|
|
271
280
|
if (status === undefined) return true
|
|
272
281
|
return requires.map(String).includes(String(status))
|
|
273
282
|
}
|
|
@@ -341,8 +350,16 @@ export const isActionConditionMet = (action: any, row: any): boolean => {
|
|
|
341
350
|
* (`requiresState`) AND the declarative `condition` must pass. Shared by the
|
|
342
351
|
* table's action column and the kanban card menu so they hide/show identically.
|
|
343
352
|
*/
|
|
344
|
-
export const isRowActionVisible = (action: any, row: any): boolean =>
|
|
345
|
-
isActionAllowedForRowState(action, row) && isActionConditionMet(action, row)
|
|
353
|
+
export const isRowActionVisible = (action: any, row: any, stageField?: string): boolean =>
|
|
354
|
+
isActionAllowedForRowState(action, row, stageField) && isActionConditionMet(action, row)
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The column a model's `requiresState` gates read: the served `stage_field`
|
|
358
|
+
* (or, for hosts that predate it, the `group_by` of a model with stages).
|
|
359
|
+
* Undefined → the historical `status` / `state` lookup.
|
|
360
|
+
*/
|
|
361
|
+
export const lifecycleStageField = (metadata: any): string | undefined =>
|
|
362
|
+
metadata?.stage_field ?? metadata?.stageField ?? (metadata?.stages?.length ? metadata?.group_by : undefined)
|
|
346
363
|
|
|
347
364
|
const lowerFirst = (value?: string) => {
|
|
348
365
|
if (!value) return value
|
|
@@ -1564,7 +1581,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1564
1581
|
</DropdownMenuTrigger>
|
|
1565
1582
|
<DropdownMenuContent align="end">
|
|
1566
1583
|
{resolvedActions
|
|
1567
|
-
.filter((action) => isRowActionVisible(action, row.original))
|
|
1584
|
+
.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))
|
|
1568
1585
|
.map((action) => (
|
|
1569
1586
|
<RowActionMenuItem
|
|
1570
1587
|
key={action.key}
|
|
@@ -33,7 +33,7 @@ import { useApi } from './api-context'
|
|
|
33
33
|
import { useMetadataCache } from './metadata-cache'
|
|
34
34
|
import { DynamicTable } from './dynamic-table'
|
|
35
35
|
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
36
|
-
import { DocumentFormDialog, resolveDocumentForms } from './document-form-dialog'
|
|
36
|
+
import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from './document-form-dialog'
|
|
37
37
|
import { ExportDialog } from './dialogs/export'
|
|
38
38
|
import { ImportDialog } from './dialogs/import'
|
|
39
39
|
import { getModelExtension } from './model-extension-registry'
|
|
@@ -96,6 +96,12 @@ export interface DynamicCRUDPageProps {
|
|
|
96
96
|
* with neither, the generic record dialog is used as before.
|
|
97
97
|
*/
|
|
98
98
|
documentForms?: DocumentFormsManifest
|
|
99
|
+
/**
|
|
100
|
+
* Filtro fijo de la vista (el `filter` del nav). Si va sobre el `type_field`
|
|
101
|
+
* del alta guiada, «Crear» abre ese tipo directo o se oculta cuando no hay
|
|
102
|
+
* formulario para él.
|
|
103
|
+
*/
|
|
104
|
+
filter?: Record<string, string>
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
@@ -119,6 +125,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
119
125
|
classes,
|
|
120
126
|
onChange,
|
|
121
127
|
documentForms,
|
|
128
|
+
filter,
|
|
122
129
|
} = props
|
|
123
130
|
|
|
124
131
|
const strings = { ...defaultStrings, ...(i18n ?? {}) }
|
|
@@ -189,11 +196,22 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
189
196
|
// (useCan defaults to always-true), in which case create/export/import
|
|
190
197
|
// require `lowercase(model).create|export|import`.
|
|
191
198
|
const can = useCan()
|
|
192
|
-
const
|
|
199
|
+
const scoped = scopeDocumentFormsToFilter(resolveDocumentForms(metadata, documentForms), filter)
|
|
200
|
+
const guidedForms = scoped.forms
|
|
201
|
+
const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create'))
|
|
193
202
|
const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
|
|
194
203
|
const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
|
|
195
204
|
const showRefresh = !effectiveHideRefresh
|
|
196
|
-
|
|
205
|
+
|
|
206
|
+
// El mismo filtro acota la tabla (forma `eq:<valor>` que espera DynamicTable).
|
|
207
|
+
const filterKey = filter ? JSON.stringify(filter) : ''
|
|
208
|
+
const tableFilters = useMemo(() => {
|
|
209
|
+
if (!filter || Object.keys(filter).length === 0) return undefined
|
|
210
|
+
return Object.fromEntries(
|
|
211
|
+
Object.entries(filter).map(([k, v]) => [k, String(v).includes(':') ? String(v) : `eq:${v}`]),
|
|
212
|
+
)
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
}, [filterKey])
|
|
197
215
|
|
|
198
216
|
const handleRefresh = useCallback(() => {
|
|
199
217
|
setRefreshKey((k) => k + 1)
|
|
@@ -278,6 +296,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
278
296
|
model={model}
|
|
279
297
|
endpoint={dataEndpoint}
|
|
280
298
|
refreshTrigger={refreshKey}
|
|
299
|
+
defaultFilters={tableFilters}
|
|
281
300
|
/>
|
|
282
301
|
</div>
|
|
283
302
|
</div>
|
|
@@ -107,12 +107,25 @@ export function computeLineItemTotals(
|
|
|
107
107
|
field: ActionFieldDef,
|
|
108
108
|
rows: any[] | undefined,
|
|
109
109
|
): Record<string, number> {
|
|
110
|
-
const
|
|
110
|
+
const itemFields = getItemFields(field)
|
|
111
|
+
const cols = itemFields.filter((c) => c.total)
|
|
112
|
+
// A `total` UNIT PRICE column on a grid with a quantity and no line amount
|
|
113
|
+
// column sums the line amounts (qty × price − discount): adding unit prices
|
|
114
|
+
// is meaningless once a quantity exists (2 × 750 footed 750, not 1500).
|
|
115
|
+
const qtyKey = firstKey(itemFields, LINE_QTY_KEYS)
|
|
116
|
+
const lineValued = !!qtyKey && !firstKey(itemFields, LINE_AMOUNT_KEYS)
|
|
117
|
+
const discountKey = firstKey(itemFields, LINE_DISCOUNT_KEYS)
|
|
111
118
|
const totals: Record<string, number> = {}
|
|
112
119
|
for (const c of cols) totals[c.key] = 0
|
|
113
120
|
if (Array.isArray(rows)) {
|
|
114
121
|
for (const row of rows) {
|
|
115
|
-
for (const c of cols)
|
|
122
|
+
for (const c of cols) {
|
|
123
|
+
const v = toNumber(row?.[c.key])
|
|
124
|
+
totals[c.key] +=
|
|
125
|
+
lineValued && LINE_PRICE_KEYS.has(c.key)
|
|
126
|
+
? toNumber(row?.[qtyKey!]) * v - (discountKey ? toNumber(row?.[discountKey]) : 0)
|
|
127
|
+
: v
|
|
128
|
+
}
|
|
116
129
|
}
|
|
117
130
|
}
|
|
118
131
|
for (const k of Object.keys(totals)) totals[k] = Math.round(totals[k] * 100) / 100
|