@asteby/metacore-runtime-react 42.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +6 -4
  4. package/dist/dynamic-icon.d.ts.map +1 -1
  5. package/dist/dynamic-icon.js +9 -13
  6. package/dist/dynamic-kanban.d.ts +83 -6
  7. package/dist/dynamic-kanban.d.ts.map +1 -1
  8. package/dist/dynamic-kanban.js +647 -146
  9. package/dist/dynamic-table.d.ts +1 -1
  10. package/dist/dynamic-table.d.ts.map +1 -1
  11. package/dist/dynamic-table.js +14 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/license/license-gate.d.ts +0 -21
  16. package/dist/license/license-gate.d.ts.map +1 -1
  17. package/dist/license/license-gate.js +5 -2
  18. package/dist/portal-drag-overlay.d.ts +5 -0
  19. package/dist/portal-drag-overlay.d.ts.map +1 -0
  20. package/dist/portal-drag-overlay.js +8 -0
  21. package/dist/query-batch.d.ts.map +1 -1
  22. package/dist/query-batch.js +6 -3
  23. package/dist/sidebar-layout-manager.js +3 -2
  24. package/dist/use-flip-animation.d.ts +6 -0
  25. package/dist/use-flip-animation.d.ts.map +1 -1
  26. package/dist/use-flip-animation.js +11 -4
  27. package/package.json +4 -3
  28. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  29. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  30. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  31. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  32. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  33. package/src/api-context.tsx +7 -5
  34. package/src/dynamic-icon.tsx +12 -16
  35. package/src/dynamic-kanban.tsx +950 -262
  36. package/src/dynamic-table.tsx +16 -2
  37. package/src/index.ts +6 -0
  38. package/src/license/license-gate.tsx +6 -2
  39. package/src/portal-drag-overlay.tsx +20 -0
  40. package/src/query-batch.ts +6 -3
  41. package/src/sidebar-layout-manager.tsx +3 -3
  42. package/src/use-flip-animation.ts +16 -3
@@ -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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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
- DragOverlay,
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 { isRowActionVisible, relationKeyFor } from './dynamic-columns'
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` given the declared transitions. No
210
- * transitions declared → unrestricted (the kernel still validates server-side).
211
- * A move to the same stage is always a no-op "allowed". `'*'` is a wildcard on
212
- * either side.
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 || transitions.length === 0) return true
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
- String(card?.[c.key] ?? '')
397
- .toLowerCase()
398
- .includes(q),
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
- export function DynamicKanban({
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
- // Eager per-lane totals: so every lane header shows the REAL stage count
665
- // on first render (not just what the global page happened to load), fire
666
- // one lightweight `per_page=1` request per declared stage — in parallel,
667
- // scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
668
- // of filterParams). We read only `meta.total`; the row itself is ignored
669
- // (the lane loads its real cards via loadMoreLane on scroll). The
670
- // "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
671
- const gb = metadata.group_by
672
- const declaredStages = deriveStages(metadata)
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
- const groupByKey = metadata?.group_by || ''
714
- // Extra per-lane conditions (stage overrides) keyed by stage. A real lane
715
- // that carries these queries its data — and counts its header — with the
716
- // stage scope PLUS these filters (serialized like a smart lane's), so the
717
- // top-up + eager-total requests below layer them on. Sourced from
718
- // `metadata.stages` (the kernel applies declared + custom-real overrides).
719
- const stageExtraFilters = useMemo(() => {
720
- const m = new Map<string, CustomStageFilter[]>()
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 nextPage = current?.nextPage ?? 1
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(records, groupByKey, stages),
983
- [records, groupByKey, stages],
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
- () => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }),
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 records) m.set(String(r.id), r)
1442
+ for (const r of displayRecords) m.set(String(r.id), r)
1027
1443
  return m
1028
- }, [records])
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
- const onDragStart = useCallback((e: DragStartEvent) => {
1040
- if (e.active.data.current?.type === 'lane') {
1041
- setActiveLaneId(String(e.active.id))
1042
- } else {
1043
- setActiveId(String(e.active.id))
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
- // Never drop a card onto a smart lane (a saved view, not a stage).
1086
- if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE) return
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
- if (!isTransitionAllowed(transitions, srcStage, destStage)) {
1090
- toast.error(
1091
- t('kanban.invalidTransition', {
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
- // OPTIMISTIC: move the card in local state immediately.
1099
- const prevRecords = records
1100
- const prevPagination = lanePagination
1101
- setRecords((rs) =>
1102
- rs.map((r) =>
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
- try {
1112
- const base = endpoint || `/data/${model}`
1113
- // `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
1114
- // so the per-record update is just `<base>/<id>` — same convention
1115
- // as DynamicTable/DynamicRelation. Appending an extra `/me` here
1116
- // produced `/data/<model>/me/me/<id>` → 404 on drag-to-move.
1117
- const res = (await api.put(`${base}/${cardId}`, {
1118
- [groupByKey]: destStage,
1119
- })) as { data?: ApiResponse<any> }
1120
- if (res?.data && res.data.success === false) {
1121
- throw new Error(res.data.message || 'update_failed')
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
- } catch (err: any) {
1124
- // REVERT + toast on failure.
1125
- setRecords(prevRecords)
1126
- setLanePagination(prevPagination)
1127
- toast.error(
1128
- t('kanban.moveFailed', {
1129
- defaultValue: 'No se pudo mover la tarjeta',
1130
- }) +
1131
- (err?.response?.data?.message
1132
- ? `: ${err.response.data.message}`
1133
- : ''),
1134
- )
1135
- }
1136
- },
1137
- [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes],
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
- isTransitionAllowed(transitions, activeStage, stageKey)
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
- const laneHasMore = !isUnassigned && !laneState?.done
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: laneState?.total ?? null,
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: !!activeId && !droppableAllowedFor(stage.key),
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 sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
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 className="flex w-full min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
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
- droppableDisabled={
1524
- !!activeId && !droppableAllowedFor(lane.stage.key)
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
- droppableDisabled={
1547
- !!activeId && !droppableAllowedFor(UNASSIGNED_LANE)
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
- <DragOverlay>
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
- </DragOverlay>
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: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
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
- <CardContent className="space-y-1.5 p-3">
2385
- <div className="flex items-start justify-between gap-2">
2386
- <div className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
2387
- {titleCol ? (
2388
- <ActivityValueRenderer
2389
- value={cardCellValue(card, titleCol)}
2390
- col={titleCol}
2391
- locale={locale}
2392
- timeZone={timeZone}
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
- }: Omit<KanbanCardProps, 'actions' | 'onClick' | 'onAction'>) {
3167
+ blocked = false,
3168
+ }: Omit<KanbanCardProps, 'actions' | 'onClick' | 'onAction'> & { blocked?: boolean }) {
2464
3169
  return (
2465
- <Card className="w-[284px] cursor-grabbing border-primary/40 shadow-lg">
2466
- <CardContent className="space-y-1.5 p-3">
2467
- <div className="break-words text-sm font-medium leading-snug">
2468
- {titleCol ? (
2469
- <ActivityValueRenderer
2470
- value={cardCellValue(card, titleCol)}
2471
- col={titleCol}
2472
- locale={locale}
2473
- timeZone={timeZone}
2474
- currency={currency}
2475
- />
2476
- ) : (
2477
- String(card.id)
2478
- )}
2479
- </div>
2480
- {fieldCols.map((col) => (
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
  }