@asteby/metacore-runtime-react 21.1.0 → 23.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.
@@ -38,7 +38,18 @@ import {
38
38
  type DragStartEvent,
39
39
  type DragEndEvent,
40
40
  } from '@dnd-kit/core'
41
- import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react'
41
+ import {
42
+ Calendar,
43
+ CircleDot,
44
+ Hash,
45
+ ListFilter,
46
+ MoreHorizontal,
47
+ Search,
48
+ Tag,
49
+ ToggleLeft,
50
+ Type,
51
+ X,
52
+ } from 'lucide-react'
42
53
  import { toast } from 'sonner'
43
54
  import {
44
55
  Badge,
@@ -65,8 +76,8 @@ import {
65
76
  SheetTrigger,
66
77
  Skeleton,
67
78
  } from '@asteby/metacore-ui/primitives'
68
- import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
69
- import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
79
+ import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
+ import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
70
81
  import { useApi } from './api-context'
71
82
  import { useDynamicFilters } from './use-dynamic-filters'
72
83
  import { useMetadataCache } from './metadata-cache'
@@ -222,6 +233,122 @@ export function selectCardColumns(
222
233
  return { title, fields }
223
234
  }
224
235
 
236
+ /**
237
+ * Human-readable summary of a field's selected filter values for the removable
238
+ * chip row. Resolves option labels, unwraps the wire operators
239
+ * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
240
+ * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
241
+ */
242
+ export function summarizeFilterValues(
243
+ values: string[] | undefined,
244
+ options: { label: string; value: string }[] | undefined,
245
+ maxShown = 2,
246
+ ): string {
247
+ if (!values || values.length === 0) return ''
248
+ const opts = options ?? []
249
+ const labelFor = (v: string) =>
250
+ opts.find((o) => o.value === v)?.label ?? v
251
+ const first = values[0]
252
+ if (values.length === 1) {
253
+ if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
254
+ if (first.startsWith('IN:')) {
255
+ return summarizeList(first.slice(3).split(','), labelFor, maxShown)
256
+ }
257
+ if (first.startsWith('RANGE:')) {
258
+ const [min, max] = first.slice(6).split(',')
259
+ return `${min || '…'} – ${max || '…'}`
260
+ }
261
+ if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
262
+ }
263
+ if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
264
+ const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
265
+ const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
266
+ return `${min || '…'} – ${max || '…'}`
267
+ }
268
+ return summarizeList(values, labelFor, maxShown)
269
+ }
270
+
271
+ function summarizeList(
272
+ items: string[],
273
+ labelFor: (v: string) => string,
274
+ maxShown: number,
275
+ ): string {
276
+ const labels = items.map(labelFor)
277
+ if (labels.length <= maxShown) return labels.join(', ')
278
+ return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
279
+ }
280
+
281
+ /**
282
+ * The color of a filter's first selected value (for the chip's dot) — e.g. a
283
+ * stage's palette color. Returns a resolved CSS color, or undefined for
284
+ * operator/range/free-text values that carry no option color.
285
+ */
286
+ function chipValueColor(config: {
287
+ selectedValues: string[]
288
+ options: { value: string; color?: string }[]
289
+ }): string | undefined {
290
+ const sel = config.selectedValues
291
+ if (!sel || sel.length === 0) return undefined
292
+ const first = sel[0]
293
+ let value = first
294
+ if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
295
+ else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
296
+ else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
297
+ const opt = config.options.find((o) => o.value === value)
298
+ return opt?.color ? resolveColorCss(opt.color) : undefined
299
+ }
300
+
301
+ /**
302
+ * Whether a card passes a lane funnel. Picked select/facet `values` match by
303
+ * equality (IN — the card's field value must be one of them); a free-text
304
+ * `text` matches by case-insensitive substring. No field / no criteria → passes.
305
+ * Pure — exported for unit tests.
306
+ */
307
+ export function cardMatchesLaneFunnel(
308
+ card: any,
309
+ filter: { field?: string; values?: string[]; text?: string } | undefined,
310
+ ): boolean {
311
+ if (!filter?.field) return true
312
+ const raw = String(card?.[filter.field] ?? '')
313
+ if (filter.values && filter.values.length > 0) {
314
+ return filter.values.includes(raw)
315
+ }
316
+ if (filter.text?.trim()) {
317
+ return raw.toLowerCase().includes(filter.text.trim().toLowerCase())
318
+ }
319
+ return true
320
+ }
321
+
322
+ /**
323
+ * Translates option labels through the app translator (manifest i18n keys →
324
+ * localized text). A raw value with no matching key falls through to itself via
325
+ * `defaultValue`. Pure — exported for unit tests.
326
+ */
327
+ export function translateOptionLabels<
328
+ T extends { label: string },
329
+ >(options: T[], translate: (key: string) => string): T[] {
330
+ return options.map((o) => ({ ...o, label: translate(o.label) }))
331
+ }
332
+
333
+ /**
334
+ * Whether a card matches a free-text lane search: a case-insensitive substring
335
+ * over the card's title + every visible field value (`String(v)`). Empty query
336
+ * matches everything. Pure — exported for unit tests.
337
+ */
338
+ export function cardMatchesLaneQuery(
339
+ card: any,
340
+ cols: ColumnDefinition[],
341
+ query: string,
342
+ ): boolean {
343
+ const q = query.trim().toLowerCase()
344
+ if (!q) return true
345
+ return cols.some((c) =>
346
+ String(card?.[c.key] ?? '')
347
+ .toLowerCase()
348
+ .includes(q),
349
+ )
350
+ }
351
+
225
352
  // ---------------------------------------------------------------------------
226
353
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
227
354
  // ---------------------------------------------------------------------------
@@ -250,6 +377,20 @@ function useIsDarkTheme(): boolean {
250
377
  // Component
251
378
  // ---------------------------------------------------------------------------
252
379
 
380
+ /**
381
+ * Per-lane client-side filter. Two AND-combined dimensions:
382
+ * - The funnel: a `field` plus EITHER `values` (chosen from a select/facet —
383
+ * matched by equality/IN against the card's field value) OR `text` (a
384
+ * free-text substring for text-only fields).
385
+ * - `query`: the lane search — a substring over the card title + field values.
386
+ */
387
+ interface LaneFilterState {
388
+ field?: string
389
+ values?: string[]
390
+ text?: string
391
+ query?: string
392
+ }
393
+
253
394
  export interface DynamicKanbanProps {
254
395
  /** Model key as registered on the backend (e.g. "issue"). */
255
396
  model: string
@@ -352,13 +493,15 @@ export function DynamicKanban({
352
493
  // and `f_<key>` serialization DynamicTable uses, so the board filters
353
494
  // identically to its table sibling.
354
495
  const {
496
+ dynamicFilters,
355
497
  globalFilter,
356
498
  setGlobalFilter,
357
499
  columnFilterConfigs,
358
500
  filterParams,
359
501
  activeFilterCount,
502
+ handleDynamicFilterChange,
360
503
  clearAll,
361
- } = useDynamicFilters(metadata, { defaultFilters })
504
+ } = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
362
505
 
363
506
  // ---- records fetch (same path as DynamicTable, single large page) ----
364
507
  const fetchData = useCallback(async () => {
@@ -397,29 +540,75 @@ export function DynamicKanban({
397
540
  label: string
398
541
  config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
399
542
  }[] = []
543
+ // Option labels come from the manifest as i18n keys (e.g.
544
+ // "integration_github.stage.backlog"). ColumnFilterControl lives in the
545
+ // ui package (no i18n), so translate labels HERE — on the static options
546
+ // and on whatever the facet loader resolves — before they ever reach a
547
+ // control, chip or value summary. A raw value (a repo name) has no key,
548
+ // so t() returns it verbatim via defaultValue.
549
+ const tr = (label: string) => t(label, { defaultValue: label })
400
550
  for (const [key, config] of columnFilterConfigs) {
401
551
  const f = metadata.filters?.find((x) => x.key === key)
402
552
  const c = metadata.columns.find((x) => x.key === key)
403
553
  const rawLabel = f?.label || c?.label || key
404
- out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
554
+ const translatedConfig = {
555
+ ...config,
556
+ options: translateOptionLabels(config.options, tr),
557
+ loadOptions: config.loadOptions
558
+ ? (q?: string) =>
559
+ config.loadOptions!(q).then((opts) =>
560
+ translateOptionLabels(opts, tr),
561
+ )
562
+ : undefined,
563
+ }
564
+ out.push({
565
+ key,
566
+ label: tr(rawLabel),
567
+ config: translatedConfig,
568
+ })
405
569
  }
406
570
  return out
407
571
  }, [metadata, columnFilterConfigs, t])
408
572
 
573
+ // Split filters into active (with a selection) and the rest — the Sheet
574
+ // groups the active ones on top, the rest alphabetically. Also drives the
575
+ // removable chip row below the toolbar.
576
+ const { activeFields, inactiveFields } = useMemo(() => {
577
+ const active = filterFields.filter(
578
+ (f) => (f.config.selectedValues?.length ?? 0) > 0,
579
+ )
580
+ const inactive = filterFields
581
+ .filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
582
+ .sort((a, b) => a.label.localeCompare(b.label))
583
+ return { activeFields: active, inactiveFields: inactive }
584
+ }, [filterFields])
585
+
409
586
  // Sheet (grouped global filters) open state + per-lane client-side filters.
410
587
  // A lane filter narrows ONLY that stage's already-fetched cards by a field
411
588
  // value — instant, no refetch — so a user can drill into one column without
412
589
  // touching the rest of the board (the global filters, by contrast, refetch
413
590
  // the whole board server-side).
414
591
  const [filtersOpen, setFiltersOpen] = useState(false)
592
+ // Per-lane client-side narrowing. Two independent, AND-combined dimensions:
593
+ // - `field`/`value`: the funnel — a field-scoped substring match.
594
+ // - `query`: the lane search — a substring over the card title + every
595
+ // visible field value.
596
+ // A lane with neither is dropped from the map (so it reads as "unfiltered").
415
597
  const [laneFilters, setLaneFilters] = useState<
416
- Record<string, { field: string; value: string }>
598
+ Record<string, LaneFilterState>
417
599
  >({})
418
- const setLaneFilter = useCallback(
419
- (stageKey: string, filter: { field: string; value: string } | null) => {
600
+ const updateLaneFilter = useCallback(
601
+ (stageKey: string, patch: Partial<LaneFilterState>) => {
420
602
  setLaneFilters((prev) => {
603
+ const merged: LaneFilterState = { ...prev[stageKey], ...patch }
421
604
  const next = { ...prev }
422
- if (filter) next[stageKey] = filter
605
+ const hasFunnel = !!(
606
+ merged.field &&
607
+ ((merged.values && merged.values.length > 0) ||
608
+ merged.text?.trim())
609
+ )
610
+ const hasQuery = !!merged.query?.trim()
611
+ if (hasFunnel || hasQuery) next[stageKey] = merged
423
612
  else delete next[stageKey]
424
613
  return next
425
614
  })
@@ -444,6 +633,12 @@ export function DynamicKanban({
444
633
  [metadata],
445
634
  )
446
635
 
636
+ // Columns the lane search scans: the card title + its visible field cells.
637
+ const searchCols = useMemo(
638
+ () => [titleCol, ...fieldCols].filter(Boolean) as ColumnDefinition[],
639
+ [titleCol, fieldCols],
640
+ )
641
+
447
642
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
448
643
  // capability-gated (when a <PermissionsProvider> is mounted) and with the
449
644
  // implicit View/Edit/Delete trio materialized for CRUD models. An action the
@@ -616,69 +811,151 @@ export function DynamicKanban({
616
811
  </SheetTrigger>
617
812
  <SheetContent
618
813
  side="right"
619
- className="flex w-80 flex-col gap-4 sm:max-w-sm"
814
+ className="flex w-80 flex-col gap-0 p-0 sm:max-w-sm"
620
815
  >
621
- <SheetHeader className="space-y-0">
622
- <SheetTitle>
816
+ <SheetHeader className="space-y-0 border-b px-4 py-3">
817
+ <SheetTitle className="text-sm">
623
818
  {t('kanban.filters', { defaultValue: 'Filtros' })}
624
819
  </SheetTitle>
625
820
  </SheetHeader>
626
- {/* One labeled control per filterable field, stacked. Same
627
- configs / server-side engine as the inline chips were,
628
- just grouped so they never spill across the board. */}
629
- <div className="flex flex-1 flex-col gap-2 overflow-y-auto pr-1">
630
- {filterFields.map((field) => (
631
- <ColumnFilterControl
821
+ {/* Active filters grouped on top, the rest alphabetical
822
+ below a separator. Each row: label + its control; the
823
+ active field is highlighted by ColumnFilterControl's
824
+ own active styling. Same server-side engine as before. */}
825
+ <div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3">
826
+ {activeFields.length > 0 && (
827
+ <>
828
+ <p className="px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
829
+ {t('kanban.activeFilters', {
830
+ defaultValue: 'Con filtros activos',
831
+ })}
832
+ </p>
833
+ {activeFields.map((field) => (
834
+ <SheetFilterRow
835
+ key={field.key}
836
+ field={field}
837
+ isStage={field.key === groupByKey}
838
+ />
839
+ ))}
840
+ {inactiveFields.length > 0 && (
841
+ <div className="my-2 border-t" />
842
+ )}
843
+ </>
844
+ )}
845
+ {inactiveFields.map((field) => (
846
+ <SheetFilterRow
632
847
  key={field.key}
633
- showLabel
634
- label={field.label}
635
- filterKey={field.config.filterKey}
636
- filterType={
637
- field.config.filterType as ColumnFilterType
638
- }
639
- filterOptions={field.config.options}
640
- filterLoading={field.config.loading}
641
- filterSearchEndpoint={field.config.searchEndpoint}
642
- selectedValues={field.config.selectedValues}
643
- onFilterChange={field.config.onFilterChange}
848
+ field={field}
849
+ isStage={field.key === groupByKey}
644
850
  />
645
851
  ))}
646
852
  </div>
647
- {activeFilterCount > 0 && (
853
+ <div className="sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3">
854
+ <span className="text-xs text-muted-foreground tabular-nums">
855
+ {activeFilterCount > 0
856
+ ? t('kanban.activeCount', {
857
+ defaultValue: '{{count}} activos',
858
+ count: activeFilterCount,
859
+ })
860
+ : t('kanban.noActiveFilters', {
861
+ defaultValue: 'Sin filtros',
862
+ })}
863
+ </span>
648
864
  <Button
649
865
  variant="ghost"
650
866
  size="sm"
651
- className="gap-1 text-xs text-muted-foreground"
867
+ className="h-7 gap-1 text-xs"
652
868
  onClick={clearAll}
869
+ disabled={activeFilterCount === 0}
653
870
  >
654
871
  <X className="h-3.5 w-3.5" />
655
- {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
656
- {` (${activeFilterCount})`}
872
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
873
+ {activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
657
874
  </Button>
658
- )}
875
+ </div>
659
876
  </SheetContent>
660
877
  </Sheet>
661
878
  )}
662
879
  </div>
663
880
 
881
+ {/* Removable chip row — one chip per active field filter, plus a
882
+ "Limpiar todo" at the end. Instant feedback without opening the
883
+ Sheet; clicking a chip's X clears that field server-side. */}
884
+ {activeFields.length > 0 && (
885
+ <div
886
+ className="flex flex-wrap items-center gap-1.5"
887
+ data-testid="kanban-filter-chips"
888
+ >
889
+ {activeFields.map((field) => {
890
+ const summary = summarizeFilterValues(
891
+ field.config.selectedValues,
892
+ field.config.options,
893
+ )
894
+ const dot = chipValueColor(field.config)
895
+ return (
896
+ <Badge
897
+ key={field.key}
898
+ variant="secondary"
899
+ className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
900
+ >
901
+ {dot && (
902
+ <span
903
+ className="size-2 shrink-0 rounded-full"
904
+ style={{ backgroundColor: dot }}
905
+ />
906
+ )}
907
+ <span className="font-medium">{field.label}:</span>
908
+ <span className="max-w-[180px] truncate text-muted-foreground">
909
+ {summary}
910
+ </span>
911
+ <button
912
+ type="button"
913
+ onClick={() =>
914
+ field.config.onFilterChange(
915
+ field.config.filterKey,
916
+ [],
917
+ )
918
+ }
919
+ className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
920
+ aria-label={t('kanban.removeFilter', {
921
+ defaultValue: 'Quitar filtro',
922
+ })}
923
+ >
924
+ <X className="h-3 w-3" />
925
+ </button>
926
+ </Badge>
927
+ )
928
+ })}
929
+ <Button
930
+ variant="ghost"
931
+ size="sm"
932
+ className="h-6 gap-1 px-2 text-xs text-muted-foreground"
933
+ onClick={clearAll}
934
+ >
935
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
936
+ </Button>
937
+ </div>
938
+ )}
939
+
664
940
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
665
941
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
666
942
  {lanes.map((stage) => {
667
943
  const allCards = grouped.get(stage.key) ?? []
668
- // Per-lane client-side narrowing: substring match on the
669
- // chosen field over the already-fetched cards. Instant, and
670
- // scoped to this stage only.
944
+ // Per-lane client-side narrowing (instant, scoped to this
945
+ // stage). The funnel (field/value) and the lane search
946
+ // (query) are AND-combined.
671
947
  const laneFilter = laneFilters[stage.key]
672
- const cards =
673
- laneFilter && laneFilter.value.trim()
674
- ? allCards.filter((c) =>
675
- String(c[laneFilter.field] ?? '')
676
- .toLowerCase()
677
- .includes(
678
- laneFilter.value.trim().toLowerCase(),
679
- ),
680
- )
681
- : allCards
948
+ let cards = allCards
949
+ if (laneFilter?.field) {
950
+ cards = cards.filter((c) =>
951
+ cardMatchesLaneFunnel(c, laneFilter),
952
+ )
953
+ }
954
+ if (laneFilter?.query?.trim()) {
955
+ cards = cards.filter((c) =>
956
+ cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
957
+ )
958
+ }
682
959
  const droppableAllowed =
683
960
  !activeId ||
684
961
  stage.key === activeStage ||
@@ -691,8 +968,15 @@ export function DynamicKanban({
691
968
  totalCount={allCards.length}
692
969
  filterFields={filterFields}
693
970
  laneFilter={laneFilter}
694
- onLaneFilterChange={(f) =>
695
- setLaneFilter(stage.key, f)
971
+ onFunnelChange={(f) =>
972
+ updateLaneFilter(stage.key, {
973
+ field: f?.field,
974
+ values: f?.values,
975
+ text: f?.text,
976
+ })
977
+ }
978
+ onQueryChange={(q) =>
979
+ updateLaneFilter(stage.key, { query: q })
696
980
  }
697
981
  isDark={isDark}
698
982
  dimmed={!!activeId && !droppableAllowed}
@@ -747,17 +1031,113 @@ export function DynamicKanban({
747
1031
  )
748
1032
  }
749
1033
 
1034
+ // ---------------------------------------------------------------------------
1035
+ // Sheet filter row — a labeled ColumnFilterControl for the Filtros panel.
1036
+ // ---------------------------------------------------------------------------
1037
+
1038
+ interface SheetFilterField {
1039
+ key: string
1040
+ label: string
1041
+ config: {
1042
+ filterType: string
1043
+ filterKey: string
1044
+ options: { label: string; value: string; icon?: string; color?: string }[]
1045
+ selectedValues: string[]
1046
+ onFilterChange: (filterKey: string, values: string[]) => void
1047
+ loading?: boolean
1048
+ searchEndpoint?: string
1049
+ loadOptions?: (q?: string) => Promise<any[]>
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * A per-data-type glyph for the Filtros panel rows (and their popover header):
1055
+ * Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
1056
+ * for value pickers, ToggleLeft for booleans, Type for free text.
1057
+ */
1058
+ function filterTypeIcon(filterType: string, isStage: boolean): React.ReactNode {
1059
+ if (isStage) return <CircleDot className="h-3.5 w-3.5" />
1060
+ switch (filterType) {
1061
+ case 'number_range':
1062
+ return <Hash className="h-3.5 w-3.5" />
1063
+ case 'date_range':
1064
+ return <Calendar className="h-3.5 w-3.5" />
1065
+ case 'boolean':
1066
+ return <ToggleLeft className="h-3.5 w-3.5" />
1067
+ case 'select':
1068
+ case 'dynamic_select':
1069
+ case 'facet':
1070
+ return <Tag className="h-3.5 w-3.5" />
1071
+ default:
1072
+ return <Type className="h-3.5 w-3.5" />
1073
+ }
1074
+ }
1075
+
1076
+ function SheetFilterRow({
1077
+ field,
1078
+ isStage,
1079
+ }: {
1080
+ field: SheetFilterField
1081
+ isStage: boolean
1082
+ }) {
1083
+ const summary = summarizeFilterValues(
1084
+ field.config.selectedValues,
1085
+ field.config.options,
1086
+ )
1087
+ return (
1088
+ <ColumnFilterControl
1089
+ variant="row"
1090
+ align="end"
1091
+ icon={filterTypeIcon(field.config.filterType, isStage)}
1092
+ label={field.label}
1093
+ valueSummary={summary}
1094
+ filterKey={field.config.filterKey}
1095
+ filterType={field.config.filterType as ColumnFilterType}
1096
+ filterOptions={field.config.options}
1097
+ filterLoading={field.config.loading}
1098
+ filterSearchEndpoint={field.config.searchEndpoint}
1099
+ selectedValues={field.config.selectedValues}
1100
+ onFilterChange={field.config.onFilterChange}
1101
+ loadOptions={field.config.loadOptions}
1102
+ />
1103
+ )
1104
+ }
1105
+
750
1106
  // ---------------------------------------------------------------------------
751
1107
  // Lane (droppable column)
752
1108
  // ---------------------------------------------------------------------------
753
1109
 
1110
+ interface LaneFilterField {
1111
+ key: string
1112
+ label: string
1113
+ config?: ColumnFilterConfigLike
1114
+ }
1115
+
1116
+ /** Minimal shape the lane funnel reads off a shared filter config. */
1117
+ interface ColumnFilterConfigLike {
1118
+ filterType?: string
1119
+ filterKey?: string
1120
+ options?: { label: string; value: string; color?: string; count?: number }[]
1121
+ loadOptions?: (q?: string) => Promise<
1122
+ { label: string; value: string; color?: string; count?: number }[]
1123
+ >
1124
+ }
1125
+
1126
+ /** The funnel's committed value: a field + either picked `values` or free `text`. */
1127
+ interface LaneFunnelValue {
1128
+ field: string
1129
+ values?: string[]
1130
+ text?: string
1131
+ }
1132
+
754
1133
  interface KanbanLaneProps {
755
1134
  stage: StageMeta
756
1135
  count: number
757
1136
  totalCount: number
758
- filterFields: { key: string; label: string }[]
759
- laneFilter: { field: string; value: string } | undefined
760
- onLaneFilterChange: (filter: { field: string; value: string } | null) => void
1137
+ filterFields: LaneFilterField[]
1138
+ laneFilter: LaneFilterState | undefined
1139
+ onFunnelChange: (filter: LaneFunnelValue | null) => void
1140
+ onQueryChange: (query: string) => void
761
1141
  isDark: boolean
762
1142
  dimmed: boolean
763
1143
  disabled: boolean
@@ -770,7 +1150,8 @@ function KanbanLane({
770
1150
  totalCount,
771
1151
  filterFields,
772
1152
  laneFilter,
773
- onLaneFilterChange,
1153
+ onFunnelChange,
1154
+ onQueryChange,
774
1155
  isDark,
775
1156
  dimmed,
776
1157
  disabled,
@@ -781,14 +1162,42 @@ function KanbanLane({
781
1162
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
782
1163
  isDark,
783
1164
  })
784
- const laneActive = !!(laneFilter && laneFilter.value.trim())
785
- const activeFieldLabel =
786
- filterFields.find((f) => f.key === laneFilter?.field)?.label ??
787
- laneFilter?.field
1165
+ const funnelField = filterFields.find((f) => f.key === laneFilter?.field)
1166
+ const funnelActive = !!(
1167
+ laneFilter?.field &&
1168
+ ((laneFilter.values && laneFilter.values.length > 0) ||
1169
+ laneFilter.text?.trim())
1170
+ )
1171
+ const queryActive = !!laneFilter?.query?.trim()
1172
+ const laneActive = funnelActive || queryActive
1173
+ const activeFieldLabel = funnelField?.label ?? laneFilter?.field
1174
+ // Human summary of the funnel value: resolved option labels for picked
1175
+ // values, or the raw free text.
1176
+ const funnelSummary =
1177
+ laneFilter?.values && laneFilter.values.length > 0
1178
+ ? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
1179
+ : laneFilter?.text ?? ''
1180
+
1181
+ // Inline lane search: a Search icon expands an Input; Escape or blur-while-
1182
+ // empty collapses it. The query itself lives in the parent's laneFilters so
1183
+ // it survives collapse and combines with the funnel.
1184
+ const [searchOpen, setSearchOpen] = useState(queryActive)
1185
+ const searchRef = useRef<HTMLInputElement | null>(null)
1186
+ useEffect(() => {
1187
+ if (searchOpen) searchRef.current?.focus()
1188
+ }, [searchOpen])
1189
+ const funnelValue: LaneFunnelValue | undefined = laneFilter?.field
1190
+ ? {
1191
+ field: laneFilter.field,
1192
+ values: laneFilter.values,
1193
+ text: laneFilter.text,
1194
+ }
1195
+ : undefined
1196
+
788
1197
  return (
789
1198
  <div
790
1199
  ref={setNodeRef}
791
- className="flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity"
1200
+ className="group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
792
1201
  style={{
793
1202
  opacity: dimmed ? 0.45 : 1,
794
1203
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
@@ -798,33 +1207,79 @@ function KanbanLane({
798
1207
  data-disabled={disabled || undefined}
799
1208
  >
800
1209
  <div className="flex items-center justify-between gap-2 px-3 py-2.5">
801
- <Badge
802
- variant="outline"
803
- className="border-0 text-xs font-semibold"
804
- style={headerStyle}
805
- >
806
- {t(stage.label, { defaultValue: stage.label })}
807
- </Badge>
808
- <div className="flex items-center gap-1">
1210
+ <div className="flex min-w-0 items-center gap-2">
1211
+ <Badge
1212
+ variant="outline"
1213
+ className="border-0 text-xs font-semibold"
1214
+ style={headerStyle}
1215
+ >
1216
+ {t(stage.label, { defaultValue: stage.label })}
1217
+ </Badge>
809
1218
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
810
1219
  {laneActive ? `${count}/${totalCount}` : count}
811
1220
  </span>
1221
+ </div>
1222
+ {/* Lane actions — dimmed until the lane (or the board row) is
1223
+ hovered/focused, so an idle board stays clean. Stay lit when a
1224
+ lane filter is active. */}
1225
+ <div
1226
+ className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${
1227
+ laneActive || searchOpen ? 'opacity-100' : 'opacity-0'
1228
+ }`}
1229
+ >
1230
+ <button
1231
+ type="button"
1232
+ onClick={() => setSearchOpen((o) => !o)}
1233
+ className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1234
+ queryActive ? 'text-primary' : 'text-muted-foreground'
1235
+ }`}
1236
+ aria-label={t('kanban.searchLane', {
1237
+ defaultValue: 'Buscar en la columna',
1238
+ })}
1239
+ >
1240
+ <Search className="h-3.5 w-3.5" />
1241
+ </button>
812
1242
  <LaneFilterButton
813
1243
  fields={filterFields}
814
- value={laneFilter}
815
- onChange={onLaneFilterChange}
1244
+ value={funnelValue}
1245
+ onChange={onFunnelChange}
816
1246
  />
817
1247
  </div>
818
1248
  </div>
819
- {laneActive && (
1249
+ {searchOpen && (
1250
+ <div className="px-3 pb-1.5">
1251
+ <div className="relative">
1252
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
1253
+ <Input
1254
+ ref={searchRef}
1255
+ value={laneFilter?.query ?? ''}
1256
+ onChange={(e) => onQueryChange(e.target.value)}
1257
+ onKeyDown={(e) => {
1258
+ if (e.key === 'Escape') {
1259
+ onQueryChange('')
1260
+ setSearchOpen(false)
1261
+ }
1262
+ }}
1263
+ onBlur={() => {
1264
+ if (!laneFilter?.query?.trim()) setSearchOpen(false)
1265
+ }}
1266
+ placeholder={t('kanban.searchLanePlaceholder', {
1267
+ defaultValue: 'Buscar tarjetas...',
1268
+ })}
1269
+ className="h-7 pl-7 text-xs"
1270
+ />
1271
+ </div>
1272
+ </div>
1273
+ )}
1274
+ {funnelActive && (
820
1275
  <div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
821
1276
  <ListFilter className="h-3 w-3 shrink-0" />
822
1277
  <span className="truncate">
823
- {activeFieldLabel}: {laneFilter!.value}
1278
+ {activeFieldLabel}: {funnelSummary}
824
1279
  </span>
825
1280
  <button
826
1281
  type="button"
827
- onClick={() => onLaneFilterChange(null)}
1282
+ onClick={() => onFunnelChange(null)}
828
1283
  className="ml-auto rounded p-0.5 hover:bg-muted"
829
1284
  aria-label={t('kanban.clearFilters', {
830
1285
  defaultValue: 'Limpiar',
@@ -855,34 +1310,54 @@ function LaneFilterButton({
855
1310
  value,
856
1311
  onChange,
857
1312
  }: {
858
- fields: { key: string; label: string }[]
859
- value: { field: string; value: string } | undefined
860
- onChange: (filter: { field: string; value: string } | null) => void
1313
+ fields: LaneFilterField[]
1314
+ value: LaneFunnelValue | undefined
1315
+ onChange: (filter: LaneFunnelValue | null) => void
861
1316
  }) {
862
1317
  const { t } = useTranslation()
863
1318
  const [open, setOpen] = useState(false)
864
1319
  const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '')
865
- const [text, setText] = useState(value?.value ?? '')
1320
+ const [values, setValues] = useState<string[]>(value?.values ?? [])
1321
+ const [text, setText] = useState(value?.text ?? '')
866
1322
  // Re-seed the draft from the committed filter each time the popover opens.
867
1323
  useEffect(() => {
868
1324
  if (open) {
869
1325
  setField(value?.field ?? fields[0]?.key ?? '')
870
- setText(value?.value ?? '')
1326
+ setValues(value?.values ?? [])
1327
+ setText(value?.text ?? '')
871
1328
  }
872
1329
  }, [open, value, fields])
873
1330
  if (fields.length === 0) return null
874
- const active = !!(value && value.value.trim())
1331
+ const active = !!(
1332
+ value &&
1333
+ ((value.values && value.values.length > 0) || value.text?.trim())
1334
+ )
1335
+ // The value step mirrors the sheet: when the chosen field is a select or a
1336
+ // facet (static options OR a lazy loader), render the SAME pro combobox —
1337
+ // multi-select, searchable, with counts. Only a genuinely free-text field
1338
+ // (no options, no loader) falls back to a raw "Contiene..." input.
1339
+ const cfg = fields.find((f) => f.key === field)?.config
1340
+ const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions
1341
+ const toggle = (v: string) =>
1342
+ setValues((prev) =>
1343
+ prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v],
1344
+ )
875
1345
  const apply = () => {
876
- if (field && text.trim()) onChange({ field, value: text })
1346
+ if (field && values.length > 0) onChange({ field, values })
1347
+ else if (field && text.trim()) onChange({ field, text: text.trim() })
877
1348
  else onChange(null)
878
1349
  setOpen(false)
879
1350
  }
1351
+ const clear = () => {
1352
+ onChange(null)
1353
+ setOpen(false)
1354
+ }
880
1355
  return (
881
1356
  <Popover open={open} onOpenChange={setOpen}>
882
1357
  <PopoverTrigger asChild>
883
1358
  <button
884
1359
  type="button"
885
- className={`rounded p-1 transition-colors hover:bg-muted hover:text-foreground ${
1360
+ className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
886
1361
  active ? 'text-primary' : 'text-muted-foreground'
887
1362
  }`}
888
1363
  aria-label={t('kanban.filterLane', {
@@ -892,9 +1367,21 @@ function LaneFilterButton({
892
1367
  <ListFilter className="h-3.5 w-3.5" />
893
1368
  </button>
894
1369
  </PopoverTrigger>
895
- <PopoverContent align="end" className="w-56 space-y-2 p-2">
896
- <Select value={field} onValueChange={setField}>
897
- <SelectTrigger className="h-8 text-xs">
1370
+ <PopoverContent
1371
+ align="end"
1372
+ className="w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg"
1373
+ >
1374
+ <Select
1375
+ value={field}
1376
+ onValueChange={(f) => {
1377
+ setField(f)
1378
+ // Reset the value when switching fields — a value picked
1379
+ // for one field is meaningless for another.
1380
+ setValues([])
1381
+ setText('')
1382
+ }}
1383
+ >
1384
+ <SelectTrigger className="h-8 w-full text-xs">
898
1385
  <SelectValue />
899
1386
  </SelectTrigger>
900
1387
  <SelectContent>
@@ -905,30 +1392,50 @@ function LaneFilterButton({
905
1392
  ))}
906
1393
  </SelectContent>
907
1394
  </Select>
908
- <Input
909
- autoFocus
910
- value={text}
911
- onChange={(e) => setText(e.target.value)}
912
- onKeyDown={(e) => {
913
- if (e.key === 'Enter') apply()
914
- }}
915
- placeholder={t('kanban.filterValue', { defaultValue: 'Valor...' })}
916
- className="h-8 text-xs"
917
- />
918
- <div className="flex justify-between gap-2">
1395
+ {hasValuePicker ? (
1396
+ // `key={field}` remounts the combobox on field switch so it
1397
+ // reloads that field's values from scratch (no stale list).
1398
+ <div className="overflow-hidden rounded-lg border">
1399
+ <FilterValueCombobox
1400
+ key={field}
1401
+ staticOptions={cfg?.options}
1402
+ loadOptions={cfg?.loadOptions}
1403
+ selected={values}
1404
+ onToggle={toggle}
1405
+ />
1406
+ </div>
1407
+ ) : (
1408
+ <Input
1409
+ autoFocus
1410
+ value={text}
1411
+ onChange={(e) => setText(e.target.value)}
1412
+ onKeyDown={(e) => {
1413
+ if (e.key === 'Enter') apply()
1414
+ }}
1415
+ placeholder={t('kanban.filterValue', {
1416
+ defaultValue: 'Contiene...',
1417
+ })}
1418
+ className="h-8 w-full text-xs"
1419
+ />
1420
+ )}
1421
+ <div className="flex gap-1.5">
919
1422
  <Button
920
- variant="ghost"
1423
+ variant="outline"
921
1424
  size="sm"
922
- className="h-7 text-xs"
923
- onClick={() => {
924
- onChange(null)
925
- setOpen(false)
926
- }}
1425
+ className="h-7 flex-1 text-xs"
1426
+ onClick={clear}
1427
+ disabled={!active && values.length === 0 && !text.trim()}
927
1428
  >
928
1429
  {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
929
1430
  </Button>
930
- <Button size="sm" className="h-7 text-xs" onClick={apply}>
1431
+ <Button
1432
+ size="sm"
1433
+ className="h-7 flex-1 text-xs"
1434
+ onClick={apply}
1435
+ disabled={values.length === 0 && !text.trim()}
1436
+ >
931
1437
  {t('kanban.apply', { defaultValue: 'Aplicar' })}
1438
+ {values.length > 0 ? ` (${values.length})` : ''}
932
1439
  </Button>
933
1440
  </div>
934
1441
  </PopoverContent>