@asteby/metacore-runtime-react 41.0.0 → 43.0.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 +45 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +9 -13
- package/dist/dynamic-kanban.d.ts +83 -6
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +647 -146
- package/dist/dynamic-table.d.ts +1 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +14 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/license/license-gate.d.ts +0 -21
- package/dist/license/license-gate.d.ts.map +1 -1
- package/dist/license/license-gate.js +5 -2
- package/dist/portal-drag-overlay.d.ts +5 -0
- package/dist/portal-drag-overlay.d.ts.map +1 -0
- package/dist/portal-drag-overlay.js +8 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +6 -3
- package/dist/sidebar-layout-manager.js +3 -2
- package/dist/use-flip-animation.d.ts +6 -0
- package/dist/use-flip-animation.d.ts.map +1 -1
- package/dist/use-flip-animation.js +11 -4
- package/dist/use-persisted-query.d.ts +53 -0
- package/dist/use-persisted-query.d.ts.map +1 -0
- package/dist/use-persisted-query.js +92 -0
- package/package.json +6 -5
- package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
- package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
- package/src/__tests__/dynamic-kanban.test.tsx +15 -2
- package/src/__tests__/image-cell-lucide.test.tsx +7 -5
- package/src/__tests__/use-persisted-query.test.tsx +132 -0
- package/src/api-context.tsx +7 -5
- package/src/dynamic-icon.tsx +12 -16
- package/src/dynamic-kanban.tsx +950 -262
- package/src/dynamic-table.tsx +16 -2
- package/src/index.ts +14 -0
- package/src/license/license-gate.tsx +6 -2
- package/src/portal-drag-overlay.tsx +20 -0
- package/src/query-batch.ts +6 -3
- package/src/sidebar-layout-manager.tsx +3 -3
- package/src/use-flip-animation.ts +16 -3
- package/src/use-persisted-query.ts +152 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -25,23 +25,50 @@
|
|
|
25
25
|
// dropped into a lane reachable from its current stage. Disallowed lanes dim
|
|
26
26
|
// while dragging and reject the drop.
|
|
27
27
|
import * as React from 'react'
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
useCallback,
|
|
30
|
+
useContext,
|
|
31
|
+
useEffect,
|
|
32
|
+
useId,
|
|
33
|
+
useLayoutEffect,
|
|
34
|
+
useMemo,
|
|
35
|
+
useRef,
|
|
36
|
+
useState,
|
|
37
|
+
useSyncExternalStore,
|
|
38
|
+
} from 'react'
|
|
29
39
|
import { useTranslation } from 'react-i18next'
|
|
40
|
+
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
41
|
+
import {
|
|
42
|
+
QueryClient,
|
|
43
|
+
QueryClientContext,
|
|
44
|
+
QueryClientProvider,
|
|
45
|
+
useQueryClient,
|
|
46
|
+
} from '@tanstack/react-query'
|
|
30
47
|
import { useI18nResourceVersion } from './use-i18n-resource-version'
|
|
31
48
|
import {
|
|
32
49
|
DndContext,
|
|
33
|
-
|
|
50
|
+
KeyboardSensor,
|
|
34
51
|
PointerSensor,
|
|
52
|
+
closestCenter,
|
|
53
|
+
defaultDropAnimationSideEffects,
|
|
54
|
+
pointerWithin,
|
|
55
|
+
rectIntersection,
|
|
35
56
|
useSensor,
|
|
36
57
|
useSensors,
|
|
37
58
|
useDraggable,
|
|
38
59
|
useDroppable,
|
|
60
|
+
type Announcements,
|
|
61
|
+
type CollisionDetection,
|
|
39
62
|
type DragStartEvent,
|
|
40
63
|
type DragEndEvent,
|
|
64
|
+
type DragOverEvent,
|
|
65
|
+
type DropAnimation,
|
|
66
|
+
type KeyboardCoordinateGetter,
|
|
41
67
|
} from '@dnd-kit/core'
|
|
42
68
|
import {
|
|
43
69
|
SortableContext,
|
|
44
70
|
horizontalListSortingStrategy,
|
|
71
|
+
sortableKeyboardCoordinates,
|
|
45
72
|
useSortable,
|
|
46
73
|
} from '@dnd-kit/sortable'
|
|
47
74
|
import { CSS } from '@dnd-kit/utilities'
|
|
@@ -121,11 +148,20 @@ import {
|
|
|
121
148
|
translateOptionLabels,
|
|
122
149
|
} from './filter-chips'
|
|
123
150
|
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
151
|
+
import { useOptimisticMutation } from './use-optimistic-mutation'
|
|
152
|
+
import { useFlipAnimation } from './use-flip-animation'
|
|
153
|
+
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
154
|
+
import { objectLabel } from './dynamic-relation-helpers'
|
|
124
155
|
import { useMetadataCache } from './metadata-cache'
|
|
125
156
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
126
157
|
import { DynamicIcon } from './dynamic-icon'
|
|
127
158
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
128
|
-
import {
|
|
159
|
+
import {
|
|
160
|
+
aggregateOf,
|
|
161
|
+
formatAggregateTotal,
|
|
162
|
+
isRowActionVisible,
|
|
163
|
+
relationKeyFor,
|
|
164
|
+
} from './dynamic-columns'
|
|
129
165
|
import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
|
|
130
166
|
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
131
167
|
import { useStageLayout } from './stage-layout'
|
|
@@ -206,18 +242,29 @@ export function groupByStage(
|
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
/**
|
|
209
|
-
* Whether a card may move `from → to`
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
245
|
+
* Whether a card may move `from → to` — the same rule as the kernel's
|
|
246
|
+
* `StageMachine.Allows`, so a lane the board offers never ends in a 422:
|
|
247
|
+
*
|
|
248
|
+
* - no `transitions` in the metadata → no stage machine, any move;
|
|
249
|
+
* - a move to the same stage is a no-op, always allowed;
|
|
250
|
+
* - a card with no stage yet — or one outside `stageKeys` (a value written
|
|
251
|
+
* before the machine existed) — is being placed, not moved: any stage takes it;
|
|
252
|
+
* - otherwise the move must be declared. An EMPTY list means the machine allows
|
|
253
|
+
* no moves between its stages.
|
|
254
|
+
*
|
|
255
|
+
* `'*'` is a wildcard on either side.
|
|
213
256
|
*/
|
|
214
257
|
export function isTransitionAllowed(
|
|
215
258
|
transitions: StageTransition[] | undefined,
|
|
216
259
|
from: string,
|
|
217
260
|
to: string,
|
|
261
|
+
stageKeys?: ReadonlySet<string>,
|
|
218
262
|
): boolean {
|
|
219
263
|
if (from === to) return true
|
|
220
|
-
if (!transitions
|
|
264
|
+
if (!transitions) return true
|
|
265
|
+
if (from === '' || (stageKeys && !stageKeys.has(from))) {
|
|
266
|
+
return !stageKeys || stageKeys.has(to)
|
|
267
|
+
}
|
|
221
268
|
return transitions.some(
|
|
222
269
|
(t) =>
|
|
223
270
|
(t.from === from || t.from === '*') && (t.to === to || t.to === '*'),
|
|
@@ -346,6 +393,188 @@ export function cardCellValue(card: any, col: ColumnDefinition): unknown {
|
|
|
346
393
|
return raw
|
|
347
394
|
}
|
|
348
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Plain text of a card cell — what a person reads on the card. A resolved
|
|
398
|
+
* relation reads as its label/name (never the raw FK uuid), so the lane search
|
|
399
|
+
* and the drag announcements match what is on screen. Pure — exported for tests.
|
|
400
|
+
*/
|
|
401
|
+
export function cardCellText(card: any, col: ColumnDefinition): string {
|
|
402
|
+
const v = cardCellValue(card, col)
|
|
403
|
+
if (v === null || v === undefined) return ''
|
|
404
|
+
if (typeof v === 'object') {
|
|
405
|
+
if (Array.isArray(v)) {
|
|
406
|
+
return v
|
|
407
|
+
.map((x) => (typeof x === 'object' ? objectLabel(x) ?? '' : String(x)))
|
|
408
|
+
.filter(Boolean)
|
|
409
|
+
.join(', ')
|
|
410
|
+
}
|
|
411
|
+
return objectLabel(v) ?? ''
|
|
412
|
+
}
|
|
413
|
+
return String(v)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Whether a card field has nothing worth a row: unset, blank, an empty list,
|
|
420
|
+
* `false`, or an opaque id that no relation resolved to a name (a soft-ref
|
|
421
|
+
* uuid column). The card hides those rows instead of painting "Campo: —".
|
|
422
|
+
* Pure — exported for unit tests.
|
|
423
|
+
*/
|
|
424
|
+
export function isEmptyCardValue(card: any, col: ColumnDefinition): boolean {
|
|
425
|
+
const v = cardCellValue(card, col)
|
|
426
|
+
if (v === null || v === undefined || v === false) return true
|
|
427
|
+
if (typeof v === 'string') {
|
|
428
|
+
const s = v.trim()
|
|
429
|
+
return s === '' || UUID_RE.test(s)
|
|
430
|
+
}
|
|
431
|
+
if (Array.isArray(v)) return v.length === 0
|
|
432
|
+
if (typeof v === 'object') return Object.keys(v).length === 0
|
|
433
|
+
return false
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The fields a card shows: the first `max` candidates that carry a value for
|
|
438
|
+
* THIS card. Candidates come in metadata order, so a card with an empty
|
|
439
|
+
* "Asignado a" shows the next useful field instead of a dash. Pure.
|
|
440
|
+
*/
|
|
441
|
+
export function visibleCardFields(
|
|
442
|
+
card: any,
|
|
443
|
+
candidates: ColumnDefinition[],
|
|
444
|
+
max = 3,
|
|
445
|
+
): ColumnDefinition[] {
|
|
446
|
+
const out: ColumnDefinition[] = []
|
|
447
|
+
for (const col of candidates) {
|
|
448
|
+
if (out.length >= max) break
|
|
449
|
+
if (!isEmptyCardValue(card, col)) out.push(col)
|
|
450
|
+
}
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Records as the board paints them: each card with an unconfirmed move shows in
|
|
456
|
+
* its destination lane. The fetched records stay as the server sent them, so a
|
|
457
|
+
* rejected move just drops its override and the card is back where it was.
|
|
458
|
+
* Pure — exported for unit tests.
|
|
459
|
+
*/
|
|
460
|
+
export function applyStageOverrides(
|
|
461
|
+
records: any[],
|
|
462
|
+
overrides: Record<string, string> | undefined,
|
|
463
|
+
groupByKey: string,
|
|
464
|
+
): any[] {
|
|
465
|
+
if (!overrides || Object.keys(overrides).length === 0) return records
|
|
466
|
+
return records.map((r) => {
|
|
467
|
+
const to = overrides[String(r.id)]
|
|
468
|
+
if (to === undefined || String(r?.[groupByKey] ?? '') === to) return r
|
|
469
|
+
return { ...r, [groupByKey]: to }
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Per-lane change an in-flight move makes to the server totals. */
|
|
474
|
+
export interface LaneDelta {
|
|
475
|
+
count: number
|
|
476
|
+
sums: Record<string, number>
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function numericValue(v: unknown): number | null {
|
|
480
|
+
if (v === null || v === undefined || v === '') return null
|
|
481
|
+
const n = typeof v === 'number' ? v : Number(v)
|
|
482
|
+
return Number.isFinite(n) ? n : null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* How the unconfirmed moves shift each lane's server count and aggregate sums:
|
|
487
|
+
* the source lane loses the card (and its values), the destination gains them.
|
|
488
|
+
* Lets the header totals follow the drag live and snap back on a rollback
|
|
489
|
+
* without touching the server figures. Pure — exported for unit tests.
|
|
490
|
+
*/
|
|
491
|
+
export function laneDeltasFromOverrides(
|
|
492
|
+
records: any[],
|
|
493
|
+
overrides: Record<string, string> | undefined,
|
|
494
|
+
groupByKey: string,
|
|
495
|
+
aggregateKeys: string[] = [],
|
|
496
|
+
): Record<string, LaneDelta> {
|
|
497
|
+
const out: Record<string, LaneDelta> = {}
|
|
498
|
+
if (!overrides) return out
|
|
499
|
+
const bump = (stage: string, card: any, sign: 1 | -1) => {
|
|
500
|
+
const d = (out[stage] ??= { count: 0, sums: {} })
|
|
501
|
+
d.count += sign
|
|
502
|
+
for (const key of aggregateKeys) {
|
|
503
|
+
const n = numericValue(card?.[key])
|
|
504
|
+
if (n !== null) d.sums[key] = (d.sums[key] ?? 0) + sign * n
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const r of records) {
|
|
508
|
+
const to = overrides[String(r.id)]
|
|
509
|
+
if (to === undefined) continue
|
|
510
|
+
const from = String(r?.[groupByKey] ?? '')
|
|
511
|
+
if (from === to) continue
|
|
512
|
+
bump(from, r, -1)
|
|
513
|
+
bump(to, r, 1)
|
|
514
|
+
}
|
|
515
|
+
return out
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Returns a NEW per-lane aggregate map with a card's numeric values moved from
|
|
520
|
+
* `fromStage` to `toStage`. Lanes with no known sums yet are left alone. Pure.
|
|
521
|
+
*/
|
|
522
|
+
export function applyLaneAggregatesOnMove(
|
|
523
|
+
aggregates: Record<string, Record<string, number>>,
|
|
524
|
+
card: any,
|
|
525
|
+
fromStage: string,
|
|
526
|
+
toStage: string,
|
|
527
|
+
aggregateKeys: string[],
|
|
528
|
+
): Record<string, Record<string, number>> {
|
|
529
|
+
const next = { ...aggregates }
|
|
530
|
+
const shift = (stage: string, sign: 1 | -1) => {
|
|
531
|
+
const cur = next[stage]
|
|
532
|
+
if (!cur) return
|
|
533
|
+
const updated = { ...cur }
|
|
534
|
+
for (const key of aggregateKeys) {
|
|
535
|
+
const n = numericValue(card?.[key])
|
|
536
|
+
if (n !== null) updated[key] = (numericValue(updated[key]) ?? 0) + sign * n
|
|
537
|
+
}
|
|
538
|
+
next[stage] = updated
|
|
539
|
+
}
|
|
540
|
+
shift(fromStage, -1)
|
|
541
|
+
shift(toStage, 1)
|
|
542
|
+
return next
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** A lane's on-screen box, as the keyboard navigation reads it. */
|
|
546
|
+
export interface LaneRect {
|
|
547
|
+
id: string
|
|
548
|
+
left: number
|
|
549
|
+
top: number
|
|
550
|
+
width: number
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Keyboard drag: where the dragged card goes when the user presses ← or →.
|
|
555
|
+
* Picks the nearest lane to the left/right of the card's current center and
|
|
556
|
+
* returns the top-left coordinates that center the card in that lane, just
|
|
557
|
+
* under its header. Undefined when there is no lane further in that direction.
|
|
558
|
+
* Pure — exported for unit tests.
|
|
559
|
+
*/
|
|
560
|
+
export function nextLaneCoordinates(
|
|
561
|
+
direction: 'left' | 'right',
|
|
562
|
+
current: { left: number; top: number; width: number },
|
|
563
|
+
lanes: LaneRect[],
|
|
564
|
+
): { x: number; y: number } | undefined {
|
|
565
|
+
const center = current.left + current.width / 2
|
|
566
|
+
const sorted = [...lanes].sort((a, b) => a.left - b.left)
|
|
567
|
+
const target =
|
|
568
|
+
direction === 'right'
|
|
569
|
+
? sorted.find((l) => l.left > center)
|
|
570
|
+
: [...sorted].reverse().find((l) => l.left + l.width < center)
|
|
571
|
+
if (!target) return undefined
|
|
572
|
+
return {
|
|
573
|
+
x: target.left + (target.width - current.width) / 2,
|
|
574
|
+
y: Math.max(current.top, target.top + 56),
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
349
578
|
/**
|
|
350
579
|
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
351
580
|
* equality (IN — the card's field value must be one of them); a free-text
|
|
@@ -392,11 +621,13 @@ export function cardMatchesLaneQuery(
|
|
|
392
621
|
): boolean {
|
|
393
622
|
const q = query.trim().toLowerCase()
|
|
394
623
|
if (!q) return true
|
|
395
|
-
return cols.some((c) =>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
624
|
+
return cols.some((c) => {
|
|
625
|
+
// Match what the card shows (a relation's name) and the raw value.
|
|
626
|
+
const shown = cardCellText(card, c).toLowerCase()
|
|
627
|
+
if (shown.includes(q)) return true
|
|
628
|
+
const raw = card?.[c.key]
|
|
629
|
+
return typeof raw !== 'object' && String(raw ?? '').toLowerCase().includes(q)
|
|
630
|
+
})
|
|
400
631
|
}
|
|
401
632
|
|
|
402
633
|
// ---------------------------------------------------------------------------
|
|
@@ -500,7 +731,55 @@ export interface DynamicKanbanProps {
|
|
|
500
731
|
defaultFilters?: Record<string, any>
|
|
501
732
|
}
|
|
502
733
|
|
|
503
|
-
|
|
734
|
+
/**
|
|
735
|
+
* The optimistic card move runs on TanStack Query (useOptimisticMutation). A
|
|
736
|
+
* host that mounts no QueryClientProvider still gets a working board: the
|
|
737
|
+
* kanban brings its own client in that case.
|
|
738
|
+
*/
|
|
739
|
+
export function DynamicKanban(props: DynamicKanbanProps) {
|
|
740
|
+
const hostClient = useContext(QueryClientContext)
|
|
741
|
+
const [ownClient] = useState(() =>
|
|
742
|
+
hostClient
|
|
743
|
+
? null
|
|
744
|
+
: new QueryClient({ defaultOptions: { mutations: { retry: false } } }),
|
|
745
|
+
)
|
|
746
|
+
if (!hostClient && ownClient) {
|
|
747
|
+
return (
|
|
748
|
+
<QueryClientProvider client={ownClient}>
|
|
749
|
+
<DynamicKanbanBoard {...props} />
|
|
750
|
+
</QueryClientProvider>
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
return <DynamicKanbanBoard {...props} />
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** How long the drop animation takes to land the card in its lane. */
|
|
757
|
+
const DROP_ANIMATION: DropAnimation = {
|
|
758
|
+
duration: 220,
|
|
759
|
+
easing: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
760
|
+
sideEffects: defaultDropAnimationSideEffects({
|
|
761
|
+
styles: { active: { opacity: '0' } },
|
|
762
|
+
}),
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Card fields a card may pick from; it shows the first few with a value. */
|
|
766
|
+
const CARD_FIELD_CANDIDATES = 8
|
|
767
|
+
const CARD_FIELDS_SHOWN = 3
|
|
768
|
+
|
|
769
|
+
/** No card move in flight (a stable empty snapshot). */
|
|
770
|
+
const NO_MOVES: Record<string, string> = Object.freeze({}) as Record<string, string>
|
|
771
|
+
|
|
772
|
+
/** Server figures for one lane: its aggregate sums, keyed by column. */
|
|
773
|
+
type LaneAggregates = Record<string, Record<string, number>>
|
|
774
|
+
|
|
775
|
+
/** A card move in flight: which card, and the lanes it leaves and enters. */
|
|
776
|
+
interface CardMove {
|
|
777
|
+
cardId: string
|
|
778
|
+
from: string
|
|
779
|
+
to: string
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function DynamicKanbanBoard({
|
|
504
783
|
model,
|
|
505
784
|
endpoint,
|
|
506
785
|
refreshTrigger,
|
|
@@ -509,11 +788,17 @@ export function DynamicKanban({
|
|
|
509
788
|
onAction,
|
|
510
789
|
pageSize = 50,
|
|
511
790
|
lanePageSize = 25,
|
|
512
|
-
timeZone,
|
|
513
|
-
currency,
|
|
791
|
+
timeZone: timeZoneProp,
|
|
792
|
+
currency: currencyProp,
|
|
514
793
|
defaultFilters,
|
|
515
794
|
}: DynamicKanbanProps) {
|
|
516
795
|
const { t, i18n } = useTranslation()
|
|
796
|
+
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
797
|
+
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
798
|
+
const orgTimeZone = useTimeZone()
|
|
799
|
+
const orgCurrency = useCurrency()
|
|
800
|
+
const timeZone = timeZoneProp ?? orgTimeZone
|
|
801
|
+
const currency = currencyProp ?? orgCurrency
|
|
517
802
|
const api = useApi()
|
|
518
803
|
const isDark = useIsDarkTheme()
|
|
519
804
|
// Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
|
|
@@ -575,6 +860,9 @@ export function DynamicKanban({
|
|
|
575
860
|
// Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
|
|
576
861
|
// rather than moving a card. Kept apart so onDragEnd routes by draggable type.
|
|
577
862
|
const [activeLaneId, setActiveLaneId] = useState<string | null>(null)
|
|
863
|
+
// Lane under the dragged card — drives the "allowed / not allowed" feedback
|
|
864
|
+
// (ring on the lane, not-allowed cursor on the lifted card).
|
|
865
|
+
const [overLaneId, setOverLaneId] = useState<string | null>(null)
|
|
578
866
|
|
|
579
867
|
// Per-org lane order. `useStageLayout` reports whether the host wired the
|
|
580
868
|
// `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
|
|
@@ -591,9 +879,40 @@ export function DynamicKanban({
|
|
|
591
879
|
// stage totals into the fresh board.
|
|
592
880
|
const fetchGenRef = useRef(0)
|
|
593
881
|
|
|
882
|
+
// Keyboard drag (a11y): Space/Enter lifts a focused card, ←/→ jump to the
|
|
883
|
+
// neighbouring lane, Space/Enter drops, Escape cancels. A lane header drag
|
|
884
|
+
// keeps the sortable's own keyboard coordinates.
|
|
885
|
+
const keyboardCoordinates = useCallback<KeyboardCoordinateGetter>((event, args) => {
|
|
886
|
+
const { context } = args
|
|
887
|
+
if (context.active?.data.current?.type === 'lane') {
|
|
888
|
+
return sortableKeyboardCoordinates(event, args)
|
|
889
|
+
}
|
|
890
|
+
const direction =
|
|
891
|
+
event.code === 'ArrowRight' ? 'right' : event.code === 'ArrowLeft' ? 'left' : null
|
|
892
|
+
if (!direction) return undefined
|
|
893
|
+
event.preventDefault()
|
|
894
|
+
const rect = context.collisionRect
|
|
895
|
+
if (!rect) return undefined
|
|
896
|
+
const lanes: LaneRect[] = []
|
|
897
|
+
for (const c of context.droppableContainers.getEnabled()) {
|
|
898
|
+
const r = context.droppableRects.get(c.id)
|
|
899
|
+
if (r) lanes.push({ id: String(c.id), left: r.left, top: r.top, width: r.width })
|
|
900
|
+
}
|
|
901
|
+
return nextLaneCoordinates(direction, rect, lanes)
|
|
902
|
+
}, [])
|
|
594
903
|
const sensors = useSensors(
|
|
595
904
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
905
|
+
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }),
|
|
596
906
|
)
|
|
907
|
+
// A card lands in the lane under the pointer (a column is a tall target, so
|
|
908
|
+
// "where the pointer is" beats "what the card overlaps"); keyboard drags have
|
|
909
|
+
// no pointer and fall back to overlap. A lane header drag snaps to the
|
|
910
|
+
// nearest column.
|
|
911
|
+
const collisionDetection = useCallback<CollisionDetection>((args) => {
|
|
912
|
+
if (args.active.data.current?.type === 'lane') return closestCenter(args)
|
|
913
|
+
const hits = pointerWithin(args)
|
|
914
|
+
return hits.length > 0 ? hits : rectIntersection(args)
|
|
915
|
+
}, [])
|
|
597
916
|
|
|
598
917
|
// ---- metadata fetch (same path as DynamicTable) ----
|
|
599
918
|
useEffect(() => {
|
|
@@ -642,6 +961,102 @@ export function DynamicKanban({
|
|
|
642
961
|
clearAll,
|
|
643
962
|
} = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
|
|
644
963
|
|
|
964
|
+
const groupByKey = metadata?.group_by || ''
|
|
965
|
+
// Extra per-lane conditions (stage overrides) keyed by stage. A real lane
|
|
966
|
+
// that carries these queries its data — and counts its header — with the
|
|
967
|
+
// stage scope PLUS these filters (serialized like a smart lane's), so the
|
|
968
|
+
// top-up + lane-summary requests below layer them on. Sourced from
|
|
969
|
+
// `metadata.stages` (the kernel applies declared + custom-real overrides).
|
|
970
|
+
const stageExtraFilters = useMemo(() => {
|
|
971
|
+
const m = new Map<string, CustomStageFilter[]>()
|
|
972
|
+
for (const s of metadata?.stages ?? []) {
|
|
973
|
+
if (s.filters && s.filters.length > 0) {
|
|
974
|
+
m.set(
|
|
975
|
+
s.key,
|
|
976
|
+
s.filters.map((f) => ({
|
|
977
|
+
field: f.field,
|
|
978
|
+
op: f.op as CustomStageFilter['op'],
|
|
979
|
+
value: f.value,
|
|
980
|
+
})),
|
|
981
|
+
)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return m
|
|
985
|
+
}, [metadata?.stages])
|
|
986
|
+
|
|
987
|
+
// Columns the model declares a total for (`display_config.aggregate` — the
|
|
988
|
+
// same flag the table footer reads). Each lane header shows their sum over
|
|
989
|
+
// the lane, from the same `/aggregate` endpoint the footer uses.
|
|
990
|
+
const aggregateCols = useMemo(
|
|
991
|
+
() => (metadata?.columns ?? []).filter((c) => aggregateOf(c)),
|
|
992
|
+
[metadata],
|
|
993
|
+
)
|
|
994
|
+
const aggregateKeys = useMemo(() => aggregateCols.map((c) => c.key), [aggregateCols])
|
|
995
|
+
const [laneAggregates, setLaneAggregates] = useState<LaneAggregates>({})
|
|
996
|
+
|
|
997
|
+
// Asks the server for one lane's figures, scoped by the active filters and
|
|
998
|
+
// the lane's own conditions: the real card count (a `per_page=1` list read,
|
|
999
|
+
// only `meta.total` is used) and, when the model declares totals, the lane's
|
|
1000
|
+
// sums. `overwrite` replaces a total already known — used after a move, when
|
|
1001
|
+
// the server is the one to settle what an automation may have changed.
|
|
1002
|
+
const probeLane = useCallback(
|
|
1003
|
+
async (stageKey: string, gen: number, overwrite: boolean) => {
|
|
1004
|
+
const gb = metadata?.group_by
|
|
1005
|
+
if (!gb) return
|
|
1006
|
+
const base = endpoint || `/data/${model}`
|
|
1007
|
+
const scope = {
|
|
1008
|
+
...filterParams,
|
|
1009
|
+
...smartLaneParams(stageExtraFilters.get(stageKey)),
|
|
1010
|
+
}
|
|
1011
|
+
const count = (async () => {
|
|
1012
|
+
try {
|
|
1013
|
+
const r = (await api.get(base, {
|
|
1014
|
+
params: { ...scope, page: 1, per_page: 1, [`f_${gb}`]: stageKey },
|
|
1015
|
+
})) as { data: ApiResponse<any[]> & { meta?: any } }
|
|
1016
|
+
const total = r.data.meta?.total ?? r.data.meta?.count ?? null
|
|
1017
|
+
if (total == null || gen !== fetchGenRef.current) return
|
|
1018
|
+
setLanePagination((p) => {
|
|
1019
|
+
const existing = p[stageKey]
|
|
1020
|
+
// A real page fetch already learned this lane's total.
|
|
1021
|
+
if (!overwrite && existing?.total != null) return p
|
|
1022
|
+
return {
|
|
1023
|
+
...p,
|
|
1024
|
+
[stageKey]: {
|
|
1025
|
+
nextPage: existing?.nextPage ?? 1,
|
|
1026
|
+
total,
|
|
1027
|
+
loading: existing?.loading ?? false,
|
|
1028
|
+
done: existing?.done ?? total === 0,
|
|
1029
|
+
},
|
|
1030
|
+
}
|
|
1031
|
+
})
|
|
1032
|
+
} catch {
|
|
1033
|
+
// A failed total just leaves the header on the loaded count.
|
|
1034
|
+
}
|
|
1035
|
+
})()
|
|
1036
|
+
const sums = (async () => {
|
|
1037
|
+
if (aggregateKeys.length === 0) return
|
|
1038
|
+
try {
|
|
1039
|
+
const r = (await api.get(`${base}/aggregate`, {
|
|
1040
|
+
params: { ...scope, [`f_${gb}`]: stageKey },
|
|
1041
|
+
})) as { data: ApiResponse<Record<string, any>> }
|
|
1042
|
+
const data = r.data?.success ? r.data.data : null
|
|
1043
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return
|
|
1044
|
+
if (gen !== fetchGenRef.current) return
|
|
1045
|
+
const row: Record<string, number> = {}
|
|
1046
|
+
for (const key of aggregateKeys) {
|
|
1047
|
+
const n = Number(data[key])
|
|
1048
|
+
row[key] = Number.isFinite(n) ? n : 0
|
|
1049
|
+
}
|
|
1050
|
+
setLaneAggregates((prev) => ({ ...prev, [stageKey]: row }))
|
|
1051
|
+
} catch {
|
|
1052
|
+
// No sums for this lane: the header just shows the count.
|
|
1053
|
+
}
|
|
1054
|
+
})()
|
|
1055
|
+
await Promise.all([count, sums])
|
|
1056
|
+
},
|
|
1057
|
+
[api, endpoint, model, metadata?.group_by, filterParams, stageExtraFilters, aggregateKeys],
|
|
1058
|
+
)
|
|
1059
|
+
|
|
645
1060
|
// ---- initial board page (one request, grouped into lanes) ----
|
|
646
1061
|
// Resets the per-lane pagination so every lane restarts its incremental
|
|
647
1062
|
// top-up from scratch — called on mount, refresh, and any filter/search
|
|
@@ -661,83 +1076,49 @@ export function DynamicKanban({
|
|
|
661
1076
|
setLoadingData(false)
|
|
662
1077
|
}
|
|
663
1078
|
setLanePagination({})
|
|
664
|
-
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (gb && declaredStages.length > 0) {
|
|
674
|
-
void Promise.all(
|
|
675
|
-
declaredStages.map(async (stage) => {
|
|
676
|
-
try {
|
|
677
|
-
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
678
|
-
params: {
|
|
679
|
-
...filterParams,
|
|
680
|
-
...smartLaneParams(stage.filters),
|
|
681
|
-
page: 1,
|
|
682
|
-
per_page: 1,
|
|
683
|
-
[`f_${gb}`]: stage.key,
|
|
684
|
-
},
|
|
685
|
-
})) as { data: ApiResponse<any[]> & { meta?: any } }
|
|
686
|
-
const total = r.data.meta?.total ?? r.data.meta?.count ?? null
|
|
687
|
-
if (total == null || gen !== fetchGenRef.current) return
|
|
688
|
-
setLanePagination((p) => {
|
|
689
|
-
const existing = p[stage.key]
|
|
690
|
-
// A real page fetch already learned this lane's total.
|
|
691
|
-
if (existing?.total != null) return p
|
|
692
|
-
return {
|
|
693
|
-
...p,
|
|
694
|
-
[stage.key]: {
|
|
695
|
-
nextPage: existing?.nextPage ?? 1,
|
|
696
|
-
total,
|
|
697
|
-
loading: existing?.loading ?? false,
|
|
698
|
-
done: existing?.done ?? total === 0,
|
|
699
|
-
},
|
|
700
|
-
}
|
|
701
|
-
})
|
|
702
|
-
} catch (err) {
|
|
703
|
-
// A failed total just leaves the header on the loaded count.
|
|
704
|
-
}
|
|
705
|
-
}),
|
|
706
|
-
)
|
|
1079
|
+
setLaneAggregates({})
|
|
1080
|
+
// Eager per-lane figures: so every lane header shows the REAL stage count
|
|
1081
|
+
// (and its declared sums) on first render — not just what the global page
|
|
1082
|
+
// happened to load — probe each declared stage in parallel, scoped by the
|
|
1083
|
+
// SAME active filters/search. The "unassigned" lane can't be
|
|
1084
|
+
// stage-scoped, so it keeps its loaded count.
|
|
1085
|
+
const declared = deriveStages(metadata)
|
|
1086
|
+
if (metadata.group_by && declared.length > 0) {
|
|
1087
|
+
void Promise.all(declared.map((stage) => probeLane(stage.key, gen, false)))
|
|
707
1088
|
}
|
|
708
|
-
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
1089
|
+
}, [api, endpoint, model, metadata, pageSize, filterParams, probeLane])
|
|
1090
|
+
|
|
1091
|
+
// Re-reads the server figures of the lanes a move touched.
|
|
1092
|
+
const refreshLanes = useCallback(
|
|
1093
|
+
(stageKeys: string[]) => {
|
|
1094
|
+
const gen = fetchGenRef.current
|
|
1095
|
+
for (const key of new Set(stageKeys)) {
|
|
1096
|
+
if (key && key !== UNASSIGNED_LANE) void probeLane(key, gen, true)
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
[probeLane],
|
|
1100
|
+
)
|
|
709
1101
|
|
|
710
1102
|
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
711
1103
|
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
712
1104
|
// filterParams (the stage scope wins over any global group_by filter).
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
for (const s of metadata?.stages ?? []) {
|
|
722
|
-
if (s.filters && s.filters.length > 0) {
|
|
723
|
-
m.set(
|
|
724
|
-
s.key,
|
|
725
|
-
s.filters.map((f) => ({
|
|
726
|
-
field: f.field,
|
|
727
|
-
op: f.op as CustomStageFilter['op'],
|
|
728
|
-
value: f.value,
|
|
729
|
-
})),
|
|
730
|
-
)
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
return m
|
|
734
|
-
}, [metadata?.stages])
|
|
1105
|
+
//
|
|
1106
|
+
// The page is derived from how many of the stage's cards are loaded, not
|
|
1107
|
+
// from a counter: once a card leaves the lane the server's pages shift by
|
|
1108
|
+
// one, and a counter would skip the card that slid into the gap. Reading
|
|
1109
|
+
// from the loaded count re-requests that page instead (dedupe drops the
|
|
1110
|
+
// repeats).
|
|
1111
|
+
const recordsRef = useRef<any[]>(records)
|
|
1112
|
+
recordsRef.current = records
|
|
735
1113
|
const loadMoreLane = useCallback(
|
|
736
1114
|
async (stageKey: string) => {
|
|
737
1115
|
if (!metadata || !groupByKey) return
|
|
738
1116
|
const current = lanePagination[stageKey]
|
|
739
1117
|
if (current?.loading || current?.done) return
|
|
740
|
-
const
|
|
1118
|
+
const loaded = recordsRef.current.filter(
|
|
1119
|
+
(r) => String(r?.[groupByKey] ?? '') === stageKey,
|
|
1120
|
+
).length
|
|
1121
|
+
const nextPage = Math.floor(loaded / lanePageSize) + 1
|
|
741
1122
|
setLanePagination((p) => ({
|
|
742
1123
|
...p,
|
|
743
1124
|
[stageKey]: {
|
|
@@ -978,13 +1359,48 @@ export function DynamicKanban({
|
|
|
978
1359
|
[stages],
|
|
979
1360
|
)
|
|
980
1361
|
|
|
1362
|
+
// ---- optimistic card moves ----
|
|
1363
|
+
// Unconfirmed moves live in their own small cache entry (`cardId → lane`),
|
|
1364
|
+
// layered over the fetched records at paint time. The records themselves
|
|
1365
|
+
// only change once the server confirms, so a rollback is just dropping the
|
|
1366
|
+
// override — it can never clobber cards a lane loaded meanwhile.
|
|
1367
|
+
const qc = useQueryClient()
|
|
1368
|
+
const boardInstance = useId()
|
|
1369
|
+
const movesKey = useMemo(
|
|
1370
|
+
() => ['metacore', 'kanban-moves', model, boardInstance] as const,
|
|
1371
|
+
[model, boardInstance],
|
|
1372
|
+
)
|
|
1373
|
+
// Read synchronously (not through useQuery, whose notifications land a tick
|
|
1374
|
+
// later): the override must paint in the SAME commit as the drop, so the
|
|
1375
|
+
// drag overlay's drop animation flies to the card's new lane, not its old one.
|
|
1376
|
+
const moveOverrides = useSyncExternalStore(
|
|
1377
|
+
useCallback((onChange: () => void) => qc.getQueryCache().subscribe(onChange), [qc]),
|
|
1378
|
+
() => qc.getQueryData<Record<string, string>>(movesKey) ?? NO_MOVES,
|
|
1379
|
+
() => NO_MOVES,
|
|
1380
|
+
)
|
|
1381
|
+
useEffect(() => () => qc.removeQueries({ queryKey: movesKey, exact: true }), [qc, movesKey])
|
|
1382
|
+
const displayRecords = useMemo(
|
|
1383
|
+
() => applyStageOverrides(records, moveOverrides, groupByKey),
|
|
1384
|
+
[records, moveOverrides, groupByKey],
|
|
1385
|
+
)
|
|
1386
|
+
// How the moves in flight shift each lane's server count and sums.
|
|
1387
|
+
const laneDeltas = useMemo(
|
|
1388
|
+
() => laneDeltasFromOverrides(records, moveOverrides, groupByKey, aggregateKeys),
|
|
1389
|
+
[records, moveOverrides, groupByKey, aggregateKeys],
|
|
1390
|
+
)
|
|
1391
|
+
|
|
981
1392
|
const grouped = useMemo(
|
|
982
|
-
() => groupByStage(
|
|
983
|
-
[
|
|
1393
|
+
() => groupByStage(displayRecords, groupByKey, stages),
|
|
1394
|
+
[displayRecords, groupByKey, stages],
|
|
984
1395
|
)
|
|
985
1396
|
|
|
1397
|
+
// Card fields: a few more candidates than a card shows, so a card whose
|
|
1398
|
+
// first fields are empty shows the next ones that have a value.
|
|
986
1399
|
const { title: titleCol, fields: fieldCols } = useMemo(
|
|
987
|
-
() =>
|
|
1400
|
+
() =>
|
|
1401
|
+
metadata
|
|
1402
|
+
? selectCardColumns(metadata, CARD_FIELD_CANDIDATES)
|
|
1403
|
+
: { title: null, fields: [] },
|
|
988
1404
|
[metadata],
|
|
989
1405
|
)
|
|
990
1406
|
|
|
@@ -1023,9 +1439,9 @@ export function DynamicKanban({
|
|
|
1023
1439
|
|
|
1024
1440
|
const cardById = useMemo(() => {
|
|
1025
1441
|
const m = new Map<string, any>()
|
|
1026
|
-
for (const r of
|
|
1442
|
+
for (const r of displayRecords) m.set(String(r.id), r)
|
|
1027
1443
|
return m
|
|
1028
|
-
}, [
|
|
1444
|
+
}, [displayRecords])
|
|
1029
1445
|
|
|
1030
1446
|
const stageOfCard = useCallback(
|
|
1031
1447
|
(id: string): string => {
|
|
@@ -1036,12 +1452,125 @@ export function DynamicKanban({
|
|
|
1036
1452
|
[cardById, groupByKey],
|
|
1037
1453
|
)
|
|
1038
1454
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1455
|
+
// FLIP: cards glide to their new place when a move lands or rolls back.
|
|
1456
|
+
// The card just dropped is skipped — the drag overlay already carries it.
|
|
1457
|
+
const boardRef = useRef<HTMLDivElement | null>(null)
|
|
1458
|
+
const droppedCardRef = useRef<string | null>(null)
|
|
1459
|
+
const flipShouldAnimate = useCallback(
|
|
1460
|
+
(_el: HTMLElement, key: string) => key !== droppedCardRef.current,
|
|
1461
|
+
[],
|
|
1462
|
+
)
|
|
1463
|
+
useFlipAnimation(boardRef, displayRecords, { shouldAnimate: flipShouldAnimate })
|
|
1464
|
+
// The skip covers only the drop's own paint: a rollback later must glide the
|
|
1465
|
+
// card back. (Runs after the FLIP effect above — same component, in order.)
|
|
1466
|
+
useLayoutEffect(() => {
|
|
1467
|
+
droppedCardRef.current = null
|
|
1468
|
+
}, [displayRecords])
|
|
1469
|
+
|
|
1470
|
+
const cardText = useCallback(
|
|
1471
|
+
(card: any): string =>
|
|
1472
|
+
(titleCol ? cardCellText(card, titleCol) : '') || String(card?.id ?? ''),
|
|
1473
|
+
[titleCol],
|
|
1474
|
+
)
|
|
1475
|
+
const laneLabel = useCallback(
|
|
1476
|
+
(key: string): string => {
|
|
1477
|
+
if (key === UNASSIGNED_LANE) return t('kanban.unassigned', { defaultValue: 'Sin etapa' })
|
|
1478
|
+
const st = stages.find((x) => x.key === key)
|
|
1479
|
+
return st ? t(st.label, { defaultValue: st.label }) : key
|
|
1480
|
+
},
|
|
1481
|
+
[stages, t],
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
const base = endpoint || `/data/${model}`
|
|
1485
|
+
const moveCard = useOptimisticMutation<any, CardMove, Record<string, string>>({
|
|
1486
|
+
queryKey: movesKey,
|
|
1487
|
+
mutationFn: async (move) => {
|
|
1488
|
+
try {
|
|
1489
|
+
// `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
|
|
1490
|
+
// so the per-record update is just `<base>/<id>` — same convention
|
|
1491
|
+
// as DynamicTable/DynamicRelation.
|
|
1492
|
+
const res = (await api.put(`${base}/${move.cardId}`, {
|
|
1493
|
+
[groupByKey]: move.to,
|
|
1494
|
+
})) as { data?: ApiResponse<any> }
|
|
1495
|
+
if (res?.data && res.data.success === false) {
|
|
1496
|
+
throw Object.assign(new Error(res.data.message || 'update_failed'), {
|
|
1497
|
+
serverMessage: res.data.message,
|
|
1498
|
+
})
|
|
1499
|
+
}
|
|
1500
|
+
// Confirmed: the record takes the new lane (plus whatever the server
|
|
1501
|
+
// wrote with it — a transition's `set`, an automation), and the
|
|
1502
|
+
// lane figures follow in the same paint as the override going away.
|
|
1503
|
+
const saved = res?.data?.data
|
|
1504
|
+
const card = recordsRef.current.find((r) => String(r.id) === move.cardId)
|
|
1505
|
+
setRecords((rs) =>
|
|
1506
|
+
rs.map((r) =>
|
|
1507
|
+
String(r.id) === move.cardId
|
|
1508
|
+
? {
|
|
1509
|
+
...r,
|
|
1510
|
+
...(saved && typeof saved === 'object' && !Array.isArray(saved)
|
|
1511
|
+
? saved
|
|
1512
|
+
: {}),
|
|
1513
|
+
[groupByKey]: move.to,
|
|
1514
|
+
}
|
|
1515
|
+
: r,
|
|
1516
|
+
),
|
|
1517
|
+
)
|
|
1518
|
+
setLanePagination((p) => applyLaneTotalsOnMove(p, move.from, move.to))
|
|
1519
|
+
if (card) {
|
|
1520
|
+
setLaneAggregates((a) =>
|
|
1521
|
+
applyLaneAggregatesOnMove(a, card, move.from, move.to, aggregateKeys),
|
|
1522
|
+
)
|
|
1523
|
+
}
|
|
1524
|
+
return saved
|
|
1525
|
+
} catch (err: any) {
|
|
1526
|
+
// Rejected: drop this card's override so it glides back to its lane.
|
|
1527
|
+
// Done here (not in onError) so a rejected move that a newer drag
|
|
1528
|
+
// superseded still returns home and still tells the user why.
|
|
1529
|
+
qc.setQueryData<Record<string, string>>(movesKey, (cur) => {
|
|
1530
|
+
if (!cur || cur[move.cardId] !== move.to) return cur
|
|
1531
|
+
const { [move.cardId]: _dropped, ...rest } = cur
|
|
1532
|
+
return rest
|
|
1533
|
+
})
|
|
1534
|
+
const reason = err?.response?.data?.message ?? err?.serverMessage
|
|
1535
|
+
toast.error(
|
|
1536
|
+
t('kanban.moveFailed', {
|
|
1537
|
+
defaultValue: 'No se pudo mover la tarjeta',
|
|
1538
|
+
}) + (reason ? `: ${reason}` : ''),
|
|
1539
|
+
)
|
|
1540
|
+
throw err
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
optimistic: (current, move) => ({ ...(current ?? {}), [move.cardId]: move.to }),
|
|
1544
|
+
// The record already carries the confirmed lane: the override goes away.
|
|
1545
|
+
reconcile: (_data, move, current) => {
|
|
1546
|
+
if (!current || !(move.cardId in current)) return current ?? {}
|
|
1547
|
+
const { [move.cardId]: _confirmed, ...rest } = current
|
|
1548
|
+
return rest
|
|
1549
|
+
},
|
|
1550
|
+
isEqual: (a, b) => a.cardId === b.cardId && a.to === b.to,
|
|
1551
|
+
onSuccess: (_data, move) => refreshLanes([move.from, move.to]),
|
|
1552
|
+
})
|
|
1553
|
+
|
|
1554
|
+
const onDragStart = useCallback(
|
|
1555
|
+
(e: DragStartEvent) => {
|
|
1556
|
+
if (e.active.data.current?.type === 'lane') {
|
|
1557
|
+
setActiveLaneId(String(e.active.id))
|
|
1558
|
+
} else {
|
|
1559
|
+
droppedCardRef.current = null
|
|
1560
|
+
setActiveId(String(e.active.id))
|
|
1561
|
+
}
|
|
1562
|
+
},
|
|
1563
|
+
[],
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
const onDragOver = useCallback((e: DragOverEvent) => {
|
|
1567
|
+
setOverLaneId(e.over ? String(e.over.id) : null)
|
|
1568
|
+
}, [])
|
|
1569
|
+
|
|
1570
|
+
const onDragCancel = useCallback(() => {
|
|
1571
|
+
setActiveId(null)
|
|
1572
|
+
setActiveLaneId(null)
|
|
1573
|
+
setOverLaneId(null)
|
|
1045
1574
|
}, [])
|
|
1046
1575
|
|
|
1047
1576
|
// Optimistic lane reorder: reorder the columns in local state immediately,
|
|
@@ -1073,6 +1602,7 @@ export function DynamicKanban({
|
|
|
1073
1602
|
async (e: DragEndEvent) => {
|
|
1074
1603
|
setActiveId(null)
|
|
1075
1604
|
setActiveLaneId(null)
|
|
1605
|
+
setOverLaneId(null)
|
|
1076
1606
|
const { active, over } = e
|
|
1077
1607
|
// A header drag reorders columns rather than moving a card.
|
|
1078
1608
|
if (active.data.current?.type === 'lane') {
|
|
@@ -1082,59 +1612,106 @@ export function DynamicKanban({
|
|
|
1082
1612
|
if (!over) return
|
|
1083
1613
|
const cardId = String(active.id)
|
|
1084
1614
|
const destStage = String(over.id)
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1615
|
+
// Only a real stage takes a card: never a smart lane (a saved view)
|
|
1616
|
+
// nor the synthetic "Sin etapa" lane (not a value the record can hold).
|
|
1617
|
+
if (!realStageKeys.has(destStage)) return
|
|
1087
1618
|
const srcStage = stageOfCard(cardId)
|
|
1088
1619
|
if (srcStage === destStage) return
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
defaultValue: 'Movimiento no permitido entre estas etapas',
|
|
1093
|
-
}),
|
|
1094
|
-
)
|
|
1095
|
-
return
|
|
1096
|
-
}
|
|
1620
|
+
// A lane the stage machine doesn't reach was shown as "no permitido"
|
|
1621
|
+
// the whole drag; dropping there just sends the card home.
|
|
1622
|
+
if (!isTransitionAllowed(transitions, srcStage, destStage, realStageKeys)) return
|
|
1097
1623
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
|
|
1104
|
-
),
|
|
1105
|
-
)
|
|
1106
|
-
// Keep the server totals consistent with the moved card so a lane's
|
|
1107
|
-
// `count/total` header stays truthful with partial lanes: one leaves
|
|
1108
|
-
// the source stage, one joins the destination.
|
|
1109
|
-
setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage))
|
|
1624
|
+
droppedCardRef.current = cardId
|
|
1625
|
+
moveCard.mutate({ cardId, from: srcStage, to: destStage })
|
|
1626
|
+
},
|
|
1627
|
+
[moveCard, realStageKeys, stageOfCard, transitions, reorderLanes],
|
|
1628
|
+
)
|
|
1110
1629
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1630
|
+
// Screen-reader narration of a card drag (dnd-kit reads these aloud).
|
|
1631
|
+
const announcements = useMemo<Announcements>(() => {
|
|
1632
|
+
const cardName = (id: string | number) => {
|
|
1633
|
+
const card = cardById.get(String(id))
|
|
1634
|
+
return card ? cardText(card) : String(id)
|
|
1635
|
+
}
|
|
1636
|
+
const isCard = (a: { data: { current?: Record<string, any> } }) =>
|
|
1637
|
+
a.data.current?.type !== 'lane'
|
|
1638
|
+
return {
|
|
1639
|
+
onDragStart: ({ active }) =>
|
|
1640
|
+
isCard(active)
|
|
1641
|
+
? t('kanban.a11y.picked', {
|
|
1642
|
+
defaultValue: 'Tarjeta {{card}} levantada, en {{lane}}.',
|
|
1643
|
+
card: cardName(active.id),
|
|
1644
|
+
lane: laneLabel(stageOfCard(String(active.id))),
|
|
1645
|
+
})
|
|
1646
|
+
: t('kanban.a11y.lanePicked', {
|
|
1647
|
+
defaultValue: 'Columna {{lane}} levantada.',
|
|
1648
|
+
lane: laneLabel(String(active.id)),
|
|
1649
|
+
}),
|
|
1650
|
+
onDragOver: ({ active, over }) => {
|
|
1651
|
+
if (!over) return undefined
|
|
1652
|
+
if (!isCard(active)) {
|
|
1653
|
+
return t('kanban.a11y.laneOver', {
|
|
1654
|
+
defaultValue: 'Sobre la columna {{lane}}.',
|
|
1655
|
+
lane: laneLabel(String(over.id)),
|
|
1656
|
+
})
|
|
1122
1657
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1658
|
+
const from = stageOfCard(String(active.id))
|
|
1659
|
+
const to = String(over.id)
|
|
1660
|
+
return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys)
|
|
1661
|
+
? t('kanban.a11y.over', {
|
|
1662
|
+
defaultValue: 'Sobre {{lane}}.',
|
|
1663
|
+
lane: laneLabel(to),
|
|
1664
|
+
})
|
|
1665
|
+
: t('kanban.a11y.overBlocked', {
|
|
1666
|
+
defaultValue: '{{lane}}: no se puede mover aquí.',
|
|
1667
|
+
lane: laneLabel(to),
|
|
1668
|
+
})
|
|
1669
|
+
},
|
|
1670
|
+
onDragEnd: ({ active, over }) => {
|
|
1671
|
+
if (!isCard(active)) {
|
|
1672
|
+
return t('kanban.a11y.laneDropped', {
|
|
1673
|
+
defaultValue: 'Columna {{lane}} soltada.',
|
|
1674
|
+
lane: laneLabel(String(active.id)),
|
|
1675
|
+
})
|
|
1676
|
+
}
|
|
1677
|
+
const from = stageOfCard(String(active.id))
|
|
1678
|
+
const to = over ? String(over.id) : ''
|
|
1679
|
+
if (!over || to === from) {
|
|
1680
|
+
return t('kanban.a11y.droppedHome', {
|
|
1681
|
+
defaultValue: 'Tarjeta {{card}} se queda en {{lane}}.',
|
|
1682
|
+
card: cardName(active.id),
|
|
1683
|
+
lane: laneLabel(from),
|
|
1684
|
+
})
|
|
1685
|
+
}
|
|
1686
|
+
return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys)
|
|
1687
|
+
? t('kanban.a11y.dropped', {
|
|
1688
|
+
defaultValue: 'Tarjeta {{card}} movida a {{lane}}.',
|
|
1689
|
+
card: cardName(active.id),
|
|
1690
|
+
lane: laneLabel(to),
|
|
1691
|
+
})
|
|
1692
|
+
: t('kanban.a11y.droppedBlocked', {
|
|
1693
|
+
defaultValue:
|
|
1694
|
+
'No se puede mover de {{from}} a {{lane}}. La tarjeta vuelve a {{from}}.',
|
|
1695
|
+
from: laneLabel(from),
|
|
1696
|
+
lane: laneLabel(to),
|
|
1697
|
+
})
|
|
1698
|
+
},
|
|
1699
|
+
onDragCancel: ({ active }) =>
|
|
1700
|
+
t('kanban.a11y.cancelled', {
|
|
1701
|
+
defaultValue: 'Movimiento cancelado. {{card}} vuelve a su lugar.',
|
|
1702
|
+
card: isCard(active) ? cardName(active.id) : laneLabel(String(active.id)),
|
|
1703
|
+
}),
|
|
1704
|
+
}
|
|
1705
|
+
}, [cardById, cardText, laneLabel, realStageKeys, stageOfCard, t, transitions])
|
|
1706
|
+
|
|
1707
|
+
const screenReaderInstructions = useMemo(
|
|
1708
|
+
() => ({
|
|
1709
|
+
draggable: t('kanban.a11y.instructions', {
|
|
1710
|
+
defaultValue:
|
|
1711
|
+
'Para mover una tarjeta, presiona Espacio o Enter. Usa las flechas izquierda y derecha para cambiar de columna, Espacio o Enter para soltarla y Escape para cancelar.',
|
|
1712
|
+
}),
|
|
1713
|
+
}),
|
|
1714
|
+
[t],
|
|
1138
1715
|
)
|
|
1139
1716
|
|
|
1140
1717
|
// Board-level "Restablecer orden": drop the stored order and refetch the
|
|
@@ -1205,7 +1782,11 @@ export function DynamicKanban({
|
|
|
1205
1782
|
const droppableAllowedFor = (stageKey: string) =>
|
|
1206
1783
|
!activeId ||
|
|
1207
1784
|
stageKey === activeStage ||
|
|
1208
|
-
|
|
1785
|
+
(realStageKeys.has(stageKey) &&
|
|
1786
|
+
isTransitionAllowed(transitions, activeStage, stageKey, realStageKeys))
|
|
1787
|
+
// The lifted card is over a lane that won't take it → not-allowed cursor.
|
|
1788
|
+
const overBlocked =
|
|
1789
|
+
!!activeId && !!overLaneId && !droppableAllowedFor(overLaneId)
|
|
1209
1790
|
|
|
1210
1791
|
// Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
|
|
1211
1792
|
// right backend by kind: a custom real stage edits through /custom-stages, a
|
|
@@ -1293,12 +1874,37 @@ export function DynamicKanban({
|
|
|
1293
1874
|
}
|
|
1294
1875
|
const laneState = lanePagination[stage.key]
|
|
1295
1876
|
const isUnassigned = stage.key === UNASSIGNED_LANE
|
|
1296
|
-
|
|
1877
|
+
// Server figures, shifted by the moves still in flight.
|
|
1878
|
+
const delta = laneDeltas[stage.key]
|
|
1879
|
+
const serverTotal =
|
|
1880
|
+
laneState?.total != null ? Math.max(0, laneState.total + (delta?.count ?? 0)) : null
|
|
1881
|
+
const laneSums = laneAggregates[stage.key]
|
|
1882
|
+
const aggregates =
|
|
1883
|
+
laneSums && aggregateCols.length > 0
|
|
1884
|
+
? aggregateCols.map((col) => ({
|
|
1885
|
+
key: col.key,
|
|
1886
|
+
label: t(col.label, { defaultValue: col.label }),
|
|
1887
|
+
value: formatAggregateTotal(
|
|
1888
|
+
col,
|
|
1889
|
+
(laneSums[col.key] ?? 0) + (delta?.sums[col.key] ?? 0),
|
|
1890
|
+
currency,
|
|
1891
|
+
i18n.language,
|
|
1892
|
+
),
|
|
1893
|
+
}))
|
|
1894
|
+
: []
|
|
1895
|
+
// Nothing left to fetch once every card the server counts is loaded
|
|
1896
|
+
// (a lane the first page already filled needs no top-up request).
|
|
1897
|
+
const laneHasMore =
|
|
1898
|
+
!isUnassigned &&
|
|
1899
|
+
!laneState?.done &&
|
|
1900
|
+
!(serverTotal != null && allCards.length >= serverTotal)
|
|
1901
|
+
const blocked = !!activeId && !droppableAllowedFor(stage.key)
|
|
1297
1902
|
return {
|
|
1298
1903
|
stage,
|
|
1299
1904
|
count: cards.length,
|
|
1300
1905
|
totalCount: allCards.length,
|
|
1301
|
-
serverTotal
|
|
1906
|
+
serverTotal,
|
|
1907
|
+
aggregates,
|
|
1302
1908
|
hasMore: laneHasMore,
|
|
1303
1909
|
loadingMore: !!laneState?.loading,
|
|
1304
1910
|
onLoadMore: () => loadMoreLane(stage.key),
|
|
@@ -1312,7 +1918,8 @@ export function DynamicKanban({
|
|
|
1312
1918
|
}),
|
|
1313
1919
|
onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
|
|
1314
1920
|
isDark,
|
|
1315
|
-
dimmed:
|
|
1921
|
+
dimmed: blocked,
|
|
1922
|
+
blockedOver: blocked && overLaneId === stage.key,
|
|
1316
1923
|
model,
|
|
1317
1924
|
columns: metadata?.columns ?? [],
|
|
1318
1925
|
automationsAvailable:
|
|
@@ -1347,6 +1954,7 @@ export function DynamicKanban({
|
|
|
1347
1954
|
currency={currency}
|
|
1348
1955
|
onClick={onCardClick}
|
|
1349
1956
|
onAction={handleInternalAction}
|
|
1957
|
+
pending={card.id != null && String(card.id) in moveOverrides}
|
|
1350
1958
|
/>
|
|
1351
1959
|
))
|
|
1352
1960
|
),
|
|
@@ -1508,21 +2116,38 @@ export function DynamicKanban({
|
|
|
1508
2116
|
data-testid="kanban-filter-chips"
|
|
1509
2117
|
/>
|
|
1510
2118
|
|
|
1511
|
-
<DndContext
|
|
2119
|
+
<DndContext
|
|
2120
|
+
sensors={sensors}
|
|
2121
|
+
collisionDetection={collisionDetection}
|
|
2122
|
+
onDragStart={onDragStart}
|
|
2123
|
+
onDragOver={onDragOver}
|
|
2124
|
+
onDragEnd={onDragEnd}
|
|
2125
|
+
onDragCancel={onDragCancel}
|
|
2126
|
+
// Near the board's left/right edge the columns scroll by
|
|
2127
|
+
// themselves while a card is held there.
|
|
2128
|
+
autoScroll={{ threshold: { x: 0.15, y: 0.15 }, acceleration: 14 }}
|
|
2129
|
+
accessibility={{ announcements, screenReaderInstructions }}
|
|
2130
|
+
>
|
|
1512
2131
|
{/* Horizontal SortableContext over the draggable lanes (real
|
|
1513
2132
|
stages + smart lanes). Card drags don't touch it — their
|
|
1514
2133
|
active id isn't a sortable item, so the lanes never shift when
|
|
1515
2134
|
moving a card; a `data.type` tag routes drop handling. */}
|
|
1516
2135
|
<SortableContext items={boardLaneKeys} strategy={horizontalListSortingStrategy}>
|
|
1517
|
-
<div
|
|
2136
|
+
<div
|
|
2137
|
+
ref={boardRef}
|
|
2138
|
+
className="flex w-full min-w-0 gap-4 overflow-x-auto p-1"
|
|
2139
|
+
data-testid="kanban-board"
|
|
2140
|
+
data-dragging={activeId ? 'card' : undefined}
|
|
2141
|
+
>
|
|
1518
2142
|
{renderLanes.map((lane) =>
|
|
1519
2143
|
lane.kind === 'stage' ? (
|
|
1520
2144
|
<SortableStageLane
|
|
1521
2145
|
key={lane.stage.key}
|
|
1522
2146
|
reorderEnabled={laneReorderEnabled}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
2147
|
+
// A lane the card can't enter stays a drop target so
|
|
2148
|
+
// it can say so (red ring, not-allowed cursor); the
|
|
2149
|
+
// drop itself is refused in onDragEnd.
|
|
2150
|
+
droppableDisabled={false}
|
|
1526
2151
|
laneProps={buildLaneProps(lane.stage)}
|
|
1527
2152
|
/>
|
|
1528
2153
|
) : laneReorderEnabled ? (
|
|
@@ -1543,9 +2168,8 @@ export function DynamicKanban({
|
|
|
1543
2168
|
{unassignedStage && (
|
|
1544
2169
|
<DroppableStageLane
|
|
1545
2170
|
key={UNASSIGNED_LANE}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
}
|
|
2171
|
+
// "Sin etapa" is not a value a card can be given.
|
|
2172
|
+
droppableDisabled={!!activeId}
|
|
1549
2173
|
laneProps={buildLaneProps(unassignedStage)}
|
|
1550
2174
|
/>
|
|
1551
2175
|
)}
|
|
@@ -1557,7 +2181,9 @@ export function DynamicKanban({
|
|
|
1557
2181
|
</div>
|
|
1558
2182
|
</SortableContext>
|
|
1559
2183
|
|
|
1560
|
-
<
|
|
2184
|
+
{/* In <body>: a glass panel around the board would otherwise shift
|
|
2185
|
+
the fixed overlay away from the pointer (see PortalDragOverlay). */}
|
|
2186
|
+
<PortalDragOverlay dropAnimation={DROP_ANIMATION} zIndex={60}>
|
|
1561
2187
|
{activeCard ? (
|
|
1562
2188
|
<CardPreview
|
|
1563
2189
|
card={activeCard}
|
|
@@ -1566,9 +2192,10 @@ export function DynamicKanban({
|
|
|
1566
2192
|
locale={i18n.language}
|
|
1567
2193
|
timeZone={timeZone}
|
|
1568
2194
|
currency={currency}
|
|
2195
|
+
blocked={overBlocked}
|
|
1569
2196
|
/>
|
|
1570
2197
|
) : null}
|
|
1571
|
-
</
|
|
2198
|
+
</PortalDragOverlay>
|
|
1572
2199
|
|
|
1573
2200
|
{rowActionDialogs}
|
|
1574
2201
|
</DndContext>
|
|
@@ -1874,6 +2501,8 @@ interface KanbanLaneProps {
|
|
|
1874
2501
|
totalCount: number
|
|
1875
2502
|
/** Server-reported total for the stage (from response meta), or null. */
|
|
1876
2503
|
serverTotal: number | null
|
|
2504
|
+
/** The lane's declared totals (sum of a money/number column), formatted. */
|
|
2505
|
+
aggregates: { key: string; label: string; value: string }[]
|
|
1877
2506
|
/** More server pages available for this stage. */
|
|
1878
2507
|
hasMore: boolean
|
|
1879
2508
|
/** A top-up request for this stage is in flight. */
|
|
@@ -1885,7 +2514,10 @@ interface KanbanLaneProps {
|
|
|
1885
2514
|
onFunnelChange: (filter: LaneFunnelValue | null) => void
|
|
1886
2515
|
onQueryChange: (query: string) => void
|
|
1887
2516
|
isDark: boolean
|
|
2517
|
+
/** A card is being dragged and this lane can't take it. */
|
|
1888
2518
|
dimmed: boolean
|
|
2519
|
+
/** …and the card is right over this lane. */
|
|
2520
|
+
blockedOver: boolean
|
|
1889
2521
|
/** Drag-and-drop wiring from the lane's sortable/droppable wrapper. */
|
|
1890
2522
|
dnd: LaneDnd
|
|
1891
2523
|
/** Model key + columns for the stage-automations editor. */
|
|
@@ -1914,6 +2546,7 @@ function KanbanLane({
|
|
|
1914
2546
|
count,
|
|
1915
2547
|
totalCount,
|
|
1916
2548
|
serverTotal,
|
|
2549
|
+
aggregates,
|
|
1917
2550
|
hasMore,
|
|
1918
2551
|
loadingMore,
|
|
1919
2552
|
onLoadMore,
|
|
@@ -1923,6 +2556,7 @@ function KanbanLane({
|
|
|
1923
2556
|
onQueryChange,
|
|
1924
2557
|
isDark,
|
|
1925
2558
|
dimmed,
|
|
2559
|
+
blockedOver,
|
|
1926
2560
|
dnd,
|
|
1927
2561
|
model,
|
|
1928
2562
|
columns,
|
|
@@ -2009,14 +2643,21 @@ function KanbanLane({
|
|
|
2009
2643
|
// fit, capped at max-w so a couple of lanes don't stretch absurdly
|
|
2010
2644
|
// wide. Below min-w the board's overflow-x-auto takes over and the
|
|
2011
2645
|
// lanes scroll horizontally at their minimum width.
|
|
2012
|
-
className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
|
|
2646
|
+
className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-[opacity,outline-color] duration-150"
|
|
2013
2647
|
style={{
|
|
2014
|
-
opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
|
|
2015
|
-
outline:
|
|
2648
|
+
opacity: dnd.isDragging ? 0.6 : dimmed ? (blockedOver ? 0.7 : 0.45) : 1,
|
|
2649
|
+
outline: blockedOver
|
|
2650
|
+
? '2px dashed var(--destructive, #ef4444)'
|
|
2651
|
+
: dnd.isOver
|
|
2652
|
+
? '2px solid var(--ring, #3b82f6)'
|
|
2653
|
+
: '2px solid transparent',
|
|
2016
2654
|
outlineOffset: 2,
|
|
2655
|
+
cursor: dimmed ? 'not-allowed' : undefined,
|
|
2017
2656
|
...dnd.style,
|
|
2018
2657
|
}}
|
|
2019
2658
|
data-stage={stage.key}
|
|
2659
|
+
data-drop={dimmed ? 'blocked' : undefined}
|
|
2660
|
+
aria-disabled={dimmed || undefined}
|
|
2020
2661
|
>
|
|
2021
2662
|
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
2022
2663
|
{/* Title cluster doubles as the reorder handle (Trello/Bitrix):
|
|
@@ -2116,6 +2757,34 @@ function KanbanLane({
|
|
|
2116
2757
|
)}
|
|
2117
2758
|
</div>
|
|
2118
2759
|
</div>
|
|
2760
|
+
{(aggregates.length > 0 || blockedOver) && (
|
|
2761
|
+
<div className="-mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 px-3 pb-1.5 text-xs">
|
|
2762
|
+
{blockedOver ? (
|
|
2763
|
+
<span
|
|
2764
|
+
className="font-medium text-destructive"
|
|
2765
|
+
data-testid={`lane-blocked-${stage.key}`}
|
|
2766
|
+
>
|
|
2767
|
+
{t('kanban.dropNotAllowed', {
|
|
2768
|
+
defaultValue: 'No se puede mover aquí',
|
|
2769
|
+
})}
|
|
2770
|
+
</span>
|
|
2771
|
+
) : (
|
|
2772
|
+
aggregates.map((a) => (
|
|
2773
|
+
<span
|
|
2774
|
+
key={a.key}
|
|
2775
|
+
className="tabular-nums text-muted-foreground"
|
|
2776
|
+
title={a.label}
|
|
2777
|
+
data-testid={`lane-total-${stage.key}-${a.key}`}
|
|
2778
|
+
>
|
|
2779
|
+
{aggregates.length > 1 && (
|
|
2780
|
+
<span className="opacity-70">{a.label}: </span>
|
|
2781
|
+
)}
|
|
2782
|
+
<span className="font-semibold text-foreground">{a.value}</span>
|
|
2783
|
+
</span>
|
|
2784
|
+
))
|
|
2785
|
+
)}
|
|
2786
|
+
</div>
|
|
2787
|
+
)}
|
|
2119
2788
|
{searchOpen && (
|
|
2120
2789
|
<div className="px-3 pb-1.5">
|
|
2121
2790
|
<div className="relative">
|
|
@@ -2337,6 +3006,7 @@ function LaneFilterButton({
|
|
|
2337
3006
|
interface KanbanCardProps {
|
|
2338
3007
|
card: any
|
|
2339
3008
|
titleCol: ColumnDefinition | null
|
|
3009
|
+
/** Candidate fields; the card shows the first few that have a value. */
|
|
2340
3010
|
fieldCols: ColumnDefinition[]
|
|
2341
3011
|
actions: ActionDefinition[]
|
|
2342
3012
|
locale: string
|
|
@@ -2347,6 +3017,61 @@ interface KanbanCardProps {
|
|
|
2347
3017
|
onAction: (actionKey: string, record: any) => void
|
|
2348
3018
|
/** When false the card is static (no drag) — used by read-only smart lanes. */
|
|
2349
3019
|
draggable?: boolean
|
|
3020
|
+
/** Its move is waiting for the server. */
|
|
3021
|
+
pending?: boolean
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// The title + the card's non-empty fields. Shared by the card and the drag
|
|
3025
|
+
// preview so the lifted card looks exactly like the one on the board.
|
|
3026
|
+
function CardBody({
|
|
3027
|
+
card,
|
|
3028
|
+
titleCol,
|
|
3029
|
+
fieldCols,
|
|
3030
|
+
locale,
|
|
3031
|
+
timeZone,
|
|
3032
|
+
currency,
|
|
3033
|
+
menu,
|
|
3034
|
+
}: Pick<KanbanCardProps, 'card' | 'titleCol' | 'fieldCols' | 'locale' | 'timeZone' | 'currency'> & {
|
|
3035
|
+
menu?: React.ReactNode
|
|
3036
|
+
}) {
|
|
3037
|
+
const fields = visibleCardFields(card, fieldCols, CARD_FIELDS_SHOWN)
|
|
3038
|
+
return (
|
|
3039
|
+
<CardContent className="space-y-1.5 p-3">
|
|
3040
|
+
<div className="flex items-start justify-between gap-2">
|
|
3041
|
+
<div className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
|
|
3042
|
+
{titleCol && !isEmptyCardValue(card, titleCol) ? (
|
|
3043
|
+
<ActivityValueRenderer
|
|
3044
|
+
value={cardCellValue(card, titleCol)}
|
|
3045
|
+
col={titleCol}
|
|
3046
|
+
locale={locale}
|
|
3047
|
+
timeZone={timeZone}
|
|
3048
|
+
currency={currency}
|
|
3049
|
+
/>
|
|
3050
|
+
) : (
|
|
3051
|
+
<span className="truncate text-muted-foreground">#{String(card.id)}</span>
|
|
3052
|
+
)}
|
|
3053
|
+
</div>
|
|
3054
|
+
{menu}
|
|
3055
|
+
</div>
|
|
3056
|
+
{fields.map((col) => (
|
|
3057
|
+
<div
|
|
3058
|
+
key={col.key}
|
|
3059
|
+
className="flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
|
|
3060
|
+
>
|
|
3061
|
+
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
3062
|
+
<span className="min-w-0 break-words">
|
|
3063
|
+
<ActivityValueRenderer
|
|
3064
|
+
value={cardCellValue(card, col)}
|
|
3065
|
+
col={col}
|
|
3066
|
+
locale={locale}
|
|
3067
|
+
timeZone={timeZone}
|
|
3068
|
+
currency={currency}
|
|
3069
|
+
/>
|
|
3070
|
+
</span>
|
|
3071
|
+
</div>
|
|
3072
|
+
))}
|
|
3073
|
+
</CardContent>
|
|
3074
|
+
)
|
|
2350
3075
|
}
|
|
2351
3076
|
|
|
2352
3077
|
function KanbanCard({
|
|
@@ -2360,6 +3085,7 @@ function KanbanCard({
|
|
|
2360
3085
|
onClick,
|
|
2361
3086
|
onAction,
|
|
2362
3087
|
draggable = true,
|
|
3088
|
+
pending = false,
|
|
2363
3089
|
}: KanbanCardProps) {
|
|
2364
3090
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
2365
3091
|
id: String(card.id),
|
|
@@ -2369,90 +3095,68 @@ function KanbanCard({
|
|
|
2369
3095
|
|
|
2370
3096
|
const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
|
|
2371
3097
|
|
|
3098
|
+
const menu =
|
|
3099
|
+
visibleActions.length > 0 ? (
|
|
3100
|
+
<DropdownMenu>
|
|
3101
|
+
<DropdownMenuTrigger asChild>
|
|
3102
|
+
<Button
|
|
3103
|
+
variant="ghost"
|
|
3104
|
+
size="icon"
|
|
3105
|
+
className="h-6 w-6 shrink-0 -mr-1 -mt-1"
|
|
3106
|
+
// Don't start a drag / card click from the menu button.
|
|
3107
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
3108
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
3109
|
+
onClick={(e) => e.stopPropagation()}
|
|
3110
|
+
>
|
|
3111
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
3112
|
+
</Button>
|
|
3113
|
+
</DropdownMenuTrigger>
|
|
3114
|
+
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
|
3115
|
+
{visibleActions.map((a) => (
|
|
3116
|
+
<DropdownMenuItem
|
|
3117
|
+
key={a.key}
|
|
3118
|
+
onClick={(e) => {
|
|
3119
|
+
e.stopPropagation()
|
|
3120
|
+
onAction(a.key, card)
|
|
3121
|
+
}}
|
|
3122
|
+
>
|
|
3123
|
+
<DynamicIcon name={a.icon || 'Zap'} className="mr-2 h-4 w-4" />
|
|
3124
|
+
{a.label}
|
|
3125
|
+
</DropdownMenuItem>
|
|
3126
|
+
))}
|
|
3127
|
+
</DropdownMenuContent>
|
|
3128
|
+
</DropdownMenu>
|
|
3129
|
+
) : null
|
|
3130
|
+
|
|
2372
3131
|
return (
|
|
2373
3132
|
<Card
|
|
2374
3133
|
ref={draggable ? setNodeRef : undefined}
|
|
2375
3134
|
{...(draggable ? attributes : {})}
|
|
2376
3135
|
{...(draggable ? listeners : {})}
|
|
2377
|
-
className={`w-full min-w-0 border-border/70 shadow-sm ${
|
|
3136
|
+
className={`w-full min-w-0 shrink-0 border-border/70 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
2378
3137
|
draggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
|
2379
3138
|
}`}
|
|
2380
3139
|
style={{ opacity: isDragging ? 0.4 : 1 }}
|
|
2381
3140
|
onClick={() => onClick?.(card)}
|
|
2382
3141
|
data-card-id={String(card.id)}
|
|
3142
|
+
data-flip-key={draggable ? String(card.id) : undefined}
|
|
3143
|
+
aria-busy={pending || undefined}
|
|
2383
3144
|
>
|
|
2384
|
-
<
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
currency={currency}
|
|
2394
|
-
/>
|
|
2395
|
-
) : (
|
|
2396
|
-
<span className="truncate">{String(card.id)}</span>
|
|
2397
|
-
)}
|
|
2398
|
-
</div>
|
|
2399
|
-
{visibleActions.length > 0 && (
|
|
2400
|
-
<DropdownMenu>
|
|
2401
|
-
<DropdownMenuTrigger asChild>
|
|
2402
|
-
<Button
|
|
2403
|
-
variant="ghost"
|
|
2404
|
-
size="icon"
|
|
2405
|
-
className="h-6 w-6 shrink-0 -mr-1 -mt-1"
|
|
2406
|
-
// Don't start a drag / card click from the menu button.
|
|
2407
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
2408
|
-
onClick={(e) => e.stopPropagation()}
|
|
2409
|
-
>
|
|
2410
|
-
<MoreHorizontal className="h-4 w-4" />
|
|
2411
|
-
</Button>
|
|
2412
|
-
</DropdownMenuTrigger>
|
|
2413
|
-
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
|
2414
|
-
{visibleActions.map((a) => (
|
|
2415
|
-
<DropdownMenuItem
|
|
2416
|
-
key={a.key}
|
|
2417
|
-
onClick={(e) => {
|
|
2418
|
-
e.stopPropagation()
|
|
2419
|
-
onAction(a.key, card)
|
|
2420
|
-
}}
|
|
2421
|
-
>
|
|
2422
|
-
<DynamicIcon
|
|
2423
|
-
name={a.icon || 'Zap'}
|
|
2424
|
-
className="mr-2 h-4 w-4"
|
|
2425
|
-
/>
|
|
2426
|
-
{a.label}
|
|
2427
|
-
</DropdownMenuItem>
|
|
2428
|
-
))}
|
|
2429
|
-
</DropdownMenuContent>
|
|
2430
|
-
</DropdownMenu>
|
|
2431
|
-
)}
|
|
2432
|
-
</div>
|
|
2433
|
-
{fieldCols.map((col) => (
|
|
2434
|
-
<div
|
|
2435
|
-
key={col.key}
|
|
2436
|
-
className="flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
|
|
2437
|
-
>
|
|
2438
|
-
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
2439
|
-
<span className="min-w-0 break-words">
|
|
2440
|
-
<ActivityValueRenderer
|
|
2441
|
-
value={cardCellValue(card, col)}
|
|
2442
|
-
col={col}
|
|
2443
|
-
locale={locale}
|
|
2444
|
-
timeZone={timeZone}
|
|
2445
|
-
currency={currency}
|
|
2446
|
-
/>
|
|
2447
|
-
</span>
|
|
2448
|
-
</div>
|
|
2449
|
-
))}
|
|
2450
|
-
</CardContent>
|
|
3145
|
+
<CardBody
|
|
3146
|
+
card={card}
|
|
3147
|
+
titleCol={titleCol}
|
|
3148
|
+
fieldCols={fieldCols}
|
|
3149
|
+
locale={locale}
|
|
3150
|
+
timeZone={timeZone}
|
|
3151
|
+
currency={currency}
|
|
3152
|
+
menu={menu}
|
|
3153
|
+
/>
|
|
2451
3154
|
</Card>
|
|
2452
3155
|
)
|
|
2453
3156
|
}
|
|
2454
3157
|
|
|
2455
|
-
// Static preview rendered inside the DragOverlay (no dnd hooks, no menu).
|
|
3158
|
+
// Static preview rendered inside the DragOverlay (no dnd hooks, no menu). It
|
|
3159
|
+
// fills the overlay box, which dnd-kit sizes to the card being dragged.
|
|
2456
3160
|
function CardPreview({
|
|
2457
3161
|
card,
|
|
2458
3162
|
titleCol,
|
|
@@ -2460,41 +3164,25 @@ function CardPreview({
|
|
|
2460
3164
|
locale,
|
|
2461
3165
|
timeZone,
|
|
2462
3166
|
currency,
|
|
2463
|
-
|
|
3167
|
+
blocked = false,
|
|
3168
|
+
}: Omit<KanbanCardProps, 'actions' | 'onClick' | 'onAction'> & { blocked?: boolean }) {
|
|
2464
3169
|
return (
|
|
2465
|
-
<Card
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
<div
|
|
2482
|
-
key={col.key}
|
|
2483
|
-
className="flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
|
|
2484
|
-
>
|
|
2485
|
-
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
2486
|
-
<span className="min-w-0 break-words">
|
|
2487
|
-
<ActivityValueRenderer
|
|
2488
|
-
value={cardCellValue(card, col)}
|
|
2489
|
-
col={col}
|
|
2490
|
-
locale={locale}
|
|
2491
|
-
timeZone={timeZone}
|
|
2492
|
-
currency={currency}
|
|
2493
|
-
/>
|
|
2494
|
-
</span>
|
|
2495
|
-
</div>
|
|
2496
|
-
))}
|
|
2497
|
-
</CardContent>
|
|
3170
|
+
<Card
|
|
3171
|
+
className={`h-full w-full shadow-xl transition-[border-color] ${
|
|
3172
|
+
blocked ? 'border-destructive/60' : 'border-primary/40'
|
|
3173
|
+
}`}
|
|
3174
|
+
style={{ cursor: blocked ? 'not-allowed' : 'grabbing' }}
|
|
3175
|
+
data-testid="kanban-drag-preview"
|
|
3176
|
+
data-blocked={blocked || undefined}
|
|
3177
|
+
>
|
|
3178
|
+
<CardBody
|
|
3179
|
+
card={card}
|
|
3180
|
+
titleCol={titleCol}
|
|
3181
|
+
fieldCols={fieldCols}
|
|
3182
|
+
locale={locale}
|
|
3183
|
+
timeZone={timeZone}
|
|
3184
|
+
currency={currency}
|
|
3185
|
+
/>
|
|
2498
3186
|
</Card>
|
|
2499
3187
|
)
|
|
2500
3188
|
}
|