@asteby/metacore-runtime-react 23.7.1 → 23.9.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.
@@ -2,14 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
5
- import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, ToggleLeft, Type, X, } from 'lucide-react';
5
+ import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable';
6
+ import { CSS } from '@dnd-kit/utilities';
7
+ import { arrayMove } from '@dnd-kit/sortable';
8
+ import { Calendar, CircleDot, GripVertical, Hash, ListFilter, MoreHorizontal, RotateCcw, Search, Settings2, Tag, ToggleLeft, Type, X, } from 'lucide-react';
6
9
  import { toast } from 'sonner';
7
10
  import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
8
11
  import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
9
12
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
10
13
  import { useApi } from './api-context';
11
14
  import { useStageAutomations, StageAutomationsButton, } from './stage-automations';
12
- import { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, AddStageColumn, CustomStageLaneMenu, CustomStageDialog, CustomStageDeleteDialog, SmartLane, } from './custom-stages';
15
+ import { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, cardMatchesStageFilters, smartLaneParams, AddStageColumn, CustomStageDialog, CustomStageDeleteDialog, StageConfigDialog, SmartLane, } from './custom-stages';
16
+ import { useStageOverrides } from './stage-overrides';
13
17
  import { useDynamicFilters } from './use-dynamic-filters';
14
18
  import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
15
19
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
@@ -20,6 +24,7 @@ import { isColumnVisibleInTable } from './column-visibility';
20
24
  import { isRowActionVisible } from './dynamic-columns';
21
25
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
22
26
  import { useDynamicRowActions } from './dynamic-row-actions';
27
+ import { useStageLayout } from './stage-layout';
23
28
  // Re-exported for tests + backward-compat: these live in ./filter-chips now
24
29
  // (shared with DynamicTable) but were historically imported from here.
25
30
  export { summarizeFilterValues, translateOptionLabels };
@@ -245,10 +250,16 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
245
250
  // the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
246
251
  // and lane menus simply don't render.
247
252
  const customStages = useCustomStages(model);
253
+ // Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
254
+ // to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
255
+ // hides on declared lanes (custom lanes keep it via /custom-stages).
256
+ const stageOverrides = useStageOverrides(model);
248
257
  // Dialog state: create/edit a stage, and the delete confirmation.
249
258
  const [stageDialogOpen, setStageDialogOpen] = useState(false);
250
259
  const [editingStage, setEditingStage] = useState(null);
251
260
  const [deletingStage, setDeletingStage] = useState(null);
261
+ // The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes.
262
+ const [configTarget, setConfigTarget] = useState(null);
252
263
  const openCreateStage = useCallback(() => {
253
264
  setEditingStage(null);
254
265
  setStageDialogOpen(true);
@@ -274,6 +285,18 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
274
285
  const [lanePagination, setLanePagination] = useState({});
275
286
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
276
287
  const [activeId, setActiveId] = useState(null);
288
+ // Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
289
+ // rather than moving a card. Kept apart so onDragEnd routes by draggable type.
290
+ const [activeLaneId, setActiveLaneId] = useState(null);
291
+ // Per-org lane order. `useStageLayout` reports whether the host wired the
292
+ // `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
293
+ // order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of
294
+ // the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
295
+ // board already paints ordered on load — this only backs the live drag + the
296
+ // revert-on-failure). Null → follow the metadata order.
297
+ const stageLayout = useStageLayout(model);
298
+ const [laneOrderOverride, setLaneOrderOverride] = useState(null);
299
+ const laneReorderEnabled = stageLayout.available;
277
300
  // Monotonic token for the current board load. Bumped on every fetchData so a
278
301
  // slow eager-totals response from a superseded filter set can't inject stale
279
302
  // stage totals into the fresh board.
@@ -356,6 +379,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
356
379
  const r = (await api.get(endpoint || `/data/${model}`, {
357
380
  params: {
358
381
  ...filterParams,
382
+ ...smartLaneParams(stage.filters),
359
383
  page: 1,
360
384
  per_page: 1,
361
385
  [`f_${gb}`]: stage.key,
@@ -390,6 +414,24 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
390
414
  // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
391
415
  // filterParams (the stage scope wins over any global group_by filter).
392
416
  const groupByKey = metadata?.group_by || '';
417
+ // Extra per-lane conditions (stage overrides) keyed by stage. A real lane
418
+ // that carries these queries its data — and counts its header — with the
419
+ // stage scope PLUS these filters (serialized like a smart lane's), so the
420
+ // top-up + eager-total requests below layer them on. Sourced from
421
+ // `metadata.stages` (the kernel applies declared + custom-real overrides).
422
+ const stageExtraFilters = useMemo(() => {
423
+ const m = new Map();
424
+ for (const s of metadata?.stages ?? []) {
425
+ if (s.filters && s.filters.length > 0) {
426
+ m.set(s.key, s.filters.map((f) => ({
427
+ field: f.field,
428
+ op: f.op,
429
+ value: f.value,
430
+ })));
431
+ }
432
+ }
433
+ return m;
434
+ }, [metadata?.stages]);
393
435
  const loadMoreLane = useCallback(async (stageKey) => {
394
436
  if (!metadata || !groupByKey)
395
437
  return;
@@ -410,6 +452,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
410
452
  const res = (await api.get(endpoint || `/data/${model}`, {
411
453
  params: {
412
454
  ...filterParams,
455
+ ...smartLaneParams(stageExtraFilters.get(stageKey)),
413
456
  page: nextPage,
414
457
  per_page: lanePageSize,
415
458
  [`f_${groupByKey}`]: stageKey,
@@ -441,7 +484,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
441
484
  },
442
485
  }));
443
486
  }
444
- }, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination]);
487
+ }, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters]);
445
488
  // Refetch when metadata resolves, on an explicit refresh, or when the
446
489
  // filters change. `fetchData` is stable while `filterParams` is unchanged
447
490
  // (both memoized), so this only re-runs on real input changes. Debounced so
@@ -541,6 +584,44 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
541
584
  return Math.max(base, ...positions, 0) + 1;
542
585
  }, [customStages.stages, stages.length, smartStages.length]);
543
586
  const transitions = metadata?.transitions;
587
+ // Unified, ordered list of the DRAGGABLE lanes (real stages + smart lanes) —
588
+ // the sortable sequence the header drag reorders. Real stages and smart lanes
589
+ // are merged by their `order` (the backend stamps a global sequence once a
590
+ // custom order exists); a `laneOrderOverride` from the live drag wins. Ties
591
+ // (the default, un-customized case where both sets start at 0) keep stages
592
+ // before smart lanes, matching the pre-reorder layout.
593
+ const renderLanes = useMemo(() => {
594
+ const arr = [
595
+ ...stages.map((s, i) => ({
596
+ kind: 'stage',
597
+ stage: s,
598
+ order: s.order ?? i,
599
+ })),
600
+ ...smartStages.map((s, i) => ({
601
+ kind: 'smart',
602
+ stage: s,
603
+ order: s.position ?? 1000 + i,
604
+ })),
605
+ ];
606
+ if (laneOrderOverride) {
607
+ const idx = new Map(laneOrderOverride.map((k, i) => [k, i]));
608
+ arr.sort((a, b) => (idx.get(a.stage.key) ?? Number.MAX_SAFE_INTEGER) -
609
+ (idx.get(b.stage.key) ?? Number.MAX_SAFE_INTEGER));
610
+ }
611
+ else {
612
+ arr.sort((a, b) => a.order - b.order ||
613
+ (a.kind === b.kind ? 0 : a.kind === 'stage' ? -1 : 1));
614
+ }
615
+ return arr.map(({ kind, stage: s }) => kind === 'stage'
616
+ ? { kind: 'stage', stage: s }
617
+ : { kind: 'smart', stage: s });
618
+ }, [stages, smartStages, laneOrderOverride]);
619
+ // The sortable ids, in current visual order — SortableContext items + the
620
+ // basis for the arrayMove the drop computes.
621
+ const boardLaneKeys = useMemo(() => renderLanes.map((l) => l.stage.key), [renderLanes]);
622
+ // Real (card-droppable) stage keys — guards a card drop from ever landing on
623
+ // a smart lane (a saved view, never a stored stage value).
624
+ const realStageKeys = useMemo(() => new Set(stages.map((s) => s.key)), [stages]);
544
625
  const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
545
626
  const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
546
627
  // Columns the lane search scans: the card title + its visible field cells.
@@ -576,15 +657,51 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
576
657
  return raw === null || raw === undefined ? '' : String(raw);
577
658
  }, [cardById, groupByKey]);
578
659
  const onDragStart = useCallback((e) => {
579
- setActiveId(String(e.active.id));
660
+ if (e.active.data.current?.type === 'lane') {
661
+ setActiveLaneId(String(e.active.id));
662
+ }
663
+ else {
664
+ setActiveId(String(e.active.id));
665
+ }
580
666
  }, []);
667
+ // Optimistic lane reorder: reorder the columns in local state immediately,
668
+ // PUT the full new order, and revert + toast on failure. Keys mix real
669
+ // stages and smart lanes (the backend applies the order to both).
670
+ const reorderLanes = useCallback(async (activeKey, overKey) => {
671
+ const from = boardLaneKeys.indexOf(activeKey);
672
+ const to = boardLaneKeys.indexOf(overKey);
673
+ if (from === -1 || to === -1 || from === to)
674
+ return;
675
+ const next = arrayMove(boardLaneKeys, from, to);
676
+ const prev = laneOrderOverride;
677
+ setLaneOrderOverride(next);
678
+ try {
679
+ await stageLayout.save(next);
680
+ }
681
+ catch {
682
+ setLaneOrderOverride(prev);
683
+ toast.error(t('dynamic.stage_layout.save_error', {
684
+ defaultValue: 'No se pudo guardar el orden',
685
+ }));
686
+ }
687
+ }, [boardLaneKeys, laneOrderOverride, stageLayout, t]);
581
688
  const onDragEnd = useCallback(async (e) => {
582
689
  setActiveId(null);
690
+ setActiveLaneId(null);
583
691
  const { active, over } = e;
692
+ // A header drag reorders columns rather than moving a card.
693
+ if (active.data.current?.type === 'lane') {
694
+ if (over)
695
+ await reorderLanes(String(active.id), String(over.id));
696
+ return;
697
+ }
584
698
  if (!over)
585
699
  return;
586
700
  const cardId = String(active.id);
587
701
  const destStage = String(over.id);
702
+ // Never drop a card onto a smart lane (a saved view, not a stage).
703
+ if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE)
704
+ return;
588
705
  const srcStage = stageOfCard(cardId);
589
706
  if (srcStage === destStage)
590
707
  return;
@@ -626,7 +743,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
626
743
  ? `: ${err.response.data.message}`
627
744
  : ''));
628
745
  }
629
- }, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
746
+ }, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes]);
747
+ // Board-level "Restablecer orden": drop the stored order and refetch the
748
+ // metadata so the lanes fall back to the DECLARED order. Optimistic — reverts
749
+ // its local override on failure.
750
+ const resetLaneOrder = useCallback(async () => {
751
+ const prev = laneOrderOverride;
752
+ setLaneOrderOverride(null);
753
+ try {
754
+ await stageLayout.reset();
755
+ const res = await api.get(`/metadata/table/${model}`);
756
+ const body = res.data;
757
+ if (body.success) {
758
+ setMetadata(body.data);
759
+ cacheMetadata(model, body.data);
760
+ }
761
+ }
762
+ catch {
763
+ setLaneOrderOverride(prev);
764
+ toast.error(t('dynamic.stage_layout.reset_error', {
765
+ defaultValue: 'No se pudo restablecer el orden',
766
+ }));
767
+ }
768
+ }, [api, cacheMetadata, laneOrderOverride, model, stageLayout, t]);
630
769
  if (loading) {
631
770
  return (_jsx("div", { className: "flex w-full gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "min-w-[280px] max-w-[420px] flex-1 shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
632
771
  }
@@ -637,15 +776,151 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
637
776
  }
638
777
  const activeCard = activeId ? cardById.get(activeId) : null;
639
778
  const activeStage = activeId ? stageOfCard(activeId) : '';
640
- const lanes = [...stages];
641
- if (grouped.has(UNASSIGNED_LANE)) {
642
- lanes.push({
779
+ // The synthetic "Sin etapa" lane (only when some record's stage matches no
780
+ // declared lane). Rendered after the sortable lanes, never draggable itself.
781
+ const unassignedStage = grouped.has(UNASSIGNED_LANE)
782
+ ? {
643
783
  key: UNASSIGNED_LANE,
644
784
  label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
645
785
  color: 'slate',
646
786
  order: Number.MAX_SAFE_INTEGER,
647
- });
648
- }
787
+ }
788
+ : null;
789
+ // Whether a card may drop into a lane given the active card's stage + the
790
+ // declared transitions (a same-stage or unrestricted move always passes).
791
+ const droppableAllowedFor = (stageKey) => !activeId ||
792
+ stageKey === activeStage ||
793
+ isTransitionAllowed(transitions, activeStage, stageKey);
794
+ // Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
795
+ // right backend by kind: a custom real stage edits through /custom-stages, a
796
+ // declared stage through /stage-overrides. Seeds the current label/color and
797
+ // any extra conditions the lane already carries.
798
+ const openConfigStage = (stage) => {
799
+ const custom = customStages.available
800
+ ? customByKey.get(stage.key)
801
+ : undefined;
802
+ const raw = stageExtraFilters.get(stage.key) ??
803
+ custom?.filters ??
804
+ [];
805
+ const filters = raw.map((f) => ({
806
+ field: f.field,
807
+ op: f.op,
808
+ value: f.value,
809
+ }));
810
+ if (custom) {
811
+ setConfigTarget({
812
+ kind: 'custom',
813
+ stageKey: stage.key,
814
+ id: custom.id,
815
+ label: custom.label,
816
+ color: custom.color,
817
+ filters,
818
+ customStage: custom,
819
+ });
820
+ }
821
+ else {
822
+ const original = stage.original
823
+ ? {
824
+ label: stage.original.label,
825
+ color: stage.original.color,
826
+ filters: stage.original.filters?.map((f) => ({
827
+ field: f.field,
828
+ op: f.op,
829
+ value: f.value,
830
+ })),
831
+ }
832
+ : undefined;
833
+ setConfigTarget({
834
+ kind: 'declared',
835
+ stageKey: stage.key,
836
+ label: t(stage.label, { defaultValue: stage.label }),
837
+ color: stage.color ?? 'slate',
838
+ filters,
839
+ overridden: !!stage.overridden,
840
+ isFinal: stage.is_final,
841
+ original,
842
+ });
843
+ }
844
+ };
845
+ // Whether a lane offers the gear: custom real stages always (their CRUD is
846
+ // wired); declared lanes only when the host wired /stage-overrides. Never on
847
+ // the synthetic "Sin etapa" lane.
848
+ const isConfigurable = (stage) => {
849
+ if (stage.key === UNASSIGNED_LANE)
850
+ return false;
851
+ return customByKey.has(stage.key)
852
+ ? customStages.available
853
+ : stageOverrides.available;
854
+ };
855
+ // Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
856
+ // shared by the sortable real stages and the plain-droppable unassigned lane.
857
+ const buildLaneProps = (stage) => {
858
+ const allCards = grouped.get(stage.key) ?? [];
859
+ // Extra lane conditions (stage override): the server already scopes this
860
+ // lane's top-up + total queries by them, but the shared INITIAL board page
861
+ // is unscoped, so narrow those cards client-side too (belt-and-suspenders).
862
+ const extraFilters = stageExtraFilters.get(stage.key) ?? [];
863
+ // Per-lane client-side narrowing (instant, scoped to this stage). The
864
+ // funnel (field/value) and the lane search (query) are AND-combined.
865
+ const laneFilter = laneFilters[stage.key];
866
+ let cards = allCards;
867
+ if (extraFilters.length > 0) {
868
+ cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters));
869
+ }
870
+ if (laneFilter?.field) {
871
+ cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
872
+ }
873
+ if (laneFilter?.query?.trim()) {
874
+ cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
875
+ }
876
+ const laneState = lanePagination[stage.key];
877
+ const isUnassigned = stage.key === UNASSIGNED_LANE;
878
+ const laneHasMore = !isUnassigned && !laneState?.done;
879
+ return {
880
+ stage,
881
+ count: cards.length,
882
+ totalCount: allCards.length,
883
+ serverTotal: laneState?.total ?? null,
884
+ hasMore: laneHasMore,
885
+ loadingMore: !!laneState?.loading,
886
+ onLoadMore: () => loadMoreLane(stage.key),
887
+ filterFields,
888
+ laneFilter,
889
+ onFunnelChange: (f) => updateLaneFilter(stage.key, {
890
+ field: f?.field,
891
+ values: f?.values,
892
+ text: f?.text,
893
+ }),
894
+ onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
895
+ isDark,
896
+ dimmed: !!activeId && !droppableAllowedFor(stage.key),
897
+ model,
898
+ columns: metadata?.columns ?? [],
899
+ automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE,
900
+ automationRules: automations.byStage.get(stage.key) ?? [],
901
+ onAutomationCreate: automations.create,
902
+ onAutomationUpdate: automations.update,
903
+ onAutomationRemove: automations.remove,
904
+ extraFilters,
905
+ configurable: isConfigurable(stage),
906
+ onConfigure: () => openConfigStage(stage),
907
+ children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))),
908
+ };
909
+ };
910
+ // Props for one smart (virtual) lane — a read-only, filter-defined column.
911
+ const buildSmartProps = (smart) => ({
912
+ stage: smart,
913
+ model,
914
+ endpoint,
915
+ defaultFilters,
916
+ pageSize,
917
+ isDark,
918
+ refreshTrigger,
919
+ onEdit: openEditStage,
920
+ onDelete: openDeleteStage,
921
+ renderCard: (card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction, draggable: false })),
922
+ });
923
+ const showResetOrder = laneReorderEnabled && (stageLayout.hasCustomLayout || !!laneOrderOverride);
649
924
  return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
650
925
  defaultValue: 'Con filtros activos',
651
926
  }) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
@@ -655,41 +930,48 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
655
930
  })
656
931
  : t('kanban.noActiveFilters', {
657
932
  defaultValue: 'Sin filtros',
658
- }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsxs("div", { className: "flex w-full min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: [lanes.map((stage) => {
659
- const allCards = grouped.get(stage.key) ?? [];
660
- // Per-lane client-side narrowing (instant, scoped to this
661
- // stage). The funnel (field/value) and the lane search
662
- // (query) are AND-combined.
663
- const laneFilter = laneFilters[stage.key];
664
- let cards = allCards;
665
- if (laneFilter?.field) {
666
- cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
667
- }
668
- if (laneFilter?.query?.trim()) {
669
- cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
670
- }
671
- const droppableAllowed = !activeId ||
672
- stage.key === activeStage ||
673
- isTransitionAllowed(transitions, activeStage, stage.key);
674
- // Infinite scroll is per declared stage; the synthetic
675
- // "unassigned" lane can't be stage-scoped, so it never tops up.
676
- const laneState = lanePagination[stage.key];
677
- const isUnassigned = stage.key === UNASSIGNED_LANE;
678
- const laneHasMore = !isUnassigned && !laneState?.done;
679
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
680
- field: f?.field,
681
- values: f?.values,
682
- text: f?.text,
683
- }), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, model: model, columns: metadata?.columns ?? [], automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE, automationRules: automations.byStage.get(stage.key) ?? [], onAutomationCreate: automations.create, onAutomationUpdate: automations.update, onAutomationRemove: automations.remove, customStage: customStages.available
684
- ? customByKey.get(stage.key)
685
- : undefined, onEditStage: openEditStage, onDeleteStage: openDeleteStage, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
686
- }), smartStages.map((smart) => (_jsx(SmartLane, { stage: smart, model: model, endpoint: endpoint, defaultFilters: defaultFilters, pageSize: pageSize, isDark: isDark, refreshTrigger: refreshTrigger, onEdit: openEditStage, onDelete: openDeleteStage, renderCard: (card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction, draggable: false })) }, `smart-${smart.key}`))), customStages.available && (_jsx(AddStageColumn, { onClick: openCreateStage }))] }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] }), customStages.available && (_jsxs(_Fragment, { children: [_jsx(CustomStageDialog, { open: stageDialogOpen, onOpenChange: setStageDialogOpen, model: model, columns: metadata?.columns ?? [], initial: editingStage, nextPosition: nextStagePosition, onCreate: customStages.create, onUpdate: customStages.update }), _jsx(CustomStageDeleteDialog, { open: !!deletingStage, onOpenChange: (o) => {
933
+ }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] })), showResetOrder && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-8 gap-1.5 text-xs text-muted-foreground", onClick: () => void resetLaneOrder(), "data-testid": "kanban-reset-order", children: [_jsx(RotateCcw, { className: "h-3.5 w-3.5" }), t('dynamic.stage_layout.reset', {
934
+ defaultValue: 'Restablecer orden',
935
+ })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx(SortableContext, { items: boardLaneKeys, strategy: horizontalListSortingStrategy, children: _jsxs("div", { className: "flex w-full min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: [renderLanes.map((lane) => lane.kind === 'stage' ? (_jsx(SortableStageLane, { reorderEnabled: laneReorderEnabled, droppableDisabled: !!activeId && !droppableAllowedFor(lane.stage.key), laneProps: buildLaneProps(lane.stage) }, lane.stage.key)) : laneReorderEnabled ? (_jsx(SortableSmartLane, { droppableDisabled: !!activeId, smartProps: buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`)) : (_jsx(SmartLane, { ...buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`))), unassignedStage && (_jsx(DroppableStageLane, { droppableDisabled: !!activeId && !droppableAllowedFor(UNASSIGNED_LANE), laneProps: buildLaneProps(unassignedStage) }, UNASSIGNED_LANE)), customStages.available && (_jsx(AddStageColumn, { onClick: openCreateStage }))] }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] }), customStages.available && (_jsxs(_Fragment, { children: [_jsx(CustomStageDialog, { open: stageDialogOpen, onOpenChange: setStageDialogOpen, model: model, columns: metadata?.columns ?? [], initial: editingStage, nextPosition: nextStagePosition, onCreate: customStages.create, onUpdate: customStages.update }), _jsx(CustomStageDeleteDialog, { open: !!deletingStage, onOpenChange: (o) => {
687
936
  if (!o)
688
937
  setDeletingStage(null);
689
938
  }, stage: deletingStage, reassignTargets: stages.map((s) => ({
690
939
  key: s.key,
691
940
  label: s.label,
692
- })), onConfirm: (s, reassignTo) => customStages.remove(s.id, reassignTo) })] }))] }));
941
+ })), onConfirm: (s, reassignTo) => customStages.remove(s.id, reassignTo) })] })), _jsx(StageConfigDialog, { open: !!configTarget, onOpenChange: (o) => {
942
+ if (!o)
943
+ setConfigTarget(null);
944
+ }, columns: metadata?.columns ?? [], target: configTarget, onSaveOverride: async (stageKey, patch) => {
945
+ await stageOverrides.save(stageKey, patch);
946
+ // The override changes the served label/color/filters — refetch
947
+ // metadata so the board repaints, and reload the cards.
948
+ try {
949
+ const res = await api.get(`/metadata/table/${model}`);
950
+ const body = res.data;
951
+ if (body.success) {
952
+ setMetadata(body.data);
953
+ cacheMetadata(model, body.data);
954
+ }
955
+ }
956
+ catch {
957
+ /* keep the current metadata on a refetch miss */
958
+ }
959
+ void fetchData();
960
+ }, onResetOverride: async (stageKey) => {
961
+ await stageOverrides.reset(stageKey);
962
+ try {
963
+ const res = await api.get(`/metadata/table/${model}`);
964
+ const body = res.data;
965
+ if (body.success) {
966
+ setMetadata(body.data);
967
+ cacheMetadata(model, body.data);
968
+ }
969
+ }
970
+ catch {
971
+ /* keep the current metadata on a refetch miss */
972
+ }
973
+ void fetchData();
974
+ }, onUpdateCustom: customStages.update, onDeleteCustom: openDeleteStage })] }));
693
975
  }
694
976
  /**
695
977
  * A per-data-type glyph for the Filtros panel rows (and their popover header):
@@ -718,9 +1000,67 @@ function SheetFilterRow({ field, isStage, }) {
718
1000
  const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
719
1001
  return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
720
1002
  }
721
- function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, customStage, onEditStage, onDeleteStage, children, }) {
1003
+ // A real stage lane: sortable (header drag reorders) AND a card drop target.
1004
+ // One `useSortable` provides both roles; `disabled.draggable` follows whether
1005
+ // lane reordering is available, `disabled.droppable` follows the per-card
1006
+ // transition gate.
1007
+ function SortableStageLane({ reorderEnabled, droppableDisabled, laneProps, }) {
1008
+ const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, isOver, } = useSortable({
1009
+ id: laneProps.stage.key,
1010
+ data: { type: 'lane' },
1011
+ disabled: { draggable: !reorderEnabled, droppable: droppableDisabled },
1012
+ });
1013
+ const dnd = {
1014
+ setNodeRef,
1015
+ isOver,
1016
+ isDragging,
1017
+ draggable: reorderEnabled,
1018
+ handleRef: setActivatorNodeRef,
1019
+ handleProps: { ...attributes, ...listeners },
1020
+ style: {
1021
+ transform: CSS.Translate.toString(transform),
1022
+ transition,
1023
+ zIndex: isDragging ? 30 : undefined,
1024
+ position: isDragging ? 'relative' : undefined,
1025
+ },
1026
+ };
1027
+ return _jsx(KanbanLane, { ...laneProps, dnd: dnd });
1028
+ }
1029
+ // The synthetic "Sin etapa" lane: a card drop target, never draggable.
1030
+ function DroppableStageLane({ droppableDisabled, laneProps, }) {
1031
+ const { setNodeRef, isOver } = useDroppable({
1032
+ id: laneProps.stage.key,
1033
+ disabled: droppableDisabled,
1034
+ });
1035
+ return (_jsx(KanbanLane, { ...laneProps, dnd: { setNodeRef, isOver, draggable: false } }));
1036
+ }
1037
+ // A smart lane: sortable (header drag reorders) but NOT a card drop target — its
1038
+ // droppable is disabled while a card is dragged, enabled while a lane is dragged
1039
+ // (so a stage/smart lane can be reordered relative to it).
1040
+ function SortableSmartLane({ droppableDisabled, smartProps, }) {
1041
+ const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, } = useSortable({
1042
+ id: smartProps.stage.key,
1043
+ data: { type: 'lane' },
1044
+ disabled: { draggable: false, droppable: droppableDisabled },
1045
+ });
1046
+ const dnd = {
1047
+ setNodeRef,
1048
+ isOver: false,
1049
+ isDragging,
1050
+ draggable: true,
1051
+ handleRef: setActivatorNodeRef,
1052
+ handleProps: { ...attributes, ...listeners },
1053
+ style: {
1054
+ transform: CSS.Translate.toString(transform),
1055
+ transition,
1056
+ zIndex: isDragging ? 30 : undefined,
1057
+ position: isDragging ? 'relative' : undefined,
1058
+ },
1059
+ };
1060
+ return _jsx(SmartLane, { ...smartProps, dnd: dnd });
1061
+ }
1062
+ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, dnd, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, extraFilters, configurable, onConfigure, children, }) {
722
1063
  const { t } = useTranslation();
723
- const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
724
1064
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
725
1065
  // container; a load in flight or an exhausted stage disables it.
726
1066
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
@@ -758,18 +1098,44 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
758
1098
  text: laneFilter.text,
759
1099
  }
760
1100
  : undefined;
761
- return (_jsxs("div", { ref: setNodeRef,
1101
+ // Stage-override conditions active on this lane → a small filter dot in the
1102
+ // header, its `title` listing the conditions (e.g. "priority es igual high").
1103
+ const hasConditions = extraFilters.length > 0;
1104
+ const conditionsSummary = extraFilters
1105
+ .map((f) => {
1106
+ const opLabel = t(`dynamic.custom_stages.op.${f.op}`, {
1107
+ defaultValue: f.op === 'eq'
1108
+ ? 'es igual'
1109
+ : f.op === 'neq'
1110
+ ? 'distinto'
1111
+ : f.op === 'contains'
1112
+ ? 'contiene'
1113
+ : 'en lista',
1114
+ });
1115
+ return `${f.field} ${opLabel} ${f.value}`;
1116
+ })
1117
+ .join(' · ');
1118
+ return (_jsxs("div", { ref: dnd.setNodeRef,
762
1119
  // Fluid width: lanes grow (flex-1) to fill the board when they all
763
1120
  // fit, capped at max-w so a couple of lanes don't stretch absurdly
764
1121
  // wide. Below min-w the board's overflow-x-auto takes over and the
765
1122
  // lanes scroll horizontally at their minimum width.
766
1123
  className: "group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
767
- opacity: dimmed ? 0.45 : 1,
768
- outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
1124
+ opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
1125
+ outline: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
769
1126
  outlineOffset: 2,
770
- }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
1127
+ ...dnd.style,
1128
+ }, "data-stage": stage.key, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { ref: dnd.draggable ? dnd.handleRef : undefined, ...(dnd.draggable ? dnd.handleProps : {}), className: `flex min-w-0 items-center gap-1.5 ${dnd.draggable ? 'cursor-grab active:cursor-grabbing' : ''}`, children: [dnd.draggable && (_jsx(GripVertical, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70", "aria-hidden": true })), _jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) }), hasConditions && (_jsx("span", { className: "flex items-center text-primary", title: t('dynamic.stage_config.conditions_active', {
1129
+ defaultValue: 'Condiciones: {{list}}',
1130
+ list: conditionsSummary,
1131
+ }), "data-testid": `lane-conditions-${stage.key}`, "aria-label": t('dynamic.stage_config.conditions_active', {
1132
+ defaultValue: 'Condiciones: {{list}}',
1133
+ list: conditionsSummary,
1134
+ }), children: _jsx(ListFilter, { className: "h-3 w-3" }) }))] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
771
1135
  defaultValue: 'Buscar en la columna',
772
- }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange }), automationsAvailable && (_jsx(StageAutomationsButton, { model: model, stageKey: stage.key, stageLabel: stage.label, columns: columns, rules: automationRules, onCreate: onAutomationCreate, onUpdate: onAutomationUpdate, onRemove: onAutomationRemove })), customStage && onEditStage && onDeleteStage && (_jsx(CustomStageLaneMenu, { stage: customStage, onEdit: onEditStage, onDelete: onDeleteStage }))] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
1136
+ }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange }), automationsAvailable && (_jsx(StageAutomationsButton, { model: model, stageKey: stage.key, stageLabel: stage.label, columns: columns, rules: automationRules, onCreate: onAutomationCreate, onUpdate: onAutomationUpdate, onRemove: onAutomationRemove })), configurable && (_jsx("button", { type: "button", onClick: onConfigure, className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${hasConditions ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('dynamic.stage_config.open', {
1137
+ defaultValue: 'Configurar etapa',
1138
+ }), "data-testid": `lane-config-${stage.key}`, children: _jsx(Settings2, { className: "h-3.5 w-3.5" }) }))] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
773
1139
  if (e.key === 'Escape') {
774
1140
  onQueryChange('');
775
1141
  setSearchOpen(false);
@@ -844,6 +1210,7 @@ function LaneFilterButton({ fields, value, onChange, }) {
844
1210
  function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, draggable = true, }) {
845
1211
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
846
1212
  id: String(card.id),
1213
+ data: { type: 'card' },
847
1214
  disabled: !draggable,
848
1215
  });
849
1216
  const visibleActions = actions.filter((a) => isRowActionVisible(a, card));
package/dist/index.d.ts CHANGED
@@ -3,7 +3,9 @@ export * from './options-context';
3
3
  export * from './dynamic-table';
4
4
  export { DynamicKanban, type DynamicKanbanProps, deriveStages, groupByStage, isTransitionAllowed, applyOptimisticMove, selectCardColumns, UNASSIGNED_LANE, } from './dynamic-kanban';
5
5
  export { useStageAutomations, StageAutomationsButton, isTagColumn, automationFieldOptions, groupAutomationsByStage, activeAutomationCount, type StageAutomation, type StageAutomationAction, type StageAutomationActionType, type NewStageAutomation, type UseStageAutomationsResult, type StageAutomationsButtonProps, } from './stage-automations';
6
- export { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, smartLaneParams, customStageFilterFields, isCustomStageDraftValid, slugifyStageKey, emptyCustomStageFilter, AddStageColumn, CustomStageLaneMenu, CustomStageDialog, CustomStageDeleteDialog, SmartLane, CUSTOM_STAGE_COLORS, CUSTOM_STAGE_FILTER_OPS, type CustomStage, type NewCustomStage, type CustomStageType, type CustomStageFilter, type CustomStageFilterOp, type UseCustomStagesResult, } from './custom-stages';
6
+ export { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, smartLaneParams, cardMatchesStageFilters, customStageFilterFields, isCustomStageDraftValid, slugifyStageKey, emptyCustomStageFilter, AddStageColumn, CustomStageLaneMenu, CustomStageDialog, CustomStageDeleteDialog, StageConditionBuilder, StageConfigDialog, stageFilterOpSymbol, SmartLane, CUSTOM_STAGE_COLORS, CUSTOM_STAGE_FILTER_OPS, type CustomStage, type NewCustomStage, type CustomStageType, type CustomStageFilter, type CustomStageFilterOp, type UseCustomStagesResult, type StageConfigTarget, type StageConfigKind, type StageConditionBuilderProps, type StageConfigDialogProps, } from './custom-stages';
7
+ export { useStageLayout, type StageLayout, type UseStageLayoutResult, } from './stage-layout';
8
+ export { useStageOverrides, type StageOverridePatch, type UseStageOverridesResult, } from './stage-overrides';
7
9
  export { DynamicView, resolveViewRenderer, readViewFromSearch, resolveActiveView, type DynamicViewProps, } from './dynamic-view';
8
10
  export { useDynamicFilters, type UseDynamicFiltersOptions, type UseDynamicFiltersResult, } from './use-dynamic-filters';
9
11
  export * from './dynamic-form';