@asteby/metacore-runtime-react 37.0.3 → 37.0.5

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.
@@ -40,6 +40,13 @@ import { toast } from 'sonner'
40
40
  import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
41
  import type { Translate } from './server-error'
42
42
  import { validateValues, bagHasErrors } from './validator'
43
+ import {
44
+ clearFieldErrorTree,
45
+ formatFieldErrorsDescription,
46
+ labelsForValidationFields,
47
+ labelForValidationPath,
48
+ lineItemErrorsFor,
49
+ } from './field-validation-ui'
43
50
  import { validationCatalog } from './validation-catalog'
44
51
  import { useApi } from './api-context'
45
52
  import { DynamicIcon } from './dynamic-icon'
@@ -621,33 +628,43 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
621
628
 
622
629
  const updateField = (key: string, value: any) => {
623
630
  setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
624
- setFieldErrors(prev => {
625
- if (!prev[key]) return prev
626
- const next = { ...prev }
627
- delete next[key]
628
- return next
629
- })
631
+ setFieldErrors((prev) => clearFieldErrorTree(prev, key))
630
632
  }
631
633
 
632
634
  const lang = i18n.language
633
635
  const handleActionError = (err: unknown) => {
636
+ const labels = labelsForValidationFields(action.fields, t)
634
637
  const localized = localizeActionFieldErrors(err, action.fields, t, lang)
635
638
  if (localized) {
636
- setFieldErrors(localized)
637
- toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
639
+ // Enrich labels for dotted line-item paths before toasting.
640
+ const withPathLabels: Record<string, string> = {}
641
+ for (const [path, msg] of Object.entries(localized)) {
642
+ withPathLabels[path] = msg
643
+ if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t)
644
+ }
645
+ setFieldErrors(withPathLabels)
646
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
647
+ description: formatFieldErrorsDescription(withPathLabels, action.fields, t),
648
+ })
638
649
  return
639
650
  }
640
- toastServerError(err, { t, language: lang })
651
+ toastServerError(err, { t, language: lang, labels })
641
652
  }
642
653
 
643
654
  const execute = async () => {
644
655
  if (action.fields) {
645
656
  const bag = validateValues(action.fields, formData)
646
657
  if (bagHasErrors(bag)) {
647
- const labels: Record<string, string> = {}
648
- for (const f of action.fields) labels[f.key] = tl(f.label)
649
- setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
650
- toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }))
658
+ const labels = labelsForValidationFields(action.fields, (k, o) => t(k, o))
659
+ // Exact dotted keys (`lines.0.qty`) need path-aware labels.
660
+ for (const path of Object.keys(bag)) {
661
+ if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t)
662
+ }
663
+ const next = localizeFieldErrorMap(bag, t, { labels, language: lang })
664
+ setFieldErrors(next)
665
+ toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
666
+ description: formatFieldErrorsDescription(next, action.fields, t),
667
+ })
651
668
  return
652
669
  }
653
670
  }
@@ -727,7 +744,14 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
727
744
  <FieldLabel htmlFor={field.key} required={field.required}>
728
745
  {tl(field.label)}
729
746
  </FieldLabel>
730
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)}
747
+ {renderField(
748
+ field,
749
+ formData[field.key],
750
+ (v: any) => updateField(field.key, v),
751
+ formData,
752
+ record,
753
+ fieldErrors,
754
+ )}
731
755
  {fieldErrors[field.key] && (
732
756
  <p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
733
757
  )}
@@ -1011,17 +1035,30 @@ function renderField(
1011
1035
  // then treated as having no resolvable dependency).
1012
1036
  formValues?: Record<string, any>,
1013
1037
  record?: Record<string, any>,
1038
+ fieldErrors?: Record<string, string>,
1014
1039
  ) {
1015
1040
  // Repeatable line-items group → row grid (value is an array of row objects).
1016
1041
  // The header form values flow in so a cell can depend on a header field.
1017
1042
  if (isLineItemsField(field)) {
1018
- return <DynamicLineItems field={applyPrefillLock(field)} value={value} onChange={onChange} formValues={formValues} />
1043
+ return (
1044
+ <DynamicLineItems
1045
+ field={applyPrefillLock(field)}
1046
+ value={value}
1047
+ onChange={onChange}
1048
+ formValues={formValues}
1049
+ errors={lineItemErrorsFor(field.key, fieldErrors)}
1050
+ />
1051
+ )
1019
1052
  }
1020
1053
  // Resolve the widget the same way DynamicForm does (explicit widget wins,
1021
1054
  // else inferred from type) so action modals and the standalone form stay in
1022
1055
  // lockstep — previously this switch keyed off `field.type` and silently
1023
1056
  // dropped `dynamic_select` to a plain text input.
1024
1057
  const widget = resolveWidget(field)
1058
+ const invalid = !!(fieldErrors && fieldErrors[field.key])
1059
+ const invalidCls = invalid
1060
+ ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
1061
+ : ''
1025
1062
  if (widget === 'dynamic_select') {
1026
1063
  // A header-level dynamic_select may itself depend on another header
1027
1064
  // field; resolve its filter_value from the form context.
@@ -1035,6 +1072,7 @@ function renderField(
1035
1072
  onChange={onChange}
1036
1073
  dependsValue={dependsValue}
1037
1074
  seedOption={seedOptionFromRecord(field, value, record)}
1075
+ invalid={invalid}
1038
1076
  />
1039
1077
  )
1040
1078
  }
@@ -1045,11 +1083,11 @@ function renderField(
1045
1083
  }
1046
1084
  switch (widget) {
1047
1085
  case 'textarea':
1048
- return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
1086
+ return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
1049
1087
  case 'select':
1050
1088
  return (
1051
1089
  <Select value={value || ''} onValueChange={onChange}>
1052
- <SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
1090
+ <SelectTrigger className={'w-full' + (invalidCls ? ` ${invalidCls}` : '')} aria-invalid={invalid || undefined}><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
1053
1091
  <SelectContent>
1054
1092
  {field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
1055
1093
  </SelectContent>
@@ -1058,12 +1096,15 @@ function renderField(
1058
1096
  case 'switch':
1059
1097
  return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
1060
1098
  case 'number':
1061
- return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
1099
+ return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
1100
+ const n = e.target.valueAsNumber
1101
+ onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
1102
+ }} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
1062
1103
  case 'date':
1063
1104
  // Modern shadcn Calendar in a Popover (portaled, never clipped by the
1064
1105
  // modal) instead of the native, dated, easily-cut <input type=date>.
1065
1106
  return <DynamicDateField field={field} value={value} onChange={onChange} />
1066
1107
  default:
1067
- return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
1108
+ return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} aria-invalid={invalid || undefined} className={invalidCls || undefined} />
1068
1109
  }
1069
1110
  }
@@ -9,7 +9,7 @@
9
9
  // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
10
10
  // the image-url resolver and the org IANA timezone — are passed as props so the
11
11
  // SDK stays transport- and host-agnostic.
12
- import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
12
+ import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
13
13
  import { useTranslation } from 'react-i18next'
14
14
  import type { ModelSchema } from './types'
15
15
 
@@ -53,7 +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 { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
56
+ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error'
57
57
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
58
58
  import { DynamicRelations } from '../dynamic-relations'
59
59
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
@@ -64,6 +64,7 @@ import { FieldSection, WizardProgress } from '../form-layout-ui'
64
64
  import { FieldCell } from '../field-grid'
65
65
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
66
66
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
67
+ import { validateValues, bagHasErrors } from '../validator'
67
68
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
68
69
  import { IconPickerField } from '../icon-picker-field'
69
70
  import { humanizeToken } from '../dynamic-columns-helpers'
@@ -224,6 +225,12 @@ export interface DynamicRecordDialogProps {
224
225
  * lets it close while the depth lock is held.
225
226
  */
226
227
  nestedInlineCreateSelf?: boolean
228
+ /**
229
+ * Fields merged into the modal schema after load (by key). Existing keys are
230
+ * shallow-merged; missing keys are prepended. Hosts use this to inject
231
+ * required scope fields (e.g. branch_id) omitted from compiled DefineModal.
232
+ */
233
+ ensureFields?: FieldDef[]
227
234
  mode: 'view' | 'edit' | 'create'
228
235
  model: string
229
236
  recordId?: string | null
@@ -567,10 +574,26 @@ export function stripHiddenFieldValues(
567
574
  return out
568
575
  }
569
576
 
577
+ function applyEnsureFields(meta: ModalMetadata | null | undefined, ensureFields?: FieldDef[]): ModalMetadata | null {
578
+ if (!meta) return meta ?? null
579
+ if (!ensureFields?.length) return meta
580
+ const fields = Array.isArray(meta.fields) ? [...meta.fields] : []
581
+ for (const ensure of ensureFields) {
582
+ const idx = fields.findIndex((f) => f?.key === ensure.key)
583
+ if (idx >= 0) {
584
+ fields[idx] = { ...fields[idx], ...ensure }
585
+ } else {
586
+ fields.unshift(ensure)
587
+ }
588
+ }
589
+ return { ...meta, fields }
590
+ }
591
+
570
592
  export function DynamicRecordDialog({
571
593
  open,
572
594
  onOpenChange,
573
595
  nestedInlineCreateSelf,
596
+ ensureFields,
574
597
  mode,
575
598
  model,
576
599
  recordId,
@@ -602,6 +625,9 @@ export function DynamicRecordDialog({
602
625
  // inline under each input; populated from a 422 `errors` map or the client
603
626
  // required-field check, cleared per-field on change and wholesale on reopen.
604
627
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
628
+ // Unique form id per dialog instance — nested create must not share
629
+ // id={formId} or the child footer submits the parent.
630
+ const formId = useId()
605
631
  const [loading, setLoading] = useState(false)
606
632
  const [saving, setSaving] = useState(false)
607
633
  const [deleting, setDeleting] = useState(false)
@@ -663,6 +689,7 @@ export function DynamicRecordDialog({
663
689
  if (cancelled) return
664
690
  meta = metaRes.data?.data ?? metaRes.data
665
691
  }
692
+ meta = applyEnsureFields(meta, ensureFields)
666
693
  setModalMeta(meta)
667
694
 
668
695
  if (isCreate) {
@@ -710,7 +737,7 @@ export function DynamicRecordDialog({
710
737
  // initialRecord intentionally omitted: the row identity is captured per open
711
738
  // via recordId; re-seeding mid-open would clobber edits.
712
739
  // eslint-disable-next-line react-hooks/exhaustive-deps
713
- }, [open, recordId, model, endpoint, isCreate, schema])
740
+ }, [open, recordId, model, endpoint, isCreate, schema, ensureFields])
714
741
 
715
742
  // Reset when closed
716
743
  useEffect(() => {
@@ -807,7 +834,17 @@ export function DynamicRecordDialog({
807
834
  next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
808
835
  }
809
836
  setFieldErrors(next)
810
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
837
+ const visibleKeys = new Set(
838
+ filterVisibleFields(modalMeta?.fields ?? [], mode, formValues).map(f => f.key),
839
+ )
840
+ const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k))
841
+ const description = orphans.length
842
+ ? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ')
843
+ : undefined
844
+ toast.error(
845
+ t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
846
+ description ? { description } : undefined,
847
+ )
811
848
  return
812
849
  }
813
850
  toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
@@ -818,20 +855,27 @@ export function DynamicRecordDialog({
818
855
  if (!modalMeta) return
819
856
 
820
857
  if (isEditable) {
821
- // Collect ALL missing required fields (not just the first) and mark
822
- // each inline instead of a single toast. Only CURRENTLY-VISIBLE
823
- // fields are gated: a field hidden by its `visible_when` predicate
824
- // must not block submit even when it is declared required (matching
825
- // the render, which drops it via the same filter).
826
- const missing: Record<string, string> = {}
827
- for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
828
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
829
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
830
- }
831
- }
832
- if (Object.keys(missing).length) {
833
- setFieldErrors(missing)
834
- toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
858
+ // Laravel-style: collect every issue from the shared validator
859
+ // (required + rule strings / min/max / email…) on visible fields only.
860
+ const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
861
+ const bag = validateValues(visible as ActionFieldDef[], formValues)
862
+ if (bagHasErrors(bag)) {
863
+ const labels: Record<string, string> = {}
864
+ for (const f of visible) labels[f.key] = f.label
865
+ const next = localizeFieldErrorMap(bag, t, { labels })
866
+ setFieldErrors(next)
867
+ const description = Object.entries(next)
868
+ .map(([k, msg]) => {
869
+ const label = labels[k] || labelForKey(k)
870
+ return msg.toLowerCase().startsWith(String(label).toLowerCase())
871
+ ? msg
872
+ : `${label}: ${msg}`
873
+ })
874
+ .join(' · ')
875
+ toast.error(
876
+ t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }),
877
+ description ? { description } : undefined,
878
+ )
835
879
  return
836
880
  }
837
881
  }
@@ -973,14 +1017,12 @@ export function DynamicRecordDialog({
973
1017
  // then advance. Mirrors handleSubmit's required check but scoped to the step.
974
1018
  const goNextStep = () => {
975
1019
  const step = groups[clampedStep]
976
- const missing: Record<string, string> = {}
977
- for (const field of step?.fields ?? []) {
978
- if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
979
- missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
980
- }
981
- }
982
- if (Object.keys(missing).length) {
983
- setFieldErrors(missing)
1020
+ const stepFields = step?.fields ?? []
1021
+ const bag = validateValues(stepFields as ActionFieldDef[], formValues)
1022
+ if (bagHasErrors(bag)) {
1023
+ const labels: Record<string, string> = {}
1024
+ for (const f of stepFields) labels[f.key] = f.label
1025
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
984
1026
  toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
985
1027
  return
986
1028
  }
@@ -1014,7 +1056,7 @@ export function DynamicRecordDialog({
1014
1056
  cell `min-w-0` so a long select/input value can't
1015
1057
  blow the two columns past the dialog width. */}
1016
1058
  <form
1017
- id="dynamic-record-form"
1059
+ id={formId}
1018
1060
  onSubmit={handleSubmit}
1019
1061
  className="grid gap-y-4"
1020
1062
  >
@@ -1124,7 +1166,7 @@ export function DynamicRecordDialog({
1124
1166
  {isEditable && (!isSteps || isLastStep) && (
1125
1167
  <Button
1126
1168
  type="submit"
1127
- form="dynamic-record-form"
1169
+ form={formId}
1128
1170
  disabled={saving || loading}
1129
1171
  >
1130
1172
  {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1193,7 +1235,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1193
1235
  ) : isEditReadonly ? (
1194
1236
  <ReadonlyEditField field={field} value={value} />
1195
1237
  ) : (
1196
- <EditField field={field} value={value} onChange={onChange} record={record} />
1238
+ <EditField field={field} value={value} onChange={onChange} record={record} invalid={!!error} />
1197
1239
  )}
1198
1240
 
1199
1241
  {error && mode !== 'view' && (
@@ -1836,13 +1878,19 @@ function JsonObjectViewValue({ value }: { value: Record<string, unknown> }) {
1836
1878
  )
1837
1879
  }
1838
1880
 
1839
- export function EditField({ field, value, onChange, record }: {
1881
+ export function EditField({ field, value, onChange, record, invalid }: {
1840
1882
  field: FieldDef
1841
1883
  value: any
1842
1884
  onChange: (val: any) => void
1843
1885
  /** The full record being edited — supplies FK relation siblings + line-items. */
1844
1886
  record?: any
1887
+ /** When true, paint the control with a destructive border (Laravel-style). */
1888
+ invalid?: boolean
1845
1889
  }) {
1890
+ const invalidCls = invalid
1891
+ ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive aria-invalid:border-destructive'
1892
+ : undefined
1893
+
1846
1894
  const { t, i18n } = useTranslation()
1847
1895
  const editFieldImageUrl = useContext(ImageUrlContext)
1848
1896
  const dialogModel = useContext(RecordDialogModelContext)
@@ -1892,6 +1940,8 @@ export function EditField({ field, value, onChange, record }: {
1892
1940
  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
1893
1941
  placeholder={field.placeholder}
1894
1942
  rows={4}
1943
+ aria-invalid={invalid || undefined}
1944
+ className={invalidCls}
1895
1945
  />
1896
1946
  )
1897
1947
  }
@@ -1952,6 +2002,7 @@ export function EditField({ field, value, onChange, record }: {
1952
2002
  // for the popover to open and fetch a page.
1953
2003
  seedOption={fkSeedOption(field, value, record)}
1954
2004
  hideCreate={hideSelfCreate}
2005
+ invalid={invalid}
1955
2006
  />
1956
2007
  )
1957
2008
  }
@@ -1967,7 +2018,7 @@ export function EditField({ field, value, onChange, record }: {
1967
2018
  if (field.type === 'select' && field.options?.length) {
1968
2019
  return (
1969
2020
  <Select value={String(value ?? '')} onValueChange={onChange}>
1970
- <SelectTrigger className="w-full">
2021
+ <SelectTrigger className={cn("w-full", invalidCls)} aria-invalid={invalid || undefined}>
1971
2022
  <SelectValue placeholder="Seleccionar..." />
1972
2023
  </SelectTrigger>
1973
2024
  <SelectContent>
@@ -2043,7 +2094,7 @@ export function EditField({ field, value, onChange, record }: {
2043
2094
  ? 'email'
2044
2095
  : 'text'
2045
2096
 
2046
- return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} />
2097
+ return <ScannableRecordInput field={field} value={value} onChange={onChange} inputType={inputType} invalid={invalid} className={invalidCls} />
2047
2098
  }
2048
2099
 
2049
2100
  /**
@@ -2062,11 +2113,15 @@ function ScannableRecordInput({
2062
2113
  value,
2063
2114
  onChange,
2064
2115
  inputType,
2116
+ invalid,
2117
+ className,
2065
2118
  }: {
2066
2119
  field: FieldDef
2067
2120
  value: any
2068
2121
  onChange: (val: any) => void
2069
2122
  inputType: string
2123
+ invalid?: boolean
2124
+ className?: string
2070
2125
  }) {
2071
2126
  const [scanOpen, setScanOpen] = useState(false)
2072
2127
  // El botón de escaneo aparece siempre que el campo declara `scan` (como el
@@ -2086,6 +2141,8 @@ function ScannableRecordInput({
2086
2141
  )
2087
2142
  }
2088
2143
  placeholder={field.placeholder}
2144
+ aria-invalid={invalid || undefined}
2145
+ className={className}
2089
2146
  />
2090
2147
  )
2091
2148
  if (!scanEnabled) return input
@@ -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
  )
@@ -47,6 +47,11 @@ export interface DynamicLineItemsProps {
47
47
  * (e.g. `source_warehouse_id`), not just a sibling cell on the same row.
48
48
  */
49
49
  formValues?: Record<string, any>
50
+ /**
51
+ * Localized validation messages keyed as `rowIndex.columnKey` (e.g.
52
+ * `0.unit_price`). Painted as destructive borders + under-cell text.
53
+ */
54
+ errors?: Record<string, string>
50
55
  }
51
56
 
52
57
  const fmtNumber = (n: number): string =>
@@ -65,9 +70,10 @@ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
65
70
  return row
66
71
  }
67
72
 
68
- export function DynamicLineItems({ field, value, onChange, disabled = false, formValues }: DynamicLineItemsProps) {
73
+ export function DynamicLineItems({ field, value, onChange, disabled = false, formValues, errors }: DynamicLineItemsProps) {
69
74
  const itemFields = getItemFields(field)
70
75
  const rows: any[] = Array.isArray(value) ? value : []
76
+ const errMap = errors ?? {}
71
77
 
72
78
  // `lock_rows` fixes the row set: no add-row button, no per-row delete. Rows
73
79
  // stay editable cell-by-cell. Snake_case is what the kernel serves; tolerate
@@ -149,7 +155,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
149
155
  )}
150
156
  </div>
151
157
  <div className="grid gap-2.5">
152
- {itemFields.map((col) => (
158
+ {itemFields.map((col) => {
159
+ const cellErr = errMap[`${idx}.${col.key}`]
160
+ return (
153
161
  <div key={col.key} className="grid gap-1">
154
162
  <span className="text-xs font-medium">
155
163
  {col.label}
@@ -164,9 +172,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
164
172
  disabled={disabled}
165
173
  formValues={formValues}
166
174
  rowValues={row}
175
+ invalid={!!cellErr}
167
176
  />
177
+ {cellErr && (
178
+ <p className="text-destructive text-xs">{cellErr}</p>
179
+ )}
168
180
  </div>
169
- ))}
181
+ )
182
+ })}
170
183
  </div>
171
184
  </div>
172
185
  ))}
@@ -219,7 +232,9 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
219
232
  )}
220
233
  {rows.map((row, idx) => (
221
234
  <tr key={idx} className="border-t align-top">
222
- {itemFields.map((col) => (
235
+ {itemFields.map((col) => {
236
+ const cellErr = errMap[`${idx}.${col.key}`]
237
+ return (
223
238
  <td key={col.key} className="px-2 py-1.5">
224
239
  <CellRenderer
225
240
  field={col}
@@ -228,9 +243,14 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
228
243
  disabled={disabled}
229
244
  formValues={formValues}
230
245
  rowValues={row}
246
+ invalid={!!cellErr}
231
247
  />
248
+ {cellErr && (
249
+ <p className="text-destructive mt-1 text-xs">{cellErr}</p>
250
+ )}
232
251
  </td>
233
- ))}
252
+ )
253
+ })}
234
254
  {!lockRows && (
235
255
  <td className="px-2 py-1.5 text-center">
236
256
  <Button
@@ -342,14 +362,19 @@ interface CellRendererProps {
342
362
  formValues?: Record<string, any>
343
363
  /** This row's values — for resolving a cell's `dependsOn` to a sibling cell. */
344
364
  rowValues?: Record<string, any>
365
+ /** Paint the control as invalid (destructive border). */
366
+ invalid?: boolean
345
367
  }
346
368
 
347
369
  // Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
348
370
  // without the per-field Label (the column header is the label) and sized for a
349
371
  // table cell. Nested line-items inside a row are not supported (a row column is
350
372
  // a scalar widget).
351
- function CellRenderer({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
373
+ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) {
352
374
  const widget = resolveWidget(field)
375
+ const invalidCls = invalid
376
+ ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
377
+ : ''
353
378
  // Per-field read-only: a column locked by a PrefillSpec.lock (e.g. the
354
379
  // "ordered" / "already received" progress columns of a receive-goods modal)
355
380
  // renders disabled so it shows context without being editable. Tolerates the
@@ -384,7 +409,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
384
409
  // Async searchable picker per row cell — e.g. the account_id column of a
385
410
  // journal entry's debit/credit lines. Same widget as the flat form.
386
411
  if (widget === 'dynamic_select') {
387
- return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} readonly={ro} />
412
+ return (
413
+ <DynamicSelectField
414
+ field={field}
415
+ value={value}
416
+ onChange={onChange}
417
+ dependsValue={dependsValue}
418
+ readonly={ro}
419
+ invalid={invalid}
420
+ />
421
+ )
388
422
  }
389
423
  if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
390
424
  return (
@@ -408,6 +442,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
408
442
  placeholder={field.placeholder}
409
443
  disabled={off}
410
444
  rows={2}
445
+ aria-invalid={invalid || undefined}
446
+ className={invalidCls || undefined}
411
447
  />
412
448
  )
413
449
  case 'color':
@@ -417,6 +453,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
417
453
  value={value || '#000000'}
418
454
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
419
455
  disabled={off}
456
+ aria-invalid={invalid || undefined}
457
+ className={invalidCls || undefined}
420
458
  />
421
459
  )
422
460
  case 'select': {
@@ -425,7 +463,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
425
463
  if (effectiveOptions && effectiveOptions.length === 0) return null
426
464
  return (
427
465
  <Select value={value || ''} onValueChange={onChange} disabled={off}>
428
- <SelectTrigger className="w-full">
466
+ <SelectTrigger className={'w-full' + (invalidCls ? ` ${invalidCls}` : '')} aria-invalid={invalid || undefined}>
429
467
  <SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
430
468
  </SelectTrigger>
431
469
  <SelectContent>
@@ -445,9 +483,16 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
445
483
  <Input
446
484
  type="number"
447
485
  value={value ?? ''}
448
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')}
486
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
487
+ const n = e.target.valueAsNumber
488
+ // `0 || ''` would wipe a legitimate zero (unit_price=0)
489
+ // and then required-validation fires with nothing marked.
490
+ onChange(e.target.value === '' || !Number.isFinite(n) ? '' : n)
491
+ }}
449
492
  placeholder={field.placeholder}
450
493
  disabled={off}
494
+ aria-invalid={invalid || undefined}
495
+ className={invalidCls || undefined}
451
496
  />
452
497
  )
453
498
  case 'date':
@@ -457,6 +502,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
457
502
  value={value || ''}
458
503
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
459
504
  disabled={off}
505
+ aria-invalid={invalid || undefined}
506
+ className={invalidCls || undefined}
460
507
  />
461
508
  )
462
509
  default:
@@ -467,6 +514,8 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
467
514
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
468
515
  placeholder={field.placeholder}
469
516
  disabled={off}
517
+ aria-invalid={invalid || undefined}
518
+ className={invalidCls || undefined}
470
519
  />
471
520
  )
472
521
  }