@asteby/metacore-runtime-react 21.1.0 → 22.0.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,51 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 22.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bd30e57: Filtros pro para tabla y kanban dinámicos.
8
+ - **Etapa como select (kanban/tabla):** una columna `group_by` sin opciones
9
+ propias hereda las etapas del pipeline (`metadata.stages`, con su color) como
10
+ un select real en lugar de caer a un cuadro de texto "Contiene...".
11
+ - **Filtros por facetas:** las columnas de texto filtrables se convierten en un
12
+ selector de valores (`filterType: 'facet'`) que carga de forma perezosa los
13
+ valores distintos + su conteo desde `GET /data/:model/facets` al abrir el
14
+ popover, con búsqueda server-side. Degrada con gracia al input "Contiene..."
15
+ (ILIKE) si el endpoint falla, devuelve vacío o no está disponible, y mantiene
16
+ ese affordance de texto libre incluso cuando hay opciones. Las columnas de
17
+ texto largo (`body`/`description`, `cellStyle: truncate-text`, tipos
18
+ json/long_text) se quedan como texto plano. Nuevo `ColumnFilterControl`
19
+ `loadOptions` y `FilterOption.count` en `@asteby/metacore-ui`; hook compartido
20
+ `useFacetLoaders` y `filterType: 'facet'` en `@asteby/metacore-runtime-react`.
21
+ - **Búsqueda por columna del kanban:** cada lane tiene un icono de búsqueda que
22
+ expande un input inline (autofocus, colapsa con Escape) filtrando client-side
23
+ las tarjetas de esa columna por título y valores de campo visibles. Convive y
24
+ se combina (AND) con el embudo por campo, que ahora ofrece un select de
25
+ valores cuando el campo tiene opciones conocidas.
26
+ - **Panel de filtros rediseñado (nivel Linear/Notion):** el Sheet deja de ser
27
+ una lista de botones grises idénticos: cada campo es una fila tipo settings
28
+ con icono por tipo de dato (Hash número, Calendar fecha, CircleDot etapa, Tag
29
+ select/facet, ToggleLeft boolean, Type texto), label y resumen del valor
30
+ activo a la derecha ("Cualquiera" en muted si no hay). Nueva variante
31
+ `ColumnFilterControl` `variant='row'` con props `icon` y `valueSummary`.
32
+ Popovers con `rounded-xl`, sombra, header del campo (el input "Contiene..."
33
+ nunca es contenido crudo sin jerarquía), opciones con checkbox + dot de color
34
+ - count a la derecha. Filtros con selección agrupados arriba, resto
35
+ alfabético, footer sticky con conteo. Fila de chips activos removibles bajo la
36
+ toolbar (con dot de color del valor, p.ej. color de la etapa) y "Limpiar
37
+ todo". Header de lane del kanban con acciones (buscar/embudo) en hover-reveal
38
+ para un board limpio. Pensado dark-mode-first (muted/accent del theme).
39
+
40
+ La URL del endpoint de facetas se deriva como el endpoint de agregados de
41
+ DynamicTable: `<endpoint>/facets` (o `/data/<model>/facets` como fallback), así
42
+ funciona tanto con `endpoint="/data/:model/me"` como sin él.
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [bd30e57]
47
+ - @asteby/metacore-ui@2.8.0
48
+
3
49
  ## 21.1.0
4
50
 
5
51
  ### Minor Changes
@@ -5,15 +5,23 @@ export interface FilterOption {
5
5
  value: string;
6
6
  icon?: string;
7
7
  color?: string;
8
+ /** Occurrence count for `facet` options (rendered muted, right-aligned). */
9
+ count?: number;
8
10
  }
9
11
  export interface ColumnFilterConfig {
10
- filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | string;
12
+ filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | 'facet' | string;
11
13
  filterKey: string;
12
14
  options: FilterOption[];
13
15
  selectedValues: string[];
14
16
  onFilterChange: (filterKey: string, values: string[]) => void;
15
17
  loading?: boolean;
16
18
  searchEndpoint?: string;
19
+ /**
20
+ * Lazy option loader for `facet` filters — resolves the column's distinct
21
+ * values + counts from the `/facets` endpoint. Passed straight through to
22
+ * `ColumnFilterControl.loadOptions`.
23
+ */
24
+ loadOptions?: (q?: string) => Promise<FilterOption[]>;
17
25
  }
18
26
  /** Signature for the host-provided `getDynamicColumns` factory. */
19
27
  export type GetDynamicColumns = (metadata: TableMetadata, handleAction: (action: string, row: any) => void, t: (key: string, options?: any) => string, language: string, columnFilterConfigs: Map<string, ColumnFilterConfig>, timeZone?: string, currency?: string) => ColumnDef<any>[];
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAA;IAClF,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAC5B,QAAQ,EAAE,aAAa,EACvB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,EAChD,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,EACzC,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACpD,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,KAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;AAErB,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA"}
1
+ {"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAA;IAC5F,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;CACxD;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAC5B,QAAQ,EAAE,aAAa,EACvB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,EAChD,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,EACzC,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACpD,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,KAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;AAErB,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAiC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqKhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA6mBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAiC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqKhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA8mBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -490,6 +490,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
490
490
  filterSearchEndpoint: filterConfig.searchEndpoint,
491
491
  selectedValues: filterConfig.selectedValues,
492
492
  onFilterChange: filterConfig.onFilterChange,
493
+ loadOptions: filterConfig.loadOptions,
493
494
  };
494
495
  Object.assign(columnMeta, fm);
495
496
  }
@@ -40,6 +40,22 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
40
40
  title: ColumnDefinition | null;
41
41
  fields: ColumnDefinition[];
42
42
  };
43
+ /**
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.
48
+ */
49
+ export declare function summarizeFilterValues(values: string[] | undefined, options: {
50
+ label: string;
51
+ value: string;
52
+ }[] | undefined, maxShown?: number): string;
53
+ /**
54
+ * Whether a card matches a free-text lane search: a case-insensitive substring
55
+ * over the card's title + every visible field value (`String(v)`). Empty query
56
+ * matches everything. Pure — exported for unit tests.
57
+ */
58
+ export declare function cardMatchesLaneQuery(card: any, cols: ColumnDefinition[], query: string): boolean;
43
59
  export interface DynamicKanbanProps {
44
60
  /** Model key as registered on the backend (e.g. "issue"). */
45
61
  model: string;
@@ -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;AAoD9B,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;AA8BD,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,qBAkcpB"}
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,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
5
- import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react';
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
8
  import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
9
- import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
9
+ import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
11
11
  import { useDynamicFilters } from './use-dynamic-filters';
12
12
  import { useMetadataCache } from './metadata-cache';
@@ -129,6 +129,77 @@ export function selectCardColumns(metadata, maxFields = 3) {
129
129
  .slice(0, maxFields);
130
130
  return { title, fields };
131
131
  }
132
+ /**
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
+ */
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('_', ' – ');
156
+ }
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 || '…'}`;
161
+ }
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}`;
169
+ }
170
+ /**
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.
174
+ */
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;
189
+ }
190
+ /**
191
+ * Whether a card matches a free-text lane search: a case-insensitive substring
192
+ * over the card's title + every visible field value (`String(v)`). Empty query
193
+ * matches everything. Pure — exported for unit tests.
194
+ */
195
+ export function cardMatchesLaneQuery(card, cols, query) {
196
+ const q = query.trim().toLowerCase();
197
+ if (!q)
198
+ return true;
199
+ return cols.some((c) => String(card?.[c.key] ?? '')
200
+ .toLowerCase()
201
+ .includes(q));
202
+ }
132
203
  // ---------------------------------------------------------------------------
133
204
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
134
205
  // ---------------------------------------------------------------------------
@@ -199,7 +270,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
199
270
  // Shared metadata-driven filter engine — the SAME configs, option prefetch
200
271
  // and `f_<key>` serialization DynamicTable uses, so the board filters
201
272
  // identically to its table sibling.
202
- const { globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, clearAll, } = useDynamicFilters(metadata, { defaultFilters });
273
+ const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
203
274
  // ---- records fetch (same path as DynamicTable, single large page) ----
204
275
  const fetchData = useCallback(async () => {
205
276
  if (!metadata)
@@ -245,18 +316,36 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
245
316
  }
246
317
  return out;
247
318
  }, [metadata, columnFilterConfigs, t]);
319
+ // Split filters into active (with a selection) and the rest — the Sheet
320
+ // groups the active ones on top, the rest alphabetically. Also drives the
321
+ // removable chip row below the toolbar.
322
+ const { activeFields, inactiveFields } = useMemo(() => {
323
+ const active = filterFields.filter((f) => (f.config.selectedValues?.length ?? 0) > 0);
324
+ const inactive = filterFields
325
+ .filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
326
+ .sort((a, b) => a.label.localeCompare(b.label));
327
+ return { activeFields: active, inactiveFields: inactive };
328
+ }, [filterFields]);
248
329
  // Sheet (grouped global filters) open state + per-lane client-side filters.
249
330
  // A lane filter narrows ONLY that stage's already-fetched cards by a field
250
331
  // value — instant, no refetch — so a user can drill into one column without
251
332
  // touching the rest of the board (the global filters, by contrast, refetch
252
333
  // the whole board server-side).
253
334
  const [filtersOpen, setFiltersOpen] = useState(false);
335
+ // Per-lane client-side narrowing. Two independent, AND-combined dimensions:
336
+ // - `field`/`value`: the funnel — a field-scoped substring match.
337
+ // - `query`: the lane search — a substring over the card title + every
338
+ // visible field value.
339
+ // A lane with neither is dropped from the map (so it reads as "unfiltered").
254
340
  const [laneFilters, setLaneFilters] = useState({});
255
- const setLaneFilter = useCallback((stageKey, filter) => {
341
+ const updateLaneFilter = useCallback((stageKey, patch) => {
256
342
  setLaneFilters((prev) => {
343
+ const merged = { ...prev[stageKey], ...patch };
257
344
  const next = { ...prev };
258
- if (filter)
259
- next[stageKey] = filter;
345
+ const hasFunnel = !!(merged.field && merged.value?.trim());
346
+ const hasQuery = !!merged.query?.trim();
347
+ if (hasFunnel || hasQuery)
348
+ next[stageKey] = merged;
260
349
  else
261
350
  delete next[stageKey];
262
351
  return next;
@@ -267,6 +356,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
267
356
  const transitions = metadata?.transitions;
268
357
  const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
269
358
  const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
359
+ // Columns the lane search scans: the card title + its visible field cells.
360
+ const searchCols = useMemo(() => [titleCol, ...fieldCols].filter(Boolean), [titleCol, fieldCols]);
270
361
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
271
362
  // capability-gated (when a <PermissionsProvider> is mounted) and with the
272
363
  // implicit View/Edit/Delete trio materialized for CRUD models. An action the
@@ -362,37 +453,114 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
362
453
  order: Number.MAX_SAFE_INTEGER,
363
454
  });
364
455
  }
365
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-4 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0", children: _jsx(SheetTitle, { children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsx("div", { className: "flex flex-1 flex-col gap-2 overflow-y-auto pr-1", children: filterFields.map((field) => (_jsx(ColumnFilterControl, { showLabel: true, label: field.label, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange }, field.key))) }), activeFilterCount > 0 && (_jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1 text-xs text-muted-foreground", onClick: clearAll, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearFilters', { defaultValue: 'Limpiar' }), ` (${activeFilterCount})`] }))] })] }))] }), _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) => {
456
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
457
+ defaultValue: 'Con filtros activos',
458
+ }) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
459
+ ? t('kanban.activeCount', {
460
+ defaultValue: '{{count}} activos',
461
+ count: activeFilterCount,
462
+ })
463
+ : t('kanban.noActiveFilters', {
464
+ 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) => {
366
472
  const allCards = grouped.get(stage.key) ?? [];
367
- // Per-lane client-side narrowing: substring match on the
368
- // chosen field over the already-fetched cards. Instant, and
369
- // scoped to this stage only.
473
+ // Per-lane client-side narrowing (instant, scoped to this
474
+ // stage). The funnel (field/value) and the lane search
475
+ // (query) are AND-combined.
370
476
  const laneFilter = laneFilters[stage.key];
371
- const cards = laneFilter && laneFilter.value.trim()
372
- ? allCards.filter((c) => String(c[laneFilter.field] ?? '')
477
+ 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] ?? '')
373
482
  .toLowerCase()
374
- .includes(laneFilter.value.trim().toLowerCase()))
375
- : allCards;
483
+ .includes(v));
484
+ }
485
+ if (laneFilter?.query?.trim()) {
486
+ cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
487
+ }
376
488
  const droppableAllowed = !activeId ||
377
489
  stage.key === activeStage ||
378
490
  isTransitionAllowed(transitions, activeStage, stage.key);
379
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onLaneFilterChange: (f) => setLaneFilter(stage.key, f), 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));
491
+ return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
492
+ field: f?.field,
493
+ value: f?.value,
494
+ }), 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));
380
495
  }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
381
496
  }
382
- function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onLaneFilterChange, isDark, dimmed, disabled, children, }) {
497
+ /**
498
+ * A per-data-type glyph for the Filtros panel rows (and their popover header):
499
+ * Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
500
+ * for value pickers, ToggleLeft for booleans, Type for free text.
501
+ */
502
+ function filterTypeIcon(filterType, isStage) {
503
+ if (isStage)
504
+ return _jsx(CircleDot, { className: "h-3.5 w-3.5" });
505
+ switch (filterType) {
506
+ case 'number_range':
507
+ return _jsx(Hash, { className: "h-3.5 w-3.5" });
508
+ case 'date_range':
509
+ return _jsx(Calendar, { className: "h-3.5 w-3.5" });
510
+ case 'boolean':
511
+ return _jsx(ToggleLeft, { className: "h-3.5 w-3.5" });
512
+ case 'select':
513
+ case 'dynamic_select':
514
+ case 'facet':
515
+ return _jsx(Tag, { className: "h-3.5 w-3.5" });
516
+ default:
517
+ return _jsx(Type, { className: "h-3.5 w-3.5" });
518
+ }
519
+ }
520
+ function SheetFilterRow({ field, isStage, }) {
521
+ const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
522
+ return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
523
+ }
524
+ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
383
525
  const { t } = useTranslation();
384
526
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
385
527
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
386
528
  isDark,
387
529
  });
388
- const laneActive = !!(laneFilter && laneFilter.value.trim());
530
+ const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim());
531
+ const queryActive = !!laneFilter?.query?.trim();
532
+ const laneActive = funnelActive || queryActive;
389
533
  const activeFieldLabel = filterFields.find((f) => f.key === laneFilter?.field)?.label ??
390
534
  laneFilter?.field;
391
- return (_jsxs("div", { ref: setNodeRef, className: "flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity", style: {
535
+ // Inline lane search: a Search icon expands an Input; Escape or blur-while-
536
+ // empty collapses it. The query itself lives in the parent's laneFilters so
537
+ // it survives collapse and combines with the funnel.
538
+ const [searchOpen, setSearchOpen] = useState(queryActive);
539
+ const searchRef = useRef(null);
540
+ useEffect(() => {
541
+ if (searchOpen)
542
+ searchRef.current?.focus();
543
+ }, [searchOpen]);
544
+ const funnelValue = laneFilter?.field && laneFilter.value
545
+ ? { field: laneFilter.field, value: laneFilter.value }
546
+ : undefined;
547
+ return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
392
548
  opacity: dimmed ? 0.45 : 1,
393
549
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
394
550
  outlineOffset: 2,
395
- }, "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: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count }), _jsx(LaneFilterButton, { fields: filterFields, value: laneFilter, onChange: onLaneFilterChange })] })] }), laneActive && (_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: () => onLaneFilterChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
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', {
552
+ 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) => {
554
+ if (e.key === 'Escape') {
555
+ onQueryChange('');
556
+ setSearchOpen(false);
557
+ }
558
+ }, onBlur: () => {
559
+ if (!laneFilter?.query?.trim())
560
+ setSearchOpen(false);
561
+ }, placeholder: t('kanban.searchLanePlaceholder', {
562
+ 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', {
396
564
  defaultValue: 'Limpiar',
397
565
  }), 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 })] }));
398
566
  }
@@ -414,6 +582,12 @@ function LaneFilterButton({ fields, value, onChange, }) {
414
582
  if (fields.length === 0)
415
583
  return null;
416
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;
417
591
  const apply = () => {
418
592
  if (field && text.trim())
419
593
  onChange({ field, value: text });
@@ -421,12 +595,26 @@ function LaneFilterButton({ fields, value, onChange, }) {
421
595
  onChange(null);
422
596
  setOpen(false);
423
597
  };
424
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `rounded p-1 transition-colors hover:bg-muted hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
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', {
425
599
  defaultValue: 'Filtrar columna',
426
- }), 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: setField, 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))) })] }), _jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
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) => {
601
+ setField(f);
602
+ // Reset the value when switching fields — a value picked
603
+ // for one field is meaningless for another.
604
+ 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) => {
427
613
  if (e.key === 'Enter')
428
614
  apply();
429
- }, placeholder: t('kanban.filterValue', { defaultValue: 'Valor...' }), 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: () => {
615
+ }, 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: () => {
430
618
  onChange(null);
431
619
  setOpen(false);
432
620
  }, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
@@ -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;AAUnF,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,+BA2zBnB"}
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"}
@@ -24,6 +24,7 @@ import { Progress } from './dialogs/_primitives';
24
24
  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
+ import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
27
28
  import { OptionsContext } from './options-context';
28
29
  import { getSearchableColumnKeys } from './column-visibility';
29
30
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
@@ -456,16 +457,28 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
456
457
  setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }));
457
458
  setPagination((prev) => ({ ...prev, pageIndex: 0 }));
458
459
  }, []);
460
+ // Same facet loader machinery the board uses, so a text column filters
461
+ // identically in the table header and the kanban Sheet (the host's
462
+ // getDynamicColumns forwards `loadOptions` into the column meta). Facets base
463
+ // derived off the list endpoint exactly like the aggregate endpoint below.
464
+ const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
465
+ const getFacetLoader = useFacetLoaders(facetsBase);
459
466
  const columnFilterConfigs = useMemo(() => {
460
467
  const map = new Map();
461
468
  if (!metadata)
462
469
  return map;
470
+ const stageOptions = (metadata.stages ?? []).map((s) => ({
471
+ label: s.label,
472
+ value: s.key,
473
+ color: s.color,
474
+ }));
475
+ const groupBy = metadata.group_by;
463
476
  // Explicit `metadata.filters` wins. When the backend does not emit
464
477
  // them, derive a filter chip from every column flagged
465
478
  // `filterable: true` — keeps the kernel API minimal (one flag on the
466
479
  // column) while still rendering the FilterableColumnHeader.
467
480
  for (const f of metadata.filters ?? []) {
468
- const fType = f.type;
481
+ let fType = f.type;
469
482
  let options = [];
470
483
  if (f.options && f.options.length > 0) {
471
484
  options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }));
@@ -473,6 +486,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
473
486
  if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
474
487
  options = filterOptionsMap.get(f.searchEndpoint) || [];
475
488
  }
489
+ // (A) A stage column with no options of its own inherits the
490
+ // pipeline stages (with their colors) as a real select.
491
+ if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) {
492
+ fType = 'select';
493
+ options = stageOptions;
494
+ }
495
+ // (B) A plain text filter becomes a facet value-picker when a facets
496
+ // endpoint is available (degrades to "Contiene..." if it yields nothing).
497
+ let loadOptions;
498
+ if (fType === 'text' && facetsBase) {
499
+ const loader = getFacetLoader(f.column || f.key);
500
+ if (loader) {
501
+ fType = 'facet';
502
+ loadOptions = loader;
503
+ }
504
+ }
476
505
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
477
506
  continue;
478
507
  map.set(f.key, {
@@ -483,6 +512,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
483
512
  onFilterChange: handleDynamicFilterChange,
484
513
  loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
485
514
  searchEndpoint: f.searchEndpoint,
515
+ loadOptions,
486
516
  });
487
517
  }
488
518
  for (const c of metadata.columns ?? []) {
@@ -500,8 +530,12 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
500
530
  // - number / number_range / numeric → number range
501
531
  // - date → date range picker (start/end calendar)
502
532
  // - everything else (text, email, phone, tags…) → text contains
533
+ // (A) Stage column with no options of its own → colored select.
534
+ const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0;
503
535
  let filterType;
504
- if (c.filterType)
536
+ if (isStageColumn)
537
+ filterType = 'select';
538
+ else if (c.filterType)
505
539
  filterType = c.filterType;
506
540
  else if (isRelation && hasEndpoint)
507
541
  filterType = 'dynamic_select';
@@ -515,7 +549,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
515
549
  filterType = 'date_range';
516
550
  else
517
551
  filterType = 'text';
518
- const options = hasStaticOptions
552
+ let options = hasStaticOptions
519
553
  ? c.options.map(o => ({
520
554
  label: o.label,
521
555
  value: String(o.value),
@@ -525,6 +559,18 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
525
559
  : hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
526
560
  ? filterOptionsMap.get(c.searchEndpoint) || []
527
561
  : [];
562
+ if (isStageColumn)
563
+ options = stageOptions;
564
+ // (B) Upgrade a plain text filter to a facet value-picker unless it's
565
+ // a long-text/body column (too many unique values to enumerate).
566
+ let loadOptions;
567
+ if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
568
+ const loader = getFacetLoader(c.key);
569
+ if (loader) {
570
+ filterType = 'facet';
571
+ loadOptions = loader;
572
+ }
573
+ }
528
574
  map.set(c.key, {
529
575
  filterType,
530
576
  filterKey: c.key,
@@ -533,10 +579,11 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
533
579
  onFilterChange: handleDynamicFilterChange,
534
580
  loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
535
581
  searchEndpoint: c.searchEndpoint,
582
+ loadOptions,
536
583
  });
537
584
  }
538
585
  return map;
539
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
586
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
540
587
  const columns = useMemo(() => {
541
588
  if (!viewMetadata)
542
589
  return [];