@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.
- package/CHANGELOG.md +59 -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 +16 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +265 -11
- 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 +75 -5
- package/dist/use-facet-loaders.d.ts +22 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +67 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +106 -5
- package/src/__tests__/use-dynamic-filters.test.tsx +182 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +676 -36
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +100 -5
- package/src/use-facet-loaders.ts +79 -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,
|
|
@@ -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.
|
|
568
|
-
<
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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-
|
|
868
|
+
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
586
869
|
onClick={clearAll}
|
|
587
870
|
>
|
|
588
|
-
|
|
589
|
-
{t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
|
|
590
|
-
{` (${activeFilterCount})`}
|
|
871
|
+
{t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
591
872
|
</Button>
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
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({
|
|
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-
|
|
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
|
-
<
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|