@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.
@@ -38,13 +38,23 @@ 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,
57
+ Settings2,
48
58
  Tag,
49
59
  ToggleLeft,
50
60
  Type,
@@ -90,13 +100,18 @@ import {
90
100
  splitCustomStages,
91
101
  mergeLaneStages,
92
102
  resolveSmartLanes,
103
+ cardMatchesStageFilters,
104
+ smartLaneParams,
93
105
  AddStageColumn,
94
- CustomStageLaneMenu,
95
106
  CustomStageDialog,
96
107
  CustomStageDeleteDialog,
108
+ StageConfigDialog,
97
109
  SmartLane,
98
110
  type CustomStage,
111
+ type CustomStageFilter,
112
+ type StageConfigTarget,
99
113
  } from './custom-stages'
114
+ import { useStageOverrides } from './stage-overrides'
100
115
  import { useDynamicFilters } from './use-dynamic-filters'
101
116
  import {
102
117
  FilterChipsRow,
@@ -111,6 +126,7 @@ import { isColumnVisibleInTable } from './column-visibility'
111
126
  import { isRowActionVisible } from './dynamic-columns'
112
127
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
113
128
  import { useDynamicRowActions } from './dynamic-row-actions'
129
+ import { useStageLayout } from './stage-layout'
114
130
  import type {
115
131
  TableMetadata,
116
132
  ColumnDefinition,
@@ -479,10 +495,16 @@ export function DynamicKanban({
479
495
  // the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
480
496
  // and lane menus simply don't render.
481
497
  const customStages = useCustomStages(model)
498
+ // Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
499
+ // to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
500
+ // hides on declared lanes (custom lanes keep it via /custom-stages).
501
+ const stageOverrides = useStageOverrides(model)
482
502
  // Dialog state: create/edit a stage, and the delete confirmation.
483
503
  const [stageDialogOpen, setStageDialogOpen] = useState(false)
484
504
  const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
485
505
  const [deletingStage, setDeletingStage] = useState<CustomStage | null>(null)
506
+ // The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes.
507
+ const [configTarget, setConfigTarget] = useState<StageConfigTarget | null>(null)
486
508
  const openCreateStage = useCallback(() => {
487
509
  setEditingStage(null)
488
510
  setStageDialogOpen(true)
@@ -513,6 +535,19 @@ export function DynamicKanban({
513
535
 
514
536
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
515
537
  const [activeId, setActiveId] = useState<string | null>(null)
538
+ // Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
539
+ // rather than moving a card. Kept apart so onDragEnd routes by draggable type.
540
+ const [activeLaneId, setActiveLaneId] = useState<string | null>(null)
541
+
542
+ // Per-org lane order. `useStageLayout` reports whether the host wired the
543
+ // `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
544
+ // order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of
545
+ // the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
546
+ // board already paints ordered on load — this only backs the live drag + the
547
+ // revert-on-failure). Null → follow the metadata order.
548
+ const stageLayout = useStageLayout(model)
549
+ const [laneOrderOverride, setLaneOrderOverride] = useState<string[] | null>(null)
550
+ const laneReorderEnabled = stageLayout.available
516
551
 
517
552
  // Monotonic token for the current board load. Bumped on every fetchData so a
518
553
  // slow eager-totals response from a superseded filter set can't inject stale
@@ -605,6 +640,7 @@ export function DynamicKanban({
605
640
  const r = (await api.get(endpoint || `/data/${model}`, {
606
641
  params: {
607
642
  ...filterParams,
643
+ ...smartLaneParams(stage.filters),
608
644
  page: 1,
609
645
  per_page: 1,
610
646
  [`f_${gb}`]: stage.key,
@@ -638,6 +674,27 @@ export function DynamicKanban({
638
674
  // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
639
675
  // filterParams (the stage scope wins over any global group_by filter).
640
676
  const groupByKey = metadata?.group_by || ''
677
+ // Extra per-lane conditions (stage overrides) keyed by stage. A real lane
678
+ // that carries these queries its data — and counts its header — with the
679
+ // stage scope PLUS these filters (serialized like a smart lane's), so the
680
+ // top-up + eager-total requests below layer them on. Sourced from
681
+ // `metadata.stages` (the kernel applies declared + custom-real overrides).
682
+ const stageExtraFilters = useMemo(() => {
683
+ const m = new Map<string, CustomStageFilter[]>()
684
+ for (const s of metadata?.stages ?? []) {
685
+ if (s.filters && s.filters.length > 0) {
686
+ m.set(
687
+ s.key,
688
+ s.filters.map((f) => ({
689
+ field: f.field,
690
+ op: f.op as CustomStageFilter['op'],
691
+ value: f.value,
692
+ })),
693
+ )
694
+ }
695
+ }
696
+ return m
697
+ }, [metadata?.stages])
641
698
  const loadMoreLane = useCallback(
642
699
  async (stageKey: string) => {
643
700
  if (!metadata || !groupByKey) return
@@ -657,6 +714,7 @@ export function DynamicKanban({
657
714
  const res = (await api.get(endpoint || `/data/${model}`, {
658
715
  params: {
659
716
  ...filterParams,
717
+ ...smartLaneParams(stageExtraFilters.get(stageKey)),
660
718
  page: nextPage,
661
719
  per_page: lanePageSize,
662
720
  [`f_${groupByKey}`]: stageKey,
@@ -689,7 +747,7 @@ export function DynamicKanban({
689
747
  }))
690
748
  }
691
749
  },
692
- [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
750
+ [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters],
693
751
  )
694
752
 
695
753
  // Refetch when metadata resolves, on an explicit refresh, or when the
@@ -819,6 +877,68 @@ export function DynamicKanban({
819
877
  }, [customStages.stages, stages.length, smartStages.length])
820
878
  const transitions = metadata?.transitions
821
879
 
880
+ // Unified, ordered list of the DRAGGABLE lanes (real stages + smart lanes) —
881
+ // the sortable sequence the header drag reorders. Real stages and smart lanes
882
+ // are merged by their `order` (the backend stamps a global sequence once a
883
+ // custom order exists); a `laneOrderOverride` from the live drag wins. Ties
884
+ // (the default, un-customized case where both sets start at 0) keep stages
885
+ // before smart lanes, matching the pre-reorder layout.
886
+ const renderLanes = useMemo<
887
+ Array<
888
+ | { kind: 'stage'; stage: StageMeta }
889
+ | { kind: 'smart'; stage: CustomStage }
890
+ >
891
+ >(() => {
892
+ const arr: Array<{
893
+ kind: 'stage' | 'smart'
894
+ stage: any
895
+ order: number
896
+ }> = [
897
+ ...stages.map((s, i) => ({
898
+ kind: 'stage' as const,
899
+ stage: s,
900
+ order: s.order ?? i,
901
+ })),
902
+ ...smartStages.map((s, i) => ({
903
+ kind: 'smart' as const,
904
+ stage: s,
905
+ order: s.position ?? 1000 + i,
906
+ })),
907
+ ]
908
+ if (laneOrderOverride) {
909
+ const idx = new Map(laneOrderOverride.map((k, i) => [k, i]))
910
+ arr.sort(
911
+ (a, b) =>
912
+ (idx.get(a.stage.key) ?? Number.MAX_SAFE_INTEGER) -
913
+ (idx.get(b.stage.key) ?? Number.MAX_SAFE_INTEGER),
914
+ )
915
+ } else {
916
+ arr.sort(
917
+ (a, b) =>
918
+ a.order - b.order ||
919
+ (a.kind === b.kind ? 0 : a.kind === 'stage' ? -1 : 1),
920
+ )
921
+ }
922
+ return arr.map(({ kind, stage: s }) =>
923
+ kind === 'stage'
924
+ ? { kind: 'stage' as const, stage: s as StageMeta }
925
+ : { kind: 'smart' as const, stage: s as CustomStage },
926
+ )
927
+ }, [stages, smartStages, laneOrderOverride])
928
+
929
+ // The sortable ids, in current visual order — SortableContext items + the
930
+ // basis for the arrayMove the drop computes.
931
+ const boardLaneKeys = useMemo(
932
+ () => renderLanes.map((l) => l.stage.key),
933
+ [renderLanes],
934
+ )
935
+ // Real (card-droppable) stage keys — guards a card drop from ever landing on
936
+ // a smart lane (a saved view, never a stored stage value).
937
+ const realStageKeys = useMemo(
938
+ () => new Set(stages.map((s) => s.key)),
939
+ [stages],
940
+ )
941
+
822
942
  const grouped = useMemo(
823
943
  () => groupByStage(records, groupByKey, stages),
824
944
  [records, groupByKey, stages],
@@ -878,16 +998,53 @@ export function DynamicKanban({
878
998
  )
879
999
 
880
1000
  const onDragStart = useCallback((e: DragStartEvent) => {
881
- setActiveId(String(e.active.id))
1001
+ if (e.active.data.current?.type === 'lane') {
1002
+ setActiveLaneId(String(e.active.id))
1003
+ } else {
1004
+ setActiveId(String(e.active.id))
1005
+ }
882
1006
  }, [])
883
1007
 
1008
+ // Optimistic lane reorder: reorder the columns in local state immediately,
1009
+ // PUT the full new order, and revert + toast on failure. Keys mix real
1010
+ // stages and smart lanes (the backend applies the order to both).
1011
+ const reorderLanes = useCallback(
1012
+ async (activeKey: string, overKey: string) => {
1013
+ const from = boardLaneKeys.indexOf(activeKey)
1014
+ const to = boardLaneKeys.indexOf(overKey)
1015
+ if (from === -1 || to === -1 || from === to) return
1016
+ const next = arrayMove(boardLaneKeys, from, to)
1017
+ const prev = laneOrderOverride
1018
+ setLaneOrderOverride(next)
1019
+ try {
1020
+ await stageLayout.save(next)
1021
+ } catch {
1022
+ setLaneOrderOverride(prev)
1023
+ toast.error(
1024
+ t('dynamic.stage_layout.save_error', {
1025
+ defaultValue: 'No se pudo guardar el orden',
1026
+ }),
1027
+ )
1028
+ }
1029
+ },
1030
+ [boardLaneKeys, laneOrderOverride, stageLayout, t],
1031
+ )
1032
+
884
1033
  const onDragEnd = useCallback(
885
1034
  async (e: DragEndEvent) => {
886
1035
  setActiveId(null)
1036
+ setActiveLaneId(null)
887
1037
  const { active, over } = e
1038
+ // A header drag reorders columns rather than moving a card.
1039
+ if (active.data.current?.type === 'lane') {
1040
+ if (over) await reorderLanes(String(active.id), String(over.id))
1041
+ return
1042
+ }
888
1043
  if (!over) return
889
1044
  const cardId = String(active.id)
890
1045
  const destStage = String(over.id)
1046
+ // Never drop a card onto a smart lane (a saved view, not a stage).
1047
+ if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE) return
891
1048
  const srcStage = stageOfCard(cardId)
892
1049
  if (srcStage === destStage) return
893
1050
  if (!isTransitionAllowed(transitions, srcStage, destStage)) {
@@ -938,9 +1095,33 @@ export function DynamicKanban({
938
1095
  )
939
1096
  }
940
1097
  },
941
- [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
1098
+ [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes],
942
1099
  )
943
1100
 
1101
+ // Board-level "Restablecer orden": drop the stored order and refetch the
1102
+ // metadata so the lanes fall back to the DECLARED order. Optimistic — reverts
1103
+ // its local override on failure.
1104
+ const resetLaneOrder = useCallback(async () => {
1105
+ const prev = laneOrderOverride
1106
+ setLaneOrderOverride(null)
1107
+ try {
1108
+ await stageLayout.reset()
1109
+ const res = await api.get(`/metadata/table/${model}`)
1110
+ const body = res.data as ApiResponse<TableMetadata>
1111
+ if (body.success) {
1112
+ setMetadata(body.data)
1113
+ cacheMetadata(model, body.data)
1114
+ }
1115
+ } catch {
1116
+ setLaneOrderOverride(prev)
1117
+ toast.error(
1118
+ t('dynamic.stage_layout.reset_error', {
1119
+ defaultValue: 'No se pudo restablecer el orden',
1120
+ }),
1121
+ )
1122
+ }
1123
+ }, [api, cacheMetadata, laneOrderOverride, model, stageLayout, t])
1124
+
944
1125
  if (loading) {
945
1126
  return (
946
1127
  <div className="flex w-full gap-4 overflow-x-auto p-1">
@@ -969,16 +1150,202 @@ export function DynamicKanban({
969
1150
  const activeCard = activeId ? cardById.get(activeId) : null
970
1151
  const activeStage = activeId ? stageOfCard(activeId) : ''
971
1152
 
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
- })
1153
+ // The synthetic "Sin etapa" lane (only when some record's stage matches no
1154
+ // declared lane). Rendered after the sortable lanes, never draggable itself.
1155
+ const unassignedStage: StageMeta | null = grouped.has(UNASSIGNED_LANE)
1156
+ ? {
1157
+ key: UNASSIGNED_LANE,
1158
+ label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
1159
+ color: 'slate',
1160
+ order: Number.MAX_SAFE_INTEGER,
1161
+ }
1162
+ : null
1163
+
1164
+ // Whether a card may drop into a lane given the active card's stage + the
1165
+ // declared transitions (a same-stage or unrestricted move always passes).
1166
+ const droppableAllowedFor = (stageKey: string) =>
1167
+ !activeId ||
1168
+ stageKey === activeStage ||
1169
+ isTransitionAllowed(transitions, activeStage, stageKey)
1170
+
1171
+ // Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
1172
+ // right backend by kind: a custom real stage edits through /custom-stages, a
1173
+ // declared stage through /stage-overrides. Seeds the current label/color and
1174
+ // any extra conditions the lane already carries.
1175
+ const openConfigStage = (stage: StageMeta) => {
1176
+ const custom = customStages.available
1177
+ ? customByKey.get(stage.key)
1178
+ : undefined
1179
+ const raw =
1180
+ stageExtraFilters.get(stage.key) ??
1181
+ (custom?.filters as CustomStageFilter[] | undefined) ??
1182
+ []
1183
+ const filters: CustomStageFilter[] = raw.map((f) => ({
1184
+ field: f.field,
1185
+ op: f.op as CustomStageFilter['op'],
1186
+ value: f.value,
1187
+ }))
1188
+ if (custom) {
1189
+ setConfigTarget({
1190
+ kind: 'custom',
1191
+ stageKey: stage.key,
1192
+ id: custom.id,
1193
+ label: custom.label,
1194
+ color: custom.color,
1195
+ filters,
1196
+ customStage: custom,
1197
+ })
1198
+ } else {
1199
+ const original = stage.original
1200
+ ? {
1201
+ label: stage.original.label,
1202
+ color: stage.original.color,
1203
+ filters: stage.original.filters?.map((f) => ({
1204
+ field: f.field,
1205
+ op: f.op as CustomStageFilter['op'],
1206
+ value: f.value,
1207
+ })),
1208
+ }
1209
+ : undefined
1210
+ setConfigTarget({
1211
+ kind: 'declared',
1212
+ stageKey: stage.key,
1213
+ label: t(stage.label, { defaultValue: stage.label }),
1214
+ color: stage.color ?? 'slate',
1215
+ filters,
1216
+ overridden: !!stage.overridden,
1217
+ isFinal: stage.is_final,
1218
+ original,
1219
+ })
1220
+ }
1221
+ }
1222
+ // Whether a lane offers the gear: custom real stages always (their CRUD is
1223
+ // wired); declared lanes only when the host wired /stage-overrides. Never on
1224
+ // the synthetic "Sin etapa" lane.
1225
+ const isConfigurable = (stage: StageMeta): boolean => {
1226
+ if (stage.key === UNASSIGNED_LANE) return false
1227
+ return customByKey.has(stage.key)
1228
+ ? customStages.available
1229
+ : stageOverrides.available
1230
+ }
1231
+
1232
+ // Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
1233
+ // shared by the sortable real stages and the plain-droppable unassigned lane.
1234
+ const buildLaneProps = (stage: StageMeta): Omit<KanbanLaneProps, 'dnd'> => {
1235
+ const allCards = grouped.get(stage.key) ?? []
1236
+ // Extra lane conditions (stage override): the server already scopes this
1237
+ // lane's top-up + total queries by them, but the shared INITIAL board page
1238
+ // is unscoped, so narrow those cards client-side too (belt-and-suspenders).
1239
+ const extraFilters = stageExtraFilters.get(stage.key) ?? []
1240
+ // Per-lane client-side narrowing (instant, scoped to this stage). The
1241
+ // funnel (field/value) and the lane search (query) are AND-combined.
1242
+ const laneFilter = laneFilters[stage.key]
1243
+ let cards = allCards
1244
+ if (extraFilters.length > 0) {
1245
+ cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters))
1246
+ }
1247
+ if (laneFilter?.field) {
1248
+ cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter))
1249
+ }
1250
+ if (laneFilter?.query?.trim()) {
1251
+ cards = cards.filter((c) =>
1252
+ cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
1253
+ )
1254
+ }
1255
+ const laneState = lanePagination[stage.key]
1256
+ const isUnassigned = stage.key === UNASSIGNED_LANE
1257
+ const laneHasMore = !isUnassigned && !laneState?.done
1258
+ return {
1259
+ stage,
1260
+ count: cards.length,
1261
+ totalCount: allCards.length,
1262
+ serverTotal: laneState?.total ?? null,
1263
+ hasMore: laneHasMore,
1264
+ loadingMore: !!laneState?.loading,
1265
+ onLoadMore: () => loadMoreLane(stage.key),
1266
+ filterFields,
1267
+ laneFilter,
1268
+ onFunnelChange: (f) =>
1269
+ updateLaneFilter(stage.key, {
1270
+ field: f?.field,
1271
+ values: f?.values,
1272
+ text: f?.text,
1273
+ }),
1274
+ onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
1275
+ isDark,
1276
+ dimmed: !!activeId && !droppableAllowedFor(stage.key),
1277
+ model,
1278
+ columns: metadata?.columns ?? [],
1279
+ automationsAvailable:
1280
+ automations.available && stage.key !== UNASSIGNED_LANE,
1281
+ automationRules: automations.byStage.get(stage.key) ?? [],
1282
+ onAutomationCreate: automations.create,
1283
+ onAutomationUpdate: automations.update,
1284
+ onAutomationRemove: automations.remove,
1285
+ extraFilters,
1286
+ configurable: isConfigurable(stage),
1287
+ onConfigure: () => openConfigStage(stage),
1288
+ children:
1289
+ loadingData && cards.length === 0 ? (
1290
+ <>
1291
+ <Skeleton className="h-20 w-full" />
1292
+ <Skeleton className="h-20 w-full" />
1293
+ </>
1294
+ ) : cards.length === 0 ? (
1295
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1296
+ {t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' })}
1297
+ </p>
1298
+ ) : (
1299
+ cards.map((card) => (
1300
+ <KanbanCard
1301
+ key={String(card.id)}
1302
+ card={card}
1303
+ titleCol={titleCol}
1304
+ fieldCols={fieldCols}
1305
+ actions={rowActions}
1306
+ locale={i18n.language}
1307
+ timeZone={timeZone}
1308
+ currency={currency}
1309
+ onClick={onCardClick}
1310
+ onAction={handleInternalAction}
1311
+ />
1312
+ ))
1313
+ ),
1314
+ }
980
1315
  }
981
1316
 
1317
+ // Props for one smart (virtual) lane — a read-only, filter-defined column.
1318
+ const buildSmartProps = (
1319
+ smart: CustomStage,
1320
+ ): React.ComponentProps<typeof SmartLane> => ({
1321
+ stage: smart,
1322
+ model,
1323
+ endpoint,
1324
+ defaultFilters,
1325
+ pageSize,
1326
+ isDark,
1327
+ refreshTrigger,
1328
+ onEdit: openEditStage,
1329
+ onDelete: openDeleteStage,
1330
+ renderCard: (card: any) => (
1331
+ <KanbanCard
1332
+ card={card}
1333
+ titleCol={titleCol}
1334
+ fieldCols={fieldCols}
1335
+ actions={rowActions}
1336
+ locale={i18n.language}
1337
+ timeZone={timeZone}
1338
+ currency={currency}
1339
+ onClick={onCardClick}
1340
+ onAction={handleInternalAction}
1341
+ draggable={false}
1342
+ />
1343
+ ),
1344
+ })
1345
+
1346
+ const showResetOrder =
1347
+ laneReorderEnabled && (stageLayout.hasCustomLayout || !!laneOrderOverride)
1348
+
982
1349
  return (
983
1350
  <div className="flex flex-col gap-3">
984
1351
  {/* Filter bar — global search + one chip per filterable field, the
@@ -1078,6 +1445,20 @@ export function DynamicKanban({
1078
1445
  </SheetContent>
1079
1446
  </Sheet>
1080
1447
  )}
1448
+ {showResetOrder && (
1449
+ <Button
1450
+ variant="ghost"
1451
+ size="sm"
1452
+ className="h-8 gap-1.5 text-xs text-muted-foreground"
1453
+ onClick={() => void resetLaneOrder()}
1454
+ data-testid="kanban-reset-order"
1455
+ >
1456
+ <RotateCcw className="h-3.5 w-3.5" />
1457
+ {t('dynamic.stage_layout.reset', {
1458
+ defaultValue: 'Restablecer orden',
1459
+ })}
1460
+ </Button>
1461
+ )}
1081
1462
  </div>
1082
1463
 
1083
1464
  {/* Removable chip row — shared with DynamicTable. Instant feedback
@@ -1089,140 +1470,53 @@ export function DynamicKanban({
1089
1470
  />
1090
1471
 
1091
1472
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
1473
+ {/* Horizontal SortableContext over the draggable lanes (real
1474
+ stages + smart lanes). Card drags don't touch it — their
1475
+ active id isn't a sortable item, so the lanes never shift when
1476
+ moving a card; a `data.type` tag routes drop handling. */}
1477
+ <SortableContext items={boardLaneKeys} strategy={horizontalListSortingStrategy}>
1092
1478
  <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
1148
- }
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
1479
+ {renderLanes.map((lane) =>
1480
+ lane.kind === 'stage' ? (
1481
+ <SortableStageLane
1482
+ key={lane.stage.key}
1483
+ reorderEnabled={laneReorderEnabled}
1484
+ droppableDisabled={
1485
+ !!activeId && !droppableAllowedFor(lane.stage.key)
1157
1486
  }
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
- })}
1487
+ laneProps={buildLaneProps(lane.stage)}
1488
+ />
1489
+ ) : laneReorderEnabled ? (
1490
+ <SortableSmartLane
1491
+ key={`smart-${lane.stage.key}`}
1492
+ droppableDisabled={!!activeId}
1493
+ smartProps={buildSmartProps(lane.stage)}
1494
+ />
1495
+ ) : (
1496
+ <SmartLane
1497
+ key={`smart-${lane.stage.key}`}
1498
+ {...buildSmartProps(lane.stage)}
1499
+ />
1500
+ ),
1501
+ )}
1189
1502
 
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
- )}
1503
+ {/* Synthetic "Sin etapa" lane a card drop target, not sortable. */}
1504
+ {unassignedStage && (
1505
+ <DroppableStageLane
1506
+ key={UNASSIGNED_LANE}
1507
+ droppableDisabled={
1508
+ !!activeId && !droppableAllowedFor(UNASSIGNED_LANE)
1509
+ }
1510
+ laneProps={buildLaneProps(unassignedStage)}
1218
1511
  />
1219
- ))}
1512
+ )}
1220
1513
 
1221
1514
  {/* "+ Agregar etapa" — only when the host wired /custom-stages. */}
1222
1515
  {customStages.available && (
1223
1516
  <AddStageColumn onClick={openCreateStage} />
1224
1517
  )}
1225
- </div>
1518
+ </div>
1519
+ </SortableContext>
1226
1520
 
1227
1521
  <DragOverlay>
1228
1522
  {activeCard ? (
@@ -1268,6 +1562,50 @@ export function DynamicKanban({
1268
1562
  />
1269
1563
  </>
1270
1564
  )}
1565
+
1566
+ {/* Unified "Configurar etapa" dialog opened by the per-lane ⚙ gear —
1567
+ routes saves to /stage-overrides (declared) or /custom-stages
1568
+ (custom) by the target's kind. */}
1569
+ <StageConfigDialog
1570
+ open={!!configTarget}
1571
+ onOpenChange={(o) => {
1572
+ if (!o) setConfigTarget(null)
1573
+ }}
1574
+ columns={metadata?.columns ?? []}
1575
+ target={configTarget}
1576
+ onSaveOverride={async (stageKey, patch) => {
1577
+ await stageOverrides.save(stageKey, patch)
1578
+ // The override changes the served label/color/filters — refetch
1579
+ // metadata so the board repaints, and reload the cards.
1580
+ try {
1581
+ const res = await api.get(`/metadata/table/${model}`)
1582
+ const body = res.data as ApiResponse<TableMetadata>
1583
+ if (body.success) {
1584
+ setMetadata(body.data)
1585
+ cacheMetadata(model, body.data)
1586
+ }
1587
+ } catch {
1588
+ /* keep the current metadata on a refetch miss */
1589
+ }
1590
+ void fetchData()
1591
+ }}
1592
+ onResetOverride={async (stageKey) => {
1593
+ await stageOverrides.reset(stageKey)
1594
+ try {
1595
+ const res = await api.get(`/metadata/table/${model}`)
1596
+ const body = res.data as ApiResponse<TableMetadata>
1597
+ if (body.success) {
1598
+ setMetadata(body.data)
1599
+ cacheMetadata(model, body.data)
1600
+ }
1601
+ } catch {
1602
+ /* keep the current metadata on a refetch miss */
1603
+ }
1604
+ void fetchData()
1605
+ }}
1606
+ onUpdateCustom={customStages.update}
1607
+ onDeleteCustom={openDeleteStage}
1608
+ />
1271
1609
  </div>
1272
1610
  )
1273
1611
  }
@@ -1345,9 +1683,129 @@ function SheetFilterRow({
1345
1683
  }
1346
1684
 
1347
1685
  // ---------------------------------------------------------------------------
1348
- // Lane (droppable column)
1686
+ // Lane (droppable + sortable column)
1349
1687
  // ---------------------------------------------------------------------------
1350
1688
 
1689
+ /**
1690
+ * The drag-and-drop wiring a lane wrapper hands to `KanbanLane`. Card drops use
1691
+ * `setNodeRef`/`isOver`; the header drag (lane reorder) uses `handleRef` +
1692
+ * `handleProps` on the lane's title cluster and `style` (the sortable
1693
+ * transform). `draggable` gates whether the reorder grip + listeners render.
1694
+ */
1695
+ interface LaneDnd {
1696
+ setNodeRef: (el: HTMLElement | null) => void
1697
+ isOver: boolean
1698
+ draggable: boolean
1699
+ isDragging?: boolean
1700
+ style?: React.CSSProperties
1701
+ handleRef?: (el: HTMLElement | null) => void
1702
+ handleProps?: Record<string, any>
1703
+ }
1704
+
1705
+ // A real stage lane: sortable (header drag reorders) AND a card drop target.
1706
+ // One `useSortable` provides both roles; `disabled.draggable` follows whether
1707
+ // lane reordering is available, `disabled.droppable` follows the per-card
1708
+ // transition gate.
1709
+ function SortableStageLane({
1710
+ reorderEnabled,
1711
+ droppableDisabled,
1712
+ laneProps,
1713
+ }: {
1714
+ reorderEnabled: boolean
1715
+ droppableDisabled: boolean
1716
+ laneProps: Omit<KanbanLaneProps, 'dnd'>
1717
+ }) {
1718
+ const {
1719
+ setNodeRef,
1720
+ setActivatorNodeRef,
1721
+ attributes,
1722
+ listeners,
1723
+ transform,
1724
+ transition,
1725
+ isDragging,
1726
+ isOver,
1727
+ } = useSortable({
1728
+ id: laneProps.stage.key,
1729
+ data: { type: 'lane' },
1730
+ disabled: { draggable: !reorderEnabled, droppable: droppableDisabled },
1731
+ })
1732
+ const dnd: LaneDnd = {
1733
+ setNodeRef,
1734
+ isOver,
1735
+ isDragging,
1736
+ draggable: reorderEnabled,
1737
+ handleRef: setActivatorNodeRef,
1738
+ handleProps: { ...attributes, ...listeners },
1739
+ style: {
1740
+ transform: CSS.Translate.toString(transform),
1741
+ transition,
1742
+ zIndex: isDragging ? 30 : undefined,
1743
+ position: isDragging ? 'relative' : undefined,
1744
+ },
1745
+ }
1746
+ return <KanbanLane {...laneProps} dnd={dnd} />
1747
+ }
1748
+
1749
+ // The synthetic "Sin etapa" lane: a card drop target, never draggable.
1750
+ function DroppableStageLane({
1751
+ droppableDisabled,
1752
+ laneProps,
1753
+ }: {
1754
+ droppableDisabled: boolean
1755
+ laneProps: Omit<KanbanLaneProps, 'dnd'>
1756
+ }) {
1757
+ const { setNodeRef, isOver } = useDroppable({
1758
+ id: laneProps.stage.key,
1759
+ disabled: droppableDisabled,
1760
+ })
1761
+ return (
1762
+ <KanbanLane
1763
+ {...laneProps}
1764
+ dnd={{ setNodeRef, isOver, draggable: false }}
1765
+ />
1766
+ )
1767
+ }
1768
+
1769
+ // A smart lane: sortable (header drag reorders) but NOT a card drop target — its
1770
+ // droppable is disabled while a card is dragged, enabled while a lane is dragged
1771
+ // (so a stage/smart lane can be reordered relative to it).
1772
+ function SortableSmartLane({
1773
+ droppableDisabled,
1774
+ smartProps,
1775
+ }: {
1776
+ droppableDisabled: boolean
1777
+ smartProps: React.ComponentProps<typeof SmartLane>
1778
+ }) {
1779
+ const {
1780
+ setNodeRef,
1781
+ setActivatorNodeRef,
1782
+ attributes,
1783
+ listeners,
1784
+ transform,
1785
+ transition,
1786
+ isDragging,
1787
+ } = useSortable({
1788
+ id: smartProps.stage.key,
1789
+ data: { type: 'lane' },
1790
+ disabled: { draggable: false, droppable: droppableDisabled },
1791
+ })
1792
+ const dnd: LaneDnd = {
1793
+ setNodeRef,
1794
+ isOver: false,
1795
+ isDragging,
1796
+ draggable: true,
1797
+ handleRef: setActivatorNodeRef,
1798
+ handleProps: { ...attributes, ...listeners },
1799
+ style: {
1800
+ transform: CSS.Translate.toString(transform),
1801
+ transition,
1802
+ zIndex: isDragging ? 30 : undefined,
1803
+ position: isDragging ? 'relative' : undefined,
1804
+ },
1805
+ }
1806
+ return <SmartLane {...smartProps} dnd={dnd} />
1807
+ }
1808
+
1351
1809
  interface LaneFilterField {
1352
1810
  key: string
1353
1811
  label: string
@@ -1389,7 +1847,8 @@ interface KanbanLaneProps {
1389
1847
  onQueryChange: (query: string) => void
1390
1848
  isDark: boolean
1391
1849
  dimmed: boolean
1392
- disabled: boolean
1850
+ /** Drag-and-drop wiring from the lane's sortable/droppable wrapper. */
1851
+ dnd: LaneDnd
1393
1852
  /** Model key + columns for the stage-automations editor. */
1394
1853
  model: string
1395
1854
  columns: ColumnDefinition[]
@@ -1402,10 +1861,12 @@ interface KanbanLaneProps {
1402
1861
  patch: Partial<StageAutomation>,
1403
1862
  ) => Promise<void>
1404
1863
  onAutomationRemove: (id: StageAutomation['id']) => Promise<void>
1405
- /** When set, this lane is a user-defined custom stage show Editar/Eliminar. */
1406
- customStage?: CustomStage
1407
- onEditStage?: (stage: CustomStage) => void
1408
- onDeleteStage?: (stage: CustomStage) => void
1864
+ /** Extra lane conditions (stage override) drives the header filter dot + tooltip. */
1865
+ extraFilters: CustomStageFilter[]
1866
+ /** Whether the "Configurar etapa" gear should render for this lane. */
1867
+ configurable: boolean
1868
+ /** Opens the gear config dialog for this lane. */
1869
+ onConfigure: () => void
1409
1870
  children: React.ReactNode
1410
1871
  }
1411
1872
 
@@ -1423,7 +1884,7 @@ function KanbanLane({
1423
1884
  onQueryChange,
1424
1885
  isDark,
1425
1886
  dimmed,
1426
- disabled,
1887
+ dnd,
1427
1888
  model,
1428
1889
  columns,
1429
1890
  automationsAvailable,
@@ -1431,13 +1892,12 @@ function KanbanLane({
1431
1892
  onAutomationCreate,
1432
1893
  onAutomationUpdate,
1433
1894
  onAutomationRemove,
1434
- customStage,
1435
- onEditStage,
1436
- onDeleteStage,
1895
+ extraFilters,
1896
+ configurable,
1897
+ onConfigure,
1437
1898
  children,
1438
1899
  }: KanbanLaneProps) {
1439
1900
  const { t } = useTranslation()
1440
- const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
1441
1901
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1442
1902
  // container; a load in flight or an exhausted stage disables it.
1443
1903
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
@@ -1479,24 +1939,59 @@ function KanbanLane({
1479
1939
  }
1480
1940
  : undefined
1481
1941
 
1942
+ // Stage-override conditions active on this lane → a small filter dot in the
1943
+ // header, its `title` listing the conditions (e.g. "priority es igual high").
1944
+ const hasConditions = extraFilters.length > 0
1945
+ const conditionsSummary = extraFilters
1946
+ .map((f) => {
1947
+ const opLabel = t(`dynamic.custom_stages.op.${f.op}`, {
1948
+ defaultValue:
1949
+ f.op === 'eq'
1950
+ ? 'es igual'
1951
+ : f.op === 'neq'
1952
+ ? 'distinto'
1953
+ : f.op === 'contains'
1954
+ ? 'contiene'
1955
+ : 'en lista',
1956
+ })
1957
+ return `${f.field} ${opLabel} ${f.value}`
1958
+ })
1959
+ .join(' · ')
1960
+
1482
1961
  return (
1483
1962
  <div
1484
- ref={setNodeRef}
1963
+ ref={dnd.setNodeRef}
1485
1964
  // Fluid width: lanes grow (flex-1) to fill the board when they all
1486
1965
  // fit, capped at max-w so a couple of lanes don't stretch absurdly
1487
1966
  // wide. Below min-w the board's overflow-x-auto takes over and the
1488
1967
  // lanes scroll horizontally at their minimum width.
1489
1968
  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
1969
  style={{
1491
- opacity: dimmed ? 0.45 : 1,
1492
- outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
1970
+ opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
1971
+ outline: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
1493
1972
  outlineOffset: 2,
1973
+ ...dnd.style,
1494
1974
  }}
1495
1975
  data-stage={stage.key}
1496
- data-disabled={disabled || undefined}
1497
1976
  >
1498
1977
  <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">
1978
+ {/* Title cluster doubles as the reorder handle (Trello/Bitrix):
1979
+ grabbing the label drags the whole column. A subtle grip fades
1980
+ in on hover; the lane action buttons stay outside so they never
1981
+ initiate a drag. */}
1982
+ <div
1983
+ ref={dnd.draggable ? dnd.handleRef : undefined}
1984
+ {...(dnd.draggable ? dnd.handleProps : {})}
1985
+ className={`flex min-w-0 items-center gap-1.5 ${
1986
+ dnd.draggable ? 'cursor-grab active:cursor-grabbing' : ''
1987
+ }`}
1988
+ >
1989
+ {dnd.draggable && (
1990
+ <GripVertical
1991
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70"
1992
+ aria-hidden
1993
+ />
1994
+ )}
1500
1995
  <Badge
1501
1996
  variant="outline"
1502
1997
  className="border-0 text-xs font-semibold"
@@ -1507,6 +2002,22 @@ function KanbanLane({
1507
2002
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
1508
2003
  {formatLaneCount(count, totalCount, serverTotal, laneActive)}
1509
2004
  </span>
2005
+ {hasConditions && (
2006
+ <span
2007
+ className="flex items-center text-primary"
2008
+ title={t('dynamic.stage_config.conditions_active', {
2009
+ defaultValue: 'Condiciones: {{list}}',
2010
+ list: conditionsSummary,
2011
+ })}
2012
+ data-testid={`lane-conditions-${stage.key}`}
2013
+ aria-label={t('dynamic.stage_config.conditions_active', {
2014
+ defaultValue: 'Condiciones: {{list}}',
2015
+ list: conditionsSummary,
2016
+ })}
2017
+ >
2018
+ <ListFilter className="h-3 w-3" />
2019
+ </span>
2020
+ )}
1510
2021
  </div>
1511
2022
  {/* Lane actions — always visible in muted (a hidden hover-reveal
1512
2023
  was undiscoverable); active state is a primary tint + a count
@@ -1544,12 +2055,20 @@ function KanbanLane({
1544
2055
  onRemove={onAutomationRemove}
1545
2056
  />
1546
2057
  )}
1547
- {customStage && onEditStage && onDeleteStage && (
1548
- <CustomStageLaneMenu
1549
- stage={customStage}
1550
- onEdit={onEditStage}
1551
- onDelete={onDeleteStage}
1552
- />
2058
+ {configurable && (
2059
+ <button
2060
+ type="button"
2061
+ onClick={onConfigure}
2062
+ className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
2063
+ hasConditions ? 'text-primary' : 'text-muted-foreground'
2064
+ }`}
2065
+ aria-label={t('dynamic.stage_config.open', {
2066
+ defaultValue: 'Configurar etapa',
2067
+ })}
2068
+ data-testid={`lane-config-${stage.key}`}
2069
+ >
2070
+ <Settings2 className="h-3.5 w-3.5" />
2071
+ </button>
1553
2072
  )}
1554
2073
  </div>
1555
2074
  </div>
@@ -1800,6 +2319,7 @@ function KanbanCard({
1800
2319
  }: KanbanCardProps) {
1801
2320
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1802
2321
  id: String(card.id),
2322
+ data: { type: 'card' },
1803
2323
  disabled: !draggable,
1804
2324
  })
1805
2325