@asteby/metacore-runtime-react 22.0.0 → 23.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 08e18bf: Paridad de la experiencia pro de filtros en DynamicTable (vista tabla de los
8
+ módulos dinámicos), igualando al kanban.
9
+ - **Prefetch de facetas en la tabla:** al resolver la metadata se precargan en
10
+ paralelo (dedup por firma, `allSettled`) las facetas de todos los campos facet,
11
+ sembrando sus `options`. Abrir el filtro de una columna de texto en el header
12
+ ya muestra el combobox con valores + counts al instante, sin "Cargando…"; el
13
+ spinner queda solo para el refetch con búsqueda.
14
+ - **i18n de opciones en la tabla:** las opciones (stages y cualquier key i18n del
15
+ manifest) se traducen con `t(label, {defaultValue})` en runtime-react antes de
16
+ pasarlas al ui package, tanto en los filtros de header como en los chips y
17
+ resúmenes de valor.
18
+ - **Fila de chips de filtros activos sobre la tabla:** debajo de la toolbar,
19
+ removibles ("Campo: valor(es) ×" + "Limpiar todo"), con el color del valor
20
+ cuando aplica (p.ej. la etapa). Extraído a un componente compartido
21
+ `FilterChipsRow` (con `summarizeFilterValues`/`chipValueColor`/
22
+ `translateOptionLabels`) reusado por el kanban y la tabla — sin duplicar.
23
+ - **Stage-select en la tabla:** la columna `group_by`/stage sin opciones propias
24
+ ofrece el select con las etapas del pipeline (traducidas, con color), no un
25
+ cuadro de texto — sale del motor compartido; confirmado con test.
26
+ - **Header de lane del kanban:** los botones de búsqueda y embudo se ven SIEMPRE
27
+ (se quitó el hover-reveal, que nadie descubría) en muted con hover a foreground.
28
+ El embudo muestra un badge numérico con la cantidad de valores/filtros
29
+ aplicados en esa lane (como el badge del botón Filtros de la toolbar); la lupa
30
+ tiñe a primary + dot cuando hay búsqueda activa.
31
+
32
+ ## 23.0.0
33
+
34
+ ### Patch Changes
35
+
36
+ - 25a78e7: Fixes del embudo por columna del kanban (LaneFilterButton).
37
+ - **Precarga de opciones dinámicas:** el value-picker del embudo ahora usa el
38
+ MISMO combobox pro que el Sheet — si el campo elegido tiene options estáticas
39
+ o `loadOptions` (facet), renderiza el combobox multi-select con carga lazy
40
+ (counts, buscador "Buscar valores...", estados de carga). Antes un campo facet
41
+ (p.ej. "Repo") caía al input crudo "Valor..." porque sus options aún no
42
+ estaban cargadas. Solo cae a input de texto cuando el campo es texto libre sin
43
+ facets. Nuevo componente compartible `FilterValueCombobox` en
44
+ `@asteby/metacore-ui`. El filtrado del lane sigue client-side: igualdad/IN
45
+ para valores de select/facet, substring para texto libre.
46
+ - **Traducción de opciones:** las opciones de Stage (y cualquier option con key
47
+ i18n del manifest) se traducen con `t(label, {defaultValue})` en runtime-react
48
+ ANTES de pasarlas al control (ColumnFilterControl vive en ui, sin i18n). Aplica
49
+ al embudo, al Sheet, a los chips y a los resúmenes de valor; el valor activo de
50
+ stage se muestra traducido y con su color.
51
+ - **Diseño del popover del embudo:** select de campo y control de valor a ancho
52
+ completo, popover más ancho (`w-72`), combobox con borde redondeado, botones
53
+ Limpiar/Aplicar full-width en fila — al nivel del resto del rediseño.
54
+ - **Prefetch de facetas:** al resolver la metadata se precargan en paralelo las
55
+ facetas de TODOS los campos facet (un `api.get` por campo, dedupeado por firma,
56
+ `allSettled` para que un campo con error no rompa el resto), sembrando el cache
57
+ del loader y poblando las `options` del `ColumnFilterConfig`. Así el popover
58
+ del Sheet y el value-picker del lane abren instantáneos con valores + counts,
59
+ sin "Cargando…"; el spinner queda solo para el refetch con búsqueda (`q`).
60
+ `useFacetLoaders` ahora expone `prefetchFacets` y `facetOptions`.
61
+
62
+ - Updated dependencies [25a78e7]
63
+ - @asteby/metacore-ui@2.9.0
64
+
3
65
  ## 22.0.0
4
66
 
5
67
  ### Minor Changes
@@ -1,5 +1,7 @@
1
1
  import * as React from 'react';
2
+ import { summarizeFilterValues, translateOptionLabels } from './filter-chips';
2
3
  import type { TableMetadata, ColumnDefinition, StageMeta, StageTransition } from './types';
4
+ export { summarizeFilterValues, translateOptionLabels };
3
5
  /**
4
6
  * Resolves the board lanes for a kanban view. Prefers the model-level
5
7
  * `metadata.stages` (the kernel's `stages[]`); falls back to the `group_by`
@@ -41,15 +43,25 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
41
43
  fields: ColumnDefinition[];
42
44
  };
43
45
  /**
44
- * Human-readable summary of a field's selected filter values for the removable
45
- * chip row. Resolves option labels, unwraps the wire operators
46
- * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
47
- * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
46
+ * Whether a card passes a lane funnel. Picked select/facet `values` match by
47
+ * equality (IN the card's field value must be one of them); a free-text
48
+ * `text` matches by case-insensitive substring. No field / no criteria → passes.
49
+ * Pure — exported for unit tests.
48
50
  */
49
- export declare function summarizeFilterValues(values: string[] | undefined, options: {
50
- label: string;
51
- value: string;
52
- }[] | undefined, maxShown?: number): string;
51
+ export declare function cardMatchesLaneFunnel(card: any, filter: {
52
+ field?: string;
53
+ values?: string[];
54
+ text?: string;
55
+ } | undefined): boolean;
56
+ /**
57
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
58
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
59
+ * badge. Pure — exported for unit tests.
60
+ */
61
+ export declare function laneFunnelCount(value: {
62
+ values?: string[];
63
+ text?: string;
64
+ } | undefined): number;
53
65
  /**
54
66
  * Whether a card matches a free-text lane search: a case-insensitive substring
55
67
  * over the card's title + every visible field value (`String(v)`). Empty query
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA+D9B,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAMhB;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAejE;AAQD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAA;AAE/C,wBAAgB,YAAY,CACxB,OAAO,EAAE,GAAG,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,EAAE,GACpB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAepB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,WAAW,EAAE,eAAe,EAAE,GAAG,SAAS,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACX,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAC3B,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAYpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,EAC5B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,SAAS,EACvD,QAAQ,SAAI,GACb,MAAM,CAuBR;AAgCD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;AAqCD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACvC;AAED,wBAAgB,aAAa,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,EACR,QAAc,EACd,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBA2jBpB"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
@@ -5,10 +5,11 @@ import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDragg
5
5
  import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, ToggleLeft, Type, X, } from 'lucide-react';
6
6
  import { toast } from 'sonner';
7
7
  import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
8
- import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
9
- import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
8
+ import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
9
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
11
11
  import { useDynamicFilters } from './use-dynamic-filters';
12
+ import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
12
13
  import { useMetadataCache } from './metadata-cache';
13
14
  import { ActivityValueRenderer } from './activity-value-renderer';
14
15
  import { DynamicIcon } from './dynamic-icon';
@@ -16,6 +17,9 @@ import { isColumnVisibleInTable } from './column-visibility';
16
17
  import { isRowActionVisible } from './dynamic-columns';
17
18
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
18
19
  import { useDynamicRowActions } from './dynamic-row-actions';
20
+ // Re-exported for tests + backward-compat: these live in ./filter-chips now
21
+ // (shared with DynamicTable) but were historically imported from here.
22
+ export { summarizeFilterValues, translateOptionLabels };
19
23
  // ---------------------------------------------------------------------------
20
24
  // Pure helpers (exported for unit tests — no React, no transport)
21
25
  // ---------------------------------------------------------------------------
@@ -130,62 +134,34 @@ export function selectCardColumns(metadata, maxFields = 3) {
130
134
  return { title, fields };
131
135
  }
132
136
  /**
133
- * Human-readable summary of a field's selected filter values for the removable
134
- * chip row. Resolves option labels, unwraps the wire operators
135
- * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
136
- * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
137
+ * Whether a card passes a lane funnel. Picked select/facet `values` match by
138
+ * equality (IN the card's field value must be one of them); a free-text
139
+ * `text` matches by case-insensitive substring. No field / no criteria → passes.
140
+ * Pure — exported for unit tests.
137
141
  */
138
- export function summarizeFilterValues(values, options, maxShown = 2) {
139
- if (!values || values.length === 0)
140
- return '';
141
- const opts = options ?? [];
142
- const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
143
- const first = values[0];
144
- if (values.length === 1) {
145
- if (first.startsWith('ILIKE:'))
146
- return `"${first.slice(6)}"`;
147
- if (first.startsWith('IN:')) {
148
- return summarizeList(first.slice(3).split(','), labelFor, maxShown);
149
- }
150
- if (first.startsWith('RANGE:')) {
151
- const [min, max] = first.slice(6).split(',');
152
- return `${min || '…'} – ${max || '…'}`;
153
- }
154
- if (/^\d{4}-\d{2}-\d{2}_/.test(first))
155
- return first.replace('_', ' – ');
142
+ export function cardMatchesLaneFunnel(card, filter) {
143
+ if (!filter?.field)
144
+ return true;
145
+ const raw = String(card?.[filter.field] ?? '');
146
+ if (filter.values && filter.values.length > 0) {
147
+ return filter.values.includes(raw);
156
148
  }
157
- if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
158
- const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
159
- const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
160
- return `${min || '…'} – ${max || '…'}`;
149
+ if (filter.text?.trim()) {
150
+ return raw.toLowerCase().includes(filter.text.trim().toLowerCase());
161
151
  }
162
- return summarizeList(values, labelFor, maxShown);
163
- }
164
- function summarizeList(items, labelFor, maxShown) {
165
- const labels = items.map(labelFor);
166
- if (labels.length <= maxShown)
167
- return labels.join(', ');
168
- return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
152
+ return true;
169
153
  }
170
154
  /**
171
- * The color of a filter's first selected value (for the chip's dot) e.g. a
172
- * stage's palette color. Returns a resolved CSS color, or undefined for
173
- * operator/range/free-text values that carry no option color.
155
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
156
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
157
+ * badge. Pure exported for unit tests.
174
158
  */
175
- function chipValueColor(config) {
176
- const sel = config.selectedValues;
177
- if (!sel || sel.length === 0)
178
- return undefined;
179
- const first = sel[0];
180
- let value = first;
181
- if (first.startsWith('IN:'))
182
- value = first.slice(3).split(',')[0];
183
- else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
184
- return undefined;
185
- else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
186
- return undefined;
187
- const opt = config.options.find((o) => o.value === value);
188
- return opt?.color ? resolveColorCss(opt.color) : undefined;
159
+ export function laneFunnelCount(value) {
160
+ if (value?.values?.length)
161
+ return value.values.length;
162
+ if (value?.text?.trim())
163
+ return 1;
164
+ return 0;
189
165
  }
190
166
  /**
191
167
  * Whether a card matches a free-text lane search: a case-insensitive substring
@@ -308,11 +284,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
308
284
  if (!metadata)
309
285
  return [];
310
286
  const out = [];
287
+ // Option labels come from the manifest as i18n keys (e.g.
288
+ // "integration_github.stage.backlog"). ColumnFilterControl lives in the
289
+ // ui package (no i18n), so translate labels HERE — on the static options
290
+ // and on whatever the facet loader resolves — before they ever reach a
291
+ // control, chip or value summary. A raw value (a repo name) has no key,
292
+ // so t() returns it verbatim via defaultValue.
293
+ const tr = (label) => t(label, { defaultValue: label });
311
294
  for (const [key, config] of columnFilterConfigs) {
312
295
  const f = metadata.filters?.find((x) => x.key === key);
313
296
  const c = metadata.columns.find((x) => x.key === key);
314
297
  const rawLabel = f?.label || c?.label || key;
315
- out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config });
298
+ const translatedConfig = {
299
+ ...config,
300
+ options: translateOptionLabels(config.options, tr),
301
+ loadOptions: config.loadOptions
302
+ ? (q) => config.loadOptions(q).then((opts) => translateOptionLabels(opts, tr))
303
+ : undefined,
304
+ };
305
+ out.push({
306
+ key,
307
+ label: tr(rawLabel),
308
+ config: translatedConfig,
309
+ });
316
310
  }
317
311
  return out;
318
312
  }, [metadata, columnFilterConfigs, t]);
@@ -342,7 +336,9 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
342
336
  setLaneFilters((prev) => {
343
337
  const merged = { ...prev[stageKey], ...patch };
344
338
  const next = { ...prev };
345
- const hasFunnel = !!(merged.field && merged.value?.trim());
339
+ const hasFunnel = !!(merged.field &&
340
+ ((merged.values && merged.values.length > 0) ||
341
+ merged.text?.trim()));
346
342
  const hasQuery = !!merged.query?.trim();
347
343
  if (hasFunnel || hasQuery)
348
344
  next[stageKey] = merged;
@@ -462,25 +458,15 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
462
458
  })
463
459
  : t('kanban.noActiveFilters', {
464
460
  defaultValue: 'Sin filtros',
465
- }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), activeFields.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "kanban-filter-chips", children: [activeFields.map((field) => {
466
- const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
467
- const dot = chipValueColor(field.config);
468
- return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('kanban.removeFilter', {
469
- defaultValue: 'Quitar filtro',
470
- }), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
471
- }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: clearAll, children: t('kanban.clearAll', { defaultValue: 'Limpiar todo' }) })] })), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
461
+ }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
472
462
  const allCards = grouped.get(stage.key) ?? [];
473
463
  // Per-lane client-side narrowing (instant, scoped to this
474
464
  // stage). The funnel (field/value) and the lane search
475
465
  // (query) are AND-combined.
476
466
  const laneFilter = laneFilters[stage.key];
477
467
  let cards = allCards;
478
- if (laneFilter?.field && laneFilter.value?.trim()) {
479
- const v = laneFilter.value.trim().toLowerCase();
480
- const field = laneFilter.field;
481
- cards = cards.filter((c) => String(c[field] ?? '')
482
- .toLowerCase()
483
- .includes(v));
468
+ if (laneFilter?.field) {
469
+ cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
484
470
  }
485
471
  if (laneFilter?.query?.trim()) {
486
472
  cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
@@ -490,7 +476,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
490
476
  isTransitionAllowed(transitions, activeStage, stage.key);
491
477
  return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
492
478
  field: f?.field,
493
- value: f?.value,
479
+ values: f?.values,
480
+ text: f?.text,
494
481
  }), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
495
482
  }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
496
483
  }
@@ -527,11 +514,18 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
527
514
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
528
515
  isDark,
529
516
  });
530
- const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim());
517
+ const funnelField = filterFields.find((f) => f.key === laneFilter?.field);
518
+ const funnelActive = !!(laneFilter?.field &&
519
+ ((laneFilter.values && laneFilter.values.length > 0) ||
520
+ laneFilter.text?.trim()));
531
521
  const queryActive = !!laneFilter?.query?.trim();
532
522
  const laneActive = funnelActive || queryActive;
533
- const activeFieldLabel = filterFields.find((f) => f.key === laneFilter?.field)?.label ??
534
- laneFilter?.field;
523
+ const activeFieldLabel = funnelField?.label ?? laneFilter?.field;
524
+ // Human summary of the funnel value: resolved option labels for picked
525
+ // values, or the raw free text.
526
+ const funnelSummary = laneFilter?.values && laneFilter.values.length > 0
527
+ ? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
528
+ : laneFilter?.text ?? '';
535
529
  // Inline lane search: a Search icon expands an Input; Escape or blur-while-
536
530
  // empty collapses it. The query itself lives in the parent's laneFilters so
537
531
  // it survives collapse and combines with the funnel.
@@ -541,16 +535,20 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
541
535
  if (searchOpen)
542
536
  searchRef.current?.focus();
543
537
  }, [searchOpen]);
544
- const funnelValue = laneFilter?.field && laneFilter.value
545
- ? { field: laneFilter.field, value: laneFilter.value }
538
+ const funnelValue = laneFilter?.field
539
+ ? {
540
+ field: laneFilter.field,
541
+ values: laneFilter.values,
542
+ text: laneFilter.text,
543
+ }
546
544
  : undefined;
547
545
  return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
548
546
  opacity: dimmed ? 0.45 : 1,
549
547
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
550
548
  outlineOffset: 2,
551
- }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: `flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${laneActive || searchOpen ? 'opacity-100' : 'opacity-0'}`, children: [_jsx("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
549
+ }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
552
550
  defaultValue: 'Buscar en la columna',
553
- }), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
551
+ }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
554
552
  if (e.key === 'Escape') {
555
553
  onQueryChange('');
556
554
  setSearchOpen(false);
@@ -560,7 +558,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
560
558
  setSearchOpen(false);
561
559
  }, placeholder: t('kanban.searchLanePlaceholder', {
562
560
  defaultValue: 'Buscar tarjetas...',
563
- }), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", laneFilter.value] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
561
+ }), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", funnelSummary] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
564
562
  defaultValue: 'Limpiar',
565
563
  }), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
566
564
  }
@@ -571,53 +569,56 @@ function LaneFilterButton({ fields, value, onChange, }) {
571
569
  const { t } = useTranslation();
572
570
  const [open, setOpen] = useState(false);
573
571
  const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
574
- const [text, setText] = useState(value?.value ?? '');
572
+ const [values, setValues] = useState(value?.values ?? []);
573
+ const [text, setText] = useState(value?.text ?? '');
575
574
  // Re-seed the draft from the committed filter each time the popover opens.
576
575
  useEffect(() => {
577
576
  if (open) {
578
577
  setField(value?.field ?? fields[0]?.key ?? '');
579
- setText(value?.value ?? '');
578
+ setValues(value?.values ?? []);
579
+ setText(value?.text ?? '');
580
580
  }
581
581
  }, [open, value, fields]);
582
582
  if (fields.length === 0)
583
583
  return null;
584
- const active = !!(value && value.value.trim());
585
- // When the chosen field carries a known option set (select/facet with
586
- // pre-loaded values), offer those values as a Select instead of a raw text
587
- // box so the funnel picks real values, not free strings.
588
- const selectedFieldCfg = fields.find((f) => f.key === field)?.config;
589
- const valueOptions = selectedFieldCfg?.options ?? [];
590
- const hasValueOptions = valueOptions.length > 0;
584
+ const active = !!(value &&
585
+ ((value.values && value.values.length > 0) || value.text?.trim()));
586
+ // Number of applied criteria on this lane's funnel (drives the count badge).
587
+ const activeCount = laneFunnelCount(value);
588
+ // The value step mirrors the sheet: when the chosen field is a select or a
589
+ // facet (static options OR a lazy loader), render the SAME pro combobox —
590
+ // multi-select, searchable, with counts. Only a genuinely free-text field
591
+ // (no options, no loader) falls back to a raw "Contiene..." input.
592
+ const cfg = fields.find((f) => f.key === field)?.config;
593
+ const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions;
594
+ const toggle = (v) => setValues((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
591
595
  const apply = () => {
592
- if (field && text.trim())
593
- onChange({ field, value: text });
596
+ if (field && values.length > 0)
597
+ onChange({ field, values });
598
+ else if (field && text.trim())
599
+ onChange({ field, text: text.trim() });
594
600
  else
595
601
  onChange(null);
596
602
  setOpen(false);
597
603
  };
598
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
604
+ const clear = () => {
605
+ onChange(null);
606
+ setOpen(false);
607
+ };
608
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
599
609
  defaultValue: 'Filtrar columna',
600
- }), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-56 space-y-2 p-2", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
610
+ }), children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), activeCount > 0 && (_jsx("span", { className: "absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums", children: activeCount }))] }) }), _jsxs(PopoverContent, { align: "end", className: "w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
601
611
  setField(f);
602
612
  // Reset the value when switching fields — a value picked
603
613
  // for one field is meaningless for another.
614
+ setValues([]);
604
615
  setText('');
605
- }, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), hasValueOptions ? (_jsxs(Select, { value: text || undefined, onValueChange: (v) => {
606
- setText(v);
607
- if (field && v)
608
- onChange({ field, value: v });
609
- setOpen(false);
610
- }, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: t('kanban.filterValue', {
611
- defaultValue: 'Valor...',
612
- }) }) }), _jsx(SelectContent, { children: valueOptions.map((o) => (_jsx(SelectItem, { value: o.value, className: "text-xs", children: o.label }, o.value))) })] })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
616
+ }, children: [_jsx(SelectTrigger, { className: "h-8 w-full text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), hasValuePicker ? (_jsx("div", { className: "overflow-hidden rounded-lg border", children: _jsx(FilterValueCombobox, { staticOptions: cfg?.options, loadOptions: cfg?.loadOptions, selected: values, onToggle: toggle }, field) })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
613
617
  if (e.key === 'Enter')
614
618
  apply();
615
619
  }, placeholder: t('kanban.filterValue', {
616
- defaultValue: 'Valor...',
617
- }), className: "h-8 text-xs" })), _jsxs("div", { className: "flex justify-between gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "h-7 text-xs", onClick: () => {
618
- onChange(null);
619
- setOpen(false);
620
- }, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
620
+ defaultValue: 'Contiene...',
621
+ }), className: "h-8 w-full text-xs" })), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { variant: "outline", size: "sm", className: "h-7 flex-1 text-xs", onClick: clear, disabled: !active && values.length === 0 && !text.trim(), children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsxs(Button, { size: "sm", className: "h-7 flex-1 text-xs", onClick: apply, disabled: values.length === 0 && !text.trim(), children: [t('kanban.apply', { defaultValue: 'Aplicar' }), values.length > 0 ? ` (${values.length})` : ''] })] })] })] }));
621
622
  }
622
623
  function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
623
624
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAWnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,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;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BA02BnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAYnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,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;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BAw6BnB"}
@@ -25,6 +25,7 @@ import { useMetadataCache } from './metadata-cache';
25
25
  import { useApi, useCurrentBranch } from './api-context';
26
26
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
27
27
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
28
+ import { FilterChipsRow, translateOptionLabels } from './filter-chips';
28
29
  import { OptionsContext } from './options-context';
29
30
  import { getSearchableColumnKeys } from './column-visibility';
30
31
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
@@ -462,11 +463,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
462
463
  // getDynamicColumns forwards `loadOptions` into the column meta). Facets base
463
464
  // derived off the list endpoint exactly like the aggregate endpoint below.
464
465
  const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
465
- const getFacetLoader = useFacetLoaders(facetsBase);
466
+ const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase);
466
467
  const columnFilterConfigs = useMemo(() => {
467
468
  const map = new Map();
468
469
  if (!metadata)
469
470
  return map;
471
+ // Option labels arrive as manifest i18n keys; translate them here (the ui
472
+ // package has no i18n) so header filters, chips and value summaries show
473
+ // localized text. A raw value with no key falls through via defaultValue.
474
+ const tr = (label) => t(label, { defaultValue: label });
470
475
  const stageOptions = (metadata.stages ?? []).map((s) => ({
471
476
  label: s.label,
472
477
  value: s.key,
@@ -500,6 +505,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
500
505
  if (loader) {
501
506
  fType = 'facet';
502
507
  loadOptions = loader;
508
+ // Prewarmed values (from prefetchFacets) → the header filter
509
+ // opens with the list already there, no "Cargando…" flash.
510
+ options = facetOptions.get(f.column || f.key) ?? [];
503
511
  }
504
512
  }
505
513
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
@@ -507,12 +515,14 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
507
515
  map.set(f.key, {
508
516
  filterType: fType,
509
517
  filterKey: f.column || f.key,
510
- options,
518
+ options: translateOptionLabels(options, tr),
511
519
  selectedValues: dynamicFilters[f.column || f.key] || [],
512
520
  onFilterChange: handleDynamicFilterChange,
513
521
  loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
514
522
  searchEndpoint: f.searchEndpoint,
515
- loadOptions,
523
+ loadOptions: loadOptions
524
+ ? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
525
+ : undefined,
516
526
  });
517
527
  }
518
528
  for (const c of metadata.columns ?? []) {
@@ -569,21 +579,64 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
569
579
  if (loader) {
570
580
  filterType = 'facet';
571
581
  loadOptions = loader;
582
+ // Prewarmed values (from prefetchFacets) → instant open.
583
+ options = facetOptions.get(c.key) ?? [];
572
584
  }
573
585
  }
574
586
  map.set(c.key, {
575
587
  filterType,
576
588
  filterKey: c.key,
577
- options,
589
+ options: translateOptionLabels(options, tr),
578
590
  selectedValues: dynamicFilters[c.key] || [],
579
591
  onFilterChange: handleDynamicFilterChange,
580
592
  loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
581
593
  searchEndpoint: c.searchEndpoint,
582
- loadOptions,
594
+ loadOptions: loadOptions
595
+ ? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
596
+ : undefined,
583
597
  });
584
598
  }
585
599
  return map;
586
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
600
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t]);
601
+ // Prewarm every facet field once the configs settle, so a text column's
602
+ // header filter opens instantly with values + counts (same as the kanban).
603
+ const facetFieldsSig = useMemo(() => {
604
+ const keys = [];
605
+ for (const config of columnFilterConfigs.values()) {
606
+ if (config.filterType === 'facet')
607
+ keys.push(config.filterKey);
608
+ }
609
+ return keys.join('|');
610
+ }, [columnFilterConfigs]);
611
+ useEffect(() => {
612
+ if (!facetFieldsSig)
613
+ return;
614
+ prefetchFacets(facetFieldsSig.split('|'));
615
+ }, [facetFieldsSig, prefetchFacets]);
616
+ // Active-filter chips (shared row with the kanban). One chip per field with a
617
+ // selection; the label is the translated filter/column label.
618
+ const activeFilterChips = useMemo(() => {
619
+ if (!metadata)
620
+ return [];
621
+ const out = [];
622
+ for (const [key, config] of columnFilterConfigs) {
623
+ if ((config.selectedValues?.length ?? 0) === 0)
624
+ continue;
625
+ const f = metadata.filters?.find((x) => x.key === key);
626
+ const c = metadata.columns?.find((x) => x.key === key);
627
+ const rawLabel = f?.label || c?.label || key;
628
+ out.push({
629
+ key,
630
+ label: t(rawLabel, { defaultValue: rawLabel }),
631
+ config,
632
+ });
633
+ }
634
+ return out;
635
+ }, [metadata, columnFilterConfigs, t]);
636
+ const clearAllDynamicFilters = useCallback(() => {
637
+ setDynamicFilters({});
638
+ setPagination((prev) => ({ ...prev, pageIndex: 0 }));
639
+ }, []);
587
640
  const columns = useMemo(() => {
588
641
  if (!viewMetadata)
589
642
  return [];
@@ -629,7 +682,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
629
682
  if (!metadata) {
630
683
  return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
631
684
  }
632
- return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsx("div", { className: 'pb-4 shrink-0', children: _jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }) }), _jsx("div", { className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: _jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
685
+ return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsx("div", { className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: _jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
633
686
  const isActionsColumn = header.id === 'actions';
634
687
  return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
635
688
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {