@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.
- package/CHANGELOG.md +6 -0
- package/dist/custom-stages.d.ts +13 -1
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +3 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +259 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/stage-layout.d.ts +23 -0
- package/dist/stage-layout.d.ts.map +1 -0
- package/dist/stage-layout.js +70 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +228 -0
- package/src/__tests__/stage-layout.test.tsx +210 -0
- package/src/custom-stages.tsx +30 -3
- package/src/dynamic-kanban.tsx +474 -144
- package/src/index.ts +5 -0
- package/src/stage-layout.ts +93 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
{
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
{/*
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|