@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +62 -25
  4. package/dist/branch-create-gate.d.ts +25 -0
  5. package/dist/branch-create-gate.d.ts.map +1 -0
  6. package/dist/branch-create-gate.js +24 -0
  7. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  8. package/dist/dialogs/dynamic-record.js +20 -6
  9. package/dist/dynamic-form-schema.d.ts +9 -0
  10. package/dist/dynamic-form-schema.d.ts.map +1 -1
  11. package/dist/dynamic-form-schema.js +45 -0
  12. package/dist/dynamic-form.js +12 -2
  13. package/dist/dynamic-line-items.d.ts +6 -1
  14. package/dist/dynamic-line-items.d.ts.map +1 -1
  15. package/dist/dynamic-line-items.js +38 -15
  16. package/dist/dynamic-table.d.ts.map +1 -1
  17. package/dist/dynamic-table.js +35 -0
  18. package/dist/field-validation-ui.d.ts +22 -0
  19. package/dist/field-validation-ui.d.ts.map +1 -0
  20. package/dist/field-validation-ui.js +123 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -1
  24. package/package.json +3 -3
  25. package/src/__tests__/field-validation-ui.test.ts +57 -0
  26. package/src/__tests__/line-item-formulas.test.ts +43 -0
  27. package/src/action-modal-dispatcher.tsx +82 -21
  28. package/src/branch-create-gate.tsx +40 -0
  29. package/src/dialogs/dynamic-record.tsx +21 -6
  30. package/src/dynamic-form-schema.ts +51 -0
  31. package/src/dynamic-form.tsx +12 -4
  32. package/src/dynamic-line-items.tsx +74 -12
  33. package/src/dynamic-table.tsx +34 -0
  34. package/src/field-validation-ui.ts +140 -0
  35. 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 visibleKeys = new Set(visible.map(f => f.key))
868
- const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k))
869
- const description = orphans.length
870
- ? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
871
- : undefined
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
- const payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields)
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
@@ -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>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
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
- onChange(type === 'number' ? e.target.valueAsNumber || '' : e.target.value)
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(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)))
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 ? { ...r, [key]: cellValue, ...(hasValue ? { [sibling]: '' } : {}) } : r,
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 <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} readonly={ro} />
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="w-full">
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>) => onChange(e.target.valueAsNumber || '')}
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
  }
@@ -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,