@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.
- package/CHANGELOG.md +28 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +9 -13
- package/dist/dynamic-kanban.d.ts +83 -6
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +647 -146
- package/dist/dynamic-table.d.ts +1 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +14 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/license/license-gate.d.ts +0 -21
- package/dist/license/license-gate.d.ts.map +1 -1
- package/dist/license/license-gate.js +5 -2
- package/dist/portal-drag-overlay.d.ts +5 -0
- package/dist/portal-drag-overlay.d.ts.map +1 -0
- package/dist/portal-drag-overlay.js +8 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +6 -3
- package/dist/sidebar-layout-manager.js +3 -2
- package/dist/use-flip-animation.d.ts +6 -0
- package/dist/use-flip-animation.d.ts.map +1 -1
- package/dist/use-flip-animation.js +11 -4
- package/package.json +4 -3
- package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
- package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
- package/src/__tests__/dynamic-kanban.test.tsx +15 -2
- package/src/__tests__/image-cell-lucide.test.tsx +7 -5
- package/src/api-context.tsx +7 -5
- package/src/dynamic-icon.tsx +12 -16
- package/src/dynamic-kanban.tsx +950 -262
- package/src/dynamic-table.tsx +16 -2
- package/src/index.ts +6 -0
- package/src/license/license-gate.tsx +6 -2
- package/src/portal-drag-overlay.tsx +20 -0
- package/src/query-batch.ts +6 -3
- package/src/sidebar-layout-manager.tsx +3 -3
- package/src/use-flip-animation.ts +16 -3
package/dist/dynamic-kanban.js
CHANGED
|
@@ -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,
|
|
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`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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) =>
|
|
243
|
-
|
|
244
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
const
|
|
453
|
-
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
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
|
|
999
|
+
for (const r of displayRecords)
|
|
683
1000
|
m.set(String(r.id), r);
|
|
684
1001
|
return m;
|
|
685
|
-
}, [
|
|
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
|
-
//
|
|
735
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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:
|
|
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" }) }))] })] }),
|
|
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
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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
|
}
|