@asteby/metacore-runtime-react 49.4.0 → 49.6.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 (87) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api-context.d.ts +9 -1
  3. package/dist/api-context.d.ts.map +1 -1
  4. package/dist/api-context.js +6 -4
  5. package/dist/business/customer-picker.d.ts +8 -2
  6. package/dist/business/customer-picker.d.ts.map +1 -1
  7. package/dist/business/customer-picker.js +30 -27
  8. package/dist/business/index.d.ts +1 -0
  9. package/dist/business/index.d.ts.map +1 -1
  10. package/dist/business/index.js +1 -0
  11. package/dist/business/line-product-cell.d.ts +8 -10
  12. package/dist/business/line-product-cell.d.ts.map +1 -1
  13. package/dist/business/line-product-cell.js +13 -90
  14. package/dist/business/product-options.d.ts +41 -0
  15. package/dist/business/product-options.d.ts.map +1 -0
  16. package/dist/business/product-options.js +58 -0
  17. package/dist/business/product-picker.d.ts +5 -1
  18. package/dist/business/product-picker.d.ts.map +1 -1
  19. package/dist/business/product-picker.js +26 -35
  20. package/dist/business/relate-documents.d.ts +1 -1
  21. package/dist/business/relate-documents.d.ts.map +1 -1
  22. package/dist/business/relate-documents.js +6 -8
  23. package/dist/business/vehicle-picker.d.ts +8 -2
  24. package/dist/business/vehicle-picker.d.ts.map +1 -1
  25. package/dist/business/vehicle-picker.js +28 -28
  26. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  27. package/dist/dialogs/dynamic-record.js +28 -43
  28. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-multi-select-field.js +17 -5
  30. package/dist/dynamic-select-field.d.ts +7 -23
  31. package/dist/dynamic-select-field.d.ts.map +1 -1
  32. package/dist/dynamic-select-field.js +67 -161
  33. package/dist/dynamic-table.d.ts +31 -1
  34. package/dist/dynamic-table.d.ts.map +1 -1
  35. package/dist/dynamic-table.js +37 -4
  36. package/dist/entity-select.d.ts +5 -2
  37. package/dist/entity-select.d.ts.map +1 -1
  38. package/dist/entity-select.js +23 -116
  39. package/dist/index.d.ts +7 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -1
  42. package/dist/query-batch.d.ts.map +1 -1
  43. package/dist/query-batch.js +17 -1
  44. package/dist/record-picker-actions.d.ts +40 -2
  45. package/dist/record-picker-actions.d.ts.map +1 -1
  46. package/dist/record-picker-actions.js +44 -4
  47. package/dist/record-picker-dialog.d.ts +23 -0
  48. package/dist/record-picker-dialog.d.ts.map +1 -0
  49. package/dist/record-picker-dialog.js +37 -0
  50. package/dist/record-picker-option.d.ts +25 -0
  51. package/dist/record-picker-option.d.ts.map +1 -0
  52. package/dist/record-picker-option.js +55 -0
  53. package/dist/record-picker.d.ts +149 -0
  54. package/dist/record-picker.d.ts.map +1 -0
  55. package/dist/record-picker.js +372 -0
  56. package/package.json +1 -1
  57. package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
  58. package/src/__tests__/business-pickers.test.tsx +6 -3
  59. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  60. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  61. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  62. package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
  63. package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
  64. package/src/__tests__/record-picker.test.tsx +469 -0
  65. package/src/api-context.tsx +19 -4
  66. package/src/business/customer-picker.tsx +87 -98
  67. package/src/business/index.ts +1 -0
  68. package/src/business/line-product-cell.tsx +55 -180
  69. package/src/business/product-options.tsx +119 -0
  70. package/src/business/product-picker.tsx +63 -126
  71. package/src/business/relate-documents.tsx +36 -45
  72. package/src/business/vehicle-picker.tsx +76 -99
  73. package/src/dialogs/dynamic-record.tsx +44 -97
  74. package/src/dynamic-multi-select-field.tsx +39 -13
  75. package/src/dynamic-select-field.tsx +143 -368
  76. package/src/dynamic-table.tsx +91 -4
  77. package/src/entity-select.tsx +53 -202
  78. package/src/index.ts +27 -2
  79. package/src/query-batch.ts +18 -1
  80. package/src/record-picker-actions.tsx +101 -8
  81. package/src/record-picker-dialog.tsx +80 -0
  82. package/src/record-picker-option.tsx +96 -0
  83. package/src/record-picker.tsx +971 -0
  84. package/dist/business/use-async-search.d.ts +0 -10
  85. package/dist/business/use-async-search.d.ts.map +0 -1
  86. package/dist/business/use-async-search.js +0 -38
  87. package/src/business/use-async-search.ts +0 -43
@@ -1,5 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // DynamicSelectField — async, searchable single-select for declarative forms.
3
+ // A configuration of the shared <RecordPicker> (record-picker.tsx).
3
4
  //
4
5
  // This is the declarative answer to "I don't want to type a raw FK UUID".
5
6
  // Instead of a plain <select> that dumps every option (RefSelect) or a free
@@ -25,15 +26,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
26
  import { useEffect, useRef, useState } from 'react';
26
27
  import { getOptionFilter } from './option-filter';
27
28
  import { useTranslation } from 'react-i18next';
28
- import { Badge, Button, Command, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
29
- import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react';
30
- import { resolveColorCss } from '@asteby/metacore-ui/lib';
29
+ import { Badge, Button } from '@asteby/metacore-ui/primitives';
30
+ import { ScanLine } from 'lucide-react';
31
31
  import { BarcodeScanner } from './barcode-scanner';
32
- import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions';
33
- import { DynamicIcon, isLucideIconName } from './dynamic-icon';
34
- import { useDebouncedValue } from './use-debounced-value';
32
+ import { RecordPicker } from './record-picker';
33
+ import { OptionLead } from './record-picker-option';
34
+ import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions';
35
35
  import { useOptionsResolver } from './use-options-resolver';
36
+ import { useDebouncedValue } from './use-debounced-value';
36
37
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema';
38
+ export { OptionLead, OptionThumb } from './record-picker-option';
37
39
  /**
38
40
  * Default hint shown when a cascading picker's depended-on field is still
39
41
  * empty. Domain-neutral on purpose; a caller may override it per field via
@@ -41,86 +43,36 @@ import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-
41
43
  */
42
44
  export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende';
43
45
  /**
44
- * Small square thumbnail for an option's `image`. When the option has no image
45
- * it falls back to a deterministic initials avatar (shared `InitialsAvatar`)
46
- * derived from `name`, so an imageless reference reads as a colored badge rather
47
- * than an empty placeholder — and rows/triggers stay aligned. `size` is in
48
- * pixels (kept small — 20–24px — so the picker reads as a list, not a gallery).
49
- * Inline style for the box dimensions: arbitrary Tailwind classes from a
50
- * federated addon don't always survive the host's class scan.
51
- */
52
- export function OptionThumb({ image, name, size = 20, }) {
53
- const [broken, setBroken] = useState(false);
54
- const box = { width: size, height: size };
55
- // Missing or 404 image (bare avatar filenames often 404) → initials, never
56
- // an invisible box that looks like "no avatar".
57
- if (!image || broken) {
58
- return _jsx(InitialsAvatar, { name: name, size: size, rounded: "sm", tone: "neutral" });
59
- }
60
- // A lucide icon name stored where an image url/path is expected (the `icon`
61
- // form widget's icon mode) → render the glyph instead of a broken <img>.
62
- if (isLucideIconName(image)) {
63
- return (_jsx("span", { className: "flex shrink-0 items-center justify-center rounded-sm bg-muted", style: box, "aria-hidden": true, children: _jsx(DynamicIcon, { name: image, className: "size-4" }) }));
64
- }
65
- return (_jsx("img", { src: image, alt: "", "aria-hidden": true, loading: "lazy", className: "shrink-0 rounded-sm object-cover", style: box, onError: () => setBroken(true) }));
66
- }
67
- /**
68
- * Leading visual for an option: a photo thumbnail (FK relations with an image),
69
- * else a declared icon, else a color dot (enum/status options with a color).
70
- * Returns null when the option carries none, so plain text options stay plain.
46
+ * Declarative `dynamic_select` (manifest `ref` / `optionsConfig.source`) on top
47
+ * of the shared {@link RecordPicker}: options from the canonical options
48
+ * endpoint (`useOptionsResolver`: option_filter, options[].when keep-value,
49
+ * cascade `filter_value`), inline create/edit through the host modal events.
71
50
  */
72
- export function OptionLead({ option, size = 20, }) {
73
- if (!option)
74
- return null;
75
- if (option.image)
76
- return _jsx(OptionThumb, { image: option.image, name: option.label, size: size });
77
- if (option.icon) {
78
- return (_jsx("span", { className: "flex shrink-0 items-center justify-center", style: { width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }, "aria-hidden": true, children: _jsx(DynamicIcon, { name: option.icon, className: "size-4" }) }));
79
- }
80
- if (option.color) {
81
- return (_jsx("span", { className: "shrink-0 rounded-full", style: { width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }, "aria-hidden": true }));
82
- }
83
- // No image/icon/color: an imageless reference option. Show its initials
84
- // (shared InitialsAvatar) so it stays visually aligned with the sibling
85
- // options that DO carry a thumbnail, rather than a blank gap.
86
- if (option.label)
87
- return _jsx(InitialsAvatar, { name: option.label, size: size, rounded: "sm", tone: "neutral" });
88
- return null;
89
- }
90
- function useDebounced(value, ms) {
91
- return useDebouncedValue(value, ms);
92
- }
93
51
  export function DynamicSelectField({ field, value, onChange, onPick, seedOption, dependsValue, dependsHint, readonly = false, staticOptions = null, descriptionAsBadge = false, hideCreate = false, createDefaults, createLockedFields, invalid = false, }) {
94
52
  const { t } = useTranslation();
95
53
  const ph = (fallback) => field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback;
96
54
  const [open, setOpen] = useState(false);
97
55
  const [search, setSearch] = useState('');
98
56
  const [scanOpen, setScanOpen] = useState(false);
99
- const debounced = useDebounced(search, 250);
57
+ const debounced = useDebouncedValue(search, 250);
100
58
  // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
101
- // botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
102
- // esconderse por `isCameraScanSupported()` — en escritorio el botón no salía
103
- // aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
104
- // cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
105
- // picker para elegir la referencia sin tipear el UUID.
59
+ // botón aparece SIEMPRE que el campo lo declara (igual que el POS). El
60
+ // BarcodeScanner ya degrada con un mensaje cuando no hay cámara. Un código
61
+ // escaneado alimenta la búsqueda y abre el picker.
106
62
  const scanEnabled = !!(field.scan ?? field.scannable);
107
63
  const handleScanDetected = (code) => {
108
64
  setSearch(code);
109
65
  setOpen(true);
110
66
  };
111
- // Remember the label of the option the user actually picked so the trigger
112
- // shows a name (not a UUID) without a round-trip.
67
+ // Remember the option the user actually picked so the trigger shows a name
68
+ // (not a UUID) without a round-trip.
113
69
  const [picked, setPicked] = useState(null);
114
- // Tolerate the snake_case `source`/`relation` aliases the kernel may serve
115
- // for the FK target, not just camelCase `ref`.
70
+ // Tolerate the snake_case `source`/`relation` aliases for the FK target.
116
71
  const fieldRef = getFieldRef(field);
117
- // Options routing: an `optionsConfig.source` (dependent/scoped picker) wins
118
- // over the field's `ref` — query the SOURCE model with `field=<value>`;
119
- // otherwise keep the canonical `ref`-based resolution.
72
+ // An `optionsConfig.source` (dependent/scoped picker) wins over `ref`.
120
73
  const source = resolveOptionsSource(field);
121
74
  // Cascade: a `dependsOn` field whose value is still empty leaves this
122
- // picker disabled until the parent is set. `dependsValue` is the resolved
123
- // value the caller threaded from the form context.
75
+ // picker disabled until the parent is set.
124
76
  const dependsOn = getDependsOn(field);
125
77
  const scope = dependsValue ? String(dependsValue) : '';
126
78
  const blockedByDependency = !!dependsOn && scope === '';
@@ -131,21 +83,15 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
131
83
  fieldKey: source.fieldKey,
132
84
  ref: source.ref,
133
85
  // optionsConfig.source → `/options/<source>`. Else searchEndpoint only
134
- // drives the URL when there's no ref — ref is the canonical,
135
- // kernel-derived path and wins.
86
+ // drives the URL when there's no ref (ref is canonical and wins).
136
87
  endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
137
88
  query: debounced,
138
89
  limit: 20,
139
- // Cascade scope forwarded as filter_value (only when this field
140
- // declares a dependency). Re-fetches when the parent value changes.
141
90
  filterValue: dependsOn ? scope : undefined,
142
91
  optionFilter,
143
92
  keepValue: value,
144
- // Don't fetch until the popover opens (and keep fetching as the query
145
- // changes while open). A picker blocked by an unset dependency never
146
- // fetches. A readonly cell fetches eagerly so its value's label resolves
147
- // to the name without the user ever opening it.
148
- // Static lists never hit the network.
93
+ // Fetch only while open; a readonly cell fetches eagerly so its label
94
+ // resolves to the name. Blocked cascades and static lists never fetch.
149
95
  enabled: !useStatic && (open || readonly) && !blockedByDependency,
150
96
  });
151
97
  const options = useStatic
@@ -158,8 +104,7 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
158
104
  })
159
105
  : fetchedOptions;
160
106
  const loading = useStatic ? false : fetchLoading;
161
- // When the depended-on value changes, the previously-picked option no longer
162
- // belongs to the new scope, so clear the selection (skip the initial mount).
107
+ // A new cascade scope invalidates the selection (skip the initial mount).
163
108
  const prevScopeRef = useRef(scope);
164
109
  useEffect(() => {
165
110
  if (!dependsOn)
@@ -171,17 +116,11 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
171
116
  onChange('');
172
117
  }
173
118
  }, [dependsOn, scope, value, onChange]);
174
- // The currently-selected option, resolved either from what the user picked
175
- // (cached in `picked`) or from the loaded page. Drives both the trigger
176
- // label and its thumbnail.
177
119
  const selectedOption = (picked && String(picked.id) === String(value) ? picked : null) ??
178
120
  options.find((o) => String(o.id) === String(value)) ??
179
121
  (seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
180
122
  null;
181
123
  const selectedLabel = selectedOption?.label ?? (value ? String(value) : '');
182
- // Only switch the picker into "with thumbnails" mode when the data actually
183
- // carries images — a relation whose options have no `image` keeps the plain
184
- // text list it had before (no empty placeholder column).
185
124
  const handlePick = (opt) => {
186
125
  setPicked(opt);
187
126
  onChange(String(opt.id));
@@ -189,88 +128,55 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
189
128
  setOpen(false);
190
129
  setSearch('');
191
130
  };
192
- // Inline-create: the "+" opens the REFERENCED model's own create modal (the
193
- // real one the host renders for that model — full fields, not a duplicate),
194
- // via a decoupled window event the host listens for. On success the host
195
- // hands back the new record and we select it immediately. No host import →
196
- // no circular dependency; works for ANY dynamic_select with a `ref`.
197
- // Inline-edit: with a value selected the joined pencil opens THAT record's
198
- // edit modal through the sibling `metacore:edit-record` event (the host's
199
- // RecordCreateBridge already listens for it). On save the trigger label
200
- // refreshes from the returned record.
201
- const openEdit = () => {
202
- if (!fieldRef || !value || typeof window === 'undefined')
131
+ const toOption = (id, label) => ({ id, value: id, label, name: label });
132
+ // Inline create / edit: the REFERENCED model's real modal, rendered by the
133
+ // host (RecordCreateBridge) via decoupled window events — no host import.
134
+ const openCreate = (query) => {
135
+ if (!fieldRef)
203
136
  return;
204
137
  setOpen(false);
205
- window.dispatchEvent(new CustomEvent('metacore:edit-record', {
206
- detail: {
207
- model: fieldRef,
208
- recordId: String(value),
209
- onSaved: (rec) => {
210
- const id = String(value);
211
- const label = rec ? rec.name ?? rec.label ?? rec.title : undefined;
212
- if (label != null)
213
- setPicked({ ...(selectedOption ?? { id, value: id }), id, value: id, label: String(label), name: String(label) });
214
- },
138
+ requestRecordCreate({
139
+ model: fieldRef,
140
+ // The searched text seeds the new record's name; explicit
141
+ // createDefaults win. Keys that aren't fields are ignored.
142
+ defaults: withSearchPrefill(query, 'name', createDefaults),
143
+ lockedFields: createLockedFields,
144
+ onCreated: (rec) => {
145
+ if (rec && rec.id != null) {
146
+ const id = String(rec.id);
147
+ handlePick(toOption(id, recordLabel(rec) ?? id));
148
+ }
215
149
  },
216
- }));
150
+ });
217
151
  };
218
- const openCreate = () => {
219
- if (!fieldRef || typeof window === 'undefined')
152
+ const openEdit = () => {
153
+ if (!fieldRef || !value)
220
154
  return;
221
155
  setOpen(false);
222
- window.dispatchEvent(new CustomEvent('metacore:create-record', {
223
- detail: {
224
- model: fieldRef,
225
- // Generic passthrough: the host's create modal seeds these
226
- // values and locks these fields (DynamicRecordDialog's own
227
- // defaults/lockedFields props). Undefined when unused.
228
- defaults: createDefaults,
229
- lockedFields: createLockedFields,
230
- onCreated: (rec) => {
231
- if (rec && rec.id != null) {
232
- const id = String(rec.id);
233
- const label = String(rec.name ?? rec.label ?? rec.title ?? rec.id);
234
- handlePick({ id, value: id, label, name: label });
235
- }
236
- },
156
+ const id = String(value);
157
+ requestRecordEdit({
158
+ model: fieldRef,
159
+ recordId: id,
160
+ onSaved: (rec) => {
161
+ const label = recordLabel(rec);
162
+ if (label != null)
163
+ setPicked({ ...(selectedOption ?? toOption(id, label)), id, value: id, label, name: label });
237
164
  },
238
- }));
165
+ });
239
166
  };
240
- // Locked display: the value is fixed by context, so render the resolved
241
- // label (name) as a disabled control — no popover, no inline-create. While
242
- // the eager fetch is in flight the label falls back to the seed/raw value,
243
- // then snaps to the name once options arrive.
244
- if (readonly) {
245
- return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: field.key, disabled: true, "aria-readonly": "true", className: "w-full min-w-0 cursor-default justify-start font-normal opacity-100", children: _jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [selectedOption ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground'), children: selectedOption?.label ?? (loading ? 'Cargando…' : ph('—')) })] }) }));
246
- }
247
- // w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
248
- // to the cell. Without min-w-0 the combobox+button row sizes to its content
249
- // (the long empty-state placeholder) and overflows the column, pushing the
250
- // "+" off-screen — it only "fit" once a short value was selected.
251
- const canMutate = !!fieldRef && !hideCreate && !useStatic;
252
- const joined = canMutate && !blockedByDependency && hasRecordPickerAction(!!value, openCreate, openEdit);
167
+ const canMutate = !!fieldRef && !hideCreate && !useStatic && !blockedByDependency;
253
168
  const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? '';
254
- return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", "data-slot": "dynamic-select", children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-center", children: [_jsxs(Popover, { open: open && !blockedByDependency, onOpenChange: (o) => { if (!blockedByDependency)
255
- setOpen(o); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, disabled: blockedByDependency, "aria-invalid": invalid || undefined, className: 'min-w-0 flex-1 justify-between font-normal' +
256
- (joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
257
- (invalid ? ' border-destructive ring-1 ring-destructive/30' : ''), "data-empty": !value, "data-depends-blocked": blockedByDependency ? '' : undefined, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [value && selectedOption ? (_jsx(OptionLead, { option: selectedOption, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: blockedByDependency
258
- ? (dependsHint || DEFAULT_DEPENDS_HINT)
259
- : selectedLabel || ph('Buscar…') }), descriptionAsBadge && selectedOption?.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: selectedOption.description })) : null] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
260
- // Portaled (never clipped by a table/modal overflow) and
261
- // collision-aware: flips above the trigger when there is no
262
- // room below. Match the trigger width without an arbitrary
263
- // Tailwind class (those don't always survive a consuming app's
264
- // Tailwind scan), with a floor so short cells stay readable.
265
- collisionPadding: 8, style: { width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: ph('Buscar…'), value: search, onValueChange: setSearch }), _jsxs(CommandList, { children: [loading && (_jsxs("div", { className: "text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), "Buscando\u2026"] })), !loading && options.length === 0 && (_jsx("div", { className: "text-muted-foreground py-6 text-center text-sm", "data-slot": "picker-empty", children: useStatic
266
- ? debounced
267
- ? 'Sin resultados'
268
- : 'Sin opciones'
269
- : debounced
270
- ? 'Sin resultados'
271
- : 'Sin resultados — escribe para filtrar' })), !loading && options.length > 0 && (_jsx(CommandGroup, { className: "max-h-64 overflow-auto", children: options.map((opt) => {
272
- const isSel = String(opt.id) === String(value);
273
- return (_jsxs(CommandItem, { value: String(opt.id), onSelect: () => handlePick(opt), children: [_jsx(Check, { className: 'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0') }), _jsx(OptionLead, { option: opt, size: 24 }), descriptionAsBadge ? (_jsxs("div", { className: "ml-2 flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: opt.label }), opt.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: opt.description })) : null] })) : (_jsxs("div", { className: "ml-2 flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: opt.label }), opt.description && (_jsx("span", { className: "text-muted-foreground truncate text-xs", children: opt.description }))] }))] }, String(opt.id)));
274
- }) })), canMutate && !loading && (_jsx(PickerCreateItem, { label: fieldName, onSelect: openCreate }))] })] }) })] }), canMutate && !blockedByDependency && (_jsx(RecordPickerAction, { hasValue: !!value, label: fieldName, onCreate: openCreate, onEdit: openEdit }))] }), scanEnabled && (_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" }) })), scanEnabled && (_jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: handleScanDetected, continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() }))] }));
169
+ const badge = (opt) => opt.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: opt.description })) : null;
170
+ return (_jsx(RecordPicker, { slot: "dynamic-select", id: field.key, items: options, loading: loading, getKey: (o) => String(o.id), getLabel: (o) => o.label, value: value, selected: selectedOption, onSelect: handlePick, query: search, onQueryChange: setSearch, open: open && !blockedByDependency, onOpenChange: (o) => {
171
+ if (!blockedByDependency)
172
+ setOpen(o);
173
+ }, disabled: blockedByDependency, readOnly: readonly, invalid: invalid, renderLead: (o, where) => _jsx(OptionLead, { option: o, size: where === 'option' ? 24 : 20 }), getDescription: descriptionAsBadge ? undefined : (o) => o.description, renderTrailing: descriptionAsBadge ? (o) => badge(o) : undefined, renderValue: () => {
174
+ if (readonly) {
175
+ return (_jsxs(_Fragment, { children: [selectedOption ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground'), children: selectedOption?.label ?? (loading ? 'Cargando…' : ph('—')) })] }));
176
+ }
177
+ return (_jsxs(_Fragment, { children: [value && selectedOption ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: blockedByDependency ? dependsHint || DEFAULT_DEPENDS_HINT : selectedLabel || ph('Buscar…') }), descriptionAsBadge && selectedOption ? badge(selectedOption) : null] }));
178
+ }, placeholder: ph('Buscar…'), searchPlaceholder: ph('Buscar…'), emptyText: debounced ? 'Sin resultados' : useStatic ? 'Sin opciones' : 'Sin resultados — escribe para filtrar', entityLabel: fieldName, onCreate: canMutate ? openCreate : undefined, onEdit: canMutate ? openEdit : undefined, triggerProps: {
179
+ 'data-depends-blocked': blockedByDependency ? '' : undefined,
180
+ }, after: scanEnabled ? (_jsxs(_Fragment, { children: [_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: handleScanDetected, continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() })] })) : null }));
275
181
  }
276
182
  export default DynamicSelectField;
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from 'react';
1
2
  import { type ColumnDef } from '@tanstack/react-table';
2
3
  import type { GetDynamicColumns } from './dynamic-columns-shim';
3
4
  export interface DynamicTableProps {
@@ -90,6 +91,35 @@ export interface DynamicTableProps {
90
91
  * render every row. A positive number overrides the threshold.
91
92
  */
92
93
  virtualizeRows?: boolean | number;
94
+ /**
95
+ * Host-provided actions rendered in the floating bar that appears while
96
+ * rows are selected, next to the built-in "Eliminar". Pass a node, or a
97
+ * function that receives the current selection:
98
+ * - `selectedRows`: the selected row objects (current page only);
99
+ * - `selectedIds`: their `id`s;
100
+ * - `clearSelection()`: unselect everything (call after a successful action);
101
+ * - `refresh()`: reload the list.
102
+ * The selection is cleared whenever the page, page size, sort, search or
103
+ * column filters change (classic pagination), so a bulk action can never
104
+ * hit rows the user no longer sees. The host owns the write call and its
105
+ * authorization — the table only supplies the selection.
106
+ */
107
+ extraBulkActions?: ReactNode | ((ctx: DynamicTableBulkContext) => ReactNode);
108
+ /** Hide the built-in bulk "Eliminar" (e.g. a model whose rows must not be deleted in batch). */
109
+ hideBulkDelete?: boolean;
110
+ /**
111
+ * First-use content shown instead of "No se encontraron resultados" when the
112
+ * list loaded fine, is empty, and no search/filter is active. Never shown
113
+ * after a load error (a retry is offered) or a 403.
114
+ */
115
+ emptyState?: ReactNode;
116
+ }
117
+ /** Context handed to `DynamicTableProps.extraBulkActions` when it is a function. */
118
+ export interface DynamicTableBulkContext {
119
+ selectedRows: any[];
120
+ selectedIds: Array<string | number>;
121
+ clearSelection: () => void;
122
+ refresh: () => void;
93
123
  }
94
- export declare function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync, hideImport, hideExport, hiddenColumns, allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp, virtualizeRows, }: DynamicTableProps): import("react").JSX.Element;
124
+ export declare function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync, hideImport, hideExport, hiddenColumns, allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp, virtualizeRows, extraBulkActions, hideBulkDelete, emptyState, }: DynamicTableProps): import("react").JSX.Element;
95
125
  //# sourceMappingURL=dynamic-table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAmC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAyGnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;;;;;;OASG;IACH,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;CACpC;AAQD,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,aAAoB,EACpB,UAAU,EACV,UAAU,EACV,aAAkB,EAClB,iBAAiB,EACjB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,kBAA0B,EAC1C,cAAc,GACjB,EAAE,iBAAiB,+BA2kDnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAYA,OAAO,EAAsE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAM1G,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAmC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAyGnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;;;;;;OASG;IACH,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;IACjC;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,GAAG,EAAE,uBAAuB,KAAK,SAAS,CAAC,CAAA;IAC5E,gGAAgG;IAChG,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,CAAA;CACzB;AAED,oFAAoF;AACpF,MAAM,WAAW,uBAAuB;IACpC,YAAY,EAAE,GAAG,EAAE,CAAA;IACnB,WAAW,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;IACnC,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAQD,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,aAAoB,EACpB,UAAU,EACV,UAAU,EACV,aAAkB,EAClB,iBAAiB,EACjB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,kBAA0B,EAC1C,cAAc,EACd,gBAAgB,EAChB,cAAc,EACd,UAAU,GACb,EAAE,iBAAiB,+BAioDnB"}
@@ -118,7 +118,7 @@ function isForbiddenError(error) {
118
118
  const e = error;
119
119
  return e?.response?.status === 403 || e?.status === 403;
120
120
  }
121
- export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync = true, hideImport, hideExport, hiddenColumns = [], allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, virtualizeRows, }) {
121
+ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync = true, hideImport, hideExport, hiddenColumns = [], allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, virtualizeRows, extraBulkActions, hideBulkDelete, emptyState, }) {
122
122
  // The org's timezone/currency: an explicit prop wins, else the app-wide
123
123
  // OrgRuntimeProvider (without it, money fell back to USD).
124
124
  const orgTimeZone = useTimeZone();
@@ -165,6 +165,9 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
165
165
  // model. Rendered as "no permission" instead of masquerading as an empty
166
166
  // list. Purely display state — never a fetch dependency (no refetch loop).
167
167
  const [forbidden, setForbidden] = useState(false);
168
+ // A list request that failed for a reason other than 403 (500, network,
169
+ // `success: false`). Shown with a retry instead of a misleading "no results".
170
+ const [loadError, setLoadError] = useState(false);
168
171
  const infPageRef = useRef(1);
169
172
  const [optionsMap, setOptionsMap] = useState(new Map());
170
173
  const [exportOpen, setExportOpen] = useState(false);
@@ -744,6 +747,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
744
747
  params.include = relationInclude;
745
748
  const res = await api.get(endpoint || `/data/${model}`, { params });
746
749
  setForbidden(false);
750
+ setLoadError(!res.data.success);
747
751
  if (res.data.success) {
748
752
  const rows = res.data.data || [];
749
753
  setData(rows);
@@ -759,11 +763,13 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
759
763
  catch (error) {
760
764
  if (isForbiddenError(error)) {
761
765
  setForbidden(true);
766
+ setLoadError(false);
762
767
  setData([]);
763
768
  setRowCount(0);
764
769
  }
765
770
  else {
766
771
  console.error('Error al cargar los datos', error);
772
+ setLoadError(true);
767
773
  }
768
774
  }
769
775
  finally {
@@ -813,6 +819,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
813
819
  params,
814
820
  }));
815
821
  setForbidden(false);
822
+ setLoadError(!res.data.success);
816
823
  if (res.data.success) {
817
824
  const rows = res.data.data || [];
818
825
  setData((prev) => (append ? dedupeById(prev, rows) : rows));
@@ -832,6 +839,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
832
839
  catch (error) {
833
840
  if (isForbiddenError(error)) {
834
841
  setForbidden(true);
842
+ setLoadError(false);
835
843
  if (!append) {
836
844
  setData([]);
837
845
  setRowCount(0);
@@ -840,6 +848,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
840
848
  }
841
849
  else {
842
850
  console.error('Error al cargar los datos', error);
851
+ setLoadError(true);
843
852
  }
844
853
  }
845
854
  finally {
@@ -1237,6 +1246,10 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1237
1246
  manualSorting: true,
1238
1247
  manualFiltering: true,
1239
1248
  enableRowSelection: true,
1249
+ // Key the selection by record id, not by position in the page: a bulk
1250
+ // action must act on the rows the user ticked, never on whatever row
1251
+ // lands at the same index after the data changes.
1252
+ getRowId: (row, index) => (row?.id != null ? String(row.id) : String(index)),
1240
1253
  onRowSelectionChange: setRowSelection,
1241
1254
  onSortingChange: setSorting,
1242
1255
  onColumnVisibilityChange: setColumnVisibility,
@@ -1251,6 +1264,19 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1251
1264
  getFacetedUniqueValues: getFacetedUniqueValues(),
1252
1265
  });
1253
1266
  const tableRows = table.getRowModel().rows;
1267
+ const bulkSelectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
1268
+ // Classic pagination swaps the visible rows on every page / sort / search /
1269
+ // filter change; drop the selection so it never refers to rows that are no
1270
+ // longer on screen. Infinite mode accumulates rows (selection is id-keyed,
1271
+ // so it stays valid) and is left alone.
1272
+ const selectionScope = infiniteScroll ? '' : JSON.stringify([pagination.pageIndex, pagination.pageSize, sorting, columnFilters, globalFilter]);
1273
+ const prevSelectionScope = useRef(selectionScope);
1274
+ useEffect(() => {
1275
+ if (prevSelectionScope.current !== selectionScope) {
1276
+ prevSelectionScope.current = selectionScope;
1277
+ setRowSelection({});
1278
+ }
1279
+ }, [selectionScope]);
1254
1280
  const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows);
1255
1281
  const shouldVirtualize = virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold;
1256
1282
  const colSpan = Math.max(columns.length, 1);
@@ -1302,7 +1328,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1302
1328
  const isActionsColumn = cell.column.id === 'actions';
1303
1329
  const isSelectColumn = cell.column.id === 'select';
1304
1330
  return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), onClick: (isActionsColumn || isSelectColumn) ? (e) => e.stopPropagation() : undefined, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
1305
- }) }, row.id)))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsx("div", { className: "flex flex-col items-center gap-1", children: forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })) })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
1331
+ }) }, row.id)))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsx("div", { className: "flex flex-col items-center gap-1", children: forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : loadError ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' }) }), _jsx(Button, { variant: "outline", size: "sm", className: "mt-2", onClick: handleRefresh, children: t('dynamic.retry', { defaultValue: 'Reintentar' }) })] })) : emptyState && !hasActiveFilters ? (emptyState) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })) })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
1306
1332
  const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
1307
1333
  const isFirst = idx === 0;
1308
1334
  const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold';
@@ -1342,9 +1368,16 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1342
1368
  (typeof header === 'string' ? header : cell.column.id);
1343
1369
  return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 overflow-hidden break-words text-right font-medium [&_[data-slot=badge]]:max-w-full [&_[data-slot=badge]]:whitespace-normal [&_[data-slot=badge]]:text-start', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
1344
1370
  }), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
1345
- }))) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] }))] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
1371
+ }))) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : loadError ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' }) }), _jsx(Button, { variant: 'outline', size: 'sm', onClick: handleRefresh, children: t('dynamic.retry', { defaultValue: 'Reintentar' }) })] })) : emptyState && !hasActiveFilters ? (emptyState) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] }))] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
1346
1372
  defaultValue: '{{count}} de {{total}}',
1347
1373
  count: data.length,
1348
1374
  total: rowCount,
1349
- }) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), bulkReasonPrompt.dialog, exportEnabled && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), importEnabled && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }) }));
1375
+ }) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), bulkReasonPrompt.dialog, exportEnabled && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), importEnabled && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsxs(DataTableBulkActions, { table: table, entityName: "registro", children: [typeof extraBulkActions === 'function'
1376
+ ? extraBulkActions({
1377
+ selectedRows: bulkSelectedRows,
1378
+ selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
1379
+ clearSelection: () => table.resetRowSelection(),
1380
+ refresh: handleRefresh,
1381
+ })
1382
+ : extraBulkActions, !hideBulkDelete && (_jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }))] })] }) }));
1350
1383
  }
@@ -1,4 +1,4 @@
1
- import { type LucideIcon } from 'lucide-react';
1
+ import type { LucideIcon } from 'lucide-react';
2
2
  /** One searchable option: `value` is the id, `label` the display text. */
3
3
  export interface EntitySelectOption {
4
4
  value: string;
@@ -52,5 +52,8 @@ export interface EntitySelectProps {
52
52
  */
53
53
  lockedCreateFields?: string[];
54
54
  }
55
- export declare function EntitySelect({ model, value, label, onSelect, fetcher, icon: Icon, placeholder, searchPlaceholder, emptyText, preload, canCreate, canEdit, endpoint, labelField, disabled, createDefaults, lockedCreateFields, }: EntitySelectProps): import("react").JSX.Element;
55
+ /**
56
+ * @deprecated Thin wrapper over {@link RecordPicker}; prefer it for new code.
57
+ */
58
+ export declare function EntitySelect({ model, value, label, onSelect, fetcher, icon, placeholder, searchPlaceholder, emptyText, preload, canCreate, canEdit, endpoint, labelField, disabled, createDefaults, lockedCreateFields, }: EntitySelectProps): import("react").JSX.Element;
56
59
  //# sourceMappingURL=entity-select.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"entity-select.d.ts","sourceRoot":"","sources":["../src/entity-select.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA;AAiBzD,0EAA0E;AAC1E,MAAM,WAAW,kBAAkB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAC9B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAA;IACb,uCAAuC;IACvC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,kEAAkE;IAClE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,sDAAsD;IACtD,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC3D,sFAAsF;IACtF,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAAA;IAE1E,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;CAChC;AAOD,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,KAAK,EACL,KAAK,EACL,QAAQ,EACR,OAAO,EACP,IAAI,EAAE,IAAI,EACV,WAA4B,EAC5B,iBAA6B,EAC7B,SAA4B,EAC5B,OAAe,EACf,SAAS,EACT,OAAO,EACP,QAAQ,EACR,UAAmB,EACnB,QAAgB,EAChB,cAAc,EACd,kBAAkB,GACrB,EAAE,iBAAiB,+BAmMnB"}
1
+ {"version":3,"file":"entity-select.d.ts","sourceRoot":"","sources":["../src/entity-select.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,0EAA0E;AAC1E,MAAM,WAAW,kBAAkB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAC9B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAA;IACb,uCAAuC;IACvC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,kEAAkE;IAClE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,sDAAsD;IACtD,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC3D,sFAAsF;IACtF,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAAA;IAE1E,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;CAChC;AAOD;;GAEG;AACH,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,KAAK,EACL,KAAK,EACL,QAAQ,EACR,OAAO,EACP,IAAI,EACJ,WAA4B,EAC5B,iBAA6B,EAC7B,SAA4B,EAC5B,OAAe,EACf,SAAS,EACT,OAAO,EACP,QAAQ,EACR,UAAmB,EACnB,QAAgB,EAChB,cAAc,EACd,kBAAkB,GACrB,EAAE,iBAAiB,+BAmDnB"}