@asteby/metacore-runtime-react 23.0.0 → 23.2.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.
@@ -77,9 +77,15 @@ import {
77
77
  Skeleton,
78
78
  } from '@asteby/metacore-ui/primitives'
79
79
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
- import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
80
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
81
81
  import { useApi } from './api-context'
82
82
  import { useDynamicFilters } from './use-dynamic-filters'
83
+ import {
84
+ FilterChipsRow,
85
+ summarizeFilterValues,
86
+ translateOptionLabels,
87
+ } from './filter-chips'
88
+ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
83
89
  import { useMetadataCache } from './metadata-cache'
84
90
  import { ActivityValueRenderer } from './activity-value-renderer'
85
91
  import { DynamicIcon } from './dynamic-icon'
@@ -96,6 +102,10 @@ import type {
96
102
  StageTransition,
97
103
  } from './types'
98
104
 
105
+ // Re-exported for tests + backward-compat: these live in ./filter-chips now
106
+ // (shared with DynamicTable) but were historically imported from here.
107
+ export { summarizeFilterValues, translateOptionLabels }
108
+
99
109
  // ---------------------------------------------------------------------------
100
110
  // Pure helpers (exported for unit tests — no React, no transport)
101
111
  // ---------------------------------------------------------------------------
@@ -204,6 +214,30 @@ export function applyOptimisticMove(
204
214
  return next
205
215
  }
206
216
 
217
+ /**
218
+ * Returns a NEW per-lane pagination map with the server totals adjusted for a
219
+ * card moving `fromStage` → `toStage`: the source loses one, the destination
220
+ * gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
221
+ * are left alone. Pure — backs the optimistic drag so a partial lane's
222
+ * `count/total` header stays truthful, and can be restored on PUT failure.
223
+ */
224
+ export function applyLaneTotalsOnMove<T extends { total: number | null }>(
225
+ pagination: Record<string, T>,
226
+ fromStage: string,
227
+ toStage: string,
228
+ ): Record<string, T> {
229
+ const next = { ...pagination }
230
+ const bump = (key: string, delta: number) => {
231
+ const st = next[key]
232
+ if (st && st.total != null) {
233
+ next[key] = { ...st, total: Math.max(0, st.total + delta) }
234
+ }
235
+ }
236
+ bump(fromStage, -1)
237
+ bump(toStage, +1)
238
+ return next
239
+ }
240
+
207
241
  /**
208
242
  * Picks the columns shown on a card: a `title` column (first searchable column,
209
243
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -233,71 +267,6 @@ export function selectCardColumns(
233
267
  return { title, fields }
234
268
  }
235
269
 
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
270
  /**
302
271
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
303
272
  * equality (IN — the card's field value must be one of them); a free-text
@@ -320,14 +289,16 @@ export function cardMatchesLaneFunnel(
320
289
  }
321
290
 
322
291
  /**
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.
292
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
293
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
294
+ * badge. Pure — exported for unit tests.
326
295
  */
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) }))
296
+ export function laneFunnelCount(
297
+ value: { values?: string[]; text?: string } | undefined,
298
+ ): number {
299
+ if (value?.values?.length) return value.values.length
300
+ if (value?.text?.trim()) return 1
301
+ return 0
331
302
  }
332
303
 
333
304
  /**
@@ -391,6 +362,18 @@ interface LaneFilterState {
391
362
  query?: string
392
363
  }
393
364
 
365
+ /** Incremental pagination bookkeeping for one lane/stage. */
366
+ interface LanePageState {
367
+ /** Next stage-scoped page to request. */
368
+ nextPage: number
369
+ /** Server total for the stage (from response meta), or null if unknown. */
370
+ total: number | null
371
+ /** A top-up request is in flight. */
372
+ loading: boolean
373
+ /** No more pages for this stage. */
374
+ done: boolean
375
+ }
376
+
394
377
  export interface DynamicKanbanProps {
395
378
  /** Model key as registered on the backend (e.g. "issue"). */
396
379
  model: string
@@ -411,10 +394,16 @@ export interface DynamicKanbanProps {
411
394
  */
412
395
  onAction?: (action: string, row: any) => void
413
396
  /**
414
- * Max cards fetched per lane render. Kanban shows all cards at once (no
415
- * pagination UI), so it requests a single large page. Defaults to 200.
397
+ * Size of the INITIAL board page (one request, grouped into lanes). Each
398
+ * lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
399
+ * to 50 — enough to fill the visible lanes without loading the whole board.
416
400
  */
417
401
  pageSize?: number
402
+ /**
403
+ * Page size for a lane's incremental top-up fetch (scoped by
404
+ * `f_<group_by>=<stage>`). Defaults to 25.
405
+ */
406
+ lanePageSize?: number
418
407
  /** IANA timezone for datetime card fields (org config). */
419
408
  timeZone?: string
420
409
  /** ISO 4217 currency for money card fields (org config). */
@@ -432,7 +421,8 @@ export function DynamicKanban({
432
421
  refreshTrigger,
433
422
  onCardClick,
434
423
  onAction,
435
- pageSize = 200,
424
+ pageSize = 50,
425
+ lanePageSize = 25,
436
426
  timeZone,
437
427
  currency,
438
428
  defaultFilters,
@@ -448,6 +438,14 @@ export function DynamicKanban({
448
438
  const [records, setRecords] = useState<any[]>([])
449
439
  const [loading, setLoading] = useState(!cachedMeta)
450
440
  const [loadingData, setLoadingData] = useState(true)
441
+ // Per-stage incremental pagination for infinite scroll. The initial board
442
+ // page (grouped into lanes) is fetched once; each lane then tops up its OWN
443
+ // stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
444
+ // shared `records`. `total` is the stage's server count when the response
445
+ // meta carries it. Reset whenever the filters/search change.
446
+ const [lanePagination, setLanePagination] = useState<
447
+ Record<string, LanePageState>
448
+ >({})
451
449
 
452
450
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
453
451
  const [activeId, setActiveId] = useState<string | null>(null)
@@ -503,7 +501,10 @@ export function DynamicKanban({
503
501
  clearAll,
504
502
  } = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
505
503
 
506
- // ---- records fetch (same path as DynamicTable, single large page) ----
504
+ // ---- initial board page (one request, grouped into lanes) ----
505
+ // Resets the per-lane pagination so every lane restarts its incremental
506
+ // top-up from scratch — called on mount, refresh, and any filter/search
507
+ // change (fetchData's identity changes with filterParams).
507
508
  const fetchData = useCallback(async () => {
508
509
  if (!metadata) return
509
510
  setLoadingData(true)
@@ -517,8 +518,67 @@ export function DynamicKanban({
517
518
  } finally {
518
519
  setLoadingData(false)
519
520
  }
521
+ setLanePagination({})
520
522
  }, [api, endpoint, model, metadata, pageSize, filterParams])
521
523
 
524
+ // Load the next page for ONE lane/stage and append it (deduped by id) into
525
+ // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
526
+ // filterParams (the stage scope wins over any global group_by filter).
527
+ const groupByKey = metadata?.group_by || ''
528
+ const loadMoreLane = useCallback(
529
+ async (stageKey: string) => {
530
+ if (!metadata || !groupByKey) return
531
+ const current = lanePagination[stageKey]
532
+ if (current?.loading || current?.done) return
533
+ const nextPage = current?.nextPage ?? 1
534
+ setLanePagination((p) => ({
535
+ ...p,
536
+ [stageKey]: {
537
+ nextPage,
538
+ total: current?.total ?? null,
539
+ loading: true,
540
+ done: false,
541
+ },
542
+ }))
543
+ try {
544
+ const res = (await api.get(endpoint || `/data/${model}`, {
545
+ params: {
546
+ ...filterParams,
547
+ page: nextPage,
548
+ per_page: lanePageSize,
549
+ [`f_${groupByKey}`]: stageKey,
550
+ },
551
+ })) as { data: ApiResponse<any[]> & { meta?: any } }
552
+ const rows = res.data.success ? res.data.data || [] : []
553
+ const total =
554
+ res.data.meta?.total ?? res.data.meta?.count ?? null
555
+ setRecords((prev) => dedupeById(prev, rows))
556
+ setLanePagination((p) => ({
557
+ ...p,
558
+ [stageKey]: {
559
+ nextPage: nextPage + 1,
560
+ total,
561
+ loading: false,
562
+ // Exhausted when the server returned a short page.
563
+ done: rows.length < lanePageSize,
564
+ },
565
+ }))
566
+ } catch (err) {
567
+ console.error(`Error al cargar más tarjetas de ${stageKey}`, err)
568
+ setLanePagination((p) => ({
569
+ ...p,
570
+ [stageKey]: {
571
+ nextPage,
572
+ total: current?.total ?? null,
573
+ loading: false,
574
+ done: false,
575
+ },
576
+ }))
577
+ }
578
+ },
579
+ [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
580
+ )
581
+
522
582
  // Refetch when metadata resolves, on an explicit refresh, or when the
523
583
  // filters change. `fetchData` is stable while `filterParams` is unchanged
524
584
  // (both memoized), so this only re-runs on real input changes. Debounced so
@@ -620,7 +680,6 @@ export function DynamicKanban({
620
680
  () => (metadata ? deriveStages(metadata) : []),
621
681
  [metadata],
622
682
  )
623
- const groupByKey = metadata?.group_by || ''
624
683
  const transitions = metadata?.transitions
625
684
 
626
685
  const grouped = useMemo(
@@ -705,11 +764,16 @@ export function DynamicKanban({
705
764
 
706
765
  // OPTIMISTIC: move the card in local state immediately.
707
766
  const prevRecords = records
767
+ const prevPagination = lanePagination
708
768
  setRecords((rs) =>
709
769
  rs.map((r) =>
710
770
  String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
711
771
  ),
712
772
  )
773
+ // Keep the server totals consistent with the moved card so a lane's
774
+ // `count/total` header stays truthful with partial lanes: one leaves
775
+ // the source stage, one joins the destination.
776
+ setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage))
713
777
 
714
778
  try {
715
779
  const base = endpoint || `/data/${model}`
@@ -726,6 +790,7 @@ export function DynamicKanban({
726
790
  } catch (err: any) {
727
791
  // REVERT + toast on failure.
728
792
  setRecords(prevRecords)
793
+ setLanePagination(prevPagination)
729
794
  toast.error(
730
795
  t('kanban.moveFailed', {
731
796
  defaultValue: 'No se pudo mover la tarjeta',
@@ -736,7 +801,7 @@ export function DynamicKanban({
736
801
  )
737
802
  }
738
803
  },
739
- [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions],
804
+ [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
740
805
  )
741
806
 
742
807
  if (loading) {
@@ -878,64 +943,13 @@ export function DynamicKanban({
878
943
  )}
879
944
  </div>
880
945
 
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
- )}
946
+ {/* Removable chip row — shared with DynamicTable. Instant feedback
947
+ without opening the Sheet; a chip's X clears that field. */}
948
+ <FilterChipsRow
949
+ fields={activeFields}
950
+ onClearAll={clearAll}
951
+ data-testid="kanban-filter-chips"
952
+ />
939
953
 
940
954
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
941
955
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
@@ -960,12 +974,21 @@ export function DynamicKanban({
960
974
  !activeId ||
961
975
  stage.key === activeStage ||
962
976
  isTransitionAllowed(transitions, activeStage, stage.key)
977
+ // Infinite scroll is per declared stage; the synthetic
978
+ // "unassigned" lane can't be stage-scoped, so it never tops up.
979
+ const laneState = lanePagination[stage.key]
980
+ const isUnassigned = stage.key === UNASSIGNED_LANE
981
+ const laneHasMore = !isUnassigned && !laneState?.done
963
982
  return (
964
983
  <KanbanLane
965
984
  key={stage.key}
966
985
  stage={stage}
967
986
  count={cards.length}
968
987
  totalCount={allCards.length}
988
+ serverTotal={laneState?.total ?? null}
989
+ hasMore={laneHasMore}
990
+ loadingMore={!!laneState?.loading}
991
+ onLoadMore={() => loadMoreLane(stage.key)}
969
992
  filterFields={filterFields}
970
993
  laneFilter={laneFilter}
971
994
  onFunnelChange={(f) =>
@@ -1134,6 +1157,14 @@ interface KanbanLaneProps {
1134
1157
  stage: StageMeta
1135
1158
  count: number
1136
1159
  totalCount: number
1160
+ /** Server-reported total for the stage (from response meta), or null. */
1161
+ serverTotal: number | null
1162
+ /** More server pages available for this stage. */
1163
+ hasMore: boolean
1164
+ /** A top-up request for this stage is in flight. */
1165
+ loadingMore: boolean
1166
+ /** Request the next page for this stage. */
1167
+ onLoadMore: () => void
1137
1168
  filterFields: LaneFilterField[]
1138
1169
  laneFilter: LaneFilterState | undefined
1139
1170
  onFunnelChange: (filter: LaneFunnelValue | null) => void
@@ -1148,6 +1179,10 @@ function KanbanLane({
1148
1179
  stage,
1149
1180
  count,
1150
1181
  totalCount,
1182
+ serverTotal,
1183
+ hasMore,
1184
+ loadingMore,
1185
+ onLoadMore,
1151
1186
  filterFields,
1152
1187
  laneFilter,
1153
1188
  onFunnelChange,
@@ -1159,6 +1194,12 @@ function KanbanLane({
1159
1194
  }: KanbanLaneProps) {
1160
1195
  const { t } = useTranslation()
1161
1196
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
1197
+ // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1198
+ // container; a load in flight or an exhausted stage disables it.
1199
+ const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
1200
+ onLoadMore,
1201
+ disabled: !hasMore || loadingMore,
1202
+ })
1162
1203
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
1163
1204
  isDark,
1164
1205
  })
@@ -1216,21 +1257,21 @@ function KanbanLane({
1216
1257
  {t(stage.label, { defaultValue: stage.label })}
1217
1258
  </Badge>
1218
1259
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
1219
- {laneActive ? `${count}/${totalCount}` : count}
1260
+ {laneActive
1261
+ ? `${count}/${totalCount}`
1262
+ : serverTotal != null
1263
+ ? `${count}/${serverTotal}`
1264
+ : count}
1220
1265
  </span>
1221
1266
  </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
- >
1267
+ {/* Lane actions — always visible in muted (a hidden hover-reveal
1268
+ was undiscoverable); active state is a primary tint + a count
1269
+ badge on the funnel. */}
1270
+ <div className="flex items-center gap-0.5">
1230
1271
  <button
1231
1272
  type="button"
1232
1273
  onClick={() => setSearchOpen((o) => !o)}
1233
- className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1274
+ className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1234
1275
  queryActive ? 'text-primary' : 'text-muted-foreground'
1235
1276
  }`}
1236
1277
  aria-label={t('kanban.searchLane', {
@@ -1238,6 +1279,9 @@ function KanbanLane({
1238
1279
  })}
1239
1280
  >
1240
1281
  <Search className="h-3.5 w-3.5" />
1282
+ {queryActive && (
1283
+ <span className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" />
1284
+ )}
1241
1285
  </button>
1242
1286
  <LaneFilterButton
1243
1287
  fields={filterFields}
@@ -1295,8 +1339,18 @@ function KanbanLane({
1295
1339
  text wraps freely (no line-clamp) the cards grew past the lane
1296
1340
  and spilled out of the stage. A normal `overflow-y-auto` block
1297
1341
  constrains every card to the lane width so text wraps inside it. */}
1298
- <div className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3">
1342
+ <div
1343
+ ref={rootRef}
1344
+ className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3"
1345
+ >
1299
1346
  {children}
1347
+ {loadingMore && (
1348
+ <Skeleton className="h-16 w-full shrink-0" data-testid="lane-loading-more" />
1349
+ )}
1350
+ {/* Sentinel: entering view triggers the next stage page. */}
1351
+ {hasMore && (
1352
+ <div ref={sentinelRef} className="h-1 w-full shrink-0" aria-hidden />
1353
+ )}
1300
1354
  </div>
1301
1355
  </div>
1302
1356
  )
@@ -1332,6 +1386,8 @@ function LaneFilterButton({
1332
1386
  value &&
1333
1387
  ((value.values && value.values.length > 0) || value.text?.trim())
1334
1388
  )
1389
+ // Number of applied criteria on this lane's funnel (drives the count badge).
1390
+ const activeCount = laneFunnelCount(value)
1335
1391
  // The value step mirrors the sheet: when the chosen field is a select or a
1336
1392
  // facet (static options OR a lazy loader), render the SAME pro combobox —
1337
1393
  // multi-select, searchable, with counts. Only a genuinely free-text field
@@ -1357,7 +1413,7 @@ function LaneFilterButton({
1357
1413
  <PopoverTrigger asChild>
1358
1414
  <button
1359
1415
  type="button"
1360
- className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1416
+ className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1361
1417
  active ? 'text-primary' : 'text-muted-foreground'
1362
1418
  }`}
1363
1419
  aria-label={t('kanban.filterLane', {
@@ -1365,6 +1421,11 @@ function LaneFilterButton({
1365
1421
  })}
1366
1422
  >
1367
1423
  <ListFilter className="h-3.5 w-3.5" />
1424
+ {activeCount > 0 && (
1425
+ <span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums">
1426
+ {activeCount}
1427
+ </span>
1428
+ )}
1368
1429
  </button>
1369
1430
  </PopoverTrigger>
1370
1431
  <PopoverContent