@asteby/metacore-runtime-react 13.4.0 → 13.5.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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 13.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0e427f1: feat(forms): modern date picker, roomy line-items modal, 2-column layout
8
+
9
+ Three declarative-form polish items, all driven by field shape — zero per-app code:
10
+ - **DynamicDateField**: `type: "date"` fields now render a shadcn Calendar inside
11
+ a Popover instead of the native `<input type="date">`. The Popover portals to
12
+ the body so the calendar is never clipped by the modal (fixes the cut-off), and
13
+ it looks modern. The field value stays an ISO `YYYY-MM-DD` string, so payloads
14
+ are unchanged. No future-date restriction (entries can be post-dated).
15
+ - **Roomy modal for line-items**: GenericActionModal auto-widens to ~820px when
16
+ the action has a line-items (`type:"array"`) field so the debit/credit grid has
17
+ room; plain forms stay compact. An optional `action.modalWidth` overrides.
18
+ Applied as an inline style so it always takes effect.
19
+ - **2-column field layout**: scalar fields (journal, date, reference) flow
20
+ side-by-side instead of one tall vertical stack; line-items grids and textareas
21
+ span full width. Mirrors DynamicForm so the action modal and standalone form
22
+ render identically.
23
+
24
+ ## 13.4.1
25
+
26
+ ### Patch Changes
27
+
28
+ - f03fe86: fix(action-modal): render `dynamic_select` action fields as the searchable async picker instead of a plain text input
29
+
30
+ ActionModalDispatcher's GenericActionModal had its own field renderer that keyed
31
+ off `field.type` and had no `dynamic_select` case, so a declarative action field
32
+ with `type: "dynamic_select"` (e.g. the Diario/Cuenta pickers of a journal entry)
33
+ fell through to a plain text `<Input>`. It now resolves the widget the same way
34
+ DynamicForm does (`resolveWidget`) and routes `dynamic_select` to
35
+ `DynamicSelectField`, keeping action modals and the standalone form in lockstep.
36
+
3
37
  ## 13.4.0
4
38
 
5
39
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AA4CA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AAEhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,kDAiDlB"}
1
+ {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AA8CA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AAEhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,kDAiDlB"}
@@ -15,7 +15,9 @@ import { toast } from 'sonner';
15
15
  import { useApi } from './api-context';
16
16
  import { DynamicIcon } from './dynamic-icon';
17
17
  import { DynamicLineItems } from './dynamic-line-items';
18
- import { isLineItemsField } from './dynamic-form-schema';
18
+ import { DynamicSelectField } from './dynamic-select-field';
19
+ import { DynamicDateField } from './dynamic-date-field';
20
+ import { isLineItemsField, resolveWidget } from './dynamic-form-schema';
19
21
  // Canonical registry lives in @asteby/metacore-sdk
20
22
  import { getActionComponent, } from '@asteby/metacore-sdk';
21
23
  export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess, }) {
@@ -119,24 +121,53 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
119
121
  setExecuting(false);
120
122
  }
121
123
  };
122
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), action.label] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "grid gap-4 py-4", children: action.fields?.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, formData[field.key], (v) => updateField(field.key, v))] }, field.key))) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), action.label] })] })] }) }));
124
+ // Size the modal to the form. A line-items form (the debit/credit grid of a
125
+ // journal entry, a "receive goods" modal) needs room for its columns, so it
126
+ // gets a roomy width; a plain field form stays compact. An explicit
127
+ // `action.modalWidth` (number px or CSS length) overrides — the declarative
128
+ // escape hatch. Width is applied as an inline style (guaranteed to apply,
129
+ // unlike an arbitrary Tailwind class that the host's scan may drop), capped
130
+ // to the viewport so it stays responsive on phones.
131
+ const hasLineItems = useMemo(() => (action.fields ?? []).some(isLineItemsField), [action.fields]);
132
+ const explicitWidth = action.modalWidth;
133
+ const widthPx = explicitWidth != null
134
+ ? (typeof explicitWidth === 'number' ? `${explicitWidth}px` : explicitWidth)
135
+ : hasLineItems
136
+ ? '820px'
137
+ : undefined;
138
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: widthPx ? '' : 'sm:max-w-lg', style: widthPx ? { maxWidth: widthPx, width: '95vw' } : undefined, children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), action.label] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "grid gap-4 py-4 sm:grid-cols-2", children: action.fields?.map((field) => {
139
+ const fullWidth = isLineItemsField(field) ||
140
+ resolveWidget(field) === 'textarea' ||
141
+ resolveWidget(field) === 'richtext';
142
+ return (_jsxs("div", { className: 'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : ''), children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, formData[field.key], (v) => updateField(field.key, v))] }, field.key));
143
+ }) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), action.label] })] })] }) }));
123
144
  }
124
145
  function renderField(field, value, onChange) {
125
146
  // Repeatable line-items group → row grid (value is an array of row objects).
126
147
  if (isLineItemsField(field)) {
127
148
  return _jsx(DynamicLineItems, { field: field, value: value, onChange: onChange });
128
149
  }
129
- switch (field.type) {
150
+ // Resolve the widget the same way DynamicForm does (explicit widget wins,
151
+ // else inferred from type) so action modals and the standalone form stay in
152
+ // lockstep — previously this switch keyed off `field.type` and silently
153
+ // dropped `dynamic_select` to a plain text input.
154
+ const widget = resolveWidget(field);
155
+ if (widget === 'dynamic_select') {
156
+ return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
157
+ }
158
+ switch (widget) {
130
159
  case 'textarea':
131
160
  return _jsx(Textarea, { id: field.key, value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
132
161
  case 'select':
133
162
  return (_jsxs(Select, { value: value || '', onValueChange: onChange, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: field.placeholder || 'Seleccionar...' }) }), _jsx(SelectContent, { children: field.options?.map((opt) => _jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value)) })] }));
134
- case 'boolean':
163
+ case 'switch':
135
164
  return _jsx(Switch, { id: field.key, checked: !!value, onCheckedChange: onChange });
136
165
  case 'number':
137
166
  return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
138
167
  case 'date':
139
- return _jsx(Input, { id: field.key, type: "date", value: value || '', onChange: (e) => onChange(e.target.value) });
168
+ // Modern shadcn Calendar in a Popover (portaled, never clipped by the
169
+ // modal) instead of the native, dated, easily-cut <input type=date>.
170
+ return _jsx(DynamicDateField, { field: field, value: value, onChange: onChange });
140
171
  default:
141
172
  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 });
142
173
  }
@@ -0,0 +1,9 @@
1
+ import type { ActionFieldDef } from './types';
2
+ export interface DynamicDateFieldProps {
3
+ field: ActionFieldDef;
4
+ value: any;
5
+ onChange: (v: any) => void;
6
+ }
7
+ export declare function DynamicDateField({ field, value, onChange }: DynamicDateFieldProps): import("react/jsx-runtime").JSX.Element;
8
+ export default DynamicDateField;
9
+ //# sourceMappingURL=dynamic-date-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-date-field.d.ts","sourceRoot":"","sources":["../src/dynamic-date-field.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AA+BD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,qBAAqB,2CAkCjF;AAED,eAAe,gBAAgB,CAAA"}
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // DynamicDateField — modern date picker for declarative forms: a shadcn
3
+ // Calendar inside a Popover, instead of the native `<input type="date">` whose
4
+ // browser calendar looks dated and gets clipped inside a modal.
5
+ //
6
+ // Contract: the field VALUE stays an ISO date string ("YYYY-MM-DD") so the form
7
+ // payload is unchanged (the kernel/handlers already expect that). We parse the
8
+ // string to a Date for the Calendar and format back to ISO on select. The
9
+ // Popover portals to the document body, so the calendar is never clipped by the
10
+ // modal's overflow — fixing the "se corta" cut-off.
11
+ //
12
+ // Deliberately NOT reusing metacore-ui's `DatePicker` shared helper: that one
13
+ // hard-disables future dates and pins a fixed 240px width, neither of which is
14
+ // right for a generic declarative field (a journal entry can be post-dated).
15
+ import { useState } from 'react';
16
+ import { Button, Calendar, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
17
+ import { CalendarIcon } from 'lucide-react';
18
+ // Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
19
+ // undefined when empty/invalid. Uses noon to dodge timezone day-shift.
20
+ function toDate(v) {
21
+ if (!v)
22
+ return undefined;
23
+ const s = String(v);
24
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
25
+ if (m)
26
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
27
+ const d = new Date(s);
28
+ return isNaN(d.getTime()) ? undefined : d;
29
+ }
30
+ // Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
31
+ function toISO(d) {
32
+ const y = d.getFullYear();
33
+ const m = String(d.getMonth() + 1).padStart(2, '0');
34
+ const day = String(d.getDate()).padStart(2, '0');
35
+ return `${y}-${m}-${day}`;
36
+ }
37
+ // Human label for the trigger. Locale-aware, falls back to the raw ISO.
38
+ function label(d) {
39
+ if (!d)
40
+ return '';
41
+ try {
42
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
43
+ }
44
+ catch {
45
+ return toISO(d);
46
+ }
47
+ }
48
+ export function DynamicDateField({ field, value, onChange }) {
49
+ const [open, setOpen] = useState(false);
50
+ const selected = toDate(value);
51
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", id: field.key, "data-empty": !selected, className: "w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground", children: [_jsx(CalendarIcon, { className: "mr-2 size-4 shrink-0 opacity-50" }), _jsx("span", { className: "truncate", children: selected ? label(selected) : (field.placeholder || 'Elegí una fecha') })] }) }), _jsx(PopoverContent, { className: "w-auto p-0", align: "start", children: _jsx(Calendar, { mode: "single", captionLayout: "dropdown", selected: selected, defaultMonth: selected, onSelect: (d) => {
52
+ onChange(d ? toISO(d) : '');
53
+ setOpen(false);
54
+ } }) })] }));
55
+ }
56
+ export default DynamicDateField;
@@ -3,6 +3,7 @@ import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
3
3
  export { buildZodSchema, resolveWidget };
4
4
  export { DynamicLineItems } from './dynamic-line-items';
5
5
  export { DynamicSelectField } from './dynamic-select-field';
6
+ export { DynamicDateField } from './dynamic-date-field';
6
7
  export interface DynamicFormProps {
7
8
  fields: ActionFieldDef[];
8
9
  initialValues?: Record<string, any>;
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAK9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAE3D,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;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CAkGlB"}
1
+ {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAM9B,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;AAEvD,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;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CAkGlB"}
@@ -8,9 +8,11 @@ import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, } fro
8
8
  import { useOptionsResolver } from './use-options-resolver';
9
9
  import { DynamicLineItems } from './dynamic-line-items';
10
10
  import { DynamicSelectField } from './dynamic-select-field';
11
+ import { DynamicDateField } from './dynamic-date-field';
11
12
  export { buildZodSchema, resolveWidget };
12
13
  export { DynamicLineItems } from './dynamic-line-items';
13
14
  export { DynamicSelectField } from './dynamic-select-field';
15
+ export { DynamicDateField } from './dynamic-date-field';
14
16
  export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
15
17
  const [values, setValues] = useState({});
16
18
  const [errors, setErrors] = useState({});
@@ -113,7 +115,7 @@ function FieldRenderer({ field, value, onChange }) {
113
115
  case 'number':
114
116
  return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
115
117
  case 'date':
116
- return _jsx(Input, { id: field.key, type: "date", value: value || '', onChange: (e) => onChange(e.target.value) });
118
+ return _jsx(DynamicDateField, { field: field, value: value, onChange: onChange });
117
119
  default:
118
120
  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 });
119
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "13.4.0",
3
+ "version": "13.5.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,9 @@ import { toast } from 'sonner'
39
39
  import { useApi } from './api-context'
40
40
  import { DynamicIcon } from './dynamic-icon'
41
41
  import { DynamicLineItems } from './dynamic-line-items'
42
- import { isLineItemsField } from './dynamic-form-schema'
42
+ import { DynamicSelectField } from './dynamic-select-field'
43
+ import { DynamicDateField } from './dynamic-date-field'
44
+ import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
43
45
  import type { ActionFieldDef } from './types'
44
46
  // Canonical registry lives in @asteby/metacore-sdk
45
47
  import {
@@ -223,9 +225,31 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
223
225
  }
224
226
  }
225
227
 
228
+ // Size the modal to the form. A line-items form (the debit/credit grid of a
229
+ // journal entry, a "receive goods" modal) needs room for its columns, so it
230
+ // gets a roomy width; a plain field form stays compact. An explicit
231
+ // `action.modalWidth` (number px or CSS length) overrides — the declarative
232
+ // escape hatch. Width is applied as an inline style (guaranteed to apply,
233
+ // unlike an arbitrary Tailwind class that the host's scan may drop), capped
234
+ // to the viewport so it stays responsive on phones.
235
+ const hasLineItems = useMemo(
236
+ () => (action.fields ?? []).some(isLineItemsField),
237
+ [action.fields],
238
+ )
239
+ const explicitWidth = (action as unknown as { modalWidth?: number | string }).modalWidth
240
+ const widthPx =
241
+ explicitWidth != null
242
+ ? (typeof explicitWidth === 'number' ? `${explicitWidth}px` : explicitWidth)
243
+ : hasLineItems
244
+ ? '820px'
245
+ : undefined
246
+
226
247
  return (
227
248
  <Dialog open={open} onOpenChange={onOpenChange}>
228
- <DialogContent className="sm:max-w-lg">
249
+ <DialogContent
250
+ className={widthPx ? '' : 'sm:max-w-lg'}
251
+ style={widthPx ? { maxWidth: widthPx, width: '95vw' } : undefined}
252
+ >
229
253
  <DialogHeader>
230
254
  <DialogTitle className="flex items-center gap-2">
231
255
  <DynamicIcon name={action.icon} className="h-5 w-5" />
@@ -233,16 +257,30 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
233
257
  </DialogTitle>
234
258
  {action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
235
259
  </DialogHeader>
236
- <div className="grid gap-4 py-4">
237
- {action.fields?.map((field) => (
238
- <div key={field.key} className="grid gap-2">
239
- <Label htmlFor={field.key}>
240
- {field.label}
241
- {field.required && <span className="text-red-500 ml-1">*</span>}
242
- </Label>
243
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v))}
244
- </div>
245
- ))}
260
+ {/* Responsive 2-column grid: scalar fields (journal, date,
261
+ reference) flow side-by-side instead of one tall vertical
262
+ stack; line-items grids and textareas span the full width so
263
+ they get room. Mirrors DynamicForm's pro layout — driven only
264
+ by field shape, fully declarative. */}
265
+ <div className="grid gap-4 py-4 sm:grid-cols-2">
266
+ {action.fields?.map((field) => {
267
+ const fullWidth =
268
+ isLineItemsField(field) ||
269
+ resolveWidget(field) === 'textarea' ||
270
+ resolveWidget(field) === 'richtext'
271
+ return (
272
+ <div
273
+ key={field.key}
274
+ className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
275
+ >
276
+ <Label htmlFor={field.key}>
277
+ {field.label}
278
+ {field.required && <span className="text-red-500 ml-1">*</span>}
279
+ </Label>
280
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v))}
281
+ </div>
282
+ )
283
+ })}
246
284
  </div>
247
285
  <DialogFooter>
248
286
  <Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
@@ -271,7 +309,15 @@ function renderField(
271
309
  if (isLineItemsField(field)) {
272
310
  return <DynamicLineItems field={field} value={value} onChange={onChange} />
273
311
  }
274
- switch (field.type) {
312
+ // Resolve the widget the same way DynamicForm does (explicit widget wins,
313
+ // else inferred from type) so action modals and the standalone form stay in
314
+ // lockstep — previously this switch keyed off `field.type` and silently
315
+ // dropped `dynamic_select` to a plain text input.
316
+ const widget = resolveWidget(field)
317
+ if (widget === 'dynamic_select') {
318
+ return <DynamicSelectField field={field} value={value} onChange={onChange} />
319
+ }
320
+ switch (widget) {
275
321
  case 'textarea':
276
322
  return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
277
323
  case 'select':
@@ -283,12 +329,14 @@ function renderField(
283
329
  </SelectContent>
284
330
  </Select>
285
331
  )
286
- case 'boolean':
332
+ case 'switch':
287
333
  return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
288
334
  case 'number':
289
335
  return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
290
336
  case 'date':
291
- return <Input id={field.key} type="date" value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
337
+ // Modern shadcn Calendar in a Popover (portaled, never clipped by the
338
+ // modal) instead of the native, dated, easily-cut <input type=date>.
339
+ return <DynamicDateField field={field} value={value} onChange={onChange} />
292
340
  default:
293
341
  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} />
294
342
  }
@@ -0,0 +1,96 @@
1
+ // DynamicDateField — modern date picker for declarative forms: a shadcn
2
+ // Calendar inside a Popover, instead of the native `<input type="date">` whose
3
+ // browser calendar looks dated and gets clipped inside a modal.
4
+ //
5
+ // Contract: the field VALUE stays an ISO date string ("YYYY-MM-DD") so the form
6
+ // payload is unchanged (the kernel/handlers already expect that). We parse the
7
+ // string to a Date for the Calendar and format back to ISO on select. The
8
+ // Popover portals to the document body, so the calendar is never clipped by the
9
+ // modal's overflow — fixing the "se corta" cut-off.
10
+ //
11
+ // Deliberately NOT reusing metacore-ui's `DatePicker` shared helper: that one
12
+ // hard-disables future dates and pins a fixed 240px width, neither of which is
13
+ // right for a generic declarative field (a journal entry can be post-dated).
14
+ import { useState } from 'react'
15
+ import {
16
+ Button,
17
+ Calendar,
18
+ Popover,
19
+ PopoverContent,
20
+ PopoverTrigger,
21
+ } from '@asteby/metacore-ui/primitives'
22
+ import { CalendarIcon } from 'lucide-react'
23
+ import type { ActionFieldDef } from './types'
24
+
25
+ export interface DynamicDateFieldProps {
26
+ field: ActionFieldDef
27
+ value: any
28
+ onChange: (v: any) => void
29
+ }
30
+
31
+ // Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
32
+ // undefined when empty/invalid. Uses noon to dodge timezone day-shift.
33
+ function toDate(v: any): Date | undefined {
34
+ if (!v) return undefined
35
+ const s = String(v)
36
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
37
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
38
+ const d = new Date(s)
39
+ return isNaN(d.getTime()) ? undefined : d
40
+ }
41
+
42
+ // Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
43
+ function toISO(d: Date): string {
44
+ const y = d.getFullYear()
45
+ const m = String(d.getMonth() + 1).padStart(2, '0')
46
+ const day = String(d.getDate()).padStart(2, '0')
47
+ return `${y}-${m}-${day}`
48
+ }
49
+
50
+ // Human label for the trigger. Locale-aware, falls back to the raw ISO.
51
+ function label(d: Date | undefined): string {
52
+ if (!d) return ''
53
+ try {
54
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
55
+ } catch {
56
+ return toISO(d)
57
+ }
58
+ }
59
+
60
+ export function DynamicDateField({ field, value, onChange }: DynamicDateFieldProps) {
61
+ const [open, setOpen] = useState(false)
62
+ const selected = toDate(value)
63
+
64
+ return (
65
+ <Popover open={open} onOpenChange={setOpen}>
66
+ <PopoverTrigger asChild>
67
+ <Button
68
+ type="button"
69
+ variant="outline"
70
+ id={field.key}
71
+ data-empty={!selected}
72
+ className="w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground"
73
+ >
74
+ <CalendarIcon className="mr-2 size-4 shrink-0 opacity-50" />
75
+ <span className="truncate">
76
+ {selected ? label(selected) : (field.placeholder || 'Elegí una fecha')}
77
+ </span>
78
+ </Button>
79
+ </PopoverTrigger>
80
+ <PopoverContent className="w-auto p-0" align="start">
81
+ <Calendar
82
+ mode="single"
83
+ captionLayout="dropdown"
84
+ selected={selected}
85
+ defaultMonth={selected}
86
+ onSelect={(d: Date | undefined) => {
87
+ onChange(d ? toISO(d) : '')
88
+ setOpen(false)
89
+ }}
90
+ />
91
+ </PopoverContent>
92
+ </Popover>
93
+ )
94
+ }
95
+
96
+ export default DynamicDateField
@@ -24,10 +24,12 @@ import {
24
24
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
25
25
  import { DynamicLineItems } from './dynamic-line-items'
26
26
  import { DynamicSelectField } from './dynamic-select-field'
27
+ import { DynamicDateField } from './dynamic-date-field'
27
28
 
28
29
  export { buildZodSchema, resolveWidget }
29
30
  export { DynamicLineItems } from './dynamic-line-items'
30
31
  export { DynamicSelectField } from './dynamic-select-field'
32
+ export { DynamicDateField } from './dynamic-date-field'
31
33
 
32
34
  export interface DynamicFormProps {
33
35
  fields: ActionFieldDef[]
@@ -197,7 +199,7 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
197
199
  case 'number':
198
200
  return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
199
201
  case 'date':
200
- return <Input id={field.key} type="date" value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
202
+ return <DynamicDateField field={field} value={value} onChange={onChange} />
201
203
  default:
202
204
  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} />
203
205
  }