@asteby/metacore-runtime-react 42.0.0 → 44.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 (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/action-modal-dispatcher.d.ts +2 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +74 -8
  5. package/dist/api-context.d.ts.map +1 -1
  6. package/dist/api-context.js +6 -4
  7. package/dist/dynamic-icon.d.ts.map +1 -1
  8. package/dist/dynamic-icon.js +9 -13
  9. package/dist/dynamic-kanban.d.ts +83 -6
  10. package/dist/dynamic-kanban.d.ts.map +1 -1
  11. package/dist/dynamic-kanban.js +647 -146
  12. package/dist/dynamic-row-actions.d.ts.map +1 -1
  13. package/dist/dynamic-row-actions.js +11 -15
  14. package/dist/dynamic-table.d.ts +1 -1
  15. package/dist/dynamic-table.d.ts.map +1 -1
  16. package/dist/dynamic-table.js +14 -1
  17. package/dist/federated-action-loader.d.ts +18 -0
  18. package/dist/federated-action-loader.d.ts.map +1 -0
  19. package/dist/federated-action-loader.js +34 -0
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -2
  23. package/dist/license/license-gate.d.ts +0 -21
  24. package/dist/license/license-gate.d.ts.map +1 -1
  25. package/dist/license/license-gate.js +5 -2
  26. package/dist/model-action-toolbar.d.ts.map +1 -1
  27. package/dist/model-action-toolbar.js +3 -12
  28. package/dist/portal-drag-overlay.d.ts +5 -0
  29. package/dist/portal-drag-overlay.d.ts.map +1 -0
  30. package/dist/portal-drag-overlay.js +8 -0
  31. package/dist/query-batch.d.ts.map +1 -1
  32. package/dist/query-batch.js +6 -3
  33. package/dist/sidebar-layout-manager.js +3 -2
  34. package/dist/types.d.ts +4 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-flip-animation.d.ts +6 -0
  37. package/dist/use-flip-animation.d.ts.map +1 -1
  38. package/dist/use-flip-animation.js +11 -4
  39. package/package.json +6 -5
  40. package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
  41. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  42. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  43. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  44. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  45. package/src/__tests__/federated-action-modal.test.tsx +315 -0
  46. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  47. package/src/action-modal-dispatcher.tsx +102 -10
  48. package/src/api-context.tsx +7 -5
  49. package/src/dynamic-icon.tsx +12 -16
  50. package/src/dynamic-kanban.tsx +950 -262
  51. package/src/dynamic-row-actions.tsx +11 -15
  52. package/src/dynamic-table.tsx +16 -2
  53. package/src/federated-action-loader.ts +40 -0
  54. package/src/index.ts +8 -0
  55. package/src/license/license-gate.tsx +6 -2
  56. package/src/model-action-toolbar.tsx +3 -12
  57. package/src/portal-drag-overlay.tsx +20 -0
  58. package/src/query-batch.ts +6 -3
  59. package/src/sidebar-layout-manager.tsx +3 -3
  60. package/src/types.ts +4 -0
  61. package/src/use-flip-animation.ts +16 -3
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { useTimeZone, useCurrency } from './org-runtime-context';
5
+ import { QueryClient, QueryClientContext, QueryClientProvider, useQueryClient, } from '@tanstack/react-query';
4
6
  import { useI18nResourceVersion } from './use-i18n-resource-version';
5
- import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
6
- import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable';
7
+ import { DndContext, KeyboardSensor, PointerSensor, closestCenter, defaultDropAnimationSideEffects, pointerWithin, rectIntersection, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
8
+ import { SortableContext, horizontalListSortingStrategy, sortableKeyboardCoordinates, useSortable, } from '@dnd-kit/sortable';
7
9
  import { CSS } from '@dnd-kit/utilities';
8
10
  import { arrayMove } from '@dnd-kit/sortable';
9
11
  import { Calendar, CircleDot, GripVertical, Hash, ListFilter, MoreHorizontal, RotateCcw, Search, Settings2, Tag, ToggleLeft, Type, X, } from 'lucide-react';
@@ -19,11 +21,15 @@ import { useStageOverrides } from './stage-overrides';
19
21
  import { useDynamicFilters } from './use-dynamic-filters';
20
22
  import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
21
23
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
24
+ import { useOptimisticMutation } from './use-optimistic-mutation';
25
+ import { useFlipAnimation } from './use-flip-animation';
26
+ import { PortalDragOverlay } from './portal-drag-overlay';
27
+ import { objectLabel } from './dynamic-relation-helpers';
22
28
  import { useMetadataCache } from './metadata-cache';
23
29
  import { ActivityValueRenderer } from './activity-value-renderer';
24
30
  import { DynamicIcon } from './dynamic-icon';
25
31
  import { isColumnVisibleInTable } from './column-visibility';
26
- import { isRowActionVisible, relationKeyFor } from './dynamic-columns';
32
+ import { aggregateOf, formatAggregateTotal, isRowActionVisible, relationKeyFor, } from './dynamic-columns';
27
33
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
28
34
  import { useDynamicRowActions } from './dynamic-row-actions';
29
35
  import { useStageLayout } from './stage-layout';
@@ -89,16 +95,26 @@ export function groupByStage(records, groupByKey, stages) {
89
95
  return map;
90
96
  }
91
97
  /**
92
- * Whether a card may move `from → to` given the declared transitions. No
93
- * transitions declared → unrestricted (the kernel still validates server-side).
94
- * A move to the same stage is always a no-op "allowed". `'*'` is a wildcard on
95
- * either side.
98
+ * Whether a card may move `from → to` — the same rule as the kernel's
99
+ * `StageMachine.Allows`, so a lane the board offers never ends in a 422:
100
+ *
101
+ * - no `transitions` in the metadata → no stage machine, any move;
102
+ * - a move to the same stage is a no-op, always allowed;
103
+ * - a card with no stage yet — or one outside `stageKeys` (a value written
104
+ * before the machine existed) — is being placed, not moved: any stage takes it;
105
+ * - otherwise the move must be declared. An EMPTY list means the machine allows
106
+ * no moves between its stages.
107
+ *
108
+ * `'*'` is a wildcard on either side.
96
109
  */
97
- export function isTransitionAllowed(transitions, from, to) {
110
+ export function isTransitionAllowed(transitions, from, to, stageKeys) {
98
111
  if (from === to)
99
112
  return true;
100
- if (!transitions || transitions.length === 0)
113
+ if (!transitions)
101
114
  return true;
115
+ if (from === '' || (stageKeys && !stageKeys.has(from))) {
116
+ return !stageKeys || stageKeys.has(to);
117
+ }
102
118
  return transitions.some((t) => (t.from === from || t.from === '*') && (t.to === to || t.to === '*'));
103
119
  }
104
120
  /**
@@ -200,6 +216,157 @@ export function cardCellValue(card, col) {
200
216
  }
201
217
  return raw;
202
218
  }
219
+ /**
220
+ * Plain text of a card cell — what a person reads on the card. A resolved
221
+ * relation reads as its label/name (never the raw FK uuid), so the lane search
222
+ * and the drag announcements match what is on screen. Pure — exported for tests.
223
+ */
224
+ export function cardCellText(card, col) {
225
+ const v = cardCellValue(card, col);
226
+ if (v === null || v === undefined)
227
+ return '';
228
+ if (typeof v === 'object') {
229
+ if (Array.isArray(v)) {
230
+ return v
231
+ .map((x) => (typeof x === 'object' ? objectLabel(x) ?? '' : String(x)))
232
+ .filter(Boolean)
233
+ .join(', ');
234
+ }
235
+ return objectLabel(v) ?? '';
236
+ }
237
+ return String(v);
238
+ }
239
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
240
+ /**
241
+ * Whether a card field has nothing worth a row: unset, blank, an empty list,
242
+ * `false`, or an opaque id that no relation resolved to a name (a soft-ref
243
+ * uuid column). The card hides those rows instead of painting "Campo: —".
244
+ * Pure — exported for unit tests.
245
+ */
246
+ export function isEmptyCardValue(card, col) {
247
+ const v = cardCellValue(card, col);
248
+ if (v === null || v === undefined || v === false)
249
+ return true;
250
+ if (typeof v === 'string') {
251
+ const s = v.trim();
252
+ return s === '' || UUID_RE.test(s);
253
+ }
254
+ if (Array.isArray(v))
255
+ return v.length === 0;
256
+ if (typeof v === 'object')
257
+ return Object.keys(v).length === 0;
258
+ return false;
259
+ }
260
+ /**
261
+ * The fields a card shows: the first `max` candidates that carry a value for
262
+ * THIS card. Candidates come in metadata order, so a card with an empty
263
+ * "Asignado a" shows the next useful field instead of a dash. Pure.
264
+ */
265
+ export function visibleCardFields(card, candidates, max = 3) {
266
+ const out = [];
267
+ for (const col of candidates) {
268
+ if (out.length >= max)
269
+ break;
270
+ if (!isEmptyCardValue(card, col))
271
+ out.push(col);
272
+ }
273
+ return out;
274
+ }
275
+ /**
276
+ * Records as the board paints them: each card with an unconfirmed move shows in
277
+ * its destination lane. The fetched records stay as the server sent them, so a
278
+ * rejected move just drops its override and the card is back where it was.
279
+ * Pure — exported for unit tests.
280
+ */
281
+ export function applyStageOverrides(records, overrides, groupByKey) {
282
+ if (!overrides || Object.keys(overrides).length === 0)
283
+ return records;
284
+ return records.map((r) => {
285
+ const to = overrides[String(r.id)];
286
+ if (to === undefined || String(r?.[groupByKey] ?? '') === to)
287
+ return r;
288
+ return { ...r, [groupByKey]: to };
289
+ });
290
+ }
291
+ function numericValue(v) {
292
+ if (v === null || v === undefined || v === '')
293
+ return null;
294
+ const n = typeof v === 'number' ? v : Number(v);
295
+ return Number.isFinite(n) ? n : null;
296
+ }
297
+ /**
298
+ * How the unconfirmed moves shift each lane's server count and aggregate sums:
299
+ * the source lane loses the card (and its values), the destination gains them.
300
+ * Lets the header totals follow the drag live and snap back on a rollback
301
+ * without touching the server figures. Pure — exported for unit tests.
302
+ */
303
+ export function laneDeltasFromOverrides(records, overrides, groupByKey, aggregateKeys = []) {
304
+ const out = {};
305
+ if (!overrides)
306
+ return out;
307
+ const bump = (stage, card, sign) => {
308
+ const d = (out[stage] ??= { count: 0, sums: {} });
309
+ d.count += sign;
310
+ for (const key of aggregateKeys) {
311
+ const n = numericValue(card?.[key]);
312
+ if (n !== null)
313
+ d.sums[key] = (d.sums[key] ?? 0) + sign * n;
314
+ }
315
+ };
316
+ for (const r of records) {
317
+ const to = overrides[String(r.id)];
318
+ if (to === undefined)
319
+ continue;
320
+ const from = String(r?.[groupByKey] ?? '');
321
+ if (from === to)
322
+ continue;
323
+ bump(from, r, -1);
324
+ bump(to, r, 1);
325
+ }
326
+ return out;
327
+ }
328
+ /**
329
+ * Returns a NEW per-lane aggregate map with a card's numeric values moved from
330
+ * `fromStage` to `toStage`. Lanes with no known sums yet are left alone. Pure.
331
+ */
332
+ export function applyLaneAggregatesOnMove(aggregates, card, fromStage, toStage, aggregateKeys) {
333
+ const next = { ...aggregates };
334
+ const shift = (stage, sign) => {
335
+ const cur = next[stage];
336
+ if (!cur)
337
+ return;
338
+ const updated = { ...cur };
339
+ for (const key of aggregateKeys) {
340
+ const n = numericValue(card?.[key]);
341
+ if (n !== null)
342
+ updated[key] = (numericValue(updated[key]) ?? 0) + sign * n;
343
+ }
344
+ next[stage] = updated;
345
+ };
346
+ shift(fromStage, -1);
347
+ shift(toStage, 1);
348
+ return next;
349
+ }
350
+ /**
351
+ * Keyboard drag: where the dragged card goes when the user presses ← or →.
352
+ * Picks the nearest lane to the left/right of the card's current center and
353
+ * returns the top-left coordinates that center the card in that lane, just
354
+ * under its header. Undefined when there is no lane further in that direction.
355
+ * Pure — exported for unit tests.
356
+ */
357
+ export function nextLaneCoordinates(direction, current, lanes) {
358
+ const center = current.left + current.width / 2;
359
+ const sorted = [...lanes].sort((a, b) => a.left - b.left);
360
+ const target = direction === 'right'
361
+ ? sorted.find((l) => l.left > center)
362
+ : [...sorted].reverse().find((l) => l.left + l.width < center);
363
+ if (!target)
364
+ return undefined;
365
+ return {
366
+ x: target.left + (target.width - current.width) / 2,
367
+ y: Math.max(current.top, target.top + 56),
368
+ };
369
+ }
203
370
  /**
204
371
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
205
372
  * equality (IN — the card's field value must be one of them); a free-text
@@ -239,9 +406,14 @@ export function cardMatchesLaneQuery(card, cols, query) {
239
406
  const q = query.trim().toLowerCase();
240
407
  if (!q)
241
408
  return true;
242
- return cols.some((c) => String(card?.[c.key] ?? '')
243
- .toLowerCase()
244
- .includes(q));
409
+ return cols.some((c) => {
410
+ // Match what the card shows (a relation's name) and the raw value.
411
+ const shown = cardCellText(card, c).toLowerCase();
412
+ if (shown.includes(q))
413
+ return true;
414
+ const raw = card?.[c.key];
415
+ return typeof raw !== 'object' && String(raw ?? '').toLowerCase().includes(q);
416
+ });
245
417
  }
246
418
  // ---------------------------------------------------------------------------
247
419
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
@@ -262,8 +434,42 @@ function useIsDarkTheme() {
262
434
  }, []);
263
435
  return isDark;
264
436
  }
265
- export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realtimeProp, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone, currency, defaultFilters, }) {
437
+ /**
438
+ * The optimistic card move runs on TanStack Query (useOptimisticMutation). A
439
+ * host that mounts no QueryClientProvider still gets a working board: the
440
+ * kanban brings its own client in that case.
441
+ */
442
+ export function DynamicKanban(props) {
443
+ const hostClient = useContext(QueryClientContext);
444
+ const [ownClient] = useState(() => hostClient
445
+ ? null
446
+ : new QueryClient({ defaultOptions: { mutations: { retry: false } } }));
447
+ if (!hostClient && ownClient) {
448
+ return (_jsx(QueryClientProvider, { client: ownClient, children: _jsx(DynamicKanbanBoard, { ...props }) }));
449
+ }
450
+ return _jsx(DynamicKanbanBoard, { ...props });
451
+ }
452
+ /** How long the drop animation takes to land the card in its lane. */
453
+ const DROP_ANIMATION = {
454
+ duration: 220,
455
+ easing: 'cubic-bezier(0.2, 0, 0, 1)',
456
+ sideEffects: defaultDropAnimationSideEffects({
457
+ styles: { active: { opacity: '0' } },
458
+ }),
459
+ };
460
+ /** Card fields a card may pick from; it shows the first few with a value. */
461
+ const CARD_FIELD_CANDIDATES = 8;
462
+ const CARD_FIELDS_SHOWN = 3;
463
+ /** No card move in flight (a stable empty snapshot). */
464
+ const NO_MOVES = Object.freeze({});
465
+ function DynamicKanbanBoard({ model, endpoint, refreshTrigger, realtime: realtimeProp, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone: timeZoneProp, currency: currencyProp, defaultFilters, }) {
266
466
  const { t, i18n } = useTranslation();
467
+ // The org's timezone/currency: an explicit prop wins, else the app-wide
468
+ // OrgRuntimeProvider (without it, money fell back to USD).
469
+ const orgTimeZone = useTimeZone();
470
+ const orgCurrency = useCurrency();
471
+ const timeZone = timeZoneProp ?? orgTimeZone;
472
+ const currency = currencyProp ?? orgCurrency;
267
473
  const api = useApi();
268
474
  const isDark = useIsDarkTheme();
269
475
  // Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
@@ -318,6 +524,9 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
318
524
  // Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
319
525
  // rather than moving a card. Kept apart so onDragEnd routes by draggable type.
320
526
  const [activeLaneId, setActiveLaneId] = useState(null);
527
+ // Lane under the dragged card — drives the "allowed / not allowed" feedback
528
+ // (ring on the lane, not-allowed cursor on the lifted card).
529
+ const [overLaneId, setOverLaneId] = useState(null);
321
530
  // Per-org lane order. `useStageLayout` reports whether the host wired the
322
531
  // `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
323
532
  // order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of
@@ -331,7 +540,40 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
331
540
  // slow eager-totals response from a superseded filter set can't inject stale
332
541
  // stage totals into the fresh board.
333
542
  const fetchGenRef = useRef(0);
334
- const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
543
+ // Keyboard drag (a11y): Space/Enter lifts a focused card, ←/→ jump to the
544
+ // neighbouring lane, Space/Enter drops, Escape cancels. A lane header drag
545
+ // keeps the sortable's own keyboard coordinates.
546
+ const keyboardCoordinates = useCallback((event, args) => {
547
+ const { context } = args;
548
+ if (context.active?.data.current?.type === 'lane') {
549
+ return sortableKeyboardCoordinates(event, args);
550
+ }
551
+ const direction = event.code === 'ArrowRight' ? 'right' : event.code === 'ArrowLeft' ? 'left' : null;
552
+ if (!direction)
553
+ return undefined;
554
+ event.preventDefault();
555
+ const rect = context.collisionRect;
556
+ if (!rect)
557
+ return undefined;
558
+ const lanes = [];
559
+ for (const c of context.droppableContainers.getEnabled()) {
560
+ const r = context.droppableRects.get(c.id);
561
+ if (r)
562
+ lanes.push({ id: String(c.id), left: r.left, top: r.top, width: r.width });
563
+ }
564
+ return nextLaneCoordinates(direction, rect, lanes);
565
+ }, []);
566
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }));
567
+ // A card lands in the lane under the pointer (a column is a tall target, so
568
+ // "where the pointer is" beats "what the card overlaps"); keyboard drags have
569
+ // no pointer and fall back to overlap. A lane header drag snaps to the
570
+ // nearest column.
571
+ const collisionDetection = useCallback((args) => {
572
+ if (args.active.data.current?.type === 'lane')
573
+ return closestCenter(args);
574
+ const hits = pointerWithin(args);
575
+ return hits.length > 0 ? hits : rectIntersection(args);
576
+ }, []);
335
577
  // ---- metadata fetch (same path as DynamicTable) ----
336
578
  useEffect(() => {
337
579
  let cancelled = false;
@@ -371,6 +613,98 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
371
613
  // and `f_<key>` serialization DynamicTable uses, so the board filters
372
614
  // identically to its table sibling.
373
615
  const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
616
+ const groupByKey = metadata?.group_by || '';
617
+ // Extra per-lane conditions (stage overrides) keyed by stage. A real lane
618
+ // that carries these queries its data — and counts its header — with the
619
+ // stage scope PLUS these filters (serialized like a smart lane's), so the
620
+ // top-up + lane-summary requests below layer them on. Sourced from
621
+ // `metadata.stages` (the kernel applies declared + custom-real overrides).
622
+ const stageExtraFilters = useMemo(() => {
623
+ const m = new Map();
624
+ for (const s of metadata?.stages ?? []) {
625
+ if (s.filters && s.filters.length > 0) {
626
+ m.set(s.key, s.filters.map((f) => ({
627
+ field: f.field,
628
+ op: f.op,
629
+ value: f.value,
630
+ })));
631
+ }
632
+ }
633
+ return m;
634
+ }, [metadata?.stages]);
635
+ // Columns the model declares a total for (`display_config.aggregate` — the
636
+ // same flag the table footer reads). Each lane header shows their sum over
637
+ // the lane, from the same `/aggregate` endpoint the footer uses.
638
+ const aggregateCols = useMemo(() => (metadata?.columns ?? []).filter((c) => aggregateOf(c)), [metadata]);
639
+ const aggregateKeys = useMemo(() => aggregateCols.map((c) => c.key), [aggregateCols]);
640
+ const [laneAggregates, setLaneAggregates] = useState({});
641
+ // Asks the server for one lane's figures, scoped by the active filters and
642
+ // the lane's own conditions: the real card count (a `per_page=1` list read,
643
+ // only `meta.total` is used) and, when the model declares totals, the lane's
644
+ // sums. `overwrite` replaces a total already known — used after a move, when
645
+ // the server is the one to settle what an automation may have changed.
646
+ const probeLane = useCallback(async (stageKey, gen, overwrite) => {
647
+ const gb = metadata?.group_by;
648
+ if (!gb)
649
+ return;
650
+ const base = endpoint || `/data/${model}`;
651
+ const scope = {
652
+ ...filterParams,
653
+ ...smartLaneParams(stageExtraFilters.get(stageKey)),
654
+ };
655
+ const count = (async () => {
656
+ try {
657
+ const r = (await api.get(base, {
658
+ params: { ...scope, page: 1, per_page: 1, [`f_${gb}`]: stageKey },
659
+ }));
660
+ const total = r.data.meta?.total ?? r.data.meta?.count ?? null;
661
+ if (total == null || gen !== fetchGenRef.current)
662
+ return;
663
+ setLanePagination((p) => {
664
+ const existing = p[stageKey];
665
+ // A real page fetch already learned this lane's total.
666
+ if (!overwrite && existing?.total != null)
667
+ return p;
668
+ return {
669
+ ...p,
670
+ [stageKey]: {
671
+ nextPage: existing?.nextPage ?? 1,
672
+ total,
673
+ loading: existing?.loading ?? false,
674
+ done: existing?.done ?? total === 0,
675
+ },
676
+ };
677
+ });
678
+ }
679
+ catch {
680
+ // A failed total just leaves the header on the loaded count.
681
+ }
682
+ })();
683
+ const sums = (async () => {
684
+ if (aggregateKeys.length === 0)
685
+ return;
686
+ try {
687
+ const r = (await api.get(`${base}/aggregate`, {
688
+ params: { ...scope, [`f_${gb}`]: stageKey },
689
+ }));
690
+ const data = r.data?.success ? r.data.data : null;
691
+ if (!data || typeof data !== 'object' || Array.isArray(data))
692
+ return;
693
+ if (gen !== fetchGenRef.current)
694
+ return;
695
+ const row = {};
696
+ for (const key of aggregateKeys) {
697
+ const n = Number(data[key]);
698
+ row[key] = Number.isFinite(n) ? n : 0;
699
+ }
700
+ setLaneAggregates((prev) => ({ ...prev, [stageKey]: row }));
701
+ }
702
+ catch {
703
+ // No sums for this lane: the header just shows the count.
704
+ }
705
+ })();
706
+ await Promise.all([count, sums]);
707
+ }, [api, endpoint, model, metadata?.group_by, filterParams, stageExtraFilters, aggregateKeys]);
374
708
  // ---- initial board page (one request, grouped into lanes) ----
375
709
  // Resets the per-lane pagination so every lane restarts its incremental
376
710
  // top-up from scratch — called on mount, refresh, and any filter/search
@@ -394,81 +728,44 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
394
728
  setLoadingData(false);
395
729
  }
396
730
  setLanePagination({});
397
- // Eager per-lane totals: so every lane header shows the REAL stage count
398
- // on first render (not just what the global page happened to load), fire
399
- // one lightweight `per_page=1` request per declared stage — in parallel,
400
- // scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
401
- // of filterParams). We read only `meta.total`; the row itself is ignored
402
- // (the lane loads its real cards via loadMoreLane on scroll). The
403
- // "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
404
- const gb = metadata.group_by;
405
- const declaredStages = deriveStages(metadata);
406
- if (gb && declaredStages.length > 0) {
407
- void Promise.all(declaredStages.map(async (stage) => {
408
- try {
409
- const r = (await api.get(endpoint || `/data/${model}`, {
410
- params: {
411
- ...filterParams,
412
- ...smartLaneParams(stage.filters),
413
- page: 1,
414
- per_page: 1,
415
- [`f_${gb}`]: stage.key,
416
- },
417
- }));
418
- const total = r.data.meta?.total ?? r.data.meta?.count ?? null;
419
- if (total == null || gen !== fetchGenRef.current)
420
- return;
421
- setLanePagination((p) => {
422
- const existing = p[stage.key];
423
- // A real page fetch already learned this lane's total.
424
- if (existing?.total != null)
425
- return p;
426
- return {
427
- ...p,
428
- [stage.key]: {
429
- nextPage: existing?.nextPage ?? 1,
430
- total,
431
- loading: existing?.loading ?? false,
432
- done: existing?.done ?? total === 0,
433
- },
434
- };
435
- });
436
- }
437
- catch (err) {
438
- // A failed total just leaves the header on the loaded count.
439
- }
440
- }));
731
+ setLaneAggregates({});
732
+ // Eager per-lane figures: so every lane header shows the REAL stage count
733
+ // (and its declared sums) on first render — not just what the global page
734
+ // happened to load — probe each declared stage in parallel, scoped by the
735
+ // SAME active filters/search. The "unassigned" lane can't be
736
+ // stage-scoped, so it keeps its loaded count.
737
+ const declared = deriveStages(metadata);
738
+ if (metadata.group_by && declared.length > 0) {
739
+ void Promise.all(declared.map((stage) => probeLane(stage.key, gen, false)));
441
740
  }
442
- }, [api, endpoint, model, metadata, pageSize, filterParams]);
741
+ }, [api, endpoint, model, metadata, pageSize, filterParams, probeLane]);
742
+ // Re-reads the server figures of the lanes a move touched.
743
+ const refreshLanes = useCallback((stageKeys) => {
744
+ const gen = fetchGenRef.current;
745
+ for (const key of new Set(stageKeys)) {
746
+ if (key && key !== UNASSIGNED_LANE)
747
+ void probeLane(key, gen, true);
748
+ }
749
+ }, [probeLane]);
443
750
  // Load the next page for ONE lane/stage and append it (deduped by id) into
444
751
  // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
445
752
  // filterParams (the stage scope wins over any global group_by filter).
446
- const groupByKey = metadata?.group_by || '';
447
- // Extra per-lane conditions (stage overrides) keyed by stage. A real lane
448
- // that carries these queries its data — and counts its header — with the
449
- // stage scope PLUS these filters (serialized like a smart lane's), so the
450
- // top-up + eager-total requests below layer them on. Sourced from
451
- // `metadata.stages` (the kernel applies declared + custom-real overrides).
452
- const stageExtraFilters = useMemo(() => {
453
- const m = new Map();
454
- for (const s of metadata?.stages ?? []) {
455
- if (s.filters && s.filters.length > 0) {
456
- m.set(s.key, s.filters.map((f) => ({
457
- field: f.field,
458
- op: f.op,
459
- value: f.value,
460
- })));
461
- }
462
- }
463
- return m;
464
- }, [metadata?.stages]);
753
+ //
754
+ // The page is derived from how many of the stage's cards are loaded, not
755
+ // from a counter: once a card leaves the lane the server's pages shift by
756
+ // one, and a counter would skip the card that slid into the gap. Reading
757
+ // from the loaded count re-requests that page instead (dedupe drops the
758
+ // repeats).
759
+ const recordsRef = useRef(records);
760
+ recordsRef.current = records;
465
761
  const loadMoreLane = useCallback(async (stageKey) => {
466
762
  if (!metadata || !groupByKey)
467
763
  return;
468
764
  const current = lanePagination[stageKey];
469
765
  if (current?.loading || current?.done)
470
766
  return;
471
- const nextPage = current?.nextPage ?? 1;
767
+ const loaded = recordsRef.current.filter((r) => String(r?.[groupByKey] ?? '') === stageKey).length;
768
+ const nextPage = Math.floor(loaded / lanePageSize) + 1;
472
769
  setLanePagination((p) => ({
473
770
  ...p,
474
771
  [stageKey]: {
@@ -654,8 +951,28 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
654
951
  // Real (card-droppable) stage keys — guards a card drop from ever landing on
655
952
  // a smart lane (a saved view, never a stored stage value).
656
953
  const realStageKeys = useMemo(() => new Set(stages.map((s) => s.key)), [stages]);
657
- const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
658
- const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
954
+ // ---- optimistic card moves ----
955
+ // Unconfirmed moves live in their own small cache entry (`cardId → lane`),
956
+ // layered over the fetched records at paint time. The records themselves
957
+ // only change once the server confirms, so a rollback is just dropping the
958
+ // override — it can never clobber cards a lane loaded meanwhile.
959
+ const qc = useQueryClient();
960
+ const boardInstance = useId();
961
+ const movesKey = useMemo(() => ['metacore', 'kanban-moves', model, boardInstance], [model, boardInstance]);
962
+ // Read synchronously (not through useQuery, whose notifications land a tick
963
+ // later): the override must paint in the SAME commit as the drop, so the
964
+ // drag overlay's drop animation flies to the card's new lane, not its old one.
965
+ const moveOverrides = useSyncExternalStore(useCallback((onChange) => qc.getQueryCache().subscribe(onChange), [qc]), () => qc.getQueryData(movesKey) ?? NO_MOVES, () => NO_MOVES);
966
+ useEffect(() => () => qc.removeQueries({ queryKey: movesKey, exact: true }), [qc, movesKey]);
967
+ const displayRecords = useMemo(() => applyStageOverrides(records, moveOverrides, groupByKey), [records, moveOverrides, groupByKey]);
968
+ // How the moves in flight shift each lane's server count and sums.
969
+ const laneDeltas = useMemo(() => laneDeltasFromOverrides(records, moveOverrides, groupByKey, aggregateKeys), [records, moveOverrides, groupByKey, aggregateKeys]);
970
+ const grouped = useMemo(() => groupByStage(displayRecords, groupByKey, stages), [displayRecords, groupByKey, stages]);
971
+ // Card fields: a few more candidates than a card shows, so a card whose
972
+ // first fields are empty shows the next ones that have a value.
973
+ const { title: titleCol, fields: fieldCols } = useMemo(() => metadata
974
+ ? selectCardColumns(metadata, CARD_FIELD_CANDIDATES)
975
+ : { title: null, fields: [] }, [metadata]);
659
976
  // Columns the lane search scans: the card title + its visible field cells.
660
977
  const searchCols = useMemo(() => [titleCol, ...fieldCols].filter(Boolean), [titleCol, fieldCols]);
661
978
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
@@ -679,23 +996,114 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
679
996
  });
680
997
  const cardById = useMemo(() => {
681
998
  const m = new Map();
682
- for (const r of records)
999
+ for (const r of displayRecords)
683
1000
  m.set(String(r.id), r);
684
1001
  return m;
685
- }, [records]);
1002
+ }, [displayRecords]);
686
1003
  const stageOfCard = useCallback((id) => {
687
1004
  const card = cardById.get(id);
688
1005
  const raw = card?.[groupByKey];
689
1006
  return raw === null || raw === undefined ? '' : String(raw);
690
1007
  }, [cardById, groupByKey]);
1008
+ // FLIP: cards glide to their new place when a move lands or rolls back.
1009
+ // The card just dropped is skipped — the drag overlay already carries it.
1010
+ const boardRef = useRef(null);
1011
+ const droppedCardRef = useRef(null);
1012
+ const flipShouldAnimate = useCallback((_el, key) => key !== droppedCardRef.current, []);
1013
+ useFlipAnimation(boardRef, displayRecords, { shouldAnimate: flipShouldAnimate });
1014
+ // The skip covers only the drop's own paint: a rollback later must glide the
1015
+ // card back. (Runs after the FLIP effect above — same component, in order.)
1016
+ useLayoutEffect(() => {
1017
+ droppedCardRef.current = null;
1018
+ }, [displayRecords]);
1019
+ const cardText = useCallback((card) => (titleCol ? cardCellText(card, titleCol) : '') || String(card?.id ?? ''), [titleCol]);
1020
+ const laneLabel = useCallback((key) => {
1021
+ if (key === UNASSIGNED_LANE)
1022
+ return t('kanban.unassigned', { defaultValue: 'Sin etapa' });
1023
+ const st = stages.find((x) => x.key === key);
1024
+ return st ? t(st.label, { defaultValue: st.label }) : key;
1025
+ }, [stages, t]);
1026
+ const base = endpoint || `/data/${model}`;
1027
+ const moveCard = useOptimisticMutation({
1028
+ queryKey: movesKey,
1029
+ mutationFn: async (move) => {
1030
+ try {
1031
+ // `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
1032
+ // so the per-record update is just `<base>/<id>` — same convention
1033
+ // as DynamicTable/DynamicRelation.
1034
+ const res = (await api.put(`${base}/${move.cardId}`, {
1035
+ [groupByKey]: move.to,
1036
+ }));
1037
+ if (res?.data && res.data.success === false) {
1038
+ throw Object.assign(new Error(res.data.message || 'update_failed'), {
1039
+ serverMessage: res.data.message,
1040
+ });
1041
+ }
1042
+ // Confirmed: the record takes the new lane (plus whatever the server
1043
+ // wrote with it — a transition's `set`, an automation), and the
1044
+ // lane figures follow in the same paint as the override going away.
1045
+ const saved = res?.data?.data;
1046
+ const card = recordsRef.current.find((r) => String(r.id) === move.cardId);
1047
+ setRecords((rs) => rs.map((r) => String(r.id) === move.cardId
1048
+ ? {
1049
+ ...r,
1050
+ ...(saved && typeof saved === 'object' && !Array.isArray(saved)
1051
+ ? saved
1052
+ : {}),
1053
+ [groupByKey]: move.to,
1054
+ }
1055
+ : r));
1056
+ setLanePagination((p) => applyLaneTotalsOnMove(p, move.from, move.to));
1057
+ if (card) {
1058
+ setLaneAggregates((a) => applyLaneAggregatesOnMove(a, card, move.from, move.to, aggregateKeys));
1059
+ }
1060
+ return saved;
1061
+ }
1062
+ catch (err) {
1063
+ // Rejected: drop this card's override so it glides back to its lane.
1064
+ // Done here (not in onError) so a rejected move that a newer drag
1065
+ // superseded still returns home and still tells the user why.
1066
+ qc.setQueryData(movesKey, (cur) => {
1067
+ if (!cur || cur[move.cardId] !== move.to)
1068
+ return cur;
1069
+ const { [move.cardId]: _dropped, ...rest } = cur;
1070
+ return rest;
1071
+ });
1072
+ const reason = err?.response?.data?.message ?? err?.serverMessage;
1073
+ toast.error(t('kanban.moveFailed', {
1074
+ defaultValue: 'No se pudo mover la tarjeta',
1075
+ }) + (reason ? `: ${reason}` : ''));
1076
+ throw err;
1077
+ }
1078
+ },
1079
+ optimistic: (current, move) => ({ ...(current ?? {}), [move.cardId]: move.to }),
1080
+ // The record already carries the confirmed lane: the override goes away.
1081
+ reconcile: (_data, move, current) => {
1082
+ if (!current || !(move.cardId in current))
1083
+ return current ?? {};
1084
+ const { [move.cardId]: _confirmed, ...rest } = current;
1085
+ return rest;
1086
+ },
1087
+ isEqual: (a, b) => a.cardId === b.cardId && a.to === b.to,
1088
+ onSuccess: (_data, move) => refreshLanes([move.from, move.to]),
1089
+ });
691
1090
  const onDragStart = useCallback((e) => {
692
1091
  if (e.active.data.current?.type === 'lane') {
693
1092
  setActiveLaneId(String(e.active.id));
694
1093
  }
695
1094
  else {
1095
+ droppedCardRef.current = null;
696
1096
  setActiveId(String(e.active.id));
697
1097
  }
698
1098
  }, []);
1099
+ const onDragOver = useCallback((e) => {
1100
+ setOverLaneId(e.over ? String(e.over.id) : null);
1101
+ }, []);
1102
+ const onDragCancel = useCallback(() => {
1103
+ setActiveId(null);
1104
+ setActiveLaneId(null);
1105
+ setOverLaneId(null);
1106
+ }, []);
699
1107
  // Optimistic lane reorder: reorder the columns in local state immediately,
700
1108
  // PUT the full new order, and revert + toast on failure. Keys mix real
701
1109
  // stages and smart lanes (the backend applies the order to both).
@@ -720,6 +1128,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
720
1128
  const onDragEnd = useCallback(async (e) => {
721
1129
  setActiveId(null);
722
1130
  setActiveLaneId(null);
1131
+ setOverLaneId(null);
723
1132
  const { active, over } = e;
724
1133
  // A header drag reorders columns rather than moving a card.
725
1134
  if (active.data.current?.type === 'lane') {
@@ -731,51 +1140,98 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
731
1140
  return;
732
1141
  const cardId = String(active.id);
733
1142
  const destStage = String(over.id);
734
- // Never drop a card onto a smart lane (a saved view, not a stage).
735
- if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE)
1143
+ // Only a real stage takes a card: never a smart lane (a saved view)
1144
+ // nor the synthetic "Sin etapa" lane (not a value the record can hold).
1145
+ if (!realStageKeys.has(destStage))
736
1146
  return;
737
1147
  const srcStage = stageOfCard(cardId);
738
1148
  if (srcStage === destStage)
739
1149
  return;
740
- if (!isTransitionAllowed(transitions, srcStage, destStage)) {
741
- toast.error(t('kanban.invalidTransition', {
742
- defaultValue: 'Movimiento no permitido entre estas etapas',
743
- }));
1150
+ // A lane the stage machine doesn't reach was shown as "no permitido"
1151
+ // the whole drag; dropping there just sends the card home.
1152
+ if (!isTransitionAllowed(transitions, srcStage, destStage, realStageKeys))
744
1153
  return;
745
- }
746
- // OPTIMISTIC: move the card in local state immediately.
747
- const prevRecords = records;
748
- const prevPagination = lanePagination;
749
- setRecords((rs) => rs.map((r) => String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r));
750
- // Keep the server totals consistent with the moved card so a lane's
751
- // `count/total` header stays truthful with partial lanes: one leaves
752
- // the source stage, one joins the destination.
753
- setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage));
754
- try {
755
- const base = endpoint || `/data/${model}`;
756
- // `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
757
- // so the per-record update is just `<base>/<id>` — same convention
758
- // as DynamicTable/DynamicRelation. Appending an extra `/me` here
759
- // produced `/data/<model>/me/me/<id>` → 404 on drag-to-move.
760
- const res = (await api.put(`${base}/${cardId}`, {
761
- [groupByKey]: destStage,
762
- }));
763
- if (res?.data && res.data.success === false) {
764
- throw new Error(res.data.message || 'update_failed');
765
- }
766
- }
767
- catch (err) {
768
- // REVERT + toast on failure.
769
- setRecords(prevRecords);
770
- setLanePagination(prevPagination);
771
- toast.error(t('kanban.moveFailed', {
772
- defaultValue: 'No se pudo mover la tarjeta',
773
- }) +
774
- (err?.response?.data?.message
775
- ? `: ${err.response.data.message}`
776
- : ''));
777
- }
778
- }, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes]);
1154
+ droppedCardRef.current = cardId;
1155
+ moveCard.mutate({ cardId, from: srcStage, to: destStage });
1156
+ }, [moveCard, realStageKeys, stageOfCard, transitions, reorderLanes]);
1157
+ // Screen-reader narration of a card drag (dnd-kit reads these aloud).
1158
+ const announcements = useMemo(() => {
1159
+ const cardName = (id) => {
1160
+ const card = cardById.get(String(id));
1161
+ return card ? cardText(card) : String(id);
1162
+ };
1163
+ const isCard = (a) => a.data.current?.type !== 'lane';
1164
+ return {
1165
+ onDragStart: ({ active }) => isCard(active)
1166
+ ? t('kanban.a11y.picked', {
1167
+ defaultValue: 'Tarjeta {{card}} levantada, en {{lane}}.',
1168
+ card: cardName(active.id),
1169
+ lane: laneLabel(stageOfCard(String(active.id))),
1170
+ })
1171
+ : t('kanban.a11y.lanePicked', {
1172
+ defaultValue: 'Columna {{lane}} levantada.',
1173
+ lane: laneLabel(String(active.id)),
1174
+ }),
1175
+ onDragOver: ({ active, over }) => {
1176
+ if (!over)
1177
+ return undefined;
1178
+ if (!isCard(active)) {
1179
+ return t('kanban.a11y.laneOver', {
1180
+ defaultValue: 'Sobre la columna {{lane}}.',
1181
+ lane: laneLabel(String(over.id)),
1182
+ });
1183
+ }
1184
+ const from = stageOfCard(String(active.id));
1185
+ const to = String(over.id);
1186
+ return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys)
1187
+ ? t('kanban.a11y.over', {
1188
+ defaultValue: 'Sobre {{lane}}.',
1189
+ lane: laneLabel(to),
1190
+ })
1191
+ : t('kanban.a11y.overBlocked', {
1192
+ defaultValue: '{{lane}}: no se puede mover aquí.',
1193
+ lane: laneLabel(to),
1194
+ });
1195
+ },
1196
+ onDragEnd: ({ active, over }) => {
1197
+ if (!isCard(active)) {
1198
+ return t('kanban.a11y.laneDropped', {
1199
+ defaultValue: 'Columna {{lane}} soltada.',
1200
+ lane: laneLabel(String(active.id)),
1201
+ });
1202
+ }
1203
+ const from = stageOfCard(String(active.id));
1204
+ const to = over ? String(over.id) : '';
1205
+ if (!over || to === from) {
1206
+ return t('kanban.a11y.droppedHome', {
1207
+ defaultValue: 'Tarjeta {{card}} se queda en {{lane}}.',
1208
+ card: cardName(active.id),
1209
+ lane: laneLabel(from),
1210
+ });
1211
+ }
1212
+ return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys)
1213
+ ? t('kanban.a11y.dropped', {
1214
+ defaultValue: 'Tarjeta {{card}} movida a {{lane}}.',
1215
+ card: cardName(active.id),
1216
+ lane: laneLabel(to),
1217
+ })
1218
+ : t('kanban.a11y.droppedBlocked', {
1219
+ defaultValue: 'No se puede mover de {{from}} a {{lane}}. La tarjeta vuelve a {{from}}.',
1220
+ from: laneLabel(from),
1221
+ lane: laneLabel(to),
1222
+ });
1223
+ },
1224
+ onDragCancel: ({ active }) => t('kanban.a11y.cancelled', {
1225
+ defaultValue: 'Movimiento cancelado. {{card}} vuelve a su lugar.',
1226
+ card: isCard(active) ? cardName(active.id) : laneLabel(String(active.id)),
1227
+ }),
1228
+ };
1229
+ }, [cardById, cardText, laneLabel, realStageKeys, stageOfCard, t, transitions]);
1230
+ const screenReaderInstructions = useMemo(() => ({
1231
+ draggable: t('kanban.a11y.instructions', {
1232
+ defaultValue: '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.',
1233
+ }),
1234
+ }), [t]);
779
1235
  // Board-level "Restablecer orden": drop the stored order and refetch the
780
1236
  // metadata so the lanes fall back to the DECLARED order. Optimistic — reverts
781
1237
  // its local override on failure.
@@ -822,7 +1278,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
822
1278
  // declared transitions (a same-stage or unrestricted move always passes).
823
1279
  const droppableAllowedFor = (stageKey) => !activeId ||
824
1280
  stageKey === activeStage ||
825
- isTransitionAllowed(transitions, activeStage, stageKey);
1281
+ (realStageKeys.has(stageKey) &&
1282
+ isTransitionAllowed(transitions, activeStage, stageKey, realStageKeys));
1283
+ // The lifted card is over a lane that won't take it → not-allowed cursor.
1284
+ const overBlocked = !!activeId && !!overLaneId && !droppableAllowedFor(overLaneId);
826
1285
  // Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
827
1286
  // right backend by kind: a custom real stage edits through /custom-stages, a
828
1287
  // declared stage through /stage-overrides. Seeds the current label/color and
@@ -907,12 +1366,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
907
1366
  }
908
1367
  const laneState = lanePagination[stage.key];
909
1368
  const isUnassigned = stage.key === UNASSIGNED_LANE;
910
- const laneHasMore = !isUnassigned && !laneState?.done;
1369
+ // Server figures, shifted by the moves still in flight.
1370
+ const delta = laneDeltas[stage.key];
1371
+ const serverTotal = laneState?.total != null ? Math.max(0, laneState.total + (delta?.count ?? 0)) : null;
1372
+ const laneSums = laneAggregates[stage.key];
1373
+ const aggregates = laneSums && aggregateCols.length > 0
1374
+ ? aggregateCols.map((col) => ({
1375
+ key: col.key,
1376
+ label: t(col.label, { defaultValue: col.label }),
1377
+ value: formatAggregateTotal(col, (laneSums[col.key] ?? 0) + (delta?.sums[col.key] ?? 0), currency, i18n.language),
1378
+ }))
1379
+ : [];
1380
+ // Nothing left to fetch once every card the server counts is loaded
1381
+ // (a lane the first page already filled needs no top-up request).
1382
+ const laneHasMore = !isUnassigned &&
1383
+ !laneState?.done &&
1384
+ !(serverTotal != null && allCards.length >= serverTotal);
1385
+ const blocked = !!activeId && !droppableAllowedFor(stage.key);
911
1386
  return {
912
1387
  stage,
913
1388
  count: cards.length,
914
1389
  totalCount: allCards.length,
915
- serverTotal: laneState?.total ?? null,
1390
+ serverTotal,
1391
+ aggregates,
916
1392
  hasMore: laneHasMore,
917
1393
  loadingMore: !!laneState?.loading,
918
1394
  onLoadMore: () => loadMoreLane(stage.key),
@@ -925,7 +1401,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
925
1401
  }),
926
1402
  onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
927
1403
  isDark,
928
- dimmed: !!activeId && !droppableAllowedFor(stage.key),
1404
+ dimmed: blocked,
1405
+ blockedOver: blocked && overLaneId === stage.key,
929
1406
  model,
930
1407
  columns: metadata?.columns ?? [],
931
1408
  automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE,
@@ -936,7 +1413,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
936
1413
  extraFilters,
937
1414
  configurable: isConfigurable(stage),
938
1415
  onConfigure: () => openConfigStage(stage),
939
- children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))),
1416
+ children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction, pending: card.id != null && String(card.id) in moveOverrides }, String(card.id))))),
940
1417
  };
941
1418
  };
942
1419
  // Props for one smart (virtual) lane — a read-only, filter-defined column.
@@ -964,7 +1441,16 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realt
964
1441
  defaultValue: 'Sin filtros',
965
1442
  }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] })), showResetOrder && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-8 gap-1.5 text-xs text-muted-foreground", onClick: () => void resetLaneOrder(), "data-testid": "kanban-reset-order", children: [_jsx(RotateCcw, { className: "h-3.5 w-3.5" }), t('dynamic.stage_layout.reset', {
966
1443
  defaultValue: 'Restablecer orden',
967
- })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx(SortableContext, { items: boardLaneKeys, strategy: horizontalListSortingStrategy, children: _jsxs("div", { className: "flex w-full min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: [renderLanes.map((lane) => lane.kind === 'stage' ? (_jsx(SortableStageLane, { reorderEnabled: laneReorderEnabled, droppableDisabled: !!activeId && !droppableAllowedFor(lane.stage.key), laneProps: buildLaneProps(lane.stage) }, lane.stage.key)) : laneReorderEnabled ? (_jsx(SortableSmartLane, { droppableDisabled: !!activeId, smartProps: buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`)) : (_jsx(SmartLane, { ...buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`))), unassignedStage && (_jsx(DroppableStageLane, { droppableDisabled: !!activeId && !droppableAllowedFor(UNASSIGNED_LANE), laneProps: buildLaneProps(unassignedStage) }, UNASSIGNED_LANE)), customStages.available && (_jsx(AddStageColumn, { onClick: openCreateStage }))] }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] }), customStages.available && (_jsxs(_Fragment, { children: [_jsx(CustomStageDialog, { open: stageDialogOpen, onOpenChange: setStageDialogOpen, model: model, columns: metadata?.columns ?? [], initial: editingStage, nextPosition: nextStagePosition, onCreate: customStages.create, onUpdate: customStages.update }), _jsx(CustomStageDeleteDialog, { open: !!deletingStage, onOpenChange: (o) => {
1444
+ })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, collisionDetection: collisionDetection, onDragStart: onDragStart, onDragOver: onDragOver, onDragEnd: onDragEnd, onDragCancel: onDragCancel,
1445
+ // Near the board's left/right edge the columns scroll by
1446
+ // themselves while a card is held there.
1447
+ autoScroll: { threshold: { x: 0.15, y: 0.15 }, acceleration: 14 }, accessibility: { announcements, screenReaderInstructions }, children: [_jsx(SortableContext, { items: boardLaneKeys, strategy: horizontalListSortingStrategy, children: _jsxs("div", { ref: boardRef, className: "flex w-full min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", "data-dragging": activeId ? 'card' : undefined, children: [renderLanes.map((lane) => lane.kind === 'stage' ? (_jsx(SortableStageLane, { reorderEnabled: laneReorderEnabled,
1448
+ // A lane the card can't enter stays a drop target so
1449
+ // it can say so (red ring, not-allowed cursor); the
1450
+ // drop itself is refused in onDragEnd.
1451
+ droppableDisabled: false, laneProps: buildLaneProps(lane.stage) }, lane.stage.key)) : laneReorderEnabled ? (_jsx(SortableSmartLane, { droppableDisabled: !!activeId, smartProps: buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`)) : (_jsx(SmartLane, { ...buildSmartProps(lane.stage) }, `smart-${lane.stage.key}`))), unassignedStage && (_jsx(DroppableStageLane, {
1452
+ // "Sin etapa" is not a value a card can be given.
1453
+ droppableDisabled: !!activeId, laneProps: buildLaneProps(unassignedStage) }, UNASSIGNED_LANE)), customStages.available && (_jsx(AddStageColumn, { onClick: openCreateStage }))] }) }), _jsx(PortalDragOverlay, { dropAnimation: DROP_ANIMATION, zIndex: 60, children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency, blocked: overBlocked })) : null }), rowActionDialogs] }), customStages.available && (_jsxs(_Fragment, { children: [_jsx(CustomStageDialog, { open: stageDialogOpen, onOpenChange: setStageDialogOpen, model: model, columns: metadata?.columns ?? [], initial: editingStage, nextPosition: nextStagePosition, onCreate: customStages.create, onUpdate: customStages.update }), _jsx(CustomStageDeleteDialog, { open: !!deletingStage, onOpenChange: (o) => {
968
1454
  if (!o)
969
1455
  setDeletingStage(null);
970
1456
  }, stage: deletingStage, reassignTargets: stages.map((s) => ({
@@ -1091,7 +1577,7 @@ function SortableSmartLane({ droppableDisabled, smartProps, }) {
1091
1577
  };
1092
1578
  return _jsx(SmartLane, { ...smartProps, dnd: dnd });
1093
1579
  }
1094
- function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, dnd, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, extraFilters, configurable, onConfigure, children, }) {
1580
+ function KanbanLane({ stage, count, totalCount, serverTotal, aggregates, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, blockedOver, dnd, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, extraFilters, configurable, onConfigure, children, }) {
1095
1581
  const { t } = useTranslation();
1096
1582
  // Re-resolve the lane label when an addon i18n bundle lands after the board
1097
1583
  // painted (async addResourceBundle) — otherwise a manifest-key label like
@@ -1157,12 +1643,17 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
1157
1643
  // fit, capped at max-w so a couple of lanes don't stretch absurdly
1158
1644
  // wide. Below min-w the board's overflow-x-auto takes over and the
1159
1645
  // lanes scroll horizontally at their minimum width.
1160
- className: "group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
1161
- opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
1162
- outline: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
1646
+ 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", style: {
1647
+ opacity: dnd.isDragging ? 0.6 : dimmed ? (blockedOver ? 0.7 : 0.45) : 1,
1648
+ outline: blockedOver
1649
+ ? '2px dashed var(--destructive, #ef4444)'
1650
+ : dnd.isOver
1651
+ ? '2px solid var(--ring, #3b82f6)'
1652
+ : '2px solid transparent',
1163
1653
  outlineOffset: 2,
1654
+ cursor: dimmed ? 'not-allowed' : undefined,
1164
1655
  ...dnd.style,
1165
- }, "data-stage": stage.key, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { ref: dnd.draggable ? dnd.handleRef : undefined, ...(dnd.draggable ? dnd.handleProps : {}), className: `flex min-w-0 items-center gap-1.5 ${dnd.draggable ? 'cursor-grab active:cursor-grabbing' : ''}`, children: [dnd.draggable && (_jsx(GripVertical, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70", "aria-hidden": true })), _jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) }), hasConditions && (_jsx("span", { className: "flex items-center text-primary", title: t('dynamic.stage_config.conditions_active', {
1656
+ }, "data-stage": stage.key, "data-drop": dimmed ? 'blocked' : undefined, "aria-disabled": dimmed || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { ref: dnd.draggable ? dnd.handleRef : undefined, ...(dnd.draggable ? dnd.handleProps : {}), className: `flex min-w-0 items-center gap-1.5 ${dnd.draggable ? 'cursor-grab active:cursor-grabbing' : ''}`, children: [dnd.draggable && (_jsx(GripVertical, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70", "aria-hidden": true })), _jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) }), hasConditions && (_jsx("span", { className: "flex items-center text-primary", title: t('dynamic.stage_config.conditions_active', {
1166
1657
  defaultValue: 'Condiciones: {{list}}',
1167
1658
  list: conditionsSummary,
1168
1659
  }), "data-testid": `lane-conditions-${stage.key}`, "aria-label": t('dynamic.stage_config.conditions_active', {
@@ -1172,7 +1663,9 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
1172
1663
  defaultValue: 'Buscar en la columna',
1173
1664
  }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange }), automationsAvailable && (_jsx(StageAutomationsButton, { model: model, stageKey: stage.key, stageLabel: stage.label, columns: columns, rules: automationRules, onCreate: onAutomationCreate, onUpdate: onAutomationUpdate, onRemove: onAutomationRemove })), configurable && (_jsx("button", { type: "button", onClick: onConfigure, className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${hasConditions ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('dynamic.stage_config.open', {
1174
1665
  defaultValue: 'Configurar etapa',
1175
- }), "data-testid": `lane-config-${stage.key}`, children: _jsx(Settings2, { className: "h-3.5 w-3.5" }) }))] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
1666
+ }), "data-testid": `lane-config-${stage.key}`, children: _jsx(Settings2, { className: "h-3.5 w-3.5" }) }))] })] }), (aggregates.length > 0 || blockedOver) && (_jsx("div", { className: "-mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 px-3 pb-1.5 text-xs", children: blockedOver ? (_jsx("span", { className: "font-medium text-destructive", "data-testid": `lane-blocked-${stage.key}`, children: t('kanban.dropNotAllowed', {
1667
+ defaultValue: 'No se puede mover aquí',
1668
+ }) })) : (aggregates.map((a) => (_jsxs("span", { className: "tabular-nums text-muted-foreground", title: a.label, "data-testid": `lane-total-${stage.key}-${a.key}`, children: [aggregates.length > 1 && (_jsxs("span", { className: "opacity-70", children: [a.label, ": "] })), _jsx("span", { className: "font-semibold text-foreground", children: a.value })] }, a.key)))) })), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
1176
1669
  if (e.key === 'Escape') {
1177
1670
  onQueryChange('');
1178
1671
  setSearchOpen(false);
@@ -1244,21 +1737,29 @@ function LaneFilterButton({ fields, value, onChange, }) {
1244
1737
  defaultValue: 'Contiene...',
1245
1738
  }), className: "h-8 w-full text-xs" })), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { variant: "outline", size: "sm", className: "h-7 flex-1 text-xs", onClick: clear, disabled: !active && values.length === 0 && !text.trim(), children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsxs(Button, { size: "sm", className: "h-7 flex-1 text-xs", onClick: apply, disabled: values.length === 0 && !text.trim(), children: [t('kanban.apply', { defaultValue: 'Aplicar' }), values.length > 0 ? ` (${values.length})` : ''] })] })] })] }));
1246
1739
  }
1247
- function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, draggable = true, }) {
1740
+ // The title + the card's non-empty fields. Shared by the card and the drag
1741
+ // preview so the lifted card looks exactly like the one on the board.
1742
+ function CardBody({ card, titleCol, fieldCols, locale, timeZone, currency, menu, }) {
1743
+ const fields = visibleCardFields(card, fieldCols, CARD_FIELDS_SHOWN);
1744
+ return (_jsxs(CardContent, { className: "space-y-1.5 p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("div", { className: "min-w-0 flex-1 break-words text-sm font-medium leading-snug", children: titleCol && !isEmptyCardValue(card, titleCol) ? (_jsx(ActivityValueRenderer, { value: cardCellValue(card, titleCol), col: titleCol, locale: locale, timeZone: timeZone, currency: currency })) : (_jsxs("span", { className: "truncate text-muted-foreground", children: ["#", String(card.id)] })) }), menu] }), fields.map((col) => (_jsxs("div", { className: "flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground", children: [_jsxs("span", { className: "shrink-0 opacity-70", children: [col.label, ":"] }), _jsx("span", { className: "min-w-0 break-words", children: _jsx(ActivityValueRenderer, { value: cardCellValue(card, col), col: col, locale: locale, timeZone: timeZone, currency: currency }) })] }, col.key)))] }));
1745
+ }
1746
+ function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, draggable = true, pending = false, }) {
1248
1747
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1249
1748
  id: String(card.id),
1250
1749
  data: { type: 'card' },
1251
1750
  disabled: !draggable,
1252
1751
  });
1253
1752
  const visibleActions = actions.filter((a) => isRowActionVisible(a, card));
1254
- return (_jsx(Card, { ref: draggable ? setNodeRef : undefined, ...(draggable ? attributes : {}), ...(draggable ? listeners : {}), className: `w-full min-w-0 border-border/70 shadow-sm ${draggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'}`, style: { opacity: isDragging ? 0.4 : 1 }, onClick: () => onClick?.(card), "data-card-id": String(card.id), children: _jsxs(CardContent, { className: "space-y-1.5 p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("div", { className: "min-w-0 flex-1 break-words text-sm font-medium leading-snug", children: titleCol ? (_jsx(ActivityValueRenderer, { value: cardCellValue(card, titleCol), col: titleCol, locale: locale, timeZone: timeZone, currency: currency })) : (_jsx("span", { className: "truncate", children: String(card.id) })) }), visibleActions.length > 0 && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-6 w-6 shrink-0 -mr-1 -mt-1",
1255
- // Don't start a drag / card click from the menu button.
1256
- onPointerDown: (e) => e.stopPropagation(), onClick: (e) => e.stopPropagation(), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", onClick: (e) => e.stopPropagation(), children: visibleActions.map((a) => (_jsxs(DropdownMenuItem, { onClick: (e) => {
1257
- e.stopPropagation();
1258
- onAction(a.key, card);
1259
- }, children: [_jsx(DynamicIcon, { name: a.icon || 'Zap', className: "mr-2 h-4 w-4" }), a.label] }, a.key))) })] }))] }), fieldCols.map((col) => (_jsxs("div", { className: "flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground", children: [_jsxs("span", { className: "shrink-0 opacity-70", children: [col.label, ":"] }), _jsx("span", { className: "min-w-0 break-words", children: _jsx(ActivityValueRenderer, { value: cardCellValue(card, col), col: col, locale: locale, timeZone: timeZone, currency: currency }) })] }, col.key)))] }) }));
1753
+ const menu = visibleActions.length > 0 ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-6 w-6 shrink-0 -mr-1 -mt-1",
1754
+ // Don't start a drag / card click from the menu button.
1755
+ onPointerDown: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), onClick: (e) => e.stopPropagation(), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", onClick: (e) => e.stopPropagation(), children: visibleActions.map((a) => (_jsxs(DropdownMenuItem, { onClick: (e) => {
1756
+ e.stopPropagation();
1757
+ onAction(a.key, card);
1758
+ }, children: [_jsx(DynamicIcon, { name: a.icon || 'Zap', className: "mr-2 h-4 w-4" }), a.label] }, a.key))) })] })) : null;
1759
+ return (_jsx(Card, { ref: draggable ? setNodeRef : undefined, ...(draggable ? attributes : {}), ...(draggable ? listeners : {}), className: `w-full min-w-0 shrink-0 border-border/70 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring ${draggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'}`, style: { opacity: isDragging ? 0.4 : 1 }, onClick: () => onClick?.(card), "data-card-id": String(card.id), "data-flip-key": draggable ? String(card.id) : undefined, "aria-busy": pending || undefined, children: _jsx(CardBody, { card: card, titleCol: titleCol, fieldCols: fieldCols, locale: locale, timeZone: timeZone, currency: currency, menu: menu }) }));
1260
1760
  }
1261
- // Static preview rendered inside the DragOverlay (no dnd hooks, no menu).
1262
- function CardPreview({ card, titleCol, fieldCols, locale, timeZone, currency, }) {
1263
- return (_jsx(Card, { className: "w-[284px] cursor-grabbing border-primary/40 shadow-lg", children: _jsxs(CardContent, { className: "space-y-1.5 p-3", children: [_jsx("div", { className: "break-words text-sm font-medium leading-snug", children: titleCol ? (_jsx(ActivityValueRenderer, { value: cardCellValue(card, titleCol), col: titleCol, locale: locale, timeZone: timeZone, currency: currency })) : (String(card.id)) }), fieldCols.map((col) => (_jsxs("div", { className: "flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground", children: [_jsxs("span", { className: "shrink-0 opacity-70", children: [col.label, ":"] }), _jsx("span", { className: "min-w-0 break-words", children: _jsx(ActivityValueRenderer, { value: cardCellValue(card, col), col: col, locale: locale, timeZone: timeZone, currency: currency }) })] }, col.key)))] }) }));
1761
+ // Static preview rendered inside the DragOverlay (no dnd hooks, no menu). It
1762
+ // fills the overlay box, which dnd-kit sizes to the card being dragged.
1763
+ function CardPreview({ card, titleCol, fieldCols, locale, timeZone, currency, blocked = false, }) {
1764
+ return (_jsx(Card, { className: `h-full w-full shadow-xl transition-[border-color] ${blocked ? 'border-destructive/60' : 'border-primary/40'}`, style: { cursor: blocked ? 'not-allowed' : 'grabbing' }, "data-testid": "kanban-drag-preview", "data-blocked": blocked || undefined, children: _jsx(CardBody, { card: card, titleCol: titleCol, fieldCols: fieldCols, locale: locale, timeZone: timeZone, currency: currency }) }));
1264
1765
  }