@asteby/metacore-runtime-react 21.0.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 { 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,
@@ -50,10 +61,23 @@ import {
50
61
  DropdownMenuItem,
51
62
  DropdownMenuTrigger,
52
63
  Input,
64
+ Popover,
65
+ PopoverContent,
66
+ PopoverTrigger,
67
+ Select,
68
+ SelectContent,
69
+ SelectItem,
70
+ SelectTrigger,
71
+ SelectValue,
72
+ Sheet,
73
+ SheetContent,
74
+ SheetHeader,
75
+ SheetTitle,
76
+ SheetTrigger,
53
77
  Skeleton,
54
78
  } from '@asteby/metacore-ui/primitives'
55
79
  import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
56
- import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
80
+ import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
57
81
  import { useApi } from './api-context'
58
82
  import { useDynamicFilters } from './use-dynamic-filters'
59
83
  import { useMetadataCache } from './metadata-cache'
@@ -209,6 +233,90 @@ export function selectCardColumns(
209
233
  return { title, fields }
210
234
  }
211
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
+
212
320
  // ---------------------------------------------------------------------------
213
321
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
214
322
  // ---------------------------------------------------------------------------
@@ -237,6 +345,13 @@ function useIsDarkTheme(): boolean {
237
345
  // Component
238
346
  // ---------------------------------------------------------------------------
239
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
+
240
355
  export interface DynamicKanbanProps {
241
356
  /** Model key as registered on the backend (e.g. "issue"). */
242
357
  model: string
@@ -339,13 +454,15 @@ export function DynamicKanban({
339
454
  // and `f_<key>` serialization DynamicTable uses, so the board filters
340
455
  // identically to its table sibling.
341
456
  const {
457
+ dynamicFilters,
342
458
  globalFilter,
343
459
  setGlobalFilter,
344
460
  columnFilterConfigs,
345
461
  filterParams,
346
462
  activeFilterCount,
463
+ handleDynamicFilterChange,
347
464
  clearAll,
348
- } = useDynamicFilters(metadata, { defaultFilters })
465
+ } = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
349
466
 
350
467
  // ---- records fetch (same path as DynamicTable, single large page) ----
351
468
  const fetchData = useCallback(async () => {
@@ -393,6 +510,48 @@ export function DynamicKanban({
393
510
  return out
394
511
  }, [metadata, columnFilterConfigs, t])
395
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
+
526
+ // Sheet (grouped global filters) open state + per-lane client-side filters.
527
+ // A lane filter narrows ONLY that stage's already-fetched cards by a field
528
+ // value — instant, no refetch — so a user can drill into one column without
529
+ // touching the rest of the board (the global filters, by contrast, refetch
530
+ // the whole board server-side).
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").
537
+ const [laneFilters, setLaneFilters] = useState<
538
+ Record<string, LaneFilterState>
539
+ >({})
540
+ const updateLaneFilter = useCallback(
541
+ (stageKey: string, patch: Partial<LaneFilterState>) => {
542
+ setLaneFilters((prev) => {
543
+ const merged: LaneFilterState = { ...prev[stageKey], ...patch }
544
+ const next = { ...prev }
545
+ const hasFunnel = !!(merged.field && merged.value?.trim())
546
+ const hasQuery = !!merged.query?.trim()
547
+ if (hasFunnel || hasQuery) next[stageKey] = merged
548
+ else delete next[stageKey]
549
+ return next
550
+ })
551
+ },
552
+ [],
553
+ )
554
+
396
555
  const stages = useMemo(
397
556
  () => (metadata ? deriveStages(metadata) : []),
398
557
  [metadata],
@@ -410,6 +569,12 @@ export function DynamicKanban({
410
569
  [metadata],
411
570
  )
412
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
+
413
578
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
414
579
  // capability-gated (when a <PermissionsProvider> is mounted) and with the
415
580
  // implicit View/Edit/Delete trio materialized for CRUD models. An action the
@@ -564,38 +729,173 @@ export function DynamicKanban({
564
729
  className="h-8 w-52 pl-8 text-sm"
565
730
  />
566
731
  </div>
567
- {filterFields.map((field) => (
568
- <ColumnFilterControl
569
- key={field.key}
570
- showLabel
571
- label={field.label}
572
- filterKey={field.config.filterKey}
573
- filterType={field.config.filterType as ColumnFilterType}
574
- filterOptions={field.config.options}
575
- filterLoading={field.config.loading}
576
- filterSearchEndpoint={field.config.searchEndpoint}
577
- selectedValues={field.config.selectedValues}
578
- onFilterChange={field.config.onFilterChange}
579
- />
580
- ))}
581
- {activeFilterCount > 0 && (
732
+ {filterFields.length > 0 && (
733
+ <Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
734
+ <SheetTrigger asChild>
735
+ <Button variant="outline" size="sm" className="h-8 gap-1.5">
736
+ <ListFilter className="h-3.5 w-3.5" />
737
+ {t('kanban.filters', { defaultValue: 'Filtros' })}
738
+ {activeFilterCount > 0 && (
739
+ <Badge
740
+ variant="secondary"
741
+ className="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums"
742
+ >
743
+ {activeFilterCount}
744
+ </Badge>
745
+ )}
746
+ </Button>
747
+ </SheetTrigger>
748
+ <SheetContent
749
+ side="right"
750
+ className="flex w-80 flex-col gap-0 p-0 sm:max-w-sm"
751
+ >
752
+ <SheetHeader className="space-y-0 border-b px-4 py-3">
753
+ <SheetTitle className="text-sm">
754
+ {t('kanban.filters', { defaultValue: 'Filtros' })}
755
+ </SheetTitle>
756
+ </SheetHeader>
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
783
+ key={field.key}
784
+ field={field}
785
+ isStage={field.key === groupByKey}
786
+ />
787
+ ))}
788
+ </div>
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>
800
+ <Button
801
+ variant="ghost"
802
+ size="sm"
803
+ className="h-7 gap-1 text-xs"
804
+ onClick={clearAll}
805
+ disabled={activeFilterCount === 0}
806
+ >
807
+ <X className="h-3.5 w-3.5" />
808
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
809
+ {activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
810
+ </Button>
811
+ </div>
812
+ </SheetContent>
813
+ </Sheet>
814
+ )}
815
+ </div>
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
+ })}
582
865
  <Button
583
866
  variant="ghost"
584
867
  size="sm"
585
- className="h-8 gap-1 text-xs text-muted-foreground"
868
+ className="h-6 gap-1 px-2 text-xs text-muted-foreground"
586
869
  onClick={clearAll}
587
870
  >
588
- <X className="h-3.5 w-3.5" />
589
- {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
590
- {` (${activeFilterCount})`}
871
+ {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
591
872
  </Button>
592
- )}
593
- </div>
873
+ </div>
874
+ )}
594
875
 
595
876
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
596
877
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
597
878
  {lanes.map((stage) => {
598
- const cards = grouped.get(stage.key) ?? []
879
+ const allCards = grouped.get(stage.key) ?? []
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.
883
+ const laneFilter = laneFilters[stage.key]
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
+ }
599
899
  const droppableAllowed =
600
900
  !activeId ||
601
901
  stage.key === activeStage ||
@@ -605,6 +905,18 @@ export function DynamicKanban({
605
905
  key={stage.key}
606
906
  stage={stage}
607
907
  count={cards.length}
908
+ totalCount={allCards.length}
909
+ filterFields={filterFields}
910
+ laneFilter={laneFilter}
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 })
919
+ }
608
920
  isDark={isDark}
609
921
  dimmed={!!activeId && !droppableAllowed}
610
922
  disabled={!!activeId && !droppableAllowed}
@@ -658,29 +970,151 @@ export function DynamicKanban({
658
970
  )
659
971
  }
660
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
+
661
1045
  // ---------------------------------------------------------------------------
662
1046
  // Lane (droppable column)
663
1047
  // ---------------------------------------------------------------------------
664
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
+
665
1062
  interface KanbanLaneProps {
666
1063
  stage: StageMeta
667
1064
  count: number
1065
+ totalCount: number
1066
+ filterFields: LaneFilterField[]
1067
+ laneFilter: LaneFilterState | undefined
1068
+ onFunnelChange: (filter: { field: string; value: string } | null) => void
1069
+ onQueryChange: (query: string) => void
668
1070
  isDark: boolean
669
1071
  dimmed: boolean
670
1072
  disabled: boolean
671
1073
  children: React.ReactNode
672
1074
  }
673
1075
 
674
- function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: KanbanLaneProps) {
1076
+ function KanbanLane({
1077
+ stage,
1078
+ count,
1079
+ totalCount,
1080
+ filterFields,
1081
+ laneFilter,
1082
+ onFunnelChange,
1083
+ onQueryChange,
1084
+ isDark,
1085
+ dimmed,
1086
+ disabled,
1087
+ children,
1088
+ }: KanbanLaneProps) {
675
1089
  const { t } = useTranslation()
676
1090
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
677
1091
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
678
1092
  isDark,
679
1093
  })
1094
+ const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim())
1095
+ const queryActive = !!laneFilter?.query?.trim()
1096
+ const laneActive = funnelActive || queryActive
1097
+ const activeFieldLabel =
1098
+ filterFields.find((f) => f.key === laneFilter?.field)?.label ??
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
+
680
1114
  return (
681
1115
  <div
682
1116
  ref={setNodeRef}
683
- 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"
684
1118
  style={{
685
1119
  opacity: dimmed ? 0.45 : 1,
686
1120
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
@@ -690,17 +1124,88 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
690
1124
  data-disabled={disabled || undefined}
691
1125
  >
692
1126
  <div className="flex items-center justify-between gap-2 px-3 py-2.5">
693
- <Badge
694
- variant="outline"
695
- className="border-0 text-xs font-semibold"
696
- style={headerStyle}
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>
1135
+ <span className="text-xs font-medium tabular-nums text-muted-foreground">
1136
+ {laneActive ? `${count}/${totalCount}` : count}
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
+ }`}
697
1146
  >
698
- {t(stage.label, { defaultValue: stage.label })}
699
- </Badge>
700
- <span className="text-xs font-medium tabular-nums text-muted-foreground">
701
- {count}
702
- </span>
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>
1159
+ <LaneFilterButton
1160
+ fields={filterFields}
1161
+ value={funnelValue}
1162
+ onChange={onFunnelChange}
1163
+ />
1164
+ </div>
703
1165
  </div>
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 && (
1192
+ <div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
1193
+ <ListFilter className="h-3 w-3 shrink-0" />
1194
+ <span className="truncate">
1195
+ {activeFieldLabel}: {laneFilter!.value}
1196
+ </span>
1197
+ <button
1198
+ type="button"
1199
+ onClick={() => onFunnelChange(null)}
1200
+ className="ml-auto rounded p-0.5 hover:bg-muted"
1201
+ aria-label={t('kanban.clearFilters', {
1202
+ defaultValue: 'Limpiar',
1203
+ })}
1204
+ >
1205
+ <X className="h-3 w-3" />
1206
+ </button>
1207
+ </div>
1208
+ )}
704
1209
  {/* Plain vertical-scroll column, NOT a Radix ScrollArea: the
705
1210
  ScrollArea viewport wraps its content in a `display:table`
706
1211
  element that shrink-to-fits the WIDEST card, so once the card
@@ -714,6 +1219,141 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
714
1219
  )
715
1220
  }
716
1221
 
1222
+ // LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
1223
+ // ONLY this lane's cards (client-side, in the parent). Draft state lives here so
1224
+ // typing doesn't refilter mid-keystroke; Apply/Enter commits, Limpiar clears.
1225
+ function LaneFilterButton({
1226
+ fields,
1227
+ value,
1228
+ onChange,
1229
+ }: {
1230
+ fields: LaneFilterField[]
1231
+ value: { field: string; value: string } | undefined
1232
+ onChange: (filter: { field: string; value: string } | null) => void
1233
+ }) {
1234
+ const { t } = useTranslation()
1235
+ const [open, setOpen] = useState(false)
1236
+ const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '')
1237
+ const [text, setText] = useState(value?.value ?? '')
1238
+ // Re-seed the draft from the committed filter each time the popover opens.
1239
+ useEffect(() => {
1240
+ if (open) {
1241
+ setField(value?.field ?? fields[0]?.key ?? '')
1242
+ setText(value?.value ?? '')
1243
+ }
1244
+ }, [open, value, fields])
1245
+ if (fields.length === 0) return null
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
1253
+ const apply = () => {
1254
+ if (field && text.trim()) onChange({ field, value: text })
1255
+ else onChange(null)
1256
+ setOpen(false)
1257
+ }
1258
+ return (
1259
+ <Popover open={open} onOpenChange={setOpen}>
1260
+ <PopoverTrigger asChild>
1261
+ <button
1262
+ type="button"
1263
+ className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1264
+ active ? 'text-primary' : 'text-muted-foreground'
1265
+ }`}
1266
+ aria-label={t('kanban.filterLane', {
1267
+ defaultValue: 'Filtrar columna',
1268
+ })}
1269
+ >
1270
+ <ListFilter className="h-3.5 w-3.5" />
1271
+ </button>
1272
+ </PopoverTrigger>
1273
+ <PopoverContent align="end" className="w-56 space-y-2 p-2">
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
+ >
1283
+ <SelectTrigger className="h-8 text-xs">
1284
+ <SelectValue />
1285
+ </SelectTrigger>
1286
+ <SelectContent>
1287
+ {fields.map((f) => (
1288
+ <SelectItem key={f.key} value={f.key} className="text-xs">
1289
+ {f.label}
1290
+ </SelectItem>
1291
+ ))}
1292
+ </SelectContent>
1293
+ </Select>
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
+ )}
1336
+ <div className="flex justify-between gap-2">
1337
+ <Button
1338
+ variant="ghost"
1339
+ size="sm"
1340
+ className="h-7 text-xs"
1341
+ onClick={() => {
1342
+ onChange(null)
1343
+ setOpen(false)
1344
+ }}
1345
+ >
1346
+ {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
1347
+ </Button>
1348
+ <Button size="sm" className="h-7 text-xs" onClick={apply}>
1349
+ {t('kanban.apply', { defaultValue: 'Aplicar' })}
1350
+ </Button>
1351
+ </div>
1352
+ </PopoverContent>
1353
+ </Popover>
1354
+ )
1355
+ }
1356
+
717
1357
  // ---------------------------------------------------------------------------
718
1358
  // Card (draggable)
719
1359
  // ---------------------------------------------------------------------------