@asteby/metacore-runtime-react 23.7.1 → 23.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,12 +38,21 @@ import {
38
38
  type DragStartEvent,
39
39
  type DragEndEvent,
40
40
  } from '@dnd-kit/core'
41
+ import {
42
+ SortableContext,
43
+ horizontalListSortingStrategy,
44
+ useSortable,
45
+ } from '@dnd-kit/sortable'
46
+ import { CSS } from '@dnd-kit/utilities'
47
+ import { arrayMove } from '@dnd-kit/sortable'
41
48
  import {
42
49
  Calendar,
43
50
  CircleDot,
51
+ GripVertical,
44
52
  Hash,
45
53
  ListFilter,
46
54
  MoreHorizontal,
55
+ RotateCcw,
47
56
  Search,
48
57
  Tag,
49
58
  ToggleLeft,
@@ -111,6 +120,7 @@ import { isColumnVisibleInTable } from './column-visibility'
111
120
  import { isRowActionVisible } from './dynamic-columns'
112
121
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
113
122
  import { useDynamicRowActions } from './dynamic-row-actions'
123
+ import { useStageLayout } from './stage-layout'
114
124
  import type {
115
125
  TableMetadata,
116
126
  ColumnDefinition,
@@ -513,6 +523,19 @@ export function DynamicKanban({
513
523
 
514
524
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
515
525
  const [activeId, setActiveId] = useState<string | null>(null)
526
+ // Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
527
+ // rather than moving a card. Kept apart so onDragEnd routes by draggable type.
528
+ const [activeLaneId, setActiveLaneId] = useState<string | null>(null)
529
+
530
+ // Per-org lane order. `useStageLayout` reports whether the host wired the
531
+ // `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
532
+ // order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of
533
+ // the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
534
+ // board already paints ordered on load — this only backs the live drag + the
535
+ // revert-on-failure). Null → follow the metadata order.
536
+ const stageLayout = useStageLayout(model)
537
+ const [laneOrderOverride, setLaneOrderOverride] = useState<string[] | null>(null)
538
+ const laneReorderEnabled = stageLayout.available
516
539
 
517
540
  // Monotonic token for the current board load. Bumped on every fetchData so a
518
541
  // slow eager-totals response from a superseded filter set can't inject stale
@@ -819,6 +842,68 @@ export function DynamicKanban({
819
842
  }, [customStages.stages, stages.length, smartStages.length])
820
843
  const transitions = metadata?.transitions
821
844
 
845
+ // Unified, ordered list of the DRAGGABLE lanes (real stages + smart lanes) —
846
+ // the sortable sequence the header drag reorders. Real stages and smart lanes
847
+ // are merged by their `order` (the backend stamps a global sequence once a
848
+ // custom order exists); a `laneOrderOverride` from the live drag wins. Ties
849
+ // (the default, un-customized case where both sets start at 0) keep stages
850
+ // before smart lanes, matching the pre-reorder layout.
851
+ const renderLanes = useMemo<
852
+ Array<
853
+ | { kind: 'stage'; stage: StageMeta }
854
+ | { kind: 'smart'; stage: CustomStage }
855
+ >
856
+ >(() => {
857
+ const arr: Array<{
858
+ kind: 'stage' | 'smart'
859
+ stage: any
860
+ order: number
861
+ }> = [
862
+ ...stages.map((s, i) => ({
863
+ kind: 'stage' as const,
864
+ stage: s,
865
+ order: s.order ?? i,
866
+ })),
867
+ ...smartStages.map((s, i) => ({
868
+ kind: 'smart' as const,
869
+ stage: s,
870
+ order: s.position ?? 1000 + i,
871
+ })),
872
+ ]
873
+ if (laneOrderOverride) {
874
+ const idx = new Map(laneOrderOverride.map((k, i) => [k, i]))
875
+ arr.sort(
876
+ (a, b) =>
877
+ (idx.get(a.stage.key) ?? Number.MAX_SAFE_INTEGER) -
878
+ (idx.get(b.stage.key) ?? Number.MAX_SAFE_INTEGER),
879
+ )
880
+ } else {
881
+ arr.sort(
882
+ (a, b) =>
883
+ a.order - b.order ||
884
+ (a.kind === b.kind ? 0 : a.kind === 'stage' ? -1 : 1),
885
+ )
886
+ }
887
+ return arr.map(({ kind, stage: s }) =>
888
+ kind === 'stage'
889
+ ? { kind: 'stage' as const, stage: s as StageMeta }
890
+ : { kind: 'smart' as const, stage: s as CustomStage },
891
+ )
892
+ }, [stages, smartStages, laneOrderOverride])
893
+
894
+ // The sortable ids, in current visual order — SortableContext items + the
895
+ // basis for the arrayMove the drop computes.
896
+ const boardLaneKeys = useMemo(
897
+ () => renderLanes.map((l) => l.stage.key),
898
+ [renderLanes],
899
+ )
900
+ // Real (card-droppable) stage keys — guards a card drop from ever landing on
901
+ // a smart lane (a saved view, never a stored stage value).
902
+ const realStageKeys = useMemo(
903
+ () => new Set(stages.map((s) => s.key)),
904
+ [stages],
905
+ )
906
+
822
907
  const grouped = useMemo(
823
908
  () => groupByStage(records, groupByKey, stages),
824
909
  [records, groupByKey, stages],
@@ -878,16 +963,53 @@ export function DynamicKanban({
878
963
  )
879
964
 
880
965
  const onDragStart = useCallback((e: DragStartEvent) => {
881
- setActiveId(String(e.active.id))
966
+ if (e.active.data.current?.type === 'lane') {
967
+ setActiveLaneId(String(e.active.id))
968
+ } else {
969
+ setActiveId(String(e.active.id))
970
+ }
882
971
  }, [])
883
972
 
973
+ // Optimistic lane reorder: reorder the columns in local state immediately,
974
+ // PUT the full new order, and revert + toast on failure. Keys mix real
975
+ // stages and smart lanes (the backend applies the order to both).
976
+ const reorderLanes = useCallback(
977
+ async (activeKey: string, overKey: string) => {
978
+ const from = boardLaneKeys.indexOf(activeKey)
979
+ const to = boardLaneKeys.indexOf(overKey)
980
+ if (from === -1 || to === -1 || from === to) return
981
+ const next = arrayMove(boardLaneKeys, from, to)
982
+ const prev = laneOrderOverride
983
+ setLaneOrderOverride(next)
984
+ try {
985
+ await stageLayout.save(next)
986
+ } catch {
987
+ setLaneOrderOverride(prev)
988
+ toast.error(
989
+ t('dynamic.stage_layout.save_error', {
990
+ defaultValue: 'No se pudo guardar el orden',
991
+ }),
992
+ )
993
+ }
994
+ },
995
+ [boardLaneKeys, laneOrderOverride, stageLayout, t],
996
+ )
997
+
884
998
  const onDragEnd = useCallback(
885
999
  async (e: DragEndEvent) => {
886
1000
  setActiveId(null)
1001
+ setActiveLaneId(null)
887
1002
  const { active, over } = e
1003
+ // A header drag reorders columns rather than moving a card.
1004
+ if (active.data.current?.type === 'lane') {
1005
+ if (over) await reorderLanes(String(active.id), String(over.id))
1006
+ return
1007
+ }
888
1008
  if (!over) return
889
1009
  const cardId = String(active.id)
890
1010
  const destStage = String(over.id)
1011
+ // Never drop a card onto a smart lane (a saved view, not a stage).
1012
+ if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE) return
891
1013
  const srcStage = stageOfCard(cardId)
892
1014
  if (srcStage === destStage) return
893
1015
  if (!isTransitionAllowed(transitions, srcStage, destStage)) {
@@ -938,9 +1060,33 @@ export function DynamicKanban({
938
1060
  )
939
1061
  }
940
1062
  },
941
- [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
1063
+ [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes],
942
1064
  )
943
1065
 
1066
+ // Board-level "Restablecer orden": drop the stored order and refetch the
1067
+ // metadata so the lanes fall back to the DECLARED order. Optimistic — reverts
1068
+ // its local override on failure.
1069
+ const resetLaneOrder = useCallback(async () => {
1070
+ const prev = laneOrderOverride
1071
+ setLaneOrderOverride(null)
1072
+ try {
1073
+ await stageLayout.reset()
1074
+ const res = await api.get(`/metadata/table/${model}`)
1075
+ const body = res.data as ApiResponse<TableMetadata>
1076
+ if (body.success) {
1077
+ setMetadata(body.data)
1078
+ cacheMetadata(model, body.data)
1079
+ }
1080
+ } catch {
1081
+ setLaneOrderOverride(prev)
1082
+ toast.error(
1083
+ t('dynamic.stage_layout.reset_error', {
1084
+ defaultValue: 'No se pudo restablecer el orden',
1085
+ }),
1086
+ )
1087
+ }
1088
+ }, [api, cacheMetadata, laneOrderOverride, model, stageLayout, t])
1089
+
944
1090
  if (loading) {
945
1091
  return (
946
1092
  <div className="flex w-full gap-4 overflow-x-auto p-1">
@@ -969,16 +1115,136 @@ export function DynamicKanban({
969
1115
  const activeCard = activeId ? cardById.get(activeId) : null
970
1116
  const activeStage = activeId ? stageOfCard(activeId) : ''
971
1117
 
972
- const lanes: StageMeta[] = [...stages]
973
- if (grouped.has(UNASSIGNED_LANE)) {
974
- lanes.push({
975
- key: UNASSIGNED_LANE,
976
- label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
977
- color: 'slate',
978
- order: Number.MAX_SAFE_INTEGER,
979
- })
1118
+ // The synthetic "Sin etapa" lane (only when some record's stage matches no
1119
+ // declared lane). Rendered after the sortable lanes, never draggable itself.
1120
+ const unassignedStage: StageMeta | null = grouped.has(UNASSIGNED_LANE)
1121
+ ? {
1122
+ key: UNASSIGNED_LANE,
1123
+ label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
1124
+ color: 'slate',
1125
+ order: Number.MAX_SAFE_INTEGER,
1126
+ }
1127
+ : null
1128
+
1129
+ // Whether a card may drop into a lane given the active card's stage + the
1130
+ // declared transitions (a same-stage or unrestricted move always passes).
1131
+ const droppableAllowedFor = (stageKey: string) =>
1132
+ !activeId ||
1133
+ stageKey === activeStage ||
1134
+ isTransitionAllowed(transitions, activeStage, stageKey)
1135
+
1136
+ // Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
1137
+ // shared by the sortable real stages and the plain-droppable unassigned lane.
1138
+ const buildLaneProps = (stage: StageMeta): Omit<KanbanLaneProps, 'dnd'> => {
1139
+ const allCards = grouped.get(stage.key) ?? []
1140
+ // Per-lane client-side narrowing (instant, scoped to this stage). The
1141
+ // funnel (field/value) and the lane search (query) are AND-combined.
1142
+ const laneFilter = laneFilters[stage.key]
1143
+ let cards = allCards
1144
+ if (laneFilter?.field) {
1145
+ cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter))
1146
+ }
1147
+ if (laneFilter?.query?.trim()) {
1148
+ cards = cards.filter((c) =>
1149
+ cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
1150
+ )
1151
+ }
1152
+ const laneState = lanePagination[stage.key]
1153
+ const isUnassigned = stage.key === UNASSIGNED_LANE
1154
+ const laneHasMore = !isUnassigned && !laneState?.done
1155
+ return {
1156
+ stage,
1157
+ count: cards.length,
1158
+ totalCount: allCards.length,
1159
+ serverTotal: laneState?.total ?? null,
1160
+ hasMore: laneHasMore,
1161
+ loadingMore: !!laneState?.loading,
1162
+ onLoadMore: () => loadMoreLane(stage.key),
1163
+ filterFields,
1164
+ laneFilter,
1165
+ onFunnelChange: (f) =>
1166
+ updateLaneFilter(stage.key, {
1167
+ field: f?.field,
1168
+ values: f?.values,
1169
+ text: f?.text,
1170
+ }),
1171
+ onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
1172
+ isDark,
1173
+ dimmed: !!activeId && !droppableAllowedFor(stage.key),
1174
+ model,
1175
+ columns: metadata?.columns ?? [],
1176
+ automationsAvailable:
1177
+ automations.available && stage.key !== UNASSIGNED_LANE,
1178
+ automationRules: automations.byStage.get(stage.key) ?? [],
1179
+ onAutomationCreate: automations.create,
1180
+ onAutomationUpdate: automations.update,
1181
+ onAutomationRemove: automations.remove,
1182
+ customStage: customStages.available
1183
+ ? customByKey.get(stage.key)
1184
+ : undefined,
1185
+ onEditStage: openEditStage,
1186
+ onDeleteStage: openDeleteStage,
1187
+ children:
1188
+ loadingData && cards.length === 0 ? (
1189
+ <>
1190
+ <Skeleton className="h-20 w-full" />
1191
+ <Skeleton className="h-20 w-full" />
1192
+ </>
1193
+ ) : cards.length === 0 ? (
1194
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1195
+ {t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' })}
1196
+ </p>
1197
+ ) : (
1198
+ cards.map((card) => (
1199
+ <KanbanCard
1200
+ key={String(card.id)}
1201
+ card={card}
1202
+ titleCol={titleCol}
1203
+ fieldCols={fieldCols}
1204
+ actions={rowActions}
1205
+ locale={i18n.language}
1206
+ timeZone={timeZone}
1207
+ currency={currency}
1208
+ onClick={onCardClick}
1209
+ onAction={handleInternalAction}
1210
+ />
1211
+ ))
1212
+ ),
1213
+ }
980
1214
  }
981
1215
 
1216
+ // Props for one smart (virtual) lane — a read-only, filter-defined column.
1217
+ const buildSmartProps = (
1218
+ smart: CustomStage,
1219
+ ): React.ComponentProps<typeof SmartLane> => ({
1220
+ stage: smart,
1221
+ model,
1222
+ endpoint,
1223
+ defaultFilters,
1224
+ pageSize,
1225
+ isDark,
1226
+ refreshTrigger,
1227
+ onEdit: openEditStage,
1228
+ onDelete: openDeleteStage,
1229
+ renderCard: (card: any) => (
1230
+ <KanbanCard
1231
+ card={card}
1232
+ titleCol={titleCol}
1233
+ fieldCols={fieldCols}
1234
+ actions={rowActions}
1235
+ locale={i18n.language}
1236
+ timeZone={timeZone}
1237
+ currency={currency}
1238
+ onClick={onCardClick}
1239
+ onAction={handleInternalAction}
1240
+ draggable={false}
1241
+ />
1242
+ ),
1243
+ })
1244
+
1245
+ const showResetOrder =
1246
+ laneReorderEnabled && (stageLayout.hasCustomLayout || !!laneOrderOverride)
1247
+
982
1248
  return (
983
1249
  <div className="flex flex-col gap-3">
984
1250
  {/* Filter bar — global search + one chip per filterable field, the
@@ -1078,6 +1344,20 @@ export function DynamicKanban({
1078
1344
  </SheetContent>
1079
1345
  </Sheet>
1080
1346
  )}
1347
+ {showResetOrder && (
1348
+ <Button
1349
+ variant="ghost"
1350
+ size="sm"
1351
+ className="h-8 gap-1.5 text-xs text-muted-foreground"
1352
+ onClick={() => void resetLaneOrder()}
1353
+ data-testid="kanban-reset-order"
1354
+ >
1355
+ <RotateCcw className="h-3.5 w-3.5" />
1356
+ {t('dynamic.stage_layout.reset', {
1357
+ defaultValue: 'Restablecer orden',
1358
+ })}
1359
+ </Button>
1360
+ )}
1081
1361
  </div>
1082
1362
 
1083
1363
  {/* Removable chip row — shared with DynamicTable. Instant feedback
@@ -1089,140 +1369,53 @@ export function DynamicKanban({
1089
1369
  />
1090
1370
 
1091
1371
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
1372
+ {/* Horizontal SortableContext over the draggable lanes (real
1373
+ stages + smart lanes). Card drags don't touch it — their
1374
+ active id isn't a sortable item, so the lanes never shift when
1375
+ moving a card; a `data.type` tag routes drop handling. */}
1376
+ <SortableContext items={boardLaneKeys} strategy={horizontalListSortingStrategy}>
1092
1377
  <div className="flex w-full min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
1093
- {lanes.map((stage) => {
1094
- const allCards = grouped.get(stage.key) ?? []
1095
- // Per-lane client-side narrowing (instant, scoped to this
1096
- // stage). The funnel (field/value) and the lane search
1097
- // (query) are AND-combined.
1098
- const laneFilter = laneFilters[stage.key]
1099
- let cards = allCards
1100
- if (laneFilter?.field) {
1101
- cards = cards.filter((c) =>
1102
- cardMatchesLaneFunnel(c, laneFilter),
1103
- )
1104
- }
1105
- if (laneFilter?.query?.trim()) {
1106
- cards = cards.filter((c) =>
1107
- cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
1108
- )
1109
- }
1110
- const droppableAllowed =
1111
- !activeId ||
1112
- stage.key === activeStage ||
1113
- isTransitionAllowed(transitions, activeStage, stage.key)
1114
- // Infinite scroll is per declared stage; the synthetic
1115
- // "unassigned" lane can't be stage-scoped, so it never tops up.
1116
- const laneState = lanePagination[stage.key]
1117
- const isUnassigned = stage.key === UNASSIGNED_LANE
1118
- const laneHasMore = !isUnassigned && !laneState?.done
1119
- return (
1120
- <KanbanLane
1121
- key={stage.key}
1122
- stage={stage}
1123
- count={cards.length}
1124
- totalCount={allCards.length}
1125
- serverTotal={laneState?.total ?? null}
1126
- hasMore={laneHasMore}
1127
- loadingMore={!!laneState?.loading}
1128
- onLoadMore={() => loadMoreLane(stage.key)}
1129
- filterFields={filterFields}
1130
- laneFilter={laneFilter}
1131
- onFunnelChange={(f) =>
1132
- updateLaneFilter(stage.key, {
1133
- field: f?.field,
1134
- values: f?.values,
1135
- text: f?.text,
1136
- })
1137
- }
1138
- onQueryChange={(q) =>
1139
- updateLaneFilter(stage.key, { query: q })
1140
- }
1141
- isDark={isDark}
1142
- dimmed={!!activeId && !droppableAllowed}
1143
- disabled={!!activeId && !droppableAllowed}
1144
- model={model}
1145
- columns={metadata?.columns ?? []}
1146
- automationsAvailable={
1147
- automations.available && stage.key !== UNASSIGNED_LANE
1378
+ {renderLanes.map((lane) =>
1379
+ lane.kind === 'stage' ? (
1380
+ <SortableStageLane
1381
+ key={lane.stage.key}
1382
+ reorderEnabled={laneReorderEnabled}
1383
+ droppableDisabled={
1384
+ !!activeId && !droppableAllowedFor(lane.stage.key)
1148
1385
  }
1149
- automationRules={automations.byStage.get(stage.key) ?? []}
1150
- onAutomationCreate={automations.create}
1151
- onAutomationUpdate={automations.update}
1152
- onAutomationRemove={automations.remove}
1153
- customStage={
1154
- customStages.available
1155
- ? customByKey.get(stage.key)
1156
- : undefined
1157
- }
1158
- onEditStage={openEditStage}
1159
- onDeleteStage={openDeleteStage}
1160
- >
1161
- {loadingData && cards.length === 0 ? (
1162
- <>
1163
- <Skeleton className="h-20 w-full" />
1164
- <Skeleton className="h-20 w-full" />
1165
- </>
1166
- ) : cards.length === 0 ? (
1167
- <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1168
- {t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' })}
1169
- </p>
1170
- ) : (
1171
- cards.map((card) => (
1172
- <KanbanCard
1173
- key={String(card.id)}
1174
- card={card}
1175
- titleCol={titleCol}
1176
- fieldCols={fieldCols}
1177
- actions={rowActions}
1178
- locale={i18n.language}
1179
- timeZone={timeZone}
1180
- currency={currency}
1181
- onClick={onCardClick}
1182
- onAction={handleInternalAction}
1183
- />
1184
- ))
1185
- )}
1186
- </KanbanLane>
1187
- )
1188
- })}
1386
+ laneProps={buildLaneProps(lane.stage)}
1387
+ />
1388
+ ) : laneReorderEnabled ? (
1389
+ <SortableSmartLane
1390
+ key={`smart-${lane.stage.key}`}
1391
+ droppableDisabled={!!activeId}
1392
+ smartProps={buildSmartProps(lane.stage)}
1393
+ />
1394
+ ) : (
1395
+ <SmartLane
1396
+ key={`smart-${lane.stage.key}`}
1397
+ {...buildSmartProps(lane.stage)}
1398
+ />
1399
+ ),
1400
+ )}
1189
1401
 
1190
- {/* Smart (virtual) laneseach runs its own filtered query and
1191
- renders read-only cards (no drag target). */}
1192
- {smartStages.map((smart) => (
1193
- <SmartLane
1194
- key={`smart-${smart.key}`}
1195
- stage={smart}
1196
- model={model}
1197
- endpoint={endpoint}
1198
- defaultFilters={defaultFilters}
1199
- pageSize={pageSize}
1200
- isDark={isDark}
1201
- refreshTrigger={refreshTrigger}
1202
- onEdit={openEditStage}
1203
- onDelete={openDeleteStage}
1204
- renderCard={(card) => (
1205
- <KanbanCard
1206
- card={card}
1207
- titleCol={titleCol}
1208
- fieldCols={fieldCols}
1209
- actions={rowActions}
1210
- locale={i18n.language}
1211
- timeZone={timeZone}
1212
- currency={currency}
1213
- onClick={onCardClick}
1214
- onAction={handleInternalAction}
1215
- draggable={false}
1216
- />
1217
- )}
1402
+ {/* Synthetic "Sin etapa" lane a card drop target, not sortable. */}
1403
+ {unassignedStage && (
1404
+ <DroppableStageLane
1405
+ key={UNASSIGNED_LANE}
1406
+ droppableDisabled={
1407
+ !!activeId && !droppableAllowedFor(UNASSIGNED_LANE)
1408
+ }
1409
+ laneProps={buildLaneProps(unassignedStage)}
1218
1410
  />
1219
- ))}
1411
+ )}
1220
1412
 
1221
1413
  {/* "+ Agregar etapa" — only when the host wired /custom-stages. */}
1222
1414
  {customStages.available && (
1223
1415
  <AddStageColumn onClick={openCreateStage} />
1224
1416
  )}
1225
- </div>
1417
+ </div>
1418
+ </SortableContext>
1226
1419
 
1227
1420
  <DragOverlay>
1228
1421
  {activeCard ? (
@@ -1345,9 +1538,129 @@ function SheetFilterRow({
1345
1538
  }
1346
1539
 
1347
1540
  // ---------------------------------------------------------------------------
1348
- // Lane (droppable column)
1541
+ // Lane (droppable + sortable column)
1349
1542
  // ---------------------------------------------------------------------------
1350
1543
 
1544
+ /**
1545
+ * The drag-and-drop wiring a lane wrapper hands to `KanbanLane`. Card drops use
1546
+ * `setNodeRef`/`isOver`; the header drag (lane reorder) uses `handleRef` +
1547
+ * `handleProps` on the lane's title cluster and `style` (the sortable
1548
+ * transform). `draggable` gates whether the reorder grip + listeners render.
1549
+ */
1550
+ interface LaneDnd {
1551
+ setNodeRef: (el: HTMLElement | null) => void
1552
+ isOver: boolean
1553
+ draggable: boolean
1554
+ isDragging?: boolean
1555
+ style?: React.CSSProperties
1556
+ handleRef?: (el: HTMLElement | null) => void
1557
+ handleProps?: Record<string, any>
1558
+ }
1559
+
1560
+ // A real stage lane: sortable (header drag reorders) AND a card drop target.
1561
+ // One `useSortable` provides both roles; `disabled.draggable` follows whether
1562
+ // lane reordering is available, `disabled.droppable` follows the per-card
1563
+ // transition gate.
1564
+ function SortableStageLane({
1565
+ reorderEnabled,
1566
+ droppableDisabled,
1567
+ laneProps,
1568
+ }: {
1569
+ reorderEnabled: boolean
1570
+ droppableDisabled: boolean
1571
+ laneProps: Omit<KanbanLaneProps, 'dnd'>
1572
+ }) {
1573
+ const {
1574
+ setNodeRef,
1575
+ setActivatorNodeRef,
1576
+ attributes,
1577
+ listeners,
1578
+ transform,
1579
+ transition,
1580
+ isDragging,
1581
+ isOver,
1582
+ } = useSortable({
1583
+ id: laneProps.stage.key,
1584
+ data: { type: 'lane' },
1585
+ disabled: { draggable: !reorderEnabled, droppable: droppableDisabled },
1586
+ })
1587
+ const dnd: LaneDnd = {
1588
+ setNodeRef,
1589
+ isOver,
1590
+ isDragging,
1591
+ draggable: reorderEnabled,
1592
+ handleRef: setActivatorNodeRef,
1593
+ handleProps: { ...attributes, ...listeners },
1594
+ style: {
1595
+ transform: CSS.Translate.toString(transform),
1596
+ transition,
1597
+ zIndex: isDragging ? 30 : undefined,
1598
+ position: isDragging ? 'relative' : undefined,
1599
+ },
1600
+ }
1601
+ return <KanbanLane {...laneProps} dnd={dnd} />
1602
+ }
1603
+
1604
+ // The synthetic "Sin etapa" lane: a card drop target, never draggable.
1605
+ function DroppableStageLane({
1606
+ droppableDisabled,
1607
+ laneProps,
1608
+ }: {
1609
+ droppableDisabled: boolean
1610
+ laneProps: Omit<KanbanLaneProps, 'dnd'>
1611
+ }) {
1612
+ const { setNodeRef, isOver } = useDroppable({
1613
+ id: laneProps.stage.key,
1614
+ disabled: droppableDisabled,
1615
+ })
1616
+ return (
1617
+ <KanbanLane
1618
+ {...laneProps}
1619
+ dnd={{ setNodeRef, isOver, draggable: false }}
1620
+ />
1621
+ )
1622
+ }
1623
+
1624
+ // A smart lane: sortable (header drag reorders) but NOT a card drop target — its
1625
+ // droppable is disabled while a card is dragged, enabled while a lane is dragged
1626
+ // (so a stage/smart lane can be reordered relative to it).
1627
+ function SortableSmartLane({
1628
+ droppableDisabled,
1629
+ smartProps,
1630
+ }: {
1631
+ droppableDisabled: boolean
1632
+ smartProps: React.ComponentProps<typeof SmartLane>
1633
+ }) {
1634
+ const {
1635
+ setNodeRef,
1636
+ setActivatorNodeRef,
1637
+ attributes,
1638
+ listeners,
1639
+ transform,
1640
+ transition,
1641
+ isDragging,
1642
+ } = useSortable({
1643
+ id: smartProps.stage.key,
1644
+ data: { type: 'lane' },
1645
+ disabled: { draggable: false, droppable: droppableDisabled },
1646
+ })
1647
+ const dnd: LaneDnd = {
1648
+ setNodeRef,
1649
+ isOver: false,
1650
+ isDragging,
1651
+ draggable: true,
1652
+ handleRef: setActivatorNodeRef,
1653
+ handleProps: { ...attributes, ...listeners },
1654
+ style: {
1655
+ transform: CSS.Translate.toString(transform),
1656
+ transition,
1657
+ zIndex: isDragging ? 30 : undefined,
1658
+ position: isDragging ? 'relative' : undefined,
1659
+ },
1660
+ }
1661
+ return <SmartLane {...smartProps} dnd={dnd} />
1662
+ }
1663
+
1351
1664
  interface LaneFilterField {
1352
1665
  key: string
1353
1666
  label: string
@@ -1389,7 +1702,8 @@ interface KanbanLaneProps {
1389
1702
  onQueryChange: (query: string) => void
1390
1703
  isDark: boolean
1391
1704
  dimmed: boolean
1392
- disabled: boolean
1705
+ /** Drag-and-drop wiring from the lane's sortable/droppable wrapper. */
1706
+ dnd: LaneDnd
1393
1707
  /** Model key + columns for the stage-automations editor. */
1394
1708
  model: string
1395
1709
  columns: ColumnDefinition[]
@@ -1423,7 +1737,7 @@ function KanbanLane({
1423
1737
  onQueryChange,
1424
1738
  isDark,
1425
1739
  dimmed,
1426
- disabled,
1740
+ dnd,
1427
1741
  model,
1428
1742
  columns,
1429
1743
  automationsAvailable,
@@ -1437,7 +1751,6 @@ function KanbanLane({
1437
1751
  children,
1438
1752
  }: KanbanLaneProps) {
1439
1753
  const { t } = useTranslation()
1440
- const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
1441
1754
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1442
1755
  // container; a load in flight or an exhausted stage disables it.
1443
1756
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
@@ -1481,22 +1794,38 @@ function KanbanLane({
1481
1794
 
1482
1795
  return (
1483
1796
  <div
1484
- ref={setNodeRef}
1797
+ ref={dnd.setNodeRef}
1485
1798
  // Fluid width: lanes grow (flex-1) to fill the board when they all
1486
1799
  // fit, capped at max-w so a couple of lanes don't stretch absurdly
1487
1800
  // wide. Below min-w the board's overflow-x-auto takes over and the
1488
1801
  // lanes scroll horizontally at their minimum width.
1489
1802
  className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
1490
1803
  style={{
1491
- opacity: dimmed ? 0.45 : 1,
1492
- outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
1804
+ opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
1805
+ outline: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
1493
1806
  outlineOffset: 2,
1807
+ ...dnd.style,
1494
1808
  }}
1495
1809
  data-stage={stage.key}
1496
- data-disabled={disabled || undefined}
1497
1810
  >
1498
1811
  <div className="flex items-center justify-between gap-2 px-3 py-2.5">
1499
- <div className="flex min-w-0 items-center gap-2">
1812
+ {/* Title cluster doubles as the reorder handle (Trello/Bitrix):
1813
+ grabbing the label drags the whole column. A subtle grip fades
1814
+ in on hover; the lane action buttons stay outside so they never
1815
+ initiate a drag. */}
1816
+ <div
1817
+ ref={dnd.draggable ? dnd.handleRef : undefined}
1818
+ {...(dnd.draggable ? dnd.handleProps : {})}
1819
+ className={`flex min-w-0 items-center gap-1.5 ${
1820
+ dnd.draggable ? 'cursor-grab active:cursor-grabbing' : ''
1821
+ }`}
1822
+ >
1823
+ {dnd.draggable && (
1824
+ <GripVertical
1825
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70"
1826
+ aria-hidden
1827
+ />
1828
+ )}
1500
1829
  <Badge
1501
1830
  variant="outline"
1502
1831
  className="border-0 text-xs font-semibold"
@@ -1800,6 +2129,7 @@ function KanbanCard({
1800
2129
  }: KanbanCardProps) {
1801
2130
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1802
2131
  id: String(card.id),
2132
+ data: { type: 'card' },
1803
2133
  disabled: !draggable,
1804
2134
  })
1805
2135