@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.
@@ -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,
@@ -66,7 +77,7 @@ import {
66
77
  Skeleton,
67
78
  } from '@asteby/metacore-ui/primitives'
68
79
  import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
69
- import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
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,90 @@ 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 matches a free-text lane search: a case-insensitive substring
303
+ * over the card's title + every visible field value (`String(v)`). Empty query
304
+ * matches everything. Pure — exported for unit tests.
305
+ */
306
+ export function cardMatchesLaneQuery(
307
+ card: any,
308
+ cols: ColumnDefinition[],
309
+ query: string,
310
+ ): boolean {
311
+ const q = query.trim().toLowerCase()
312
+ if (!q) return true
313
+ return cols.some((c) =>
314
+ String(card?.[c.key] ?? '')
315
+ .toLowerCase()
316
+ .includes(q),
317
+ )
318
+ }
319
+
225
320
  // ---------------------------------------------------------------------------
226
321
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
227
322
  // ---------------------------------------------------------------------------
@@ -250,6 +345,13 @@ function useIsDarkTheme(): boolean {
250
345
  // Component
251
346
  // ---------------------------------------------------------------------------
252
347
 
348
+ /** Per-lane client-side filter: an optional field funnel + an optional search. */
349
+ interface LaneFilterState {
350
+ field?: string
351
+ value?: string
352
+ query?: string
353
+ }
354
+
253
355
  export interface DynamicKanbanProps {
254
356
  /** Model key as registered on the backend (e.g. "issue"). */
255
357
  model: string
@@ -352,13 +454,15 @@ export function DynamicKanban({
352
454
  // and `f_<key>` serialization DynamicTable uses, so the board filters
353
455
  // identically to its table sibling.
354
456
  const {
457
+ dynamicFilters,
355
458
  globalFilter,
356
459
  setGlobalFilter,
357
460
  columnFilterConfigs,
358
461
  filterParams,
359
462
  activeFilterCount,
463
+ handleDynamicFilterChange,
360
464
  clearAll,
361
- } = useDynamicFilters(metadata, { defaultFilters })
465
+ } = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
362
466
 
363
467
  // ---- records fetch (same path as DynamicTable, single large page) ----
364
468
  const fetchData = useCallback(async () => {
@@ -406,20 +510,41 @@ export function DynamicKanban({
406
510
  return out
407
511
  }, [metadata, columnFilterConfigs, t])
408
512
 
513
+ // Split filters into active (with a selection) and the rest — the Sheet
514
+ // groups the active ones on top, the rest alphabetically. Also drives the
515
+ // removable chip row below the toolbar.
516
+ const { activeFields, inactiveFields } = useMemo(() => {
517
+ const active = filterFields.filter(
518
+ (f) => (f.config.selectedValues?.length ?? 0) > 0,
519
+ )
520
+ const inactive = filterFields
521
+ .filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
522
+ .sort((a, b) => a.label.localeCompare(b.label))
523
+ return { activeFields: active, inactiveFields: inactive }
524
+ }, [filterFields])
525
+
409
526
  // Sheet (grouped global filters) open state + per-lane client-side filters.
410
527
  // A lane filter narrows ONLY that stage's already-fetched cards by a field
411
528
  // value — instant, no refetch — so a user can drill into one column without
412
529
  // touching the rest of the board (the global filters, by contrast, refetch
413
530
  // the whole board server-side).
414
531
  const [filtersOpen, setFiltersOpen] = useState(false)
532
+ // Per-lane client-side narrowing. Two independent, AND-combined dimensions:
533
+ // - `field`/`value`: the funnel — a field-scoped substring match.
534
+ // - `query`: the lane search — a substring over the card title + every
535
+ // visible field value.
536
+ // A lane with neither is dropped from the map (so it reads as "unfiltered").
415
537
  const [laneFilters, setLaneFilters] = useState<
416
- Record<string, { field: string; value: string }>
538
+ Record<string, LaneFilterState>
417
539
  >({})
418
- const setLaneFilter = useCallback(
419
- (stageKey: string, filter: { field: string; value: string } | null) => {
540
+ const updateLaneFilter = useCallback(
541
+ (stageKey: string, patch: Partial<LaneFilterState>) => {
420
542
  setLaneFilters((prev) => {
543
+ const merged: LaneFilterState = { ...prev[stageKey], ...patch }
421
544
  const next = { ...prev }
422
- if (filter) next[stageKey] = filter
545
+ const hasFunnel = !!(merged.field && merged.value?.trim())
546
+ const hasQuery = !!merged.query?.trim()
547
+ if (hasFunnel || hasQuery) next[stageKey] = merged
423
548
  else delete next[stageKey]
424
549
  return next
425
550
  })
@@ -444,6 +569,12 @@ export function DynamicKanban({
444
569
  [metadata],
445
570
  )
446
571
 
572
+ // Columns the lane search scans: the card title + its visible field cells.
573
+ const searchCols = useMemo(
574
+ () => [titleCol, ...fieldCols].filter(Boolean) as ColumnDefinition[],
575
+ [titleCol, fieldCols],
576
+ )
577
+
447
578
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
448
579
  // capability-gated (when a <PermissionsProvider> is mounted) and with the
449
580
  // implicit View/Edit/Delete trio materialized for CRUD models. An action the
@@ -616,69 +747,155 @@ export function DynamicKanban({
616
747
  </SheetTrigger>
617
748
  <SheetContent
618
749
  side="right"
619
- className="flex w-80 flex-col gap-4 sm:max-w-sm"
750
+ className="flex w-80 flex-col gap-0 p-0 sm:max-w-sm"
620
751
  >
621
- <SheetHeader className="space-y-0">
622
- <SheetTitle>
752
+ <SheetHeader className="space-y-0 border-b px-4 py-3">
753
+ <SheetTitle className="text-sm">
623
754
  {t('kanban.filters', { defaultValue: 'Filtros' })}
624
755
  </SheetTitle>
625
756
  </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
757
+ {/* Active filters grouped on top, the rest alphabetical
758
+ below a separator. Each row: label + its control; the
759
+ active field is highlighted by ColumnFilterControl's
760
+ own active styling. Same server-side engine as before. */}
761
+ <div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3">
762
+ {activeFields.length > 0 && (
763
+ <>
764
+ <p className="px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
765
+ {t('kanban.activeFilters', {
766
+ defaultValue: 'Con filtros activos',
767
+ })}
768
+ </p>
769
+ {activeFields.map((field) => (
770
+ <SheetFilterRow
771
+ key={field.key}
772
+ field={field}
773
+ isStage={field.key === groupByKey}
774
+ />
775
+ ))}
776
+ {inactiveFields.length > 0 && (
777
+ <div className="my-2 border-t" />
778
+ )}
779
+ </>
780
+ )}
781
+ {inactiveFields.map((field) => (
782
+ <SheetFilterRow
632
783
  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}
784
+ field={field}
785
+ isStage={field.key === groupByKey}
644
786
  />
645
787
  ))}
646
788
  </div>
647
- {activeFilterCount > 0 && (
789
+ <div className="sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3">
790
+ <span className="text-xs text-muted-foreground tabular-nums">
791
+ {activeFilterCount > 0
792
+ ? t('kanban.activeCount', {
793
+ defaultValue: '{{count}} activos',
794
+ count: activeFilterCount,
795
+ })
796
+ : t('kanban.noActiveFilters', {
797
+ defaultValue: 'Sin filtros',
798
+ })}
799
+ </span>
648
800
  <Button
649
801
  variant="ghost"
650
802
  size="sm"
651
- className="gap-1 text-xs text-muted-foreground"
803
+ className="h-7 gap-1 text-xs"
652
804
  onClick={clearAll}
805
+ disabled={activeFilterCount === 0}
653
806
  >
654
807
  <X className="h-3.5 w-3.5" />
655
- {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
656
- {` (${activeFilterCount})`}
808
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
809
+ {activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
657
810
  </Button>
658
- )}
811
+ </div>
659
812
  </SheetContent>
660
813
  </Sheet>
661
814
  )}
662
815
  </div>
663
816
 
817
+ {/* Removable chip row — one chip per active field filter, plus a
818
+ "Limpiar todo" at the end. Instant feedback without opening the
819
+ Sheet; clicking a chip's X clears that field server-side. */}
820
+ {activeFields.length > 0 && (
821
+ <div
822
+ className="flex flex-wrap items-center gap-1.5"
823
+ data-testid="kanban-filter-chips"
824
+ >
825
+ {activeFields.map((field) => {
826
+ const summary = summarizeFilterValues(
827
+ field.config.selectedValues,
828
+ field.config.options,
829
+ )
830
+ const dot = chipValueColor(field.config)
831
+ return (
832
+ <Badge
833
+ key={field.key}
834
+ variant="secondary"
835
+ className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
836
+ >
837
+ {dot && (
838
+ <span
839
+ className="size-2 shrink-0 rounded-full"
840
+ style={{ backgroundColor: dot }}
841
+ />
842
+ )}
843
+ <span className="font-medium">{field.label}:</span>
844
+ <span className="max-w-[180px] truncate text-muted-foreground">
845
+ {summary}
846
+ </span>
847
+ <button
848
+ type="button"
849
+ onClick={() =>
850
+ field.config.onFilterChange(
851
+ field.config.filterKey,
852
+ [],
853
+ )
854
+ }
855
+ className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
856
+ aria-label={t('kanban.removeFilter', {
857
+ defaultValue: 'Quitar filtro',
858
+ })}
859
+ >
860
+ <X className="h-3 w-3" />
861
+ </button>
862
+ </Badge>
863
+ )
864
+ })}
865
+ <Button
866
+ variant="ghost"
867
+ size="sm"
868
+ className="h-6 gap-1 px-2 text-xs text-muted-foreground"
869
+ onClick={clearAll}
870
+ >
871
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
872
+ </Button>
873
+ </div>
874
+ )}
875
+
664
876
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
665
877
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
666
878
  {lanes.map((stage) => {
667
879
  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.
880
+ // Per-lane client-side narrowing (instant, scoped to this
881
+ // stage). The funnel (field/value) and the lane search
882
+ // (query) are AND-combined.
671
883
  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
884
+ let cards = allCards
885
+ if (laneFilter?.field && laneFilter.value?.trim()) {
886
+ const v = laneFilter.value.trim().toLowerCase()
887
+ const field = laneFilter.field
888
+ cards = cards.filter((c) =>
889
+ String(c[field] ?? '')
890
+ .toLowerCase()
891
+ .includes(v),
892
+ )
893
+ }
894
+ if (laneFilter?.query?.trim()) {
895
+ cards = cards.filter((c) =>
896
+ cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
897
+ )
898
+ }
682
899
  const droppableAllowed =
683
900
  !activeId ||
684
901
  stage.key === activeStage ||
@@ -691,8 +908,14 @@ export function DynamicKanban({
691
908
  totalCount={allCards.length}
692
909
  filterFields={filterFields}
693
910
  laneFilter={laneFilter}
694
- onLaneFilterChange={(f) =>
695
- setLaneFilter(stage.key, f)
911
+ onFunnelChange={(f) =>
912
+ updateLaneFilter(stage.key, {
913
+ field: f?.field,
914
+ value: f?.value,
915
+ })
916
+ }
917
+ onQueryChange={(q) =>
918
+ updateLaneFilter(stage.key, { query: q })
696
919
  }
697
920
  isDark={isDark}
698
921
  dimmed={!!activeId && !droppableAllowed}
@@ -747,17 +970,103 @@ export function DynamicKanban({
747
970
  )
748
971
  }
749
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // Sheet filter row — a labeled ColumnFilterControl for the Filtros panel.
975
+ // ---------------------------------------------------------------------------
976
+
977
+ interface SheetFilterField {
978
+ key: string
979
+ label: string
980
+ config: {
981
+ filterType: string
982
+ filterKey: string
983
+ options: { label: string; value: string; icon?: string; color?: string }[]
984
+ selectedValues: string[]
985
+ onFilterChange: (filterKey: string, values: string[]) => void
986
+ loading?: boolean
987
+ searchEndpoint?: string
988
+ loadOptions?: (q?: string) => Promise<any[]>
989
+ }
990
+ }
991
+
992
+ /**
993
+ * A per-data-type glyph for the Filtros panel rows (and their popover header):
994
+ * Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
995
+ * for value pickers, ToggleLeft for booleans, Type for free text.
996
+ */
997
+ function filterTypeIcon(filterType: string, isStage: boolean): React.ReactNode {
998
+ if (isStage) return <CircleDot className="h-3.5 w-3.5" />
999
+ switch (filterType) {
1000
+ case 'number_range':
1001
+ return <Hash className="h-3.5 w-3.5" />
1002
+ case 'date_range':
1003
+ return <Calendar className="h-3.5 w-3.5" />
1004
+ case 'boolean':
1005
+ return <ToggleLeft className="h-3.5 w-3.5" />
1006
+ case 'select':
1007
+ case 'dynamic_select':
1008
+ case 'facet':
1009
+ return <Tag className="h-3.5 w-3.5" />
1010
+ default:
1011
+ return <Type className="h-3.5 w-3.5" />
1012
+ }
1013
+ }
1014
+
1015
+ function SheetFilterRow({
1016
+ field,
1017
+ isStage,
1018
+ }: {
1019
+ field: SheetFilterField
1020
+ isStage: boolean
1021
+ }) {
1022
+ const summary = summarizeFilterValues(
1023
+ field.config.selectedValues,
1024
+ field.config.options,
1025
+ )
1026
+ return (
1027
+ <ColumnFilterControl
1028
+ variant="row"
1029
+ align="end"
1030
+ icon={filterTypeIcon(field.config.filterType, isStage)}
1031
+ label={field.label}
1032
+ valueSummary={summary}
1033
+ filterKey={field.config.filterKey}
1034
+ filterType={field.config.filterType as ColumnFilterType}
1035
+ filterOptions={field.config.options}
1036
+ filterLoading={field.config.loading}
1037
+ filterSearchEndpoint={field.config.searchEndpoint}
1038
+ selectedValues={field.config.selectedValues}
1039
+ onFilterChange={field.config.onFilterChange}
1040
+ loadOptions={field.config.loadOptions}
1041
+ />
1042
+ )
1043
+ }
1044
+
750
1045
  // ---------------------------------------------------------------------------
751
1046
  // Lane (droppable column)
752
1047
  // ---------------------------------------------------------------------------
753
1048
 
1049
+ interface LaneFilterField {
1050
+ key: string
1051
+ label: string
1052
+ config?: ColumnFilterConfigLike
1053
+ }
1054
+
1055
+ /** Minimal shape the lane funnel reads off a shared filter config. */
1056
+ interface ColumnFilterConfigLike {
1057
+ filterType?: string
1058
+ filterKey?: string
1059
+ options?: { label: string; value: string; color?: string }[]
1060
+ }
1061
+
754
1062
  interface KanbanLaneProps {
755
1063
  stage: StageMeta
756
1064
  count: number
757
1065
  totalCount: number
758
- filterFields: { key: string; label: string }[]
759
- laneFilter: { field: string; value: string } | undefined
760
- onLaneFilterChange: (filter: { field: string; value: string } | null) => void
1066
+ filterFields: LaneFilterField[]
1067
+ laneFilter: LaneFilterState | undefined
1068
+ onFunnelChange: (filter: { field: string; value: string } | null) => void
1069
+ onQueryChange: (query: string) => void
761
1070
  isDark: boolean
762
1071
  dimmed: boolean
763
1072
  disabled: boolean
@@ -770,7 +1079,8 @@ function KanbanLane({
770
1079
  totalCount,
771
1080
  filterFields,
772
1081
  laneFilter,
773
- onLaneFilterChange,
1082
+ onFunnelChange,
1083
+ onQueryChange,
774
1084
  isDark,
775
1085
  dimmed,
776
1086
  disabled,
@@ -781,14 +1091,30 @@ function KanbanLane({
781
1091
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
782
1092
  isDark,
783
1093
  })
784
- const laneActive = !!(laneFilter && laneFilter.value.trim())
1094
+ const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim())
1095
+ const queryActive = !!laneFilter?.query?.trim()
1096
+ const laneActive = funnelActive || queryActive
785
1097
  const activeFieldLabel =
786
1098
  filterFields.find((f) => f.key === laneFilter?.field)?.label ??
787
1099
  laneFilter?.field
1100
+
1101
+ // Inline lane search: a Search icon expands an Input; Escape or blur-while-
1102
+ // empty collapses it. The query itself lives in the parent's laneFilters so
1103
+ // it survives collapse and combines with the funnel.
1104
+ const [searchOpen, setSearchOpen] = useState(queryActive)
1105
+ const searchRef = useRef<HTMLInputElement | null>(null)
1106
+ useEffect(() => {
1107
+ if (searchOpen) searchRef.current?.focus()
1108
+ }, [searchOpen])
1109
+ const funnelValue =
1110
+ laneFilter?.field && laneFilter.value
1111
+ ? { field: laneFilter.field, value: laneFilter.value }
1112
+ : undefined
1113
+
788
1114
  return (
789
1115
  <div
790
1116
  ref={setNodeRef}
791
- className="flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity"
1117
+ className="group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
792
1118
  style={{
793
1119
  opacity: dimmed ? 0.45 : 1,
794
1120
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
@@ -798,25 +1124,71 @@ function KanbanLane({
798
1124
  data-disabled={disabled || undefined}
799
1125
  >
800
1126
  <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">
1127
+ <div className="flex min-w-0 items-center gap-2">
1128
+ <Badge
1129
+ variant="outline"
1130
+ className="border-0 text-xs font-semibold"
1131
+ style={headerStyle}
1132
+ >
1133
+ {t(stage.label, { defaultValue: stage.label })}
1134
+ </Badge>
809
1135
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
810
1136
  {laneActive ? `${count}/${totalCount}` : count}
811
1137
  </span>
1138
+ </div>
1139
+ {/* Lane actions — dimmed until the lane (or the board row) is
1140
+ hovered/focused, so an idle board stays clean. Stay lit when a
1141
+ lane filter is active. */}
1142
+ <div
1143
+ className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${
1144
+ laneActive || searchOpen ? 'opacity-100' : 'opacity-0'
1145
+ }`}
1146
+ >
1147
+ <button
1148
+ type="button"
1149
+ onClick={() => setSearchOpen((o) => !o)}
1150
+ className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1151
+ queryActive ? 'text-primary' : 'text-muted-foreground'
1152
+ }`}
1153
+ aria-label={t('kanban.searchLane', {
1154
+ defaultValue: 'Buscar en la columna',
1155
+ })}
1156
+ >
1157
+ <Search className="h-3.5 w-3.5" />
1158
+ </button>
812
1159
  <LaneFilterButton
813
1160
  fields={filterFields}
814
- value={laneFilter}
815
- onChange={onLaneFilterChange}
1161
+ value={funnelValue}
1162
+ onChange={onFunnelChange}
816
1163
  />
817
1164
  </div>
818
1165
  </div>
819
- {laneActive && (
1166
+ {searchOpen && (
1167
+ <div className="px-3 pb-1.5">
1168
+ <div className="relative">
1169
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
1170
+ <Input
1171
+ ref={searchRef}
1172
+ value={laneFilter?.query ?? ''}
1173
+ onChange={(e) => onQueryChange(e.target.value)}
1174
+ onKeyDown={(e) => {
1175
+ if (e.key === 'Escape') {
1176
+ onQueryChange('')
1177
+ setSearchOpen(false)
1178
+ }
1179
+ }}
1180
+ onBlur={() => {
1181
+ if (!laneFilter?.query?.trim()) setSearchOpen(false)
1182
+ }}
1183
+ placeholder={t('kanban.searchLanePlaceholder', {
1184
+ defaultValue: 'Buscar tarjetas...',
1185
+ })}
1186
+ className="h-7 pl-7 text-xs"
1187
+ />
1188
+ </div>
1189
+ </div>
1190
+ )}
1191
+ {funnelActive && (
820
1192
  <div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
821
1193
  <ListFilter className="h-3 w-3 shrink-0" />
822
1194
  <span className="truncate">
@@ -824,7 +1196,7 @@ function KanbanLane({
824
1196
  </span>
825
1197
  <button
826
1198
  type="button"
827
- onClick={() => onLaneFilterChange(null)}
1199
+ onClick={() => onFunnelChange(null)}
828
1200
  className="ml-auto rounded p-0.5 hover:bg-muted"
829
1201
  aria-label={t('kanban.clearFilters', {
830
1202
  defaultValue: 'Limpiar',
@@ -855,7 +1227,7 @@ function LaneFilterButton({
855
1227
  value,
856
1228
  onChange,
857
1229
  }: {
858
- fields: { key: string; label: string }[]
1230
+ fields: LaneFilterField[]
859
1231
  value: { field: string; value: string } | undefined
860
1232
  onChange: (filter: { field: string; value: string } | null) => void
861
1233
  }) {
@@ -872,6 +1244,12 @@ function LaneFilterButton({
872
1244
  }, [open, value, fields])
873
1245
  if (fields.length === 0) return null
874
1246
  const active = !!(value && value.value.trim())
1247
+ // When the chosen field carries a known option set (select/facet with
1248
+ // pre-loaded values), offer those values as a Select instead of a raw text
1249
+ // box — so the funnel picks real values, not free strings.
1250
+ const selectedFieldCfg = fields.find((f) => f.key === field)?.config
1251
+ const valueOptions = selectedFieldCfg?.options ?? []
1252
+ const hasValueOptions = valueOptions.length > 0
875
1253
  const apply = () => {
876
1254
  if (field && text.trim()) onChange({ field, value: text })
877
1255
  else onChange(null)
@@ -882,7 +1260,7 @@ function LaneFilterButton({
882
1260
  <PopoverTrigger asChild>
883
1261
  <button
884
1262
  type="button"
885
- className={`rounded p-1 transition-colors hover:bg-muted hover:text-foreground ${
1263
+ className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
886
1264
  active ? 'text-primary' : 'text-muted-foreground'
887
1265
  }`}
888
1266
  aria-label={t('kanban.filterLane', {
@@ -893,7 +1271,15 @@ function LaneFilterButton({
893
1271
  </button>
894
1272
  </PopoverTrigger>
895
1273
  <PopoverContent align="end" className="w-56 space-y-2 p-2">
896
- <Select value={field} onValueChange={setField}>
1274
+ <Select
1275
+ value={field}
1276
+ onValueChange={(f) => {
1277
+ setField(f)
1278
+ // Reset the value when switching fields — a value picked
1279
+ // for one field is meaningless for another.
1280
+ setText('')
1281
+ }}
1282
+ >
897
1283
  <SelectTrigger className="h-8 text-xs">
898
1284
  <SelectValue />
899
1285
  </SelectTrigger>
@@ -905,16 +1291,48 @@ function LaneFilterButton({
905
1291
  ))}
906
1292
  </SelectContent>
907
1293
  </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
- />
1294
+ {hasValueOptions ? (
1295
+ <Select
1296
+ value={text || undefined}
1297
+ onValueChange={(v) => {
1298
+ setText(v)
1299
+ if (field && v) onChange({ field, value: v })
1300
+ setOpen(false)
1301
+ }}
1302
+ >
1303
+ <SelectTrigger className="h-8 text-xs">
1304
+ <SelectValue
1305
+ placeholder={t('kanban.filterValue', {
1306
+ defaultValue: 'Valor...',
1307
+ })}
1308
+ />
1309
+ </SelectTrigger>
1310
+ <SelectContent>
1311
+ {valueOptions.map((o) => (
1312
+ <SelectItem
1313
+ key={o.value}
1314
+ value={o.value}
1315
+ className="text-xs"
1316
+ >
1317
+ {o.label}
1318
+ </SelectItem>
1319
+ ))}
1320
+ </SelectContent>
1321
+ </Select>
1322
+ ) : (
1323
+ <Input
1324
+ autoFocus
1325
+ value={text}
1326
+ onChange={(e) => setText(e.target.value)}
1327
+ onKeyDown={(e) => {
1328
+ if (e.key === 'Enter') apply()
1329
+ }}
1330
+ placeholder={t('kanban.filterValue', {
1331
+ defaultValue: 'Valor...',
1332
+ })}
1333
+ className="h-8 text-xs"
1334
+ />
1335
+ )}
918
1336
  <div className="flex justify-between gap-2">
919
1337
  <Button
920
1338
  variant="ghost"