@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.
- package/CHANGELOG.md +39 -0
- package/dist/custom-stages.d.ts +109 -2
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +261 -26
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +414 -47
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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/dist/stage-overrides.d.ts +25 -0
- package/dist/stage-overrides.d.ts.map +1 -0
- package/dist/stage-overrides.js +64 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +228 -0
- package/src/__tests__/dynamic-kanban.test.tsx +75 -0
- package/src/__tests__/stage-layout.test.tsx +210 -0
- package/src/__tests__/stage-overrides.test.tsx +293 -0
- package/src/custom-stages.tsx +745 -111
- package/src/dynamic-kanban.tsx +679 -159
- package/src/index.ts +18 -0
- package/src/stage-layout.ts +93 -0
- package/src/stage-overrides.ts +95 -0
- package/src/types.ts +28 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
{/*
|
|
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
|
-
)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
1548
|
-
<
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
|