@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.
- package/CHANGELOG.md +79 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +35 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +279 -36
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +99 -5
- package/dist/use-facet-loaders.d.ts +38 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +108 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +197 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +243 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +607 -100
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +125 -5
- package/src/use-facet-loaders.ts +146 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -38,7 +38,18 @@ import {
|
|
|
38
38
|
type DragStartEvent,
|
|
39
39
|
type DragEndEvent,
|
|
40
40
|
} from '@dnd-kit/core'
|
|
41
|
-
import {
|
|
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
|
-
|
|
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,
|
|
598
|
+
Record<string, LaneFilterState>
|
|
417
599
|
>({})
|
|
418
|
-
const
|
|
419
|
-
(stageKey: string,
|
|
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
|
-
|
|
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-
|
|
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
|
-
{/*
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
669
|
-
//
|
|
670
|
-
//
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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:
|
|
759
|
-
laneFilter:
|
|
760
|
-
|
|
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
|
-
|
|
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
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
laneFilter
|
|
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-
|
|
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
|
-
<
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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={
|
|
815
|
-
onChange={
|
|
1244
|
+
value={funnelValue}
|
|
1245
|
+
onChange={onFunnelChange}
|
|
816
1246
|
/>
|
|
817
1247
|
</div>
|
|
818
1248
|
</div>
|
|
819
|
-
{
|
|
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}: {
|
|
1278
|
+
{activeFieldLabel}: {funnelSummary}
|
|
824
1279
|
</span>
|
|
825
1280
|
<button
|
|
826
1281
|
type="button"
|
|
827
|
-
onClick={() =>
|
|
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:
|
|
859
|
-
value:
|
|
860
|
-
onChange: (filter:
|
|
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 [
|
|
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
|
-
|
|
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 = !!(
|
|
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 &&
|
|
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={`
|
|
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
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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="
|
|
1423
|
+
variant="outline"
|
|
921
1424
|
size="sm"
|
|
922
|
-
className="h-7 text-xs"
|
|
923
|
-
onClick={
|
|
924
|
-
|
|
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
|
|
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>
|