@asteby/metacore-runtime-react 46.2.3 → 46.4.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 +12 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +16 -4
- package/dist/business/document-lines.d.ts +30 -0
- package/dist/business/document-lines.d.ts.map +1 -0
- package/dist/business/document-lines.js +65 -0
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +28 -4
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +139 -14
- package/dist/business/line-items.d.ts +24 -2
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +50 -5
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/business/product-search.js +5 -1
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -6
- package/dist/dynamic-form-schema.d.ts +9 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +61 -0
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +18 -8
- package/dist/dynamic-select-field.d.ts +3 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/list-primary-action.d.ts +31 -0
- package/dist/list-primary-action.d.ts.map +1 -0
- package/dist/list-primary-action.js +35 -0
- package/dist/model-action-toolbar.d.ts +7 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +10 -3
- package/dist/model-extension-registry.d.ts +7 -0
- package/dist/model-extension-registry.d.ts.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +5 -5
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +11 -0
- package/package.json +1 -1
- package/src/__tests__/document-lines-grid.test.tsx +79 -0
- package/src/__tests__/line-item-formulas.test.ts +18 -1
- package/src/__tests__/list-primary-action.test.tsx +157 -0
- package/src/__tests__/use-options-resolver.test.ts +5 -0
- package/src/business/business.test.ts +43 -0
- package/src/business/catalog.ts +16 -4
- package/src/business/document-lines.ts +91 -0
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +365 -46
- package/src/business/line-items.ts +65 -7
- package/src/business/product-search.ts +9 -1
- package/src/dynamic-crud-page.tsx +15 -5
- package/src/dynamic-form-schema.ts +60 -0
- package/src/dynamic-line-items.tsx +23 -3
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +8 -0
- package/src/list-primary-action.ts +71 -0
- package/src/model-action-toolbar.tsx +18 -3
- package/src/model-extension-registry.tsx +7 -0
- package/src/types.ts +14 -0
- package/src/use-options-resolver.ts +15 -0
|
@@ -16,10 +16,22 @@ export interface LineItem {
|
|
|
16
16
|
description: string
|
|
17
17
|
quantity: number
|
|
18
18
|
unit_price: number
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Descuento. Porcentaje 0-100 salvo `discount_kind: 'amount'`, donde es importe.
|
|
21
|
+
*/
|
|
20
22
|
discount: number
|
|
23
|
+
/** `percent` (default, se omite al serializar) o `amount`. */
|
|
24
|
+
discount_kind?: 'percent' | 'amount'
|
|
21
25
|
/** Tasa de impuesto como fracción (0.16). Viene de la config fiscal de la org. */
|
|
22
26
|
tax_rate: number
|
|
27
|
+
/** Unidad de medida (pza, juego…). Solo se serializa si viene informada. */
|
|
28
|
+
unit?: string
|
|
29
|
+
/** Precio o costo de catálogo, para mostrarlo junto al precio editado. */
|
|
30
|
+
catalog_price?: number
|
|
31
|
+
/** Costo conocido. Aviso si el precio de venta queda por debajo. No se serializa. */
|
|
32
|
+
cost?: number
|
|
33
|
+
/** Tope de cantidad (NC: facturado − acreditado; devolución: vendido − devuelto). */
|
|
34
|
+
max_quantity?: number
|
|
23
35
|
technician_id?: string
|
|
24
36
|
lot?: string
|
|
25
37
|
dot?: string
|
|
@@ -37,6 +49,8 @@ export interface LineItemsPolicy {
|
|
|
37
49
|
allowNegative?: boolean
|
|
38
50
|
/** Exige al menos un renglón `item`. Default true. */
|
|
39
51
|
requireItems?: boolean
|
|
52
|
+
/** Permite cantidad 0 (renglón de origen que no se acredita/devuelve). Default false. */
|
|
53
|
+
allowZeroQuantity?: boolean
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
let seq = 0
|
|
@@ -67,10 +81,16 @@ export interface LineAmounts {
|
|
|
67
81
|
total: number
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
export function computeLine(
|
|
84
|
+
export function computeLine(
|
|
85
|
+
l: Pick<LineItem, 'kind' | 'quantity' | 'unit_price' | 'discount' | 'tax_rate'> & { discount_kind?: LineItem['discount_kind'] },
|
|
86
|
+
): LineAmounts {
|
|
71
87
|
if (l.kind !== 'item') return { gross: 0, discount: 0, net: 0, tax: 0, total: 0 }
|
|
72
88
|
const gross = roundMoney(toAmount(l.quantity) * toAmount(l.unit_price))
|
|
73
|
-
const
|
|
89
|
+
const raw = toAmount(l.discount)
|
|
90
|
+
const discount =
|
|
91
|
+
l.discount_kind === 'amount'
|
|
92
|
+
? roundMoney(Math.min(Math.max(raw, 0), Math.max(gross, 0)))
|
|
93
|
+
: roundMoney(gross * (raw / 100))
|
|
74
94
|
const net = roundMoney(gross - discount)
|
|
75
95
|
const tax = roundMoney(net * toAmount(l.tax_rate))
|
|
76
96
|
return { gross, discount, net, tax, total: roundMoney(net + tax) }
|
|
@@ -102,6 +122,25 @@ export function computeTotals(lines: LineItem[]): LineItemsTotals {
|
|
|
102
122
|
return t
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
/** Impuesto agrupado por tasa, sumando el impuesto ya redondeado de cada renglón. */
|
|
126
|
+
export function taxBreakdown(lines: LineItem[]): Array<{ rate: number; base: number; tax: number }> {
|
|
127
|
+
const map = new Map<number, { base: number; tax: number }>()
|
|
128
|
+
for (const l of lines) {
|
|
129
|
+
if (l.kind !== 'item') continue
|
|
130
|
+
const a = computeLine(l)
|
|
131
|
+
const rate = toAmount(l.tax_rate)
|
|
132
|
+
const cur = map.get(rate) ?? { base: 0, tax: 0 }
|
|
133
|
+
cur.base += a.net
|
|
134
|
+
cur.tax += a.tax
|
|
135
|
+
map.set(rate, cur)
|
|
136
|
+
}
|
|
137
|
+
return [...map.entries()].map(([rate, v]) => ({
|
|
138
|
+
rate,
|
|
139
|
+
base: roundMoney(v.base),
|
|
140
|
+
tax: roundMoney(v.tax),
|
|
141
|
+
}))
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
export interface LineItemsValidation {
|
|
106
145
|
/** Errores por celda `"<índice>.<campo>"` (mismo formato que DynamicLineItems). */
|
|
107
146
|
errors: Record<string, string>
|
|
@@ -113,7 +152,7 @@ export interface LineItemsValidation {
|
|
|
113
152
|
}
|
|
114
153
|
|
|
115
154
|
export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {}): LineItemsValidation {
|
|
116
|
-
const { stockPolicy = 'warn', allowNegative = false, requireItems = true } = policy
|
|
155
|
+
const { stockPolicy = 'warn', allowNegative = false, requireItems = true, allowZeroQuantity = false } = policy
|
|
117
156
|
const errors: Record<string, string> = {}
|
|
118
157
|
const warnings: Record<string, string> = {}
|
|
119
158
|
let items = 0
|
|
@@ -127,10 +166,22 @@ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {
|
|
|
127
166
|
const price = toAmount(l.unit_price)
|
|
128
167
|
const disc = toAmount(l.discount)
|
|
129
168
|
if (!l.description.trim() && !l.product_id) errors[`${i}.description`] = 'Elige un producto o escribe una descripción'
|
|
130
|
-
if (qty === 0) errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero'
|
|
169
|
+
if (qty === 0 && !allowZeroQuantity) errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero'
|
|
131
170
|
else if (qty < 0 && !allowNegative) errors[`${i}.quantity`] = 'La cantidad no puede ser negativa'
|
|
171
|
+
if (l.max_quantity != null && qty > toAmount(l.max_quantity)) {
|
|
172
|
+
errors[`${i}.quantity`] = `El máximo es ${l.max_quantity}`
|
|
173
|
+
}
|
|
132
174
|
if (price < 0 && !allowNegative) errors[`${i}.unit_price`] = 'El precio no puede ser negativo'
|
|
133
|
-
if (
|
|
175
|
+
if (l.cost != null && price < toAmount(l.cost)) {
|
|
176
|
+
warnings[`${i}.unit_price`] = 'El precio está por debajo del costo'
|
|
177
|
+
}
|
|
178
|
+
if (l.discount_kind === 'amount') {
|
|
179
|
+
const gross = roundMoney(qty * price)
|
|
180
|
+
if (disc < 0) errors[`${i}.discount`] = 'El descuento no puede ser negativo'
|
|
181
|
+
else if (disc > gross) errors[`${i}.discount`] = 'El descuento no puede superar el importe'
|
|
182
|
+
} else if (disc < 0 || disc > 100) {
|
|
183
|
+
errors[`${i}.discount`] = 'El descuento debe estar entre 0 y 100%'
|
|
184
|
+
}
|
|
134
185
|
if (l.available != null && qty > l.available && stockPolicy !== 'allow') {
|
|
135
186
|
const msg = `Solo hay ${l.available} disponibles`
|
|
136
187
|
if (stockPolicy === 'block') errors[`${i}.quantity`] ??= msg
|
|
@@ -157,7 +208,9 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
|
|
|
157
208
|
out.unit_price = toAmount(l.unit_price)
|
|
158
209
|
out.discount = toAmount(l.discount)
|
|
159
210
|
out.tax_rate = toAmount(l.tax_rate)
|
|
160
|
-
|
|
211
|
+
if (l.discount_kind === 'amount') out.discount_kind = 'amount'
|
|
212
|
+
if (l.catalog_price != null) out.catalog_price = toAmount(l.catalog_price)
|
|
213
|
+
for (const k of ['product_id', 'sku', 'technician_id', 'lot', 'dot', 'unit'] as const) {
|
|
161
214
|
const v = l[k]
|
|
162
215
|
if (typeof v === 'string' && v.trim()) out[k] = v.trim()
|
|
163
216
|
}
|
|
@@ -181,7 +234,12 @@ export function parseLineItems(rows: unknown): LineItem[] {
|
|
|
181
234
|
quantity: o.quantity == null ? 1 : toAmount(o.quantity),
|
|
182
235
|
unit_price: toAmount(o.unit_price),
|
|
183
236
|
discount: toAmount(o.discount),
|
|
237
|
+
discount_kind: o.discount_kind === 'amount' ? 'amount' : undefined,
|
|
184
238
|
tax_rate: toAmount(o.tax_rate),
|
|
239
|
+
unit: str(o.unit),
|
|
240
|
+
catalog_price: o.catalog_price == null || o.catalog_price === '' ? undefined : toAmount(o.catalog_price),
|
|
241
|
+
cost: o.cost == null || o.cost === '' ? undefined : toAmount(o.cost),
|
|
242
|
+
max_quantity: o.max_quantity == null || o.max_quantity === '' ? undefined : toAmount(o.max_quantity),
|
|
185
243
|
technician_id: str(o.technician_id),
|
|
186
244
|
lot: str(o.lot),
|
|
187
245
|
dot: str(o.dot),
|
|
@@ -58,6 +58,8 @@ export interface ProductVariant {
|
|
|
58
58
|
sku?: string
|
|
59
59
|
barcode?: string
|
|
60
60
|
price?: number
|
|
61
|
+
/** Costo de catálogo (compras). Opcional; el precio de lista sigue en `price`. */
|
|
62
|
+
cost?: number
|
|
61
63
|
stock?: StockByWarehouse[]
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -70,6 +72,8 @@ export interface ProductResult {
|
|
|
70
72
|
supplier_sku?: string
|
|
71
73
|
kind?: 'product' | 'tire' | 'service' | 'bundle'
|
|
72
74
|
price?: number
|
|
75
|
+
/** Costo de catálogo (compras). */
|
|
76
|
+
cost?: number
|
|
73
77
|
tax_rate?: number
|
|
74
78
|
tire?: TireSize
|
|
75
79
|
variants?: ProductVariant[]
|
|
@@ -96,12 +100,16 @@ export function productToLine(
|
|
|
96
100
|
opts: { warehouseId?: string; quantity?: number } = {},
|
|
97
101
|
): LineItem {
|
|
98
102
|
const src = variant ?? p
|
|
103
|
+
const list = variant?.price ?? p.price
|
|
104
|
+
const cost = variant?.cost ?? p.cost
|
|
99
105
|
return makeLine({
|
|
100
106
|
product_id: variant?.id ?? p.id,
|
|
101
107
|
sku: variant?.sku ?? p.sku,
|
|
102
108
|
description: variant ? `${p.name} · ${variant.label}` : p.name,
|
|
103
109
|
quantity: opts.quantity ?? 1,
|
|
104
|
-
unit_price:
|
|
110
|
+
unit_price: list ?? 0,
|
|
111
|
+
catalog_price: list,
|
|
112
|
+
cost,
|
|
105
113
|
tax_rate: p.tax_rate ?? 0,
|
|
106
114
|
available: availableStock(src, opts.warehouseId) ?? availableStock(p, opts.warehouseId),
|
|
107
115
|
})
|
|
@@ -37,6 +37,7 @@ import { ExportDialog } from './dialogs/export'
|
|
|
37
37
|
import { ImportDialog } from './dialogs/import'
|
|
38
38
|
import { getModelExtension } from './model-extension-registry'
|
|
39
39
|
import { ModelActionToolbar } from './model-action-toolbar'
|
|
40
|
+
import { resolveListPrimaryAction } from './list-primary-action'
|
|
40
41
|
import { useCan, modelCapability } from './permissions-context'
|
|
41
42
|
import type { TableMetadata } from './types'
|
|
42
43
|
|
|
@@ -159,10 +160,17 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
159
160
|
}, [title])
|
|
160
161
|
|
|
161
162
|
const enableCRUD = metadata?.enableCRUDActions ?? false
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
const
|
|
163
|
+
// Una sola primaria: placement create, replaces_create, create_mode o la
|
|
164
|
+
// extensión del modelo ocultan el Crear genérico. El shell de ops debe
|
|
165
|
+
// usar el mismo resolveListPrimaryAction.
|
|
166
|
+
const primary = resolveListPrimaryAction({
|
|
167
|
+
enableCRUD,
|
|
168
|
+
canCreate: metadata?.canCreate,
|
|
169
|
+
hideCreate: hideCreate || ext?.hideCreate,
|
|
170
|
+
createMode: ext?.createMode ?? metadata?.create_mode,
|
|
171
|
+
primaryActionKey: ext?.primaryActionKey,
|
|
172
|
+
actions: metadata?.actions,
|
|
173
|
+
})
|
|
166
174
|
const effectiveHideExport = hideExport || ext?.hideExport
|
|
167
175
|
const effectiveHideImport = hideImport || ext?.hideImport
|
|
168
176
|
// Refresh defaults to hidden in the page header — <DynamicTable> ships
|
|
@@ -174,7 +182,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
174
182
|
// (useCan defaults to always-true), in which case create/export/import
|
|
175
183
|
// require `lowercase(model).create|export|import`.
|
|
176
184
|
const can = useCan()
|
|
177
|
-
const showCreate =
|
|
185
|
+
const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
|
|
178
186
|
const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
|
|
179
187
|
const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
|
|
180
188
|
const showRefresh = !effectiveHideRefresh
|
|
@@ -239,11 +247,13 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
239
247
|
model={model}
|
|
240
248
|
endpoint={dataEndpoint}
|
|
241
249
|
actions={metadata?.actions}
|
|
250
|
+
primaryActionKey={ext?.primaryActionKey}
|
|
242
251
|
onChange={handleRefresh}
|
|
243
252
|
/>
|
|
244
253
|
{showCreate && (
|
|
245
254
|
<button
|
|
246
255
|
type='button'
|
|
256
|
+
data-primary='true'
|
|
247
257
|
onClick={() => setOpenCreate(true)}
|
|
248
258
|
className='inline-flex items-center gap-2 h-9 px-3 rounded-md bg-primary text-primary-foreground hover:opacity-90 text-sm font-medium'
|
|
249
259
|
>
|
|
@@ -177,6 +177,66 @@ export function applyLineItemRowFormulas(
|
|
|
177
177
|
return out
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
const AUTOFILL_PRICE_KEYS = new Set(['unit_price', 'price', 'sale_price'])
|
|
181
|
+
const AUTOFILL_COST_KEYS = new Set(['cost', 'unit_cost', 'catalog_cost', 'catalog_price'])
|
|
182
|
+
|
|
183
|
+
function metaNumber(meta: Record<string, unknown>, keys: string[]): number | undefined {
|
|
184
|
+
for (const k of keys) {
|
|
185
|
+
const v = meta[k]
|
|
186
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
187
|
+
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
|
188
|
+
}
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Al elegir un producto en una grilla declarativa, copia precio/costo/SKU/tasa
|
|
194
|
+
* que vengan en `option.meta` a las columnas hermanas y recalcula el importe.
|
|
195
|
+
* No-op si el meta no trae datos útiles (el journal entry no se toca).
|
|
196
|
+
*/
|
|
197
|
+
export function autofillLineFromOption(
|
|
198
|
+
itemFields: ActionFieldDef[],
|
|
199
|
+
row: Record<string, any>,
|
|
200
|
+
option: { label?: string; meta?: Record<string, unknown> },
|
|
201
|
+
): Record<string, any> {
|
|
202
|
+
const meta = option.meta
|
|
203
|
+
if (!meta || Object.keys(meta).length === 0) return row
|
|
204
|
+
const next: Record<string, any> = { ...row }
|
|
205
|
+
let changed = false
|
|
206
|
+
for (const f of itemFields) {
|
|
207
|
+
if (AUTOFILL_PRICE_KEYS.has(f.key)) {
|
|
208
|
+
const v = metaNumber(meta, ['unit_price', 'price', 'sale_price'])
|
|
209
|
+
if (v != null) {
|
|
210
|
+
next[f.key] = v
|
|
211
|
+
changed = true
|
|
212
|
+
}
|
|
213
|
+
} else if (AUTOFILL_COST_KEYS.has(f.key)) {
|
|
214
|
+
const v = metaNumber(meta, ['cost', 'unit_cost', 'catalog_price', 'catalog_cost'])
|
|
215
|
+
if (v != null) {
|
|
216
|
+
next[f.key] = v
|
|
217
|
+
changed = true
|
|
218
|
+
}
|
|
219
|
+
} else if (f.key === 'tax_rate' || f.key === 'tax') {
|
|
220
|
+
const v = metaNumber(meta, ['tax_rate', 'tax'])
|
|
221
|
+
if (v != null) {
|
|
222
|
+
next[f.key] = v
|
|
223
|
+
changed = true
|
|
224
|
+
}
|
|
225
|
+
} else if (f.key === 'sku' || f.key === 'code') {
|
|
226
|
+
const v = meta.sku ?? meta.code
|
|
227
|
+
if (typeof v === 'string' && v.trim()) {
|
|
228
|
+
next[f.key] = v.trim()
|
|
229
|
+
changed = true
|
|
230
|
+
}
|
|
231
|
+
} else if ((f.key === 'description' || f.key === 'name') && !next[f.key] && option.label) {
|
|
232
|
+
next[f.key] = option.label
|
|
233
|
+
changed = true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!changed) return row
|
|
237
|
+
return applyLineItemRowFormulas(itemFields, next)
|
|
238
|
+
}
|
|
239
|
+
|
|
180
240
|
export interface BalanceState {
|
|
181
241
|
debit: number
|
|
182
242
|
credit: number
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getItemFields,
|
|
27
27
|
computeLineItemTotals,
|
|
28
28
|
applyLineItemRowFormulas,
|
|
29
|
+
autofillLineFromOption,
|
|
29
30
|
evaluateBalance,
|
|
30
31
|
toNumber,
|
|
31
32
|
getDependsOn,
|
|
@@ -139,6 +140,16 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
139
140
|
updateCell(idx, key, cellValue)
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
const handlePick = (idx: number, key: string, opt: ResolvedOption) => {
|
|
144
|
+
onChange(
|
|
145
|
+
rows.map((r, i) =>
|
|
146
|
+
i === idx
|
|
147
|
+
? autofillLineFromOption(itemFields, { ...r, [key]: String(opt.id) }, opt)
|
|
148
|
+
: r,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
142
153
|
const totalCols = itemFields.filter((c) => c.total)
|
|
143
154
|
|
|
144
155
|
return (
|
|
@@ -189,6 +200,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
189
200
|
field={col}
|
|
190
201
|
value={row?.[col.key]}
|
|
191
202
|
onChange={(v: any) => handleCell(idx, col.key, v)}
|
|
203
|
+
onPick={(opt) => handlePick(idx, col.key, opt)}
|
|
192
204
|
disabled={disabled}
|
|
193
205
|
formValues={formValues}
|
|
194
206
|
rowValues={row}
|
|
@@ -260,6 +272,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
260
272
|
field={col}
|
|
261
273
|
value={row?.[col.key]}
|
|
262
274
|
onChange={(v: any) => handleCell(idx, col.key, v)}
|
|
275
|
+
onPick={(opt) => handlePick(idx, col.key, opt)}
|
|
263
276
|
disabled={disabled}
|
|
264
277
|
formValues={formValues}
|
|
265
278
|
rowValues={row}
|
|
@@ -377,6 +390,7 @@ interface CellRendererProps {
|
|
|
377
390
|
field: ActionFieldDef
|
|
378
391
|
value: any
|
|
379
392
|
onChange: (v: any) => void
|
|
393
|
+
onPick?: (opt: ResolvedOption) => void
|
|
380
394
|
disabled?: boolean
|
|
381
395
|
/** Header form values — for resolving a cell's `dependsOn` to a header field. */
|
|
382
396
|
formValues?: Record<string, any>
|
|
@@ -390,7 +404,7 @@ interface CellRendererProps {
|
|
|
390
404
|
// without the per-field Label (the column header is the label) and sized for a
|
|
391
405
|
// table cell. Nested line-items inside a row are not supported (a row column is
|
|
392
406
|
// a scalar widget).
|
|
393
|
-
function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) {
|
|
407
|
+
function CellRenderer({ field, value, onChange, onPick, disabled, formValues, rowValues, invalid }: CellRendererProps) {
|
|
394
408
|
const widget = resolveWidget(field)
|
|
395
409
|
const invalidCls = invalid
|
|
396
410
|
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
|
|
@@ -434,6 +448,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
|
|
|
434
448
|
field={field}
|
|
435
449
|
value={value}
|
|
436
450
|
onChange={onChange}
|
|
451
|
+
onPick={onPick}
|
|
437
452
|
dependsValue={dependsValue}
|
|
438
453
|
readonly={ro}
|
|
439
454
|
invalid={invalid}
|
|
@@ -446,6 +461,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
|
|
|
446
461
|
field={field}
|
|
447
462
|
value={value}
|
|
448
463
|
onChange={onChange}
|
|
464
|
+
onPick={onPick}
|
|
449
465
|
disabled={disabled}
|
|
450
466
|
formValues={formValues}
|
|
451
467
|
rowValues={rowValues}
|
|
@@ -541,7 +557,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
|
|
|
541
557
|
}
|
|
542
558
|
}
|
|
543
559
|
|
|
544
|
-
function RefCell({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
|
|
560
|
+
function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValues }: CellRendererProps) {
|
|
545
561
|
// Cascade: resolve the value of the field this cell `dependsOn` from the
|
|
546
562
|
// row (sibling) first, then the header form. While empty, the picker is
|
|
547
563
|
// disabled with a hint instead of listing the whole (unscoped) table.
|
|
@@ -581,7 +597,11 @@ function RefCell({ field, value, onChange, disabled, formValues, rowValues }: Ce
|
|
|
581
597
|
return (
|
|
582
598
|
<Select
|
|
583
599
|
value={value || ''}
|
|
584
|
-
onValueChange={
|
|
600
|
+
onValueChange={(v) => {
|
|
601
|
+
onChange(v)
|
|
602
|
+
const opt = options.find((o) => String(o.id) === String(v))
|
|
603
|
+
if (opt) onPick?.(opt)
|
|
604
|
+
}}
|
|
585
605
|
disabled={disabled || loading || blockedByDependency}
|
|
586
606
|
>
|
|
587
607
|
<SelectTrigger className="w-full" data-depends-blocked={blockedByDependency ? '' : undefined}>
|
|
@@ -149,6 +149,8 @@ export interface DynamicSelectFieldProps {
|
|
|
149
149
|
field: ActionFieldDef
|
|
150
150
|
value: any
|
|
151
151
|
onChange: (v: any) => void
|
|
152
|
+
/** Opción completa recién elegida (incluye `meta` para autollenar columnas hermanas). */
|
|
153
|
+
onPick?: (opt: ResolvedOption) => void
|
|
152
154
|
/**
|
|
153
155
|
* Pre-resolved option for the CURRENT value (label + image/color/icon) the
|
|
154
156
|
* caller already has — e.g. the relation sibling the table served. Lets the
|
|
@@ -212,6 +214,7 @@ export function DynamicSelectField({
|
|
|
212
214
|
field,
|
|
213
215
|
value,
|
|
214
216
|
onChange,
|
|
217
|
+
onPick,
|
|
215
218
|
seedOption,
|
|
216
219
|
dependsValue,
|
|
217
220
|
dependsHint,
|
|
@@ -327,6 +330,7 @@ export function DynamicSelectField({
|
|
|
327
330
|
const handlePick = (opt: ResolvedOption) => {
|
|
328
331
|
setPicked(opt)
|
|
329
332
|
onChange(String(opt.id))
|
|
333
|
+
onPick?.(opt)
|
|
330
334
|
setOpen(false)
|
|
331
335
|
setSearch('')
|
|
332
336
|
}
|
package/src/index.ts
CHANGED
|
@@ -188,6 +188,14 @@ export {
|
|
|
188
188
|
type ModelActionToolbarProps,
|
|
189
189
|
type ActionPlacement,
|
|
190
190
|
} from './model-action-toolbar'
|
|
191
|
+
export {
|
|
192
|
+
resolveListPrimaryAction,
|
|
193
|
+
actionReplacesCreate,
|
|
194
|
+
type ListPrimaryActionInput,
|
|
195
|
+
type ListPrimaryActionResult,
|
|
196
|
+
type ListActionRef,
|
|
197
|
+
type CreateMode,
|
|
198
|
+
} from './list-primary-action'
|
|
191
199
|
export * from './addon-loader'
|
|
192
200
|
export {
|
|
193
201
|
PURGE_ADDON_MESSAGE,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Una sola acción primaria de alta por pantalla (UX-1).
|
|
2
|
+
// Puro: DynamicCRUDPage y ModelActionToolbar lo usan, y el shell de ops
|
|
3
|
+
// (`/m/$model`) debe llamarlo también para no pintar un segundo «Crear».
|
|
4
|
+
|
|
5
|
+
export type CreateMode = 'generic' | 'hidden' | 'action'
|
|
6
|
+
|
|
7
|
+
export interface ListActionRef {
|
|
8
|
+
key: string
|
|
9
|
+
placement?: string
|
|
10
|
+
/** El kernel sirve snake_case. CamelCase se acepta por si el host ya lo normalizó. */
|
|
11
|
+
replaces_create?: boolean
|
|
12
|
+
replacesCreate?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ListPrimaryActionInput {
|
|
16
|
+
/** `metadata.enableCRUDActions`. Sin él no hay Crear genérico. */
|
|
17
|
+
enableCRUD?: boolean
|
|
18
|
+
/** `metadata.canCreate`. `false` oculta el genérico; `undefined` no cambia nada. */
|
|
19
|
+
canCreate?: boolean
|
|
20
|
+
hideCreate?: boolean
|
|
21
|
+
createMode?: CreateMode
|
|
22
|
+
/** Si apunta a una acción de toolbar, esa es la primaria y oculta el genérico. */
|
|
23
|
+
primaryActionKey?: string
|
|
24
|
+
actions?: ListActionRef[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ListPrimaryActionResult {
|
|
28
|
+
showGenericCreate: boolean
|
|
29
|
+
primaryAction?: ListActionRef
|
|
30
|
+
/** Acciones de toolbar que no son la primaria (siguen visibles, en secundario). */
|
|
31
|
+
secondaryActions: ListActionRef[]
|
|
32
|
+
/** Más de una acción reclama ser la primaria. Solo una se usa. */
|
|
33
|
+
conflict: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `placement: create`, o el flag declarativo para una acción que hoy es `table`. */
|
|
37
|
+
export function actionReplacesCreate(action: ListActionRef): boolean {
|
|
38
|
+
return action.placement === 'create' || action.replaces_create === true || action.replacesCreate === true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isToolbar(action: ListActionRef): boolean {
|
|
42
|
+
const placement = action.placement ?? 'row'
|
|
43
|
+
return placement === 'table' || placement === 'create' || actionReplacesCreate(action)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveListPrimaryAction(input: ListPrimaryActionInput): ListPrimaryActionResult {
|
|
47
|
+
const actions = input.actions ?? []
|
|
48
|
+
const claimants = actions.filter(actionReplacesCreate)
|
|
49
|
+
|
|
50
|
+
let primary: ListActionRef | undefined
|
|
51
|
+
if (input.primaryActionKey) {
|
|
52
|
+
const keyed = actions.find((a) => a.key === input.primaryActionKey)
|
|
53
|
+
if (keyed && isToolbar(keyed)) primary = keyed
|
|
54
|
+
}
|
|
55
|
+
if (!primary && claimants.length > 0) primary = claimants[0]
|
|
56
|
+
|
|
57
|
+
const hideGeneric =
|
|
58
|
+
input.hideCreate === true ||
|
|
59
|
+
input.canCreate === false ||
|
|
60
|
+
input.createMode === 'hidden' ||
|
|
61
|
+
input.createMode === 'action' ||
|
|
62
|
+
input.enableCRUD !== true ||
|
|
63
|
+
primary != null
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
showGenericCreate: !hideGeneric,
|
|
67
|
+
primaryAction: primary,
|
|
68
|
+
secondaryActions: actions.filter((a) => isToolbar(a) && a.key !== primary?.key),
|
|
69
|
+
conflict: claimants.length > 1,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useRequiresAddonLabel,
|
|
33
33
|
} from './requires-addon'
|
|
34
34
|
import type { ActionDefinition, ActionMetadata, RequiresAddon, TableMetadata } from './types'
|
|
35
|
+
import { resolveListPrimaryAction } from './list-primary-action'
|
|
35
36
|
|
|
36
37
|
export type ActionPlacement = 'row' | 'table' | 'create'
|
|
37
38
|
|
|
@@ -58,6 +59,12 @@ export interface ModelActionToolbarProps {
|
|
|
58
59
|
* lazy load without blocking first paint of the list page.
|
|
59
60
|
*/
|
|
60
61
|
onActionIntent?: (action: ActionDefinition) => void
|
|
62
|
+
/**
|
|
63
|
+
* Acción que queda como único botón primario (`data-primary`). El resto de
|
|
64
|
+
* las que reclaman el alta bajan a `outline`. Lo resuelve igual
|
|
65
|
+
* `resolveListPrimaryAction`.
|
|
66
|
+
*/
|
|
67
|
+
primaryActionKey?: string
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
const DEFAULT_PLACEMENTS: ActionPlacement[] = ['table', 'create']
|
|
@@ -139,6 +146,7 @@ export function ModelActionToolbar({
|
|
|
139
146
|
onChange,
|
|
140
147
|
className,
|
|
141
148
|
onActionIntent,
|
|
149
|
+
primaryActionKey,
|
|
142
150
|
}: ModelActionToolbarProps) {
|
|
143
151
|
const { t } = useTranslation()
|
|
144
152
|
const all = useModelActions(model, placements, actions)
|
|
@@ -153,6 +161,11 @@ export function ModelActionToolbar({
|
|
|
153
161
|
const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
|
|
154
162
|
const requiresAddonLabel = useRequiresAddonLabel()
|
|
155
163
|
const dataEndpoint = endpoint ?? `/data/${model}/me`
|
|
164
|
+
const primary = resolveListPrimaryAction({
|
|
165
|
+
enableCRUD: true,
|
|
166
|
+
primaryActionKey,
|
|
167
|
+
actions: surfaced,
|
|
168
|
+
})
|
|
156
169
|
|
|
157
170
|
if (surfaced.length === 0) return null
|
|
158
171
|
|
|
@@ -160,16 +173,18 @@ export function ModelActionToolbar({
|
|
|
160
173
|
<>
|
|
161
174
|
<div className={className ?? 'flex items-center gap-2'}>
|
|
162
175
|
{surfaced.map((a) => {
|
|
163
|
-
const
|
|
176
|
+
const isPrimary = primary.primaryAction?.key === a.key
|
|
177
|
+
const isCreate = isPrimary || (a.placement ?? 'row') === 'create'
|
|
164
178
|
// Locked while its optional addon is missing: still shown,
|
|
165
179
|
// but a click explains + offers install instead of running.
|
|
166
180
|
const requirement = resolveRequiresAddon(a)
|
|
167
181
|
return (
|
|
168
182
|
<Button
|
|
169
183
|
key={a.key}
|
|
170
|
-
variant={
|
|
184
|
+
variant={isPrimary ? 'default' : 'outline'}
|
|
185
|
+
data-primary={isPrimary ? 'true' : undefined}
|
|
171
186
|
data-aby-target={
|
|
172
|
-
|
|
187
|
+
isPrimary
|
|
173
188
|
? `model.${model}.create`
|
|
174
189
|
: `model.${model}.action.${a.key}`
|
|
175
190
|
}
|
|
@@ -24,6 +24,13 @@ export interface ModelExtension {
|
|
|
24
24
|
headerExtras?: React.ComponentType<ModelExtensionProps>
|
|
25
25
|
toolbarExtras?: React.ComponentType<ModelExtensionProps>
|
|
26
26
|
hideCreate?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* `hidden` quita el Crear genérico. `action` también, porque la acción del
|
|
29
|
+
* módulo (ver `primaryActionKey`) es la alta. `generic` lo deja.
|
|
30
|
+
*/
|
|
31
|
+
createMode?: 'generic' | 'hidden' | 'action'
|
|
32
|
+
/** Acción de toolbar que queda como único botón primario y oculta el Crear genérico. */
|
|
33
|
+
primaryActionKey?: string
|
|
27
34
|
hideExport?: boolean
|
|
28
35
|
hideImport?: boolean
|
|
29
36
|
hideRefresh?: boolean
|
package/src/types.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface TableMetadata {
|
|
|
36
36
|
canExport?: boolean
|
|
37
37
|
canImport?: boolean
|
|
38
38
|
canCreate?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Cómo se pinta el alta genérica. `generic` (o ausente) es el botón Crear.
|
|
41
|
+
* `hidden` lo quita. `action` lo quita porque el módulo trae la suya
|
|
42
|
+
* (`placement: create` o `replaces_create`). Lo sirve el kernel; un host
|
|
43
|
+
* también puede fijarlo con `ModelExtension.createMode` sin cambio de kernel.
|
|
44
|
+
*/
|
|
45
|
+
create_mode?: 'generic' | 'hidden' | 'action'
|
|
39
46
|
/**
|
|
40
47
|
* The model's spreadsheet-import declaration, served by the kernel: the
|
|
41
48
|
* columns of the generated template and the headers accepted when parsing
|
|
@@ -702,6 +709,13 @@ export interface ActionDefinition {
|
|
|
702
709
|
* "create" — toolbar button that replaces the generic create button.
|
|
703
710
|
*/
|
|
704
711
|
placement?: 'row' | 'table' | 'create'
|
|
712
|
+
/**
|
|
713
|
+
* Esta acción de toolbar sustituye al Crear genérico aunque su `placement`
|
|
714
|
+
* sea `table`. El kernel tiene que reenviar el campo; si lo recorta, el
|
|
715
|
+
* host usa `ModelExtension.primaryActionKey` o `createMode`.
|
|
716
|
+
*/
|
|
717
|
+
replaces_create?: boolean
|
|
718
|
+
replacesCreate?: boolean
|
|
705
719
|
/**
|
|
706
720
|
* Optional addon this action depends on, stamped by the host only while it
|
|
707
721
|
* is NOT installed (served as `requires_addon`; read through
|
|
@@ -27,6 +27,11 @@ export interface ResolvedOption {
|
|
|
27
27
|
image?: string | null
|
|
28
28
|
color?: string | null
|
|
29
29
|
icon?: string | null
|
|
30
|
+
/**
|
|
31
|
+
* Campos extra del payload (precio, costo, sku, tasa) que no son decoración.
|
|
32
|
+
* Solo se llena cuando el option trae números o strings además de id/label.
|
|
33
|
+
*/
|
|
34
|
+
meta?: Record<string, unknown>
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface OptionsMeta {
|
|
@@ -331,9 +336,18 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
331
336
|
* id/value and label/name fields for legacy parity — we accept either
|
|
332
337
|
* and surface a stable shape downstream.
|
|
333
338
|
*/
|
|
339
|
+
const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon'])
|
|
340
|
+
|
|
334
341
|
export function projectOption(raw: any): ResolvedOption {
|
|
335
342
|
const id = raw?.id ?? raw?.value ?? ''
|
|
336
343
|
const label = String(raw?.label ?? raw?.name ?? id ?? '')
|
|
344
|
+
const meta: Record<string, unknown> = {}
|
|
345
|
+
if (raw && typeof raw === 'object') {
|
|
346
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
347
|
+
if (OPTION_KNOWN_KEYS.has(k)) continue
|
|
348
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') meta[k] = v
|
|
349
|
+
}
|
|
350
|
+
}
|
|
337
351
|
return {
|
|
338
352
|
id,
|
|
339
353
|
value: raw?.value ?? id,
|
|
@@ -343,5 +357,6 @@ export function projectOption(raw: any): ResolvedOption {
|
|
|
343
357
|
image: raw?.image ?? null,
|
|
344
358
|
color: raw?.color ?? null,
|
|
345
359
|
icon: raw?.icon ?? null,
|
|
360
|
+
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
346
361
|
}
|
|
347
362
|
}
|