@asteby/metacore-runtime-react 29.1.0 → 29.2.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 29.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3358745: Modales de crear/editar y de acciones (incluida crear orden de compra con line-items) usan `90dvh` en vez de `90vh`: en móvil el `vh` contaba el alto detrás de la barra del navegador y empujaba el footer con **Guardar/Crear** fuera de lo visible. Con `dvh` el botón queda siempre alcanzable.
8
+
9
+ ## 29.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - fd10c7d: Los inputs de texto/número de formularios declarativos aceptan el opt-in `scan`: cuando el campo lo declara y el navegador soporta cámara, aparece un botón que escanea un código de barras y **llena el input** (ej. el SKU al crear producto). El escáner queda abierto (continuo) para corregir/reintentar; se cierra con la X. Complementa el `scan` ya soportado en `dynamic_select`.
14
+
3
15
  ## 29.1.0
4
16
 
5
17
  ### Minor Changes
@@ -453,7 +453,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
453
453
  : hasLineItems
454
454
  ? '820px'
455
455
  : undefined;
456
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), tl(action.label)] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4", children: _jsxs(FieldGrid, { children: [action.fields?.map((field) => {
456
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90dvh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90dvh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), tl(action.label)] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4", children: _jsxs(FieldGrid, { children: [action.fields?.map((field) => {
457
457
  const fullWidth = isLineItemsField(field) ||
458
458
  resolveWidget(field) === 'textarea' ||
459
459
  resolveWidget(field) === 'richtext';
@@ -570,7 +570,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
570
570
  };
571
571
  if (steps.length === 0)
572
572
  return null;
573
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), tl(action.label)] }), _jsxs("div", { className: "pt-2", children: [_jsx("div", { className: "flex items-center gap-1.5", role: "list", "aria-label": "progress", children: steps.map((s, i) => (_jsx("div", { role: "listitem", "aria-current": i === stepIndex ? 'step' : undefined, className: "h-1.5 flex-1 rounded-full", style: {
573
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90dvh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90dvh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), tl(action.label)] }), _jsxs("div", { className: "pt-2", children: [_jsx("div", { className: "flex items-center gap-1.5", role: "list", "aria-label": "progress", children: steps.map((s, i) => (_jsx("div", { role: "listitem", "aria-current": i === stepIndex ? 'step' : undefined, className: "h-1.5 flex-1 rounded-full", style: {
574
574
  backgroundColor: i <= stepIndex ? (action.color || 'hsl(var(--primary))') : 'hsl(var(--muted))',
575
575
  } }, i))) }), _jsxs(DialogDescription, { className: "pt-2", children: [t('common.step', { defaultValue: 'Paso' }), " ", stepIndex + 1, "/", steps.length, step?.title ? ` · ${tl(step.title)}` : ''] }), step?.description && (_jsx("p", { className: "pt-1 text-sm text-muted-foreground", children: tl(step.description) }))] })] }), _jsx("div", { className: "-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4", children: _jsx(FieldGrid, { children: stepFields.map((field) => {
576
576
  const fullWidth = isLineItemsField(field) ||
@@ -638,7 +638,7 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
638
638
  setStepIndex(Math.min(clampedStep + 1, groups.length - 1));
639
639
  };
640
640
  const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0));
641
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid gap-y-4", children: [isSteps && (_jsx(WizardProgress, { groups: groups, stepIndex: clampedStep })), (isSteps ? [groups[clampedStep]] : groups).map(group => (_jsx(FieldSection, { group: group, children: _jsx("div", { className: "grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2", children: renderFields(group.fields) }) }, group.key))), record?.external_url && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2", children: _jsx("div", { className: "sm:col-span-2 min-w-0", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, lineSubtable: true, embedOnly: true, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit', onChange: handleChildChange }) }))] }) }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [isSteps && clampedStep > 0 ? (_jsx(Button, { variant: "outline", onClick: goBackStep, disabled: saving || deleting, children: "Anterior" })) : (_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel })), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && isSteps && !isLastStep && (_jsx(Button, { type: "button", onClick: goNextStep, disabled: saving || loading, children: "Siguiente" })), isEditable && (!isSteps || isLastStep) && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
641
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden", style: { maxHeight: '90dvh' }, children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid gap-y-4", children: [isSteps && (_jsx(WizardProgress, { groups: groups, stepIndex: clampedStep })), (isSteps ? [groups[clampedStep]] : groups).map(group => (_jsx(FieldSection, { group: group, children: _jsx("div", { className: "grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2", children: renderFields(group.fields) }) }, group.key))), record?.external_url && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2", children: _jsx("div", { className: "sm:col-span-2 min-w-0", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, lineSubtable: true, embedOnly: true, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit', onChange: handleChildChange }) }))] }) }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [isSteps && clampedStep > 0 ? (_jsx(Button, { variant: "outline", onClick: goBackStep, disabled: saving || deleting, children: "Anterior" })) : (_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel })), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && isSteps && !isLastStep && (_jsx(Button, { type: "button", onClick: goNextStep, disabled: saving || loading, children: "Siguiente" })), isEditable && (!isSteps || isLastStep) && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
642
642
  }
643
643
  function LoadingSkeleton() {
644
644
  return (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: Array.from({ length: 6 }).map((_, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-24" }), _jsx(Skeleton, { className: "h-9 w-full" })] }, i))) }));
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,eAAe,CAAA;AAcrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAOhB,MAAM,uBAAuB,CAAA;AAQ9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAErD,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,UAAU,CAAA;CAC1B;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,EAChB,UAAU,GACb,EAAE,gBAAgB,+BA8LlB"}
1
+ {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,eAAe,CAAA;AAcrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAOhB,MAAM,uBAAuB,CAAA;AAU9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAErD,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,UAAU,CAAA;CAC1B;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,EAChB,UAAU,GACb,EAAE,gBAAgB,+BA8LlB"}
@@ -7,6 +7,8 @@ import { groupFieldsBySection } from './form-layout';
7
7
  import { FieldSection, WizardProgress } from './form-layout-ui';
8
8
  import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
9
9
  import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, applyOptionWhen, getDependsOn, getVisibleWhen, evaluateVisibleWhen, } from './dynamic-form-schema';
10
+ import { ScanLine } from 'lucide-react';
11
+ import { BarcodeScanner, isCameraScanSupported } from './barcode-scanner';
10
12
  import { useOptionsResolver } from './use-options-resolver';
11
13
  import { DynamicLineItems } from './dynamic-line-items';
12
14
  import { DynamicSelectField } from './dynamic-select-field';
@@ -238,9 +240,25 @@ function FieldRenderer({ field, value, onChange, initialValues, effectiveOptions
238
240
  case 'date':
239
241
  return _jsx(DynamicDateField, { field: field, value: value, onChange: onChange });
240
242
  default:
241
- return _jsx(Input, { id: field.key, type: field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text', value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
243
+ return (_jsx(ScannableInput, { field: field, value: value, onChange: onChange, type: field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text' }));
242
244
  }
243
245
  }
246
+ /**
247
+ * Input de texto/número con opción de ESCANEO por cámara para "llenar rápido".
248
+ * Cuando el campo declara `scan` (alias `scannable`) y el navegador soporta
249
+ * `BarcodeDetector`, agrega un botón de cámara: el código escaneado se escribe
250
+ * en el input y el escáner SE QUEDA ABIERTO (continuo) por si querés corregir o
251
+ * escanear otro — lo cerrás con la X. Sin el flag o sin soporte, es un Input
252
+ * normal (retrocompat total).
253
+ */
254
+ function ScannableInput({ field, value, onChange, type, }) {
255
+ const [scanOpen, setScanOpen] = useState(false);
256
+ const scanEnabled = !!(field.scan ?? field.scannable) && isCameraScanSupported();
257
+ const input = (_jsx(Input, { id: field.key, type: type, value: value ?? '', onChange: (e) => onChange(type === 'number' ? e.target.valueAsNumber || '' : e.target.value), placeholder: field.placeholder }));
258
+ if (!scanEnabled)
259
+ return input;
260
+ return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code), continuous: true, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() })] }));
261
+ }
244
262
  function RefSelect({ field, value, onChange }) {
245
263
  const { options, loading } = useOptionsResolver({
246
264
  modelKey: '', // unused — `ref` drives the URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "29.1.0",
3
+ "version": "29.2.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -645,13 +645,13 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
645
645
  <Dialog open={open} onOpenChange={onOpenChange}>
646
646
  {/* Sticky header + footer, scrollable body: the form can grow tall
647
647
  (many line-items rows) past the viewport, so cap the dialog at
648
- 90vh and let ONLY the field area scroll — the title and the
648
+ 90dvh and let ONLY the field area scroll — the title and the
649
649
  Cancel/Submit actions stay pinned and always reachable. maxHeight
650
- is inline (guaranteed) since an arbitrary max-h-[90vh] class may be
650
+ is inline (guaranteed) since an arbitrary max-h-[90dvh] class may be
651
651
  dropped by a consuming app's Tailwind scan. */}
652
652
  <DialogContent
653
- className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
654
- style={{ maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
653
+ className={'flex max-h-[90dvh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
654
+ style={{ maxHeight: '90dvh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
655
655
  >
656
656
  <DialogHeader className="shrink-0">
657
657
  <DialogTitle className="flex items-center gap-2">
@@ -837,8 +837,8 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
837
837
  return (
838
838
  <Dialog open={open} onOpenChange={onOpenChange}>
839
839
  <DialogContent
840
- className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
841
- style={{ maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
840
+ className={'flex max-h-[90dvh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
841
+ style={{ maxHeight: '90dvh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
842
842
  >
843
843
  <DialogHeader className="shrink-0">
844
844
  <DialogTitle className="flex items-center gap-2">
@@ -941,7 +941,7 @@ export function DynamicRecordDialog({
941
941
 
942
942
  return (
943
943
  <Dialog open={open} onOpenChange={onOpenChange}>
944
- <DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
944
+ <DialogContent className="sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden" style={{ maxHeight: '90dvh' }}>
945
945
  <DialogHeader className="p-6 pb-4 border-b shrink-0">
946
946
  <DialogTitle>{title}</DialogTitle>
947
947
  <DialogDescription>{config.description}</DialogDescription>
@@ -27,6 +27,8 @@ import {
27
27
  getVisibleWhen,
28
28
  evaluateVisibleWhen,
29
29
  } from './dynamic-form-schema'
30
+ import { ScanLine } from 'lucide-react'
31
+ import { BarcodeScanner, isCameraScanSupported } from './barcode-scanner'
30
32
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
31
33
  import { DynamicLineItems } from './dynamic-line-items'
32
34
  import { DynamicSelectField } from './dynamic-select-field'
@@ -414,10 +416,71 @@ function FieldRenderer({
414
416
  case 'date':
415
417
  return <DynamicDateField field={field} value={value} onChange={onChange} />
416
418
  default:
417
- 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} />
419
+ return (
420
+ <ScannableInput
421
+ field={field}
422
+ value={value}
423
+ onChange={onChange}
424
+ type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
425
+ />
426
+ )
418
427
  }
419
428
  }
420
429
 
430
+ /**
431
+ * Input de texto/número con opción de ESCANEO por cámara para "llenar rápido".
432
+ * Cuando el campo declara `scan` (alias `scannable`) y el navegador soporta
433
+ * `BarcodeDetector`, agrega un botón de cámara: el código escaneado se escribe
434
+ * en el input y el escáner SE QUEDA ABIERTO (continuo) por si querés corregir o
435
+ * escanear otro — lo cerrás con la X. Sin el flag o sin soporte, es un Input
436
+ * normal (retrocompat total).
437
+ */
438
+ function ScannableInput({
439
+ field,
440
+ value,
441
+ onChange,
442
+ type,
443
+ }: FieldRendererProps & { type: string }) {
444
+ const [scanOpen, setScanOpen] = useState(false)
445
+ const scanEnabled = !!(field.scan ?? field.scannable) && isCameraScanSupported()
446
+ const input = (
447
+ <Input
448
+ id={field.key}
449
+ type={type}
450
+ value={value ?? ''}
451
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
452
+ onChange(type === 'number' ? e.target.valueAsNumber || '' : e.target.value)
453
+ }
454
+ placeholder={field.placeholder}
455
+ />
456
+ )
457
+ if (!scanEnabled) return input
458
+ return (
459
+ <div className="flex w-full min-w-0 items-center gap-1.5">
460
+ <div className="min-w-0 flex-1">{input}</div>
461
+ <Button
462
+ type="button"
463
+ variant="outline"
464
+ size="icon"
465
+ className="size-9 shrink-0"
466
+ onClick={() => setScanOpen(true)}
467
+ title="Escanear con la cámara"
468
+ aria-label="Escanear código de barras con la cámara"
469
+ >
470
+ <ScanLine className="size-4" />
471
+ </Button>
472
+ <BarcodeScanner
473
+ open={scanOpen}
474
+ onClose={() => setScanOpen(false)}
475
+ onDetected={(code) => onChange(code)}
476
+ continuous
477
+ position="fixed"
478
+ title={`Escanear ${field.label ?? ''}`.trim()}
479
+ />
480
+ </div>
481
+ )
482
+ }
483
+
421
484
  function RefSelect({ field, value, onChange }: FieldRendererProps) {
422
485
  const { options, loading } = useOptionsResolver({
423
486
  modelKey: '', // unused — `ref` drives the URL