@asteby/metacore-runtime-react 37.0.4 → 37.1.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/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +62 -25
- package/dist/branch-create-gate.d.ts +25 -0
- package/dist/branch-create-gate.d.ts.map +1 -0
- package/dist/branch-create-gate.js +24 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +20 -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 +45 -0
- package/dist/dynamic-form.js +12 -2
- package/dist/dynamic-line-items.d.ts +6 -1
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +38 -15
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +35 -0
- package/dist/field-validation-ui.d.ts +22 -0
- package/dist/field-validation-ui.d.ts.map +1 -0
- package/dist/field-validation-ui.js +123 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/package.json +3 -3
- package/src/__tests__/field-validation-ui.test.ts +57 -0
- package/src/__tests__/line-item-formulas.test.ts +43 -0
- package/src/action-modal-dispatcher.tsx +82 -21
- package/src/branch-create-gate.tsx +40 -0
- package/src/dialogs/dynamic-record.tsx +21 -6
- package/src/dynamic-form-schema.ts +51 -0
- package/src/dynamic-form.tsx +12 -4
- package/src/dynamic-line-items.tsx +74 -12
- package/src/dynamic-table.tsx +34 -0
- package/src/field-validation-ui.ts +140 -0
- package/src/index.ts +15 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional host gate for stamping `branch_id` on creates when the sidebar is
|
|
3
|
+
* on "all branches" (no active scope).
|
|
4
|
+
*
|
|
5
|
+
* Ops (and other hosts) provide a provider that opens a confirm + branch
|
|
6
|
+
* picker. The SDK's DynamicRecordDialog / ActionModalDispatcher call
|
|
7
|
+
* `ensureBranchForCreate` right before POST — custom federated modals can use
|
|
8
|
+
* the same hook so POS-style flows stay consistent.
|
|
9
|
+
*
|
|
10
|
+
* Return contract for `ensureBranchForCreate`:
|
|
11
|
+
* - `undefined` — model has no branch_id / gate not needed / already scoped
|
|
12
|
+
* - `string` — branch id to stamp onto the payload
|
|
13
|
+
* - `null` — user cancelled the picker (abort submit)
|
|
14
|
+
*/
|
|
15
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
16
|
+
|
|
17
|
+
export type BranchCreateGateApi = {
|
|
18
|
+
ensureBranchForCreate: (model: string) => Promise<string | null | undefined>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BranchCreateGateContext = createContext<BranchCreateGateApi | null>(null)
|
|
22
|
+
|
|
23
|
+
export function BranchCreateGateProvider({
|
|
24
|
+
value,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
value: BranchCreateGateApi
|
|
28
|
+
children: ReactNode
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<BranchCreateGateContext.Provider value={value}>
|
|
32
|
+
{children}
|
|
33
|
+
</BranchCreateGateContext.Provider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Hook for custom / federated create modals. Returns null when no host gate. */
|
|
38
|
+
export function useBranchCreateGate(): BranchCreateGateApi | null {
|
|
39
|
+
return useContext(BranchCreateGateContext)
|
|
40
|
+
}
|
|
@@ -53,6 +53,7 @@ import { es } from 'date-fns/locale'
|
|
|
53
53
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
|
|
54
54
|
import { BarcodeScanner } from '../barcode-scanner'
|
|
55
55
|
import { useApi } from '../api-context'
|
|
56
|
+
import { useBranchCreateGate } from '../branch-create-gate'
|
|
56
57
|
import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error'
|
|
57
58
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
58
59
|
import { DynamicRelations } from '../dynamic-relations'
|
|
@@ -614,6 +615,7 @@ export function DynamicRecordDialog({
|
|
|
614
615
|
onChange,
|
|
615
616
|
}: DynamicRecordDialogProps) {
|
|
616
617
|
const api = useApi()
|
|
618
|
+
const branchGate = useBranchCreateGate()
|
|
617
619
|
const { t } = useTranslation()
|
|
618
620
|
const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
|
|
619
621
|
schema ? (schema as ModalMetadata) : null,
|
|
@@ -864,11 +866,14 @@ export function DynamicRecordDialog({
|
|
|
864
866
|
for (const f of visible) labels[f.key] = f.label
|
|
865
867
|
const next = localizeFieldErrorMap(bag, t, { labels })
|
|
866
868
|
setFieldErrors(next)
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
869
|
+
const description = Object.entries(next)
|
|
870
|
+
.map(([k, msg]) => {
|
|
871
|
+
const label = labels[k] || labelForKey(k)
|
|
872
|
+
return msg.toLowerCase().startsWith(String(label).toLowerCase())
|
|
873
|
+
? msg
|
|
874
|
+
: `${label}: ${msg}`
|
|
875
|
+
})
|
|
876
|
+
.join(' · ')
|
|
872
877
|
toast.error(
|
|
873
878
|
t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
|
|
874
879
|
description ? { description } : undefined,
|
|
@@ -889,7 +894,17 @@ export function DynamicRecordDialog({
|
|
|
889
894
|
|
|
890
895
|
// Empty reference pickers → null (not "" / nil-UUID) so nullable FK
|
|
891
896
|
// columns accept them instead of raising a 23503 FK violation.
|
|
892
|
-
|
|
897
|
+
let payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields)
|
|
898
|
+
|
|
899
|
+
if (
|
|
900
|
+
isCreate &&
|
|
901
|
+
branchGate &&
|
|
902
|
+
(payload.branch_id == null || payload.branch_id === '')
|
|
903
|
+
) {
|
|
904
|
+
const branchId = await branchGate.ensureBranchForCreate(model)
|
|
905
|
+
if (branchId === null) return
|
|
906
|
+
if (branchId) payload = { ...payload, branch_id: branchId }
|
|
907
|
+
}
|
|
893
908
|
|
|
894
909
|
setSaving(true)
|
|
895
910
|
try {
|
|
@@ -119,6 +119,57 @@ export function computeLineItemTotals(
|
|
|
119
119
|
return totals
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Keys that mean "line net amount" across manifests / locales. */
|
|
123
|
+
const LINE_AMOUNT_KEYS = new Set(['subtotal', 'line_total', 'importe', 'amount', 'total'])
|
|
124
|
+
/** Keys that mean quantity on a line row. */
|
|
125
|
+
const LINE_QTY_KEYS = new Set(['qty', 'quantity', 'cantidad'])
|
|
126
|
+
/** Keys that mean unit price. */
|
|
127
|
+
const LINE_PRICE_KEYS = new Set(['unit_price', 'price', 'precio', 'precio_unitario'])
|
|
128
|
+
/** Keys that mean line discount (absolute). */
|
|
129
|
+
const LINE_DISCOUNT_KEYS = new Set(['discount', 'descuento', 'discount_amount'])
|
|
130
|
+
|
|
131
|
+
function firstKey(rowOrFields: { key?: string }[] | Record<string, unknown>, candidates: Set<string>): string | undefined {
|
|
132
|
+
if (Array.isArray(rowOrFields)) {
|
|
133
|
+
for (const f of rowOrFields) {
|
|
134
|
+
if (f.key && candidates.has(f.key)) return f.key
|
|
135
|
+
}
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
for (const k of Object.keys(rowOrFields)) {
|
|
139
|
+
if (candidates.has(k)) return k
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Live line-amount formula for action / form line-items grids:
|
|
146
|
+
* `(qty|quantity) * (unit_price|…) - (discount|…)` → `subtotal|line_total|importe`.
|
|
147
|
+
*
|
|
148
|
+
* Pure + convention-based so create_sales_order (and similar modals) show
|
|
149
|
+
* Importe as the user types without each manifest declaring a client formula.
|
|
150
|
+
* No-op when the row has no amount column or no qty/price pair.
|
|
151
|
+
*/
|
|
152
|
+
export function applyLineItemRowFormulas(
|
|
153
|
+
itemFields: ActionFieldDef[],
|
|
154
|
+
row: Record<string, any>,
|
|
155
|
+
): Record<string, any> {
|
|
156
|
+
const amountKey =
|
|
157
|
+
firstKey(itemFields, LINE_AMOUNT_KEYS) ?? firstKey(row, LINE_AMOUNT_KEYS)
|
|
158
|
+
const qtyKey = firstKey(itemFields, LINE_QTY_KEYS) ?? firstKey(row, LINE_QTY_KEYS)
|
|
159
|
+
const priceKey =
|
|
160
|
+
firstKey(itemFields, LINE_PRICE_KEYS) ?? firstKey(row, LINE_PRICE_KEYS)
|
|
161
|
+
if (!amountKey || !qtyKey || !priceKey) return row
|
|
162
|
+
|
|
163
|
+
const discountKey =
|
|
164
|
+
firstKey(itemFields, LINE_DISCOUNT_KEYS) ?? firstKey(row, LINE_DISCOUNT_KEYS)
|
|
165
|
+
const qty = toNumber(row[qtyKey])
|
|
166
|
+
const price = toNumber(row[priceKey])
|
|
167
|
+
const discount = discountKey ? toNumber(row[discountKey]) : 0
|
|
168
|
+
const next = Math.round((qty * price - discount) * 100) / 100
|
|
169
|
+
if (toNumber(row[amountKey]) === next) return row
|
|
170
|
+
return { ...row, [amountKey]: next }
|
|
171
|
+
}
|
|
172
|
+
|
|
122
173
|
export interface BalanceState {
|
|
123
174
|
debit: number
|
|
124
175
|
credit: number
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -420,7 +420,10 @@ function FieldRenderer({
|
|
|
420
420
|
case 'switch':
|
|
421
421
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
422
422
|
case 'number':
|
|
423
|
-
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
423
|
+
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
424
|
+
const n = e.target.valueAsNumber
|
|
425
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
426
|
+
}} placeholder={field.placeholder} />
|
|
424
427
|
case 'date':
|
|
425
428
|
return <DynamicDateField field={field} value={value} onChange={onChange} />
|
|
426
429
|
default:
|
|
@@ -463,9 +466,14 @@ function ScannableInput({
|
|
|
463
466
|
id={field.key}
|
|
464
467
|
type={type}
|
|
465
468
|
value={value ?? ''}
|
|
466
|
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
467
|
-
|
|
468
|
-
|
|
469
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
470
|
+
if (type !== 'number') {
|
|
471
|
+
onChange(e.target.value)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
const n = e.target.valueAsNumber
|
|
475
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
476
|
+
}}
|
|
469
477
|
placeholder={field.placeholder}
|
|
470
478
|
/>
|
|
471
479
|
)
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
resolveWidget,
|
|
26
26
|
getItemFields,
|
|
27
27
|
computeLineItemTotals,
|
|
28
|
+
applyLineItemRowFormulas,
|
|
28
29
|
evaluateBalance,
|
|
29
30
|
toNumber,
|
|
30
31
|
getDependsOn,
|
|
@@ -47,6 +48,11 @@ export interface DynamicLineItemsProps {
|
|
|
47
48
|
* (e.g. `source_warehouse_id`), not just a sibling cell on the same row.
|
|
48
49
|
*/
|
|
49
50
|
formValues?: Record<string, any>
|
|
51
|
+
/**
|
|
52
|
+
* Localized validation messages keyed as `rowIndex.columnKey` (e.g.
|
|
53
|
+
* `0.unit_price`). Painted as destructive borders + under-cell text.
|
|
54
|
+
*/
|
|
55
|
+
errors?: Record<string, string>
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
const fmtNumber = (n: number): string =>
|
|
@@ -65,9 +71,10 @@ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
|
|
|
65
71
|
return row
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
export function DynamicLineItems({ field, value, onChange, disabled = false, formValues }: DynamicLineItemsProps) {
|
|
74
|
+
export function DynamicLineItems({ field, value, onChange, disabled = false, formValues, errors }: DynamicLineItemsProps) {
|
|
69
75
|
const itemFields = getItemFields(field)
|
|
70
76
|
const rows: any[] = Array.isArray(value) ? value : []
|
|
77
|
+
const errMap = errors ?? {}
|
|
71
78
|
|
|
72
79
|
// `lock_rows` fixes the row set: no add-row button, no per-row delete. Rows
|
|
73
80
|
// stay editable cell-by-cell. Snake_case is what the kernel serves; tolerate
|
|
@@ -81,10 +88,16 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
81
88
|
const hasTotals = totalKeys.length > 0
|
|
82
89
|
const balance = evaluateBalance(field, rows)
|
|
83
90
|
|
|
84
|
-
const addRow = () => onChange([...rows, emptyRow(itemFields)])
|
|
91
|
+
const addRow = () => onChange([...rows, applyLineItemRowFormulas(itemFields, emptyRow(itemFields))])
|
|
85
92
|
const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx))
|
|
86
93
|
const updateCell = (idx: number, key: string, cellValue: any) =>
|
|
87
|
-
onChange(
|
|
94
|
+
onChange(
|
|
95
|
+
rows.map((r, i) =>
|
|
96
|
+
i === idx
|
|
97
|
+
? applyLineItemRowFormulas(itemFields, { ...r, [key]: cellValue })
|
|
98
|
+
: r,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
88
101
|
|
|
89
102
|
// When a balance rule reconciles two columns (e.g. debit ↔ credit), typing
|
|
90
103
|
// into one clears the sibling on the same row — mirrors the federated modal
|
|
@@ -105,7 +118,13 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
105
118
|
const hasValue = toNumber(cellValue) > 0
|
|
106
119
|
onChange(
|
|
107
120
|
rows.map((r, i) =>
|
|
108
|
-
i === idx
|
|
121
|
+
i === idx
|
|
122
|
+
? applyLineItemRowFormulas(itemFields, {
|
|
123
|
+
...r,
|
|
124
|
+
[key]: cellValue,
|
|
125
|
+
...(hasValue ? { [sibling]: '' } : {}),
|
|
126
|
+
})
|
|
127
|
+
: r,
|
|
109
128
|
),
|
|
110
129
|
)
|
|
111
130
|
return
|
|
@@ -149,7 +168,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
149
168
|
)}
|
|
150
169
|
</div>
|
|
151
170
|
<div className="grid gap-2.5">
|
|
152
|
-
{itemFields.map((col) =>
|
|
171
|
+
{itemFields.map((col) => {
|
|
172
|
+
const cellErr = errMap[`${idx}.${col.key}`]
|
|
173
|
+
return (
|
|
153
174
|
<div key={col.key} className="grid gap-1">
|
|
154
175
|
<span className="text-xs font-medium">
|
|
155
176
|
{col.label}
|
|
@@ -164,9 +185,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
164
185
|
disabled={disabled}
|
|
165
186
|
formValues={formValues}
|
|
166
187
|
rowValues={row}
|
|
188
|
+
invalid={!!cellErr}
|
|
167
189
|
/>
|
|
190
|
+
{cellErr && (
|
|
191
|
+
<p className="text-destructive text-xs">{cellErr}</p>
|
|
192
|
+
)}
|
|
168
193
|
</div>
|
|
169
|
-
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
170
196
|
</div>
|
|
171
197
|
</div>
|
|
172
198
|
))}
|
|
@@ -219,7 +245,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
219
245
|
)}
|
|
220
246
|
{rows.map((row, idx) => (
|
|
221
247
|
<tr key={idx} className="border-t align-top">
|
|
222
|
-
{itemFields.map((col) =>
|
|
248
|
+
{itemFields.map((col) => {
|
|
249
|
+
const cellErr = errMap[`${idx}.${col.key}`]
|
|
250
|
+
return (
|
|
223
251
|
<td key={col.key} className="px-2 py-1.5">
|
|
224
252
|
<CellRenderer
|
|
225
253
|
field={col}
|
|
@@ -228,9 +256,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
|
|
|
228
256
|
disabled={disabled}
|
|
229
257
|
formValues={formValues}
|
|
230
258
|
rowValues={row}
|
|
259
|
+
invalid={!!cellErr}
|
|
231
260
|
/>
|
|
261
|
+
{cellErr && (
|
|
262
|
+
<p className="text-destructive mt-1 text-xs">{cellErr}</p>
|
|
263
|
+
)}
|
|
232
264
|
</td>
|
|
233
|
-
|
|
265
|
+
)
|
|
266
|
+
})}
|
|
234
267
|
{!lockRows && (
|
|
235
268
|
<td className="px-2 py-1.5 text-center">
|
|
236
269
|
<Button
|
|
@@ -342,14 +375,19 @@ interface CellRendererProps {
|
|
|
342
375
|
formValues?: Record<string, any>
|
|
343
376
|
/** This row's values — for resolving a cell's `dependsOn` to a sibling cell. */
|
|
344
377
|
rowValues?: Record<string, any>
|
|
378
|
+
/** Paint the control as invalid (destructive border). */
|
|
379
|
+
invalid?: boolean
|
|
345
380
|
}
|
|
346
381
|
|
|
347
382
|
// Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
|
|
348
383
|
// without the per-field Label (the column header is the label) and sized for a
|
|
349
384
|
// table cell. Nested line-items inside a row are not supported (a row column is
|
|
350
385
|
// a scalar widget).
|
|
351
|
-
function CellRenderer({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
|
|
386
|
+
function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) {
|
|
352
387
|
const widget = resolveWidget(field)
|
|
388
|
+
const invalidCls = invalid
|
|
389
|
+
? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
|
|
390
|
+
: ''
|
|
353
391
|
// Per-field read-only: a column locked by a PrefillSpec.lock (e.g. the
|
|
354
392
|
// "ordered" / "already received" progress columns of a receive-goods modal)
|
|
355
393
|
// renders disabled so it shows context without being editable. Tolerates the
|
|
@@ -384,7 +422,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
384
422
|
// Async searchable picker per row cell — e.g. the account_id column of a
|
|
385
423
|
// journal entry's debit/credit lines. Same widget as the flat form.
|
|
386
424
|
if (widget === 'dynamic_select') {
|
|
387
|
-
return
|
|
425
|
+
return (
|
|
426
|
+
<DynamicSelectField
|
|
427
|
+
field={field}
|
|
428
|
+
value={value}
|
|
429
|
+
onChange={onChange}
|
|
430
|
+
dependsValue={dependsValue}
|
|
431
|
+
readonly={ro}
|
|
432
|
+
invalid={invalid}
|
|
433
|
+
/>
|
|
434
|
+
)
|
|
388
435
|
}
|
|
389
436
|
if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
|
|
390
437
|
return (
|
|
@@ -408,6 +455,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
408
455
|
placeholder={field.placeholder}
|
|
409
456
|
disabled={off}
|
|
410
457
|
rows={2}
|
|
458
|
+
aria-invalid={invalid || undefined}
|
|
459
|
+
className={invalidCls || undefined}
|
|
411
460
|
/>
|
|
412
461
|
)
|
|
413
462
|
case 'color':
|
|
@@ -417,6 +466,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
417
466
|
value={value || '#000000'}
|
|
418
467
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
419
468
|
disabled={off}
|
|
469
|
+
aria-invalid={invalid || undefined}
|
|
470
|
+
className={invalidCls || undefined}
|
|
420
471
|
/>
|
|
421
472
|
)
|
|
422
473
|
case 'select': {
|
|
@@ -425,7 +476,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
425
476
|
if (effectiveOptions && effectiveOptions.length === 0) return null
|
|
426
477
|
return (
|
|
427
478
|
<Select value={value || ''} onValueChange={onChange} disabled={off}>
|
|
428
|
-
<SelectTrigger className=
|
|
479
|
+
<SelectTrigger className={'w-full' + (invalidCls ? ` ${invalidCls}` : '')} aria-invalid={invalid || undefined}>
|
|
429
480
|
<SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
|
|
430
481
|
</SelectTrigger>
|
|
431
482
|
<SelectContent>
|
|
@@ -445,9 +496,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
445
496
|
<Input
|
|
446
497
|
type="number"
|
|
447
498
|
value={value ?? ''}
|
|
448
|
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
499
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
500
|
+
const n = e.target.valueAsNumber
|
|
501
|
+
// `0 || ''` would wipe a legitimate zero (unit_price=0)
|
|
502
|
+
// and then required-validation fires with nothing marked.
|
|
503
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
|
|
504
|
+
}}
|
|
449
505
|
placeholder={field.placeholder}
|
|
450
506
|
disabled={off}
|
|
507
|
+
aria-invalid={invalid || undefined}
|
|
508
|
+
className={invalidCls || undefined}
|
|
451
509
|
/>
|
|
452
510
|
)
|
|
453
511
|
case 'date':
|
|
@@ -457,6 +515,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
457
515
|
value={value || ''}
|
|
458
516
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
459
517
|
disabled={off}
|
|
518
|
+
aria-invalid={invalid || undefined}
|
|
519
|
+
className={invalidCls || undefined}
|
|
460
520
|
/>
|
|
461
521
|
)
|
|
462
522
|
default:
|
|
@@ -467,6 +527,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
467
527
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
|
468
528
|
placeholder={field.placeholder}
|
|
469
529
|
disabled={off}
|
|
530
|
+
aria-invalid={invalid || undefined}
|
|
531
|
+
className={invalidCls || undefined}
|
|
470
532
|
/>
|
|
471
533
|
)
|
|
472
534
|
}
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -367,6 +367,36 @@ export function DynamicTable({
|
|
|
367
367
|
// been adopted, where `dynamicFilters`/`pagination` already mirror the URL,
|
|
368
368
|
// so the write is a no-op and the filter never gets stripped.
|
|
369
369
|
const [urlSynced, setUrlSynced] = useState(false)
|
|
370
|
+
// Keys that left `defaultFilters` (e.g. branch_id when switching to
|
|
371
|
+
// "Todas las sucursales"). The URL write effect used to carry-through every
|
|
372
|
+
// leftover `f_*` from location — that resurrected the locked branch filter
|
|
373
|
+
// forever after the host dropped it from defaultFilters.
|
|
374
|
+
const prevDefaultFilterKeys = useRef<Set<string>>(new Set())
|
|
375
|
+
const releasedDefaultFilterKeys = useRef<Set<string>>(new Set())
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const next = new Set(Object.keys(defaultFilters ?? {}))
|
|
379
|
+
const prev = prevDefaultFilterKeys.current
|
|
380
|
+
const released = new Set<string>()
|
|
381
|
+
prev.forEach((k) => {
|
|
382
|
+
if (!next.has(k)) released.add(k)
|
|
383
|
+
})
|
|
384
|
+
releasedDefaultFilterKeys.current = released
|
|
385
|
+
if (released.size > 0) {
|
|
386
|
+
setDynamicFilters((df) => {
|
|
387
|
+
let changed = false
|
|
388
|
+
const copy = { ...df }
|
|
389
|
+
released.forEach((k) => {
|
|
390
|
+
if (k in copy) {
|
|
391
|
+
delete copy[k]
|
|
392
|
+
changed = true
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
return changed ? copy : df
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
prevDefaultFilterKeys.current = next
|
|
399
|
+
}, [defaultFilters])
|
|
370
400
|
|
|
371
401
|
useEffect(() => {
|
|
372
402
|
if (prevBranchId.current !== currentBranch?.id) {
|
|
@@ -513,9 +543,13 @@ export function DynamicTable({
|
|
|
513
543
|
// Sidebar deep-links (CxC→CxP) push f_party_type=eq:supplier before React
|
|
514
544
|
// re-renders with matching defaultFilters; without this carry-through the
|
|
515
545
|
// write effect races and strips the filter down to bare ?view=list.
|
|
546
|
+
// Do NOT resurrect keys the host just released from defaultFilters
|
|
547
|
+
// (branch_id when switching to "all" — otherwise San Felipe sticks).
|
|
516
548
|
current.forEach((value, key) => {
|
|
517
549
|
if (!key.startsWith('f_')) return
|
|
518
550
|
if (params.has(key)) return
|
|
551
|
+
const filterKey = key.substring(2)
|
|
552
|
+
if (releasedDefaultFilterKeys.current.has(filterKey)) return
|
|
519
553
|
params.set(key, value)
|
|
520
554
|
})
|
|
521
555
|
const search = params.toString()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Shared helpers for painting + toasting client/server field validation so
|
|
2
|
+
// action modals and DynamicRecordDialog stay in lockstep.
|
|
3
|
+
import type { ActionFieldDef } from './types'
|
|
4
|
+
import type { Translate } from './server-error'
|
|
5
|
+
|
|
6
|
+
function itemFieldsOf(field: ActionFieldDef): ActionFieldDef[] {
|
|
7
|
+
const raw = field.itemFields ?? (field as { item_fields?: ActionFieldDef[] }).item_fields
|
|
8
|
+
return Array.isArray(raw) ? raw : []
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function translateLabel(raw: string | undefined, t?: Translate): string {
|
|
12
|
+
if (!raw) return ''
|
|
13
|
+
if (!t) return raw
|
|
14
|
+
return t(raw, { defaultValue: raw })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function humanizeToken(key: string): string {
|
|
18
|
+
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a validation path (`customer_id`, `lines.0.unit_price`) to a
|
|
23
|
+
* human label using the action/modal field schema. Nested line-item paths
|
|
24
|
+
* become "Renglones → Precio unitario (fila 1)".
|
|
25
|
+
*/
|
|
26
|
+
export function labelForValidationPath(
|
|
27
|
+
path: string,
|
|
28
|
+
fields: readonly ActionFieldDef[] | undefined,
|
|
29
|
+
t?: Translate,
|
|
30
|
+
): string {
|
|
31
|
+
if (!path) return ''
|
|
32
|
+
const parts = path.split('.')
|
|
33
|
+
if (!fields?.length) return humanizeToken(path)
|
|
34
|
+
|
|
35
|
+
let cursor: readonly ActionFieldDef[] | undefined = fields
|
|
36
|
+
const bits: string[] = []
|
|
37
|
+
let i = 0
|
|
38
|
+
while (i < parts.length && cursor) {
|
|
39
|
+
const seg = parts[i]!
|
|
40
|
+
if (/^\d+$/.test(seg)) {
|
|
41
|
+
const row = Number(seg) + 1
|
|
42
|
+
const nextKey = parts[i + 1]
|
|
43
|
+
if (nextKey && cursor) {
|
|
44
|
+
const col = cursor.find((f) => f.key === nextKey)
|
|
45
|
+
const colLabel = translateLabel(col?.label, t) || humanizeToken(nextKey)
|
|
46
|
+
bits.push(`${colLabel} (fila ${row})`)
|
|
47
|
+
i += 2
|
|
48
|
+
cursor = undefined
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
bits.push(`fila ${row}`)
|
|
52
|
+
i += 1
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const field = cursor.find((f) => f.key === seg)
|
|
56
|
+
if (!field) {
|
|
57
|
+
bits.push(humanizeToken(parts.slice(i).join('.')))
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
bits.push(translateLabel(field.label, t) || humanizeToken(seg))
|
|
61
|
+
const nested = itemFieldsOf(field)
|
|
62
|
+
cursor = nested.length ? nested : undefined
|
|
63
|
+
i += 1
|
|
64
|
+
if (!cursor && i < parts.length) {
|
|
65
|
+
bits.push(humanizeToken(parts.slice(i).join('.')))
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return bits.join(' → ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build `{path: label}` covering scalar fields and `parent.N.child` templates
|
|
73
|
+
* used by localizeFieldErrorMap (exact keys win when present). */
|
|
74
|
+
export function labelsForValidationFields(
|
|
75
|
+
fields: readonly ActionFieldDef[] | undefined,
|
|
76
|
+
t?: Translate,
|
|
77
|
+
): Record<string, string> {
|
|
78
|
+
const out: Record<string, string> = {}
|
|
79
|
+
if (!fields) return out
|
|
80
|
+
for (const f of fields) {
|
|
81
|
+
if (!f.key) continue
|
|
82
|
+
out[f.key] = translateLabel(f.label, t) || humanizeToken(f.key)
|
|
83
|
+
for (const child of itemFieldsOf(f)) {
|
|
84
|
+
if (!child.key) continue
|
|
85
|
+
out[`${f.key}.${child.key}`] = translateLabel(child.label, t) || humanizeToken(child.key)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Toast description: one "Label: message" line per error. Re-resolves labels
|
|
93
|
+
* for dotted line-item paths so operators see e.g.
|
|
94
|
+
* "Precio unitario (fila 1): es obligatorio" instead of a bare headline.
|
|
95
|
+
*/
|
|
96
|
+
export function formatFieldErrorsDescription(
|
|
97
|
+
errors: Record<string, string>,
|
|
98
|
+
fields?: readonly ActionFieldDef[],
|
|
99
|
+
t?: Translate,
|
|
100
|
+
): string | undefined {
|
|
101
|
+
const entries = Object.entries(errors)
|
|
102
|
+
if (!entries.length) return undefined
|
|
103
|
+
return entries
|
|
104
|
+
.map(([path, msg]) => {
|
|
105
|
+
const label = labelForValidationPath(path, fields, t)
|
|
106
|
+
// localizeFieldIssue already prefixes "{{label}}: …" — avoid
|
|
107
|
+
// "Label: Label: msg" when the message already starts with the label.
|
|
108
|
+
if (label && msg.toLowerCase().startsWith(label.toLowerCase())) return msg
|
|
109
|
+
return label ? `${label}: ${msg}` : msg
|
|
110
|
+
})
|
|
111
|
+
.join(' · ')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drop `key` and every `key.*` / `key.N.*` entry after the operator edits that field. */
|
|
115
|
+
export function clearFieldErrorTree(
|
|
116
|
+
prev: Record<string, string>,
|
|
117
|
+
key: string,
|
|
118
|
+
): Record<string, string> {
|
|
119
|
+
if (!prev[key] && !Object.keys(prev).some((k) => k.startsWith(`${key}.`))) return prev
|
|
120
|
+
const next: Record<string, string> = {}
|
|
121
|
+
for (const [k, v] of Object.entries(prev)) {
|
|
122
|
+
if (k === key || k.startsWith(`${key}.`)) continue
|
|
123
|
+
next[k] = v
|
|
124
|
+
}
|
|
125
|
+
return next
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Slice `parent.0.child` errors into `{ "0.child": msg }` for DynamicLineItems. */
|
|
129
|
+
export function lineItemErrorsFor(
|
|
130
|
+
parentKey: string,
|
|
131
|
+
errors: Record<string, string> | undefined,
|
|
132
|
+
): Record<string, string> {
|
|
133
|
+
if (!errors) return {}
|
|
134
|
+
const prefix = `${parentKey}.`
|
|
135
|
+
const out: Record<string, string> = {}
|
|
136
|
+
for (const [k, v] of Object.entries(errors)) {
|
|
137
|
+
if (k.startsWith(prefix)) out[k.slice(prefix.length)] = v
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,13 @@ export {
|
|
|
40
40
|
type ValidationSpec,
|
|
41
41
|
} from './validator'
|
|
42
42
|
export { VALIDATION_CATALOGS, validationCatalog, validationMessageKey } from './validation-catalog'
|
|
43
|
+
export {
|
|
44
|
+
labelForValidationPath,
|
|
45
|
+
labelsForValidationFields,
|
|
46
|
+
formatFieldErrorsDescription,
|
|
47
|
+
clearFieldErrorTree,
|
|
48
|
+
lineItemErrorsFor,
|
|
49
|
+
} from './field-validation-ui'
|
|
43
50
|
export * from './dynamic-table'
|
|
44
51
|
export {
|
|
45
52
|
DynamicKanban,
|
|
@@ -378,7 +385,15 @@ export {
|
|
|
378
385
|
scopeValueFromFilterToken,
|
|
379
386
|
buildListScopeValues,
|
|
380
387
|
evaluateVisibleWhenForListScope,
|
|
388
|
+
applyLineItemRowFormulas,
|
|
389
|
+
computeLineItemTotals,
|
|
390
|
+
toNumber,
|
|
381
391
|
} from './dynamic-form-schema'
|
|
392
|
+
export {
|
|
393
|
+
BranchCreateGateProvider,
|
|
394
|
+
useBranchCreateGate,
|
|
395
|
+
type BranchCreateGateApi,
|
|
396
|
+
} from './branch-create-gate'
|
|
382
397
|
export {
|
|
383
398
|
ActivityValueRenderer,
|
|
384
399
|
type ActivityValueRendererProps,
|