@asteby/metacore-runtime-react 47.0.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 +42 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- 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 +19 -8
- 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 +81 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +39 -13
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- 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/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -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/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -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 +142 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/package.json +5 -5
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- 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__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/option-filter.test.tsx +115 -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/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/audit-info.tsx +219 -0
- 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 +42 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dropdown-menu-entry.test.ts +23 -0
- 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 +120 -39
- package/src/dynamic-kanban.tsx +41 -10
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +34 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/option-filter.ts +88 -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 +142 -0
- package/src/use-options-resolver.ts +19 -1
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -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
|
|
|
@@ -60,6 +60,8 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeField
|
|
|
60
60
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
61
61
|
import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
|
|
62
62
|
import { DynamicRelations } from '../dynamic-relations'
|
|
63
|
+
import { AuditInfo, readAuditMeta } from '../audit-info'
|
|
64
|
+
import type { AuditMeta } from '../types'
|
|
63
65
|
import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
|
|
64
66
|
import { getFieldRef, getVisibleWhen, evaluateVisibleWhen, ATTRIBUTE_CLASSES_KEY } from '../dynamic-form-schema'
|
|
65
67
|
import { useAttributeClasses, type AttributeClass } from '../attribute-classes'
|
|
@@ -67,13 +69,14 @@ import type { VisibleWhen } from '../types'
|
|
|
67
69
|
import { groupFieldsBySection, type FormLayout } from '../form-layout'
|
|
68
70
|
import { FieldSection, WizardProgress } from '../form-layout-ui'
|
|
69
71
|
import { AssistInterview } from '../assist-interview'
|
|
72
|
+
import { RecordPrefillBar } from '../record-prefill-bar'
|
|
70
73
|
import { FieldCell } from '../field-grid'
|
|
71
74
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
72
75
|
import { normalizeRefFieldsForSubmit } from './normalize-submit'
|
|
73
76
|
import { validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
|
|
74
77
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
75
78
|
import { IconPickerField } from '../icon-picker-field'
|
|
76
|
-
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
79
|
+
import { humanizeToken, localizeFieldLabel } from '../dynamic-columns-helpers'
|
|
77
80
|
import { formatDateCell } from '../dynamic-columns'
|
|
78
81
|
import {
|
|
79
82
|
ImageStack,
|
|
@@ -643,6 +646,7 @@ export function DynamicRecordDialog({
|
|
|
643
646
|
schema ? (schema as ModalMetadata) : null,
|
|
644
647
|
)
|
|
645
648
|
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
649
|
+
const [audit, setAudit] = useState<AuditMeta | undefined>(undefined)
|
|
646
650
|
const [record, setRecord] = useState<any | null>(null)
|
|
647
651
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
648
652
|
// Classes of the record's category (plus those its saved data carries), for
|
|
@@ -664,6 +668,17 @@ export function DynamicRecordDialog({
|
|
|
664
668
|
const isCreate = mode === 'create'
|
|
665
669
|
const isView = mode === 'view'
|
|
666
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
|
+
)
|
|
667
682
|
const config = MODE_CONFIG[mode]
|
|
668
683
|
|
|
669
684
|
// ── Fetch metadata + record when dialog opens ──────────────────────────
|
|
@@ -784,6 +799,7 @@ export function DynamicRecordDialog({
|
|
|
784
799
|
useEffect(() => {
|
|
785
800
|
if (!open || mode === 'create' || !recordId) {
|
|
786
801
|
setRelations([])
|
|
802
|
+
setAudit(undefined)
|
|
787
803
|
return
|
|
788
804
|
}
|
|
789
805
|
let cancelled = false
|
|
@@ -792,6 +808,7 @@ export function DynamicRecordDialog({
|
|
|
792
808
|
if (cancelled) return
|
|
793
809
|
const meta = res.data?.data ?? res.data
|
|
794
810
|
const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
|
|
811
|
+
setAudit(readAuditMeta(meta?.audit))
|
|
795
812
|
// Localize each panel header: the backend serves `label` as an
|
|
796
813
|
// i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
|
|
797
814
|
setRelations(
|
|
@@ -846,7 +863,7 @@ export function DynamicRecordDialog({
|
|
|
846
863
|
// with no matching form field).
|
|
847
864
|
const labelForKey = (key: string): string => {
|
|
848
865
|
const f = (modalMeta?.fields ?? []).find(x => x.key === key)
|
|
849
|
-
if (f?.label) return f.label
|
|
866
|
+
if (f?.label) return localizeFieldLabel(f.label, t)
|
|
850
867
|
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
851
868
|
}
|
|
852
869
|
|
|
@@ -898,7 +915,7 @@ export function DynamicRecordDialog({
|
|
|
898
915
|
}
|
|
899
916
|
if (bagHasErrors(bag)) {
|
|
900
917
|
const labels: Record<string, string> = {}
|
|
901
|
-
for (const f of visible) labels[f.key] = f.label
|
|
918
|
+
for (const f of visible) labels[f.key] = localizeFieldLabel(f.label, t)
|
|
902
919
|
const next = localizeFieldErrorMap(bag, t, { labels })
|
|
903
920
|
setFieldErrors(next)
|
|
904
921
|
const description = Object.entries(next)
|
|
@@ -1072,7 +1089,7 @@ export function DynamicRecordDialog({
|
|
|
1072
1089
|
const bag = validateValues(stepFields as ActionFieldDef[], formValues)
|
|
1073
1090
|
if (bagHasErrors(bag)) {
|
|
1074
1091
|
const labels: Record<string, string> = {}
|
|
1075
|
-
for (const f of stepFields) labels[f.key] = f.label
|
|
1092
|
+
for (const f of stepFields) labels[f.key] = localizeFieldLabel(f.label, t)
|
|
1076
1093
|
setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
|
|
1077
1094
|
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
1078
1095
|
return
|
|
@@ -1106,6 +1123,16 @@ export function DynamicRecordDialog({
|
|
|
1106
1123
|
two-column grid, unchanged. FieldCell gives each
|
|
1107
1124
|
cell `min-w-0` so a long select/input value can't
|
|
1108
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
|
+
)}
|
|
1109
1136
|
<form
|
|
1110
1137
|
id={formId}
|
|
1111
1138
|
// Stable Aby / guidance contract: unique id={formId}
|
|
@@ -1189,6 +1216,14 @@ export function DynamicRecordDialog({
|
|
|
1189
1216
|
/>
|
|
1190
1217
|
</div>
|
|
1191
1218
|
)}
|
|
1219
|
+
{!isCreate && record && audit && (
|
|
1220
|
+
<AuditInfo
|
|
1221
|
+
record={record}
|
|
1222
|
+
audit={audit}
|
|
1223
|
+
timeZone={timeZone}
|
|
1224
|
+
className="mt-6 border-t pt-3"
|
|
1225
|
+
/>
|
|
1226
|
+
)}
|
|
1192
1227
|
</CurrencyContext.Provider>
|
|
1193
1228
|
</TimeZoneContext.Provider>
|
|
1194
1229
|
</ImageUrlContext.Provider>
|
|
@@ -1300,11 +1335,12 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
|
|
|
1300
1335
|
// the rich read-only renderer. `locked` forces the same disabled rendering on
|
|
1301
1336
|
// CREATE for a caller-specified field (see `lockedFields`).
|
|
1302
1337
|
const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked
|
|
1338
|
+
const { t } = useTranslation()
|
|
1303
1339
|
|
|
1304
1340
|
return (
|
|
1305
1341
|
<div className="flex flex-col gap-1.5" data-aby-field={field.key}>
|
|
1306
1342
|
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
1307
|
-
{field.label}
|
|
1343
|
+
{localizeFieldLabel(field.label, t)}
|
|
1308
1344
|
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
1309
1345
|
<span className="text-destructive ml-0.5">*</span>
|
|
1310
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
|
)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Radix context guard: a component that renders RowActionMenuItem (whose
|
|
6
|
+
// DropdownMenuItem is imported from the ROOT '@asteby/metacore-ui' entry) must
|
|
7
|
+
// import its DropdownMenu shell from the root entry too. Hosts share the root
|
|
8
|
+
// entry as a Module Federation singleton but bundle '/primitives' locally, so
|
|
9
|
+
// mixing entries creates two Menu contexts → "`MenuItem` must be used within
|
|
10
|
+
// `Menu`" at runtime (Pitsline kanban card menu crash, 2026-10-05).
|
|
11
|
+
describe('dropdown menu entry consistency', () => {
|
|
12
|
+
const dir = join(__dirname)
|
|
13
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.tsx'))
|
|
14
|
+
for (const f of files) {
|
|
15
|
+
const src = readFileSync(join(dir, f), 'utf8')
|
|
16
|
+
if (!src.includes('<RowActionMenuItem')) continue
|
|
17
|
+
it(`${f} imports DropdownMenu from the root ui entry`, () => {
|
|
18
|
+
const primitivesImport = src.match(/import\s*{([^}]*)}\s*from\s*'@asteby\/metacore-ui\/primitives'/)
|
|
19
|
+
const fromPrimitives = primitivesImport ? primitivesImport[1] : ''
|
|
20
|
+
expect(fromPrimitives).not.toMatch(/\bDropdownMenu(Content|Trigger)?\b/)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
})
|
|
@@ -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}
|