@asteby/metacore-runtime-react 23.7.1 → 23.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/custom-stages.d.ts +109 -2
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +261 -26
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +414 -47
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/stage-layout.d.ts +23 -0
- package/dist/stage-layout.d.ts.map +1 -0
- package/dist/stage-layout.js +70 -0
- package/dist/stage-overrides.d.ts +25 -0
- package/dist/stage-overrides.d.ts.map +1 -0
- package/dist/stage-overrides.js +64 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +228 -0
- package/src/__tests__/dynamic-kanban.test.tsx +75 -0
- package/src/__tests__/stage-layout.test.tsx +210 -0
- package/src/__tests__/stage-overrides.test.tsx +293 -0
- package/src/custom-stages.tsx +745 -111
- package/src/dynamic-kanban.tsx +679 -159
- package/src/index.ts +18 -0
- package/src/stage-layout.ts +93 -0
- package/src/stage-overrides.ts +95 -0
- package/src/types.ts +28 -0
package/dist/dynamic-kanban.js
CHANGED
|
@@ -2,14 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
|
|
5
|
-
import {
|
|
5
|
+
import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable';
|
|
6
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
7
|
+
import { arrayMove } from '@dnd-kit/sortable';
|
|
8
|
+
import { Calendar, CircleDot, GripVertical, Hash, ListFilter, MoreHorizontal, RotateCcw, Search, Settings2, Tag, ToggleLeft, Type, X, } from 'lucide-react';
|
|
6
9
|
import { toast } from 'sonner';
|
|
7
10
|
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
8
11
|
import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
|
|
9
12
|
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
10
13
|
import { useApi } from './api-context';
|
|
11
14
|
import { useStageAutomations, StageAutomationsButton, } from './stage-automations';
|
|
12
|
-
import { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes,
|
|
15
|
+
import { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, cardMatchesStageFilters, smartLaneParams, AddStageColumn, CustomStageDialog, CustomStageDeleteDialog, StageConfigDialog, SmartLane, } from './custom-stages';
|
|
16
|
+
import { useStageOverrides } from './stage-overrides';
|
|
13
17
|
import { useDynamicFilters } from './use-dynamic-filters';
|
|
14
18
|
import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
|
|
15
19
|
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
|
|
@@ -20,6 +24,7 @@ import { isColumnVisibleInTable } from './column-visibility';
|
|
|
20
24
|
import { isRowActionVisible } from './dynamic-columns';
|
|
21
25
|
import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
|
|
22
26
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
27
|
+
import { useStageLayout } from './stage-layout';
|
|
23
28
|
// Re-exported for tests + backward-compat: these live in ./filter-chips now
|
|
24
29
|
// (shared with DynamicTable) but were historically imported from here.
|
|
25
30
|
export { summarizeFilterValues, translateOptionLabels };
|
|
@@ -245,10 +250,16 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
245
250
|
// the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
|
|
246
251
|
// and lane menus simply don't render.
|
|
247
252
|
const customStages = useCustomStages(model);
|
|
253
|
+
// Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
|
|
254
|
+
// to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
|
|
255
|
+
// hides on declared lanes (custom lanes keep it via /custom-stages).
|
|
256
|
+
const stageOverrides = useStageOverrides(model);
|
|
248
257
|
// Dialog state: create/edit a stage, and the delete confirmation.
|
|
249
258
|
const [stageDialogOpen, setStageDialogOpen] = useState(false);
|
|
250
259
|
const [editingStage, setEditingStage] = useState(null);
|
|
251
260
|
const [deletingStage, setDeletingStage] = useState(null);
|
|
261
|
+
// The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes.
|
|
262
|
+
const [configTarget, setConfigTarget] = useState(null);
|
|
252
263
|
const openCreateStage = useCallback(() => {
|
|
253
264
|
setEditingStage(null);
|
|
254
265
|
setStageDialogOpen(true);
|
|
@@ -274,6 +285,18 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
274
285
|
const [lanePagination, setLanePagination] = useState({});
|
|
275
286
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
276
287
|
const [activeId, setActiveId] = useState(null);
|
|
288
|
+
// Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style)
|
|
289
|
+
// rather than moving a card. Kept apart so onDragEnd routes by draggable type.
|
|
290
|
+
const [activeLaneId, setActiveLaneId] = useState(null);
|
|
291
|
+
// Per-org lane order. `useStageLayout` reports whether the host wired the
|
|
292
|
+
// `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen
|
|
293
|
+
// order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of
|
|
294
|
+
// the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
|
|
295
|
+
// board already paints ordered on load — this only backs the live drag + the
|
|
296
|
+
// revert-on-failure). Null → follow the metadata order.
|
|
297
|
+
const stageLayout = useStageLayout(model);
|
|
298
|
+
const [laneOrderOverride, setLaneOrderOverride] = useState(null);
|
|
299
|
+
const laneReorderEnabled = stageLayout.available;
|
|
277
300
|
// Monotonic token for the current board load. Bumped on every fetchData so a
|
|
278
301
|
// slow eager-totals response from a superseded filter set can't inject stale
|
|
279
302
|
// stage totals into the fresh board.
|
|
@@ -356,6 +379,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
356
379
|
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
357
380
|
params: {
|
|
358
381
|
...filterParams,
|
|
382
|
+
...smartLaneParams(stage.filters),
|
|
359
383
|
page: 1,
|
|
360
384
|
per_page: 1,
|
|
361
385
|
[`f_${gb}`]: stage.key,
|
|
@@ -390,6 +414,24 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
390
414
|
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
391
415
|
// filterParams (the stage scope wins over any global group_by filter).
|
|
392
416
|
const groupByKey = metadata?.group_by || '';
|
|
417
|
+
// Extra per-lane conditions (stage overrides) keyed by stage. A real lane
|
|
418
|
+
// that carries these queries its data — and counts its header — with the
|
|
419
|
+
// stage scope PLUS these filters (serialized like a smart lane's), so the
|
|
420
|
+
// top-up + eager-total requests below layer them on. Sourced from
|
|
421
|
+
// `metadata.stages` (the kernel applies declared + custom-real overrides).
|
|
422
|
+
const stageExtraFilters = useMemo(() => {
|
|
423
|
+
const m = new Map();
|
|
424
|
+
for (const s of metadata?.stages ?? []) {
|
|
425
|
+
if (s.filters && s.filters.length > 0) {
|
|
426
|
+
m.set(s.key, s.filters.map((f) => ({
|
|
427
|
+
field: f.field,
|
|
428
|
+
op: f.op,
|
|
429
|
+
value: f.value,
|
|
430
|
+
})));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return m;
|
|
434
|
+
}, [metadata?.stages]);
|
|
393
435
|
const loadMoreLane = useCallback(async (stageKey) => {
|
|
394
436
|
if (!metadata || !groupByKey)
|
|
395
437
|
return;
|
|
@@ -410,6 +452,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
410
452
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
411
453
|
params: {
|
|
412
454
|
...filterParams,
|
|
455
|
+
...smartLaneParams(stageExtraFilters.get(stageKey)),
|
|
413
456
|
page: nextPage,
|
|
414
457
|
per_page: lanePageSize,
|
|
415
458
|
[`f_${groupByKey}`]: stageKey,
|
|
@@ -441,7 +484,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
441
484
|
},
|
|
442
485
|
}));
|
|
443
486
|
}
|
|
444
|
-
}, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination]);
|
|
487
|
+
}, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters]);
|
|
445
488
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
446
489
|
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
447
490
|
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
@@ -541,6 +584,44 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
541
584
|
return Math.max(base, ...positions, 0) + 1;
|
|
542
585
|
}, [customStages.stages, stages.length, smartStages.length]);
|
|
543
586
|
const transitions = metadata?.transitions;
|
|
587
|
+
// Unified, ordered list of the DRAGGABLE lanes (real stages + smart lanes) —
|
|
588
|
+
// the sortable sequence the header drag reorders. Real stages and smart lanes
|
|
589
|
+
// are merged by their `order` (the backend stamps a global sequence once a
|
|
590
|
+
// custom order exists); a `laneOrderOverride` from the live drag wins. Ties
|
|
591
|
+
// (the default, un-customized case where both sets start at 0) keep stages
|
|
592
|
+
// before smart lanes, matching the pre-reorder layout.
|
|
593
|
+
const renderLanes = useMemo(() => {
|
|
594
|
+
const arr = [
|
|
595
|
+
...stages.map((s, i) => ({
|
|
596
|
+
kind: 'stage',
|
|
597
|
+
stage: s,
|
|
598
|
+
order: s.order ?? i,
|
|
599
|
+
})),
|
|
600
|
+
...smartStages.map((s, i) => ({
|
|
601
|
+
kind: 'smart',
|
|
602
|
+
stage: s,
|
|
603
|
+
order: s.position ?? 1000 + i,
|
|
604
|
+
})),
|
|
605
|
+
];
|
|
606
|
+
if (laneOrderOverride) {
|
|
607
|
+
const idx = new Map(laneOrderOverride.map((k, i) => [k, i]));
|
|
608
|
+
arr.sort((a, b) => (idx.get(a.stage.key) ?? Number.MAX_SAFE_INTEGER) -
|
|
609
|
+
(idx.get(b.stage.key) ?? Number.MAX_SAFE_INTEGER));
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
arr.sort((a, b) => a.order - b.order ||
|
|
613
|
+
(a.kind === b.kind ? 0 : a.kind === 'stage' ? -1 : 1));
|
|
614
|
+
}
|
|
615
|
+
return arr.map(({ kind, stage: s }) => kind === 'stage'
|
|
616
|
+
? { kind: 'stage', stage: s }
|
|
617
|
+
: { kind: 'smart', stage: s });
|
|
618
|
+
}, [stages, smartStages, laneOrderOverride]);
|
|
619
|
+
// The sortable ids, in current visual order — SortableContext items + the
|
|
620
|
+
// basis for the arrayMove the drop computes.
|
|
621
|
+
const boardLaneKeys = useMemo(() => renderLanes.map((l) => l.stage.key), [renderLanes]);
|
|
622
|
+
// Real (card-droppable) stage keys — guards a card drop from ever landing on
|
|
623
|
+
// a smart lane (a saved view, never a stored stage value).
|
|
624
|
+
const realStageKeys = useMemo(() => new Set(stages.map((s) => s.key)), [stages]);
|
|
544
625
|
const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
|
|
545
626
|
const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
|
|
546
627
|
// Columns the lane search scans: the card title + its visible field cells.
|
|
@@ -576,15 +657,51 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
576
657
|
return raw === null || raw === undefined ? '' : String(raw);
|
|
577
658
|
}, [cardById, groupByKey]);
|
|
578
659
|
const onDragStart = useCallback((e) => {
|
|
579
|
-
|
|
660
|
+
if (e.active.data.current?.type === 'lane') {
|
|
661
|
+
setActiveLaneId(String(e.active.id));
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
setActiveId(String(e.active.id));
|
|
665
|
+
}
|
|
580
666
|
}, []);
|
|
667
|
+
// Optimistic lane reorder: reorder the columns in local state immediately,
|
|
668
|
+
// PUT the full new order, and revert + toast on failure. Keys mix real
|
|
669
|
+
// stages and smart lanes (the backend applies the order to both).
|
|
670
|
+
const reorderLanes = useCallback(async (activeKey, overKey) => {
|
|
671
|
+
const from = boardLaneKeys.indexOf(activeKey);
|
|
672
|
+
const to = boardLaneKeys.indexOf(overKey);
|
|
673
|
+
if (from === -1 || to === -1 || from === to)
|
|
674
|
+
return;
|
|
675
|
+
const next = arrayMove(boardLaneKeys, from, to);
|
|
676
|
+
const prev = laneOrderOverride;
|
|
677
|
+
setLaneOrderOverride(next);
|
|
678
|
+
try {
|
|
679
|
+
await stageLayout.save(next);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
setLaneOrderOverride(prev);
|
|
683
|
+
toast.error(t('dynamic.stage_layout.save_error', {
|
|
684
|
+
defaultValue: 'No se pudo guardar el orden',
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
}, [boardLaneKeys, laneOrderOverride, stageLayout, t]);
|
|
581
688
|
const onDragEnd = useCallback(async (e) => {
|
|
582
689
|
setActiveId(null);
|
|
690
|
+
setActiveLaneId(null);
|
|
583
691
|
const { active, over } = e;
|
|
692
|
+
// A header drag reorders columns rather than moving a card.
|
|
693
|
+
if (active.data.current?.type === 'lane') {
|
|
694
|
+
if (over)
|
|
695
|
+
await reorderLanes(String(active.id), String(over.id));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
584
698
|
if (!over)
|
|
585
699
|
return;
|
|
586
700
|
const cardId = String(active.id);
|
|
587
701
|
const destStage = String(over.id);
|
|
702
|
+
// Never drop a card onto a smart lane (a saved view, not a stage).
|
|
703
|
+
if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE)
|
|
704
|
+
return;
|
|
588
705
|
const srcStage = stageOfCard(cardId);
|
|
589
706
|
if (srcStage === destStage)
|
|
590
707
|
return;
|
|
@@ -626,7 +743,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
626
743
|
? `: ${err.response.data.message}`
|
|
627
744
|
: ''));
|
|
628
745
|
}
|
|
629
|
-
}, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
|
|
746
|
+
}, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes]);
|
|
747
|
+
// Board-level "Restablecer orden": drop the stored order and refetch the
|
|
748
|
+
// metadata so the lanes fall back to the DECLARED order. Optimistic — reverts
|
|
749
|
+
// its local override on failure.
|
|
750
|
+
const resetLaneOrder = useCallback(async () => {
|
|
751
|
+
const prev = laneOrderOverride;
|
|
752
|
+
setLaneOrderOverride(null);
|
|
753
|
+
try {
|
|
754
|
+
await stageLayout.reset();
|
|
755
|
+
const res = await api.get(`/metadata/table/${model}`);
|
|
756
|
+
const body = res.data;
|
|
757
|
+
if (body.success) {
|
|
758
|
+
setMetadata(body.data);
|
|
759
|
+
cacheMetadata(model, body.data);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
setLaneOrderOverride(prev);
|
|
764
|
+
toast.error(t('dynamic.stage_layout.reset_error', {
|
|
765
|
+
defaultValue: 'No se pudo restablecer el orden',
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
}, [api, cacheMetadata, laneOrderOverride, model, stageLayout, t]);
|
|
630
769
|
if (loading) {
|
|
631
770
|
return (_jsx("div", { className: "flex w-full gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "min-w-[280px] max-w-[420px] flex-1 shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
|
|
632
771
|
}
|
|
@@ -637,15 +776,151 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
637
776
|
}
|
|
638
777
|
const activeCard = activeId ? cardById.get(activeId) : null;
|
|
639
778
|
const activeStage = activeId ? stageOfCard(activeId) : '';
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
779
|
+
// The synthetic "Sin etapa" lane (only when some record's stage matches no
|
|
780
|
+
// declared lane). Rendered after the sortable lanes, never draggable itself.
|
|
781
|
+
const unassignedStage = grouped.has(UNASSIGNED_LANE)
|
|
782
|
+
? {
|
|
643
783
|
key: UNASSIGNED_LANE,
|
|
644
784
|
label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
|
|
645
785
|
color: 'slate',
|
|
646
786
|
order: Number.MAX_SAFE_INTEGER,
|
|
647
|
-
}
|
|
648
|
-
|
|
787
|
+
}
|
|
788
|
+
: null;
|
|
789
|
+
// Whether a card may drop into a lane given the active card's stage + the
|
|
790
|
+
// declared transitions (a same-stage or unrestricted move always passes).
|
|
791
|
+
const droppableAllowedFor = (stageKey) => !activeId ||
|
|
792
|
+
stageKey === activeStage ||
|
|
793
|
+
isTransitionAllowed(transitions, activeStage, stageKey);
|
|
794
|
+
// Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
|
|
795
|
+
// right backend by kind: a custom real stage edits through /custom-stages, a
|
|
796
|
+
// declared stage through /stage-overrides. Seeds the current label/color and
|
|
797
|
+
// any extra conditions the lane already carries.
|
|
798
|
+
const openConfigStage = (stage) => {
|
|
799
|
+
const custom = customStages.available
|
|
800
|
+
? customByKey.get(stage.key)
|
|
801
|
+
: undefined;
|
|
802
|
+
const raw = stageExtraFilters.get(stage.key) ??
|
|
803
|
+
custom?.filters ??
|
|
804
|
+
[];
|
|
805
|
+
const filters = raw.map((f) => ({
|
|
806
|
+
field: f.field,
|
|
807
|
+
op: f.op,
|
|
808
|
+
value: f.value,
|
|
809
|
+
}));
|
|
810
|
+
if (custom) {
|
|
811
|
+
setConfigTarget({
|
|
812
|
+
kind: 'custom',
|
|
813
|
+
stageKey: stage.key,
|
|
814
|
+
id: custom.id,
|
|
815
|
+
label: custom.label,
|
|
816
|
+
color: custom.color,
|
|
817
|
+
filters,
|
|
818
|
+
customStage: custom,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
const original = stage.original
|
|
823
|
+
? {
|
|
824
|
+
label: stage.original.label,
|
|
825
|
+
color: stage.original.color,
|
|
826
|
+
filters: stage.original.filters?.map((f) => ({
|
|
827
|
+
field: f.field,
|
|
828
|
+
op: f.op,
|
|
829
|
+
value: f.value,
|
|
830
|
+
})),
|
|
831
|
+
}
|
|
832
|
+
: undefined;
|
|
833
|
+
setConfigTarget({
|
|
834
|
+
kind: 'declared',
|
|
835
|
+
stageKey: stage.key,
|
|
836
|
+
label: t(stage.label, { defaultValue: stage.label }),
|
|
837
|
+
color: stage.color ?? 'slate',
|
|
838
|
+
filters,
|
|
839
|
+
overridden: !!stage.overridden,
|
|
840
|
+
isFinal: stage.is_final,
|
|
841
|
+
original,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
// Whether a lane offers the gear: custom real stages always (their CRUD is
|
|
846
|
+
// wired); declared lanes only when the host wired /stage-overrides. Never on
|
|
847
|
+
// the synthetic "Sin etapa" lane.
|
|
848
|
+
const isConfigurable = (stage) => {
|
|
849
|
+
if (stage.key === UNASSIGNED_LANE)
|
|
850
|
+
return false;
|
|
851
|
+
return customByKey.has(stage.key)
|
|
852
|
+
? customStages.available
|
|
853
|
+
: stageOverrides.available;
|
|
854
|
+
};
|
|
855
|
+
// Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
|
|
856
|
+
// shared by the sortable real stages and the plain-droppable unassigned lane.
|
|
857
|
+
const buildLaneProps = (stage) => {
|
|
858
|
+
const allCards = grouped.get(stage.key) ?? [];
|
|
859
|
+
// Extra lane conditions (stage override): the server already scopes this
|
|
860
|
+
// lane's top-up + total queries by them, but the shared INITIAL board page
|
|
861
|
+
// is unscoped, so narrow those cards client-side too (belt-and-suspenders).
|
|
862
|
+
const extraFilters = stageExtraFilters.get(stage.key) ?? [];
|
|
863
|
+
// Per-lane client-side narrowing (instant, scoped to this stage). The
|
|
864
|
+
// funnel (field/value) and the lane search (query) are AND-combined.
|
|
865
|
+
const laneFilter = laneFilters[stage.key];
|
|
866
|
+
let cards = allCards;
|
|
867
|
+
if (extraFilters.length > 0) {
|
|
868
|
+
cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters));
|
|
869
|
+
}
|
|
870
|
+
if (laneFilter?.field) {
|
|
871
|
+
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
|
|
872
|
+
}
|
|
873
|
+
if (laneFilter?.query?.trim()) {
|
|
874
|
+
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
875
|
+
}
|
|
876
|
+
const laneState = lanePagination[stage.key];
|
|
877
|
+
const isUnassigned = stage.key === UNASSIGNED_LANE;
|
|
878
|
+
const laneHasMore = !isUnassigned && !laneState?.done;
|
|
879
|
+
return {
|
|
880
|
+
stage,
|
|
881
|
+
count: cards.length,
|
|
882
|
+
totalCount: allCards.length,
|
|
883
|
+
serverTotal: laneState?.total ?? null,
|
|
884
|
+
hasMore: laneHasMore,
|
|
885
|
+
loadingMore: !!laneState?.loading,
|
|
886
|
+
onLoadMore: () => loadMoreLane(stage.key),
|
|
887
|
+
filterFields,
|
|
888
|
+
laneFilter,
|
|
889
|
+
onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
890
|
+
field: f?.field,
|
|
891
|
+
values: f?.values,
|
|
892
|
+
text: f?.text,
|
|
893
|
+
}),
|
|
894
|
+
onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }),
|
|
895
|
+
isDark,
|
|
896
|
+
dimmed: !!activeId && !droppableAllowedFor(stage.key),
|
|
897
|
+
model,
|
|
898
|
+
columns: metadata?.columns ?? [],
|
|
899
|
+
automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE,
|
|
900
|
+
automationRules: automations.byStage.get(stage.key) ?? [],
|
|
901
|
+
onAutomationCreate: automations.create,
|
|
902
|
+
onAutomationUpdate: automations.update,
|
|
903
|
+
onAutomationRemove: automations.remove,
|
|
904
|
+
extraFilters,
|
|
905
|
+
configurable: isConfigurable(stage),
|
|
906
|
+
onConfigure: () => openConfigStage(stage),
|
|
907
|
+
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))))),
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
// Props for one smart (virtual) lane — a read-only, filter-defined column.
|
|
911
|
+
const buildSmartProps = (smart) => ({
|
|
912
|
+
stage: smart,
|
|
913
|
+
model,
|
|
914
|
+
endpoint,
|
|
915
|
+
defaultFilters,
|
|
916
|
+
pageSize,
|
|
917
|
+
isDark,
|
|
918
|
+
refreshTrigger,
|
|
919
|
+
onEdit: openEditStage,
|
|
920
|
+
onDelete: openDeleteStage,
|
|
921
|
+
renderCard: (card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction, draggable: false })),
|
|
922
|
+
});
|
|
923
|
+
const showResetOrder = laneReorderEnabled && (stageLayout.hasCustomLayout || !!laneOrderOverride);
|
|
649
924
|
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
|
|
650
925
|
defaultValue: 'Con filtros activos',
|
|
651
926
|
}) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
|
|
@@ -655,41 +930,48 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
655
930
|
})
|
|
656
931
|
: t('kanban.noActiveFilters', {
|
|
657
932
|
defaultValue: 'Sin filtros',
|
|
658
|
-
}) }), _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})` : ''] })] })] })] }))
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
// stage). The funnel (field/value) and the lane search
|
|
662
|
-
// (query) are AND-combined.
|
|
663
|
-
const laneFilter = laneFilters[stage.key];
|
|
664
|
-
let cards = allCards;
|
|
665
|
-
if (laneFilter?.field) {
|
|
666
|
-
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
|
|
667
|
-
}
|
|
668
|
-
if (laneFilter?.query?.trim()) {
|
|
669
|
-
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
670
|
-
}
|
|
671
|
-
const droppableAllowed = !activeId ||
|
|
672
|
-
stage.key === activeStage ||
|
|
673
|
-
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
674
|
-
// Infinite scroll is per declared stage; the synthetic
|
|
675
|
-
// "unassigned" lane can't be stage-scoped, so it never tops up.
|
|
676
|
-
const laneState = lanePagination[stage.key];
|
|
677
|
-
const isUnassigned = stage.key === UNASSIGNED_LANE;
|
|
678
|
-
const laneHasMore = !isUnassigned && !laneState?.done;
|
|
679
|
-
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
680
|
-
field: f?.field,
|
|
681
|
-
values: f?.values,
|
|
682
|
-
text: f?.text,
|
|
683
|
-
}), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, model: model, columns: metadata?.columns ?? [], automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE, automationRules: automations.byStage.get(stage.key) ?? [], onAutomationCreate: automations.create, onAutomationUpdate: automations.update, onAutomationRemove: automations.remove, customStage: customStages.available
|
|
684
|
-
? customByKey.get(stage.key)
|
|
685
|
-
: undefined, onEditStage: openEditStage, onDeleteStage: openDeleteStage, 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))))) }, stage.key));
|
|
686
|
-
}), smartStages.map((smart) => (_jsx(SmartLane, { stage: smart, model: model, endpoint: endpoint, defaultFilters: defaultFilters, pageSize: pageSize, isDark: isDark, refreshTrigger: refreshTrigger, onEdit: openEditStage, onDelete: openDeleteStage, renderCard: (card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction, draggable: false })) }, `smart-${smart.key}`))), 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) => {
|
|
933
|
+
}) }), _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', {
|
|
934
|
+
defaultValue: 'Restablecer orden',
|
|
935
|
+
})] }))] }), _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) => {
|
|
687
936
|
if (!o)
|
|
688
937
|
setDeletingStage(null);
|
|
689
938
|
}, stage: deletingStage, reassignTargets: stages.map((s) => ({
|
|
690
939
|
key: s.key,
|
|
691
940
|
label: s.label,
|
|
692
|
-
})), onConfirm: (s, reassignTo) => customStages.remove(s.id, reassignTo) })] }))
|
|
941
|
+
})), onConfirm: (s, reassignTo) => customStages.remove(s.id, reassignTo) })] })), _jsx(StageConfigDialog, { open: !!configTarget, onOpenChange: (o) => {
|
|
942
|
+
if (!o)
|
|
943
|
+
setConfigTarget(null);
|
|
944
|
+
}, columns: metadata?.columns ?? [], target: configTarget, onSaveOverride: async (stageKey, patch) => {
|
|
945
|
+
await stageOverrides.save(stageKey, patch);
|
|
946
|
+
// The override changes the served label/color/filters — refetch
|
|
947
|
+
// metadata so the board repaints, and reload the cards.
|
|
948
|
+
try {
|
|
949
|
+
const res = await api.get(`/metadata/table/${model}`);
|
|
950
|
+
const body = res.data;
|
|
951
|
+
if (body.success) {
|
|
952
|
+
setMetadata(body.data);
|
|
953
|
+
cacheMetadata(model, body.data);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
/* keep the current metadata on a refetch miss */
|
|
958
|
+
}
|
|
959
|
+
void fetchData();
|
|
960
|
+
}, onResetOverride: async (stageKey) => {
|
|
961
|
+
await stageOverrides.reset(stageKey);
|
|
962
|
+
try {
|
|
963
|
+
const res = await api.get(`/metadata/table/${model}`);
|
|
964
|
+
const body = res.data;
|
|
965
|
+
if (body.success) {
|
|
966
|
+
setMetadata(body.data);
|
|
967
|
+
cacheMetadata(model, body.data);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
/* keep the current metadata on a refetch miss */
|
|
972
|
+
}
|
|
973
|
+
void fetchData();
|
|
974
|
+
}, onUpdateCustom: customStages.update, onDeleteCustom: openDeleteStage })] }));
|
|
693
975
|
}
|
|
694
976
|
/**
|
|
695
977
|
* A per-data-type glyph for the Filtros panel rows (and their popover header):
|
|
@@ -718,9 +1000,67 @@ function SheetFilterRow({ field, isStage, }) {
|
|
|
718
1000
|
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
719
1001
|
return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
|
|
720
1002
|
}
|
|
721
|
-
|
|
1003
|
+
// A real stage lane: sortable (header drag reorders) AND a card drop target.
|
|
1004
|
+
// One `useSortable` provides both roles; `disabled.draggable` follows whether
|
|
1005
|
+
// lane reordering is available, `disabled.droppable` follows the per-card
|
|
1006
|
+
// transition gate.
|
|
1007
|
+
function SortableStageLane({ reorderEnabled, droppableDisabled, laneProps, }) {
|
|
1008
|
+
const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, isOver, } = useSortable({
|
|
1009
|
+
id: laneProps.stage.key,
|
|
1010
|
+
data: { type: 'lane' },
|
|
1011
|
+
disabled: { draggable: !reorderEnabled, droppable: droppableDisabled },
|
|
1012
|
+
});
|
|
1013
|
+
const dnd = {
|
|
1014
|
+
setNodeRef,
|
|
1015
|
+
isOver,
|
|
1016
|
+
isDragging,
|
|
1017
|
+
draggable: reorderEnabled,
|
|
1018
|
+
handleRef: setActivatorNodeRef,
|
|
1019
|
+
handleProps: { ...attributes, ...listeners },
|
|
1020
|
+
style: {
|
|
1021
|
+
transform: CSS.Translate.toString(transform),
|
|
1022
|
+
transition,
|
|
1023
|
+
zIndex: isDragging ? 30 : undefined,
|
|
1024
|
+
position: isDragging ? 'relative' : undefined,
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
return _jsx(KanbanLane, { ...laneProps, dnd: dnd });
|
|
1028
|
+
}
|
|
1029
|
+
// The synthetic "Sin etapa" lane: a card drop target, never draggable.
|
|
1030
|
+
function DroppableStageLane({ droppableDisabled, laneProps, }) {
|
|
1031
|
+
const { setNodeRef, isOver } = useDroppable({
|
|
1032
|
+
id: laneProps.stage.key,
|
|
1033
|
+
disabled: droppableDisabled,
|
|
1034
|
+
});
|
|
1035
|
+
return (_jsx(KanbanLane, { ...laneProps, dnd: { setNodeRef, isOver, draggable: false } }));
|
|
1036
|
+
}
|
|
1037
|
+
// A smart lane: sortable (header drag reorders) but NOT a card drop target — its
|
|
1038
|
+
// droppable is disabled while a card is dragged, enabled while a lane is dragged
|
|
1039
|
+
// (so a stage/smart lane can be reordered relative to it).
|
|
1040
|
+
function SortableSmartLane({ droppableDisabled, smartProps, }) {
|
|
1041
|
+
const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, } = useSortable({
|
|
1042
|
+
id: smartProps.stage.key,
|
|
1043
|
+
data: { type: 'lane' },
|
|
1044
|
+
disabled: { draggable: false, droppable: droppableDisabled },
|
|
1045
|
+
});
|
|
1046
|
+
const dnd = {
|
|
1047
|
+
setNodeRef,
|
|
1048
|
+
isOver: false,
|
|
1049
|
+
isDragging,
|
|
1050
|
+
draggable: true,
|
|
1051
|
+
handleRef: setActivatorNodeRef,
|
|
1052
|
+
handleProps: { ...attributes, ...listeners },
|
|
1053
|
+
style: {
|
|
1054
|
+
transform: CSS.Translate.toString(transform),
|
|
1055
|
+
transition,
|
|
1056
|
+
zIndex: isDragging ? 30 : undefined,
|
|
1057
|
+
position: isDragging ? 'relative' : undefined,
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
return _jsx(SmartLane, { ...smartProps, dnd: dnd });
|
|
1061
|
+
}
|
|
1062
|
+
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, }) {
|
|
722
1063
|
const { t } = useTranslation();
|
|
723
|
-
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
|
|
724
1064
|
// Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
|
|
725
1065
|
// container; a load in flight or an exhausted stage disables it.
|
|
726
1066
|
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
@@ -758,18 +1098,44 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
|
|
|
758
1098
|
text: laneFilter.text,
|
|
759
1099
|
}
|
|
760
1100
|
: undefined;
|
|
761
|
-
|
|
1101
|
+
// Stage-override conditions active on this lane → a small filter dot in the
|
|
1102
|
+
// header, its `title` listing the conditions (e.g. "priority es igual high").
|
|
1103
|
+
const hasConditions = extraFilters.length > 0;
|
|
1104
|
+
const conditionsSummary = extraFilters
|
|
1105
|
+
.map((f) => {
|
|
1106
|
+
const opLabel = t(`dynamic.custom_stages.op.${f.op}`, {
|
|
1107
|
+
defaultValue: f.op === 'eq'
|
|
1108
|
+
? 'es igual'
|
|
1109
|
+
: f.op === 'neq'
|
|
1110
|
+
? 'distinto'
|
|
1111
|
+
: f.op === 'contains'
|
|
1112
|
+
? 'contiene'
|
|
1113
|
+
: 'en lista',
|
|
1114
|
+
});
|
|
1115
|
+
return `${f.field} ${opLabel} ${f.value}`;
|
|
1116
|
+
})
|
|
1117
|
+
.join(' · ');
|
|
1118
|
+
return (_jsxs("div", { ref: dnd.setNodeRef,
|
|
762
1119
|
// Fluid width: lanes grow (flex-1) to fill the board when they all
|
|
763
1120
|
// fit, capped at max-w so a couple of lanes don't stretch absurdly
|
|
764
1121
|
// wide. Below min-w the board's overflow-x-auto takes over and the
|
|
765
1122
|
// lanes scroll horizontally at their minimum width.
|
|
766
1123
|
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: {
|
|
767
|
-
opacity: dimmed ? 0.45 : 1,
|
|
768
|
-
outline: isOver
|
|
1124
|
+
opacity: dnd.isDragging ? 0.6 : dimmed ? 0.45 : 1,
|
|
1125
|
+
outline: dnd.isOver ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
769
1126
|
outlineOffset: 2,
|
|
770
|
-
|
|
1127
|
+
...dnd.style,
|
|
1128
|
+
}, "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', {
|
|
1129
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
1130
|
+
list: conditionsSummary,
|
|
1131
|
+
}), "data-testid": `lane-conditions-${stage.key}`, "aria-label": t('dynamic.stage_config.conditions_active', {
|
|
1132
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
1133
|
+
list: conditionsSummary,
|
|
1134
|
+
}), children: _jsx(ListFilter, { className: "h-3 w-3" }) }))] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
771
1135
|
defaultValue: 'Buscar en la columna',
|
|
772
|
-
}), 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 })),
|
|
1136
|
+
}), 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', {
|
|
1137
|
+
defaultValue: 'Configurar etapa',
|
|
1138
|
+
}), "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) => {
|
|
773
1139
|
if (e.key === 'Escape') {
|
|
774
1140
|
onQueryChange('');
|
|
775
1141
|
setSearchOpen(false);
|
|
@@ -844,6 +1210,7 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
844
1210
|
function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, draggable = true, }) {
|
|
845
1211
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
846
1212
|
id: String(card.id),
|
|
1213
|
+
data: { type: 'card' },
|
|
847
1214
|
disabled: !draggable,
|
|
848
1215
|
});
|
|
849
1216
|
const visibleActions = actions.filter((a) => isRowActionVisible(a, card));
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ export * from './options-context';
|
|
|
3
3
|
export * from './dynamic-table';
|
|
4
4
|
export { DynamicKanban, type DynamicKanbanProps, deriveStages, groupByStage, isTransitionAllowed, applyOptimisticMove, selectCardColumns, UNASSIGNED_LANE, } from './dynamic-kanban';
|
|
5
5
|
export { useStageAutomations, StageAutomationsButton, isTagColumn, automationFieldOptions, groupAutomationsByStage, activeAutomationCount, type StageAutomation, type StageAutomationAction, type StageAutomationActionType, type NewStageAutomation, type UseStageAutomationsResult, type StageAutomationsButtonProps, } from './stage-automations';
|
|
6
|
-
export { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, smartLaneParams, customStageFilterFields, isCustomStageDraftValid, slugifyStageKey, emptyCustomStageFilter, AddStageColumn, CustomStageLaneMenu, CustomStageDialog, CustomStageDeleteDialog, SmartLane, CUSTOM_STAGE_COLORS, CUSTOM_STAGE_FILTER_OPS, type CustomStage, type NewCustomStage, type CustomStageType, type CustomStageFilter, type CustomStageFilterOp, type UseCustomStagesResult, } from './custom-stages';
|
|
6
|
+
export { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, smartLaneParams, cardMatchesStageFilters, customStageFilterFields, isCustomStageDraftValid, slugifyStageKey, emptyCustomStageFilter, AddStageColumn, CustomStageLaneMenu, CustomStageDialog, CustomStageDeleteDialog, StageConditionBuilder, StageConfigDialog, stageFilterOpSymbol, SmartLane, CUSTOM_STAGE_COLORS, CUSTOM_STAGE_FILTER_OPS, type CustomStage, type NewCustomStage, type CustomStageType, type CustomStageFilter, type CustomStageFilterOp, type UseCustomStagesResult, type StageConfigTarget, type StageConfigKind, type StageConditionBuilderProps, type StageConfigDialogProps, } from './custom-stages';
|
|
7
|
+
export { useStageLayout, type StageLayout, type UseStageLayoutResult, } from './stage-layout';
|
|
8
|
+
export { useStageOverrides, type StageOverridePatch, type UseStageOverridesResult, } from './stage-overrides';
|
|
7
9
|
export { DynamicView, resolveViewRenderer, readViewFromSearch, resolveActiveView, type DynamicViewProps, } from './dynamic-view';
|
|
8
10
|
export { useDynamicFilters, type UseDynamicFiltersOptions, type UseDynamicFiltersResult, } from './use-dynamic-filters';
|
|
9
11
|
export * from './dynamic-form';
|