@schandlergarcia/sf-web-components 2.4.0 → 2.5.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/.a4drules/skills/command-center-builder/SKILL.md +3 -2
- package/.a4drules/skills/component-library/SKILL.md +50 -4
- package/.a4drules/skills/component-library/card-components.md +88 -0
- package/.a4drules/skills/component-library/when-to-use.md +1 -0
- package/CHANGELOG.md +24 -0
- package/dist/components/library/cards/KanbanBoard.js +313 -0
- package/dist/components/library/cards/KanbanBoard.js.map +1 -0
- package/dist/components/library/index.js +60 -57
- package/dist/components/library/index.js.map +1 -1
- package/dist/components/workspace/ComponentRegistry.js +5 -2
- package/dist/components/workspace/ComponentRegistry.js.map +1 -1
- package/dist/index.js +84 -82
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
- package/src/components/library/cards/KanbanBoard.jsx +507 -0
- package/src/components/library/index.jsx +1 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import React, { useMemo, useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
DragOverlay,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
KeyboardSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
closestCenter,
|
|
10
|
+
useDroppable,
|
|
11
|
+
} from "@dnd-kit/core";
|
|
12
|
+
import {
|
|
13
|
+
SortableContext,
|
|
14
|
+
useSortable,
|
|
15
|
+
verticalListSortingStrategy,
|
|
16
|
+
sortableKeyboardCoordinates,
|
|
17
|
+
arrayMove,
|
|
18
|
+
} from "@dnd-kit/sortable";
|
|
19
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
20
|
+
import BaseCard from "./BaseCard";
|
|
21
|
+
import UIText from "../ui/Text";
|
|
22
|
+
import UIChip from "../ui/Chip";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* KanbanBoard
|
|
26
|
+
*
|
|
27
|
+
* A swim-lane / board view with structured cards and (optional) drag-and-drop
|
|
28
|
+
* powered by @dnd-kit. Designed to live inside a CommandCenter dashboard and
|
|
29
|
+
* to behave consistently with ListCard / TableCard.
|
|
30
|
+
*
|
|
31
|
+
* Data shapes:
|
|
32
|
+
* columns: [{ id, title, accent?, limit?, footer?, emptyMessage? }]
|
|
33
|
+
* cards: [{ id, columnId, title, subtitle?, description?, badges?,
|
|
34
|
+
* status?, avatar?, meta?, actions?, accent? }]
|
|
35
|
+
*
|
|
36
|
+
* Callbacks:
|
|
37
|
+
* onCardClick(card)
|
|
38
|
+
* onCardMove({ cardId, fromColumnId, toColumnId, fromIndex, toIndex })
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const ACCENT_RING = {
|
|
42
|
+
default: "ring-slate-200 dark:ring-slate-800",
|
|
43
|
+
primary: "ring-brand-500/40",
|
|
44
|
+
success: "ring-emerald-500/40",
|
|
45
|
+
warning: "ring-amber-500/40",
|
|
46
|
+
danger: "ring-rose-500/40",
|
|
47
|
+
info: "ring-sky-500/40",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ACCENT_DOT = {
|
|
51
|
+
default: "bg-slate-400",
|
|
52
|
+
primary: "bg-brand-500",
|
|
53
|
+
success: "bg-emerald-500",
|
|
54
|
+
warning: "bg-amber-500",
|
|
55
|
+
danger: "bg-rose-500",
|
|
56
|
+
info: "bg-sky-500",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ACCENT_BORDER_LEFT = {
|
|
60
|
+
default: "border-l-slate-300 dark:border-l-slate-700",
|
|
61
|
+
primary: "border-l-brand-500",
|
|
62
|
+
success: "border-l-emerald-500",
|
|
63
|
+
warning: "border-l-amber-500",
|
|
64
|
+
danger: "border-l-rose-500",
|
|
65
|
+
info: "border-l-sky-500",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function CardBadges({ badges }) {
|
|
69
|
+
if (!badges || badges.length === 0) return null;
|
|
70
|
+
return (
|
|
71
|
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
72
|
+
{badges.map((b, i) => {
|
|
73
|
+
if (React.isValidElement(b)) return <span key={i}>{b}</span>;
|
|
74
|
+
if (typeof b === "string") {
|
|
75
|
+
return (
|
|
76
|
+
<UIChip key={i} size="sm" variant="default">
|
|
77
|
+
{b}
|
|
78
|
+
</UIChip>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
<UIChip
|
|
83
|
+
key={b.id ?? i}
|
|
84
|
+
size={b.size ?? "sm"}
|
|
85
|
+
variant={b.variant ?? "default"}
|
|
86
|
+
color={b.color}
|
|
87
|
+
>
|
|
88
|
+
{b.label ?? b.text}
|
|
89
|
+
</UIChip>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function CardAvatar({ avatar, title }) {
|
|
97
|
+
if (!avatar) return null;
|
|
98
|
+
if (typeof avatar === "string") {
|
|
99
|
+
return (
|
|
100
|
+
<img
|
|
101
|
+
src={avatar}
|
|
102
|
+
alt=""
|
|
103
|
+
className="h-7 w-7 shrink-0 rounded-full border border-slate-200 object-cover dark:border-slate-800"
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (React.isValidElement(avatar)) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800">
|
|
110
|
+
{avatar}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-slate-200 bg-slate-100 text-[10px] font-semibold text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-200">
|
|
116
|
+
{String(title ?? "??")
|
|
117
|
+
.slice(0, 2)
|
|
118
|
+
.toUpperCase()}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function KanbanCardBody({ card, accent, dragging, onClick, dense }) {
|
|
124
|
+
const accentBorder =
|
|
125
|
+
ACCENT_BORDER_LEFT[card?.accent ?? accent ?? "default"] ?? ACCENT_BORDER_LEFT.default;
|
|
126
|
+
|
|
127
|
+
const Comp = onClick ? "button" : "div";
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Comp
|
|
131
|
+
type={onClick ? "button" : undefined}
|
|
132
|
+
onClick={onClick ? () => onClick(card) : undefined}
|
|
133
|
+
className={[
|
|
134
|
+
"group block w-full rounded-xl border bg-white text-left shadow-sm transition",
|
|
135
|
+
"border-slate-200 dark:border-slate-800 dark:bg-slate-900",
|
|
136
|
+
"border-l-4",
|
|
137
|
+
accentBorder,
|
|
138
|
+
dense ? "p-2.5" : "p-3",
|
|
139
|
+
dragging ? "opacity-60 ring-2 ring-brand-500" : "hover:-translate-y-[1px] hover:shadow-md",
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(" ")}
|
|
143
|
+
>
|
|
144
|
+
<div className="flex items-start gap-2">
|
|
145
|
+
<CardAvatar avatar={card.avatar} title={card.title} />
|
|
146
|
+
<div className="min-w-0 flex-1">
|
|
147
|
+
<UIText as="div" size="sm" weight="medium" className="truncate">
|
|
148
|
+
{card.title}
|
|
149
|
+
</UIText>
|
|
150
|
+
{card.subtitle ? (
|
|
151
|
+
<UIText as="div" size="xs" muted className="mt-0.5 truncate">
|
|
152
|
+
{card.subtitle}
|
|
153
|
+
</UIText>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
{card.status ? (
|
|
157
|
+
<span
|
|
158
|
+
aria-label={String(card.status)}
|
|
159
|
+
className={[
|
|
160
|
+
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
|
|
161
|
+
ACCENT_DOT[card.status] ?? ACCENT_DOT.default,
|
|
162
|
+
].join(" ")}
|
|
163
|
+
/>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{card.description ? (
|
|
168
|
+
<UIText as="div" size="xs" muted className="mt-2 line-clamp-3 text-left">
|
|
169
|
+
{card.description}
|
|
170
|
+
</UIText>
|
|
171
|
+
) : null}
|
|
172
|
+
|
|
173
|
+
<CardBadges badges={card.badges} />
|
|
174
|
+
|
|
175
|
+
{card.meta && card.meta.length > 0 ? (
|
|
176
|
+
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
|
177
|
+
{card.meta.map((m, i) => {
|
|
178
|
+
if (React.isValidElement(m)) return <span key={i}>{m}</span>;
|
|
179
|
+
if (typeof m === "string") return <span key={i}>{m}</span>;
|
|
180
|
+
return (
|
|
181
|
+
<span key={m.id ?? i} className="inline-flex items-center gap-1">
|
|
182
|
+
{m.icon ? <span className="opacity-70">{m.icon}</span> : null}
|
|
183
|
+
<span>{m.label ?? m.text}</span>
|
|
184
|
+
</span>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</div>
|
|
188
|
+
) : null}
|
|
189
|
+
|
|
190
|
+
{card.actions ? (
|
|
191
|
+
<div
|
|
192
|
+
className="mt-2 flex justify-end gap-1.5"
|
|
193
|
+
onClick={(e) => e.stopPropagation()}
|
|
194
|
+
>
|
|
195
|
+
{card.actions}
|
|
196
|
+
</div>
|
|
197
|
+
) : null}
|
|
198
|
+
</Comp>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function SortableKanbanCard({ card, accent, onClick, isDraggable, dense }) {
|
|
203
|
+
const sortable = useSortable({
|
|
204
|
+
id: card.id,
|
|
205
|
+
data: { columnId: card.columnId, type: "card" },
|
|
206
|
+
disabled: !isDraggable,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const style = {
|
|
210
|
+
transform: CSS.Translate.toString(sortable.transform),
|
|
211
|
+
transition: sortable.transition,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
ref={sortable.setNodeRef}
|
|
217
|
+
style={style}
|
|
218
|
+
{...(isDraggable ? sortable.attributes : {})}
|
|
219
|
+
{...(isDraggable ? sortable.listeners : {})}
|
|
220
|
+
className={isDraggable ? "touch-none" : undefined}
|
|
221
|
+
>
|
|
222
|
+
<KanbanCardBody
|
|
223
|
+
card={card}
|
|
224
|
+
accent={accent}
|
|
225
|
+
dragging={sortable.isDragging}
|
|
226
|
+
onClick={onClick}
|
|
227
|
+
dense={dense}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function KanbanColumn({
|
|
234
|
+
column,
|
|
235
|
+
cards,
|
|
236
|
+
accent,
|
|
237
|
+
onCardClick,
|
|
238
|
+
isDraggable,
|
|
239
|
+
dense,
|
|
240
|
+
showCounts,
|
|
241
|
+
showLimits,
|
|
242
|
+
emptyMessage,
|
|
243
|
+
}) {
|
|
244
|
+
const droppable = useDroppable({
|
|
245
|
+
id: `column:${column.id}`,
|
|
246
|
+
data: { columnId: column.id, type: "column" },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const count = cards.length;
|
|
250
|
+
const overLimit = column.limit != null && count > column.limit;
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex w-72 shrink-0 flex-col rounded-2xl border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/60">
|
|
254
|
+
<div className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
|
|
255
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
256
|
+
<span
|
|
257
|
+
aria-hidden
|
|
258
|
+
className={[
|
|
259
|
+
"h-2 w-2 shrink-0 rounded-full",
|
|
260
|
+
ACCENT_DOT[column.accent ?? accent ?? "default"] ?? ACCENT_DOT.default,
|
|
261
|
+
].join(" ")}
|
|
262
|
+
/>
|
|
263
|
+
<UIText as="div" size="sm" weight="medium" className="truncate">
|
|
264
|
+
{column.title}
|
|
265
|
+
</UIText>
|
|
266
|
+
{showCounts ? (
|
|
267
|
+
<UIChip
|
|
268
|
+
size="sm"
|
|
269
|
+
variant="default"
|
|
270
|
+
className={overLimit ? "ring-1 ring-rose-500" : undefined}
|
|
271
|
+
>
|
|
272
|
+
{showLimits && column.limit != null ? `${count} / ${column.limit}` : count}
|
|
273
|
+
</UIChip>
|
|
274
|
+
) : null}
|
|
275
|
+
</div>
|
|
276
|
+
{column.actions ? <div className="shrink-0">{column.actions}</div> : null}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div
|
|
280
|
+
ref={droppable.setNodeRef}
|
|
281
|
+
className={[
|
|
282
|
+
"flex flex-1 flex-col gap-2 overflow-y-auto p-2",
|
|
283
|
+
droppable.isOver ? "bg-brand-50/50 dark:bg-brand-950/30" : "",
|
|
284
|
+
]
|
|
285
|
+
.filter(Boolean)
|
|
286
|
+
.join(" ")}
|
|
287
|
+
style={{ minHeight: 80 }}
|
|
288
|
+
>
|
|
289
|
+
<SortableContext
|
|
290
|
+
items={cards.map((c) => c.id)}
|
|
291
|
+
strategy={verticalListSortingStrategy}
|
|
292
|
+
>
|
|
293
|
+
{cards.length === 0 ? (
|
|
294
|
+
<div className="grid flex-1 place-items-center px-2 py-6">
|
|
295
|
+
<UIText as="div" size="xs" muted className="text-center">
|
|
296
|
+
{column.emptyMessage ?? emptyMessage ?? "No items"}
|
|
297
|
+
</UIText>
|
|
298
|
+
</div>
|
|
299
|
+
) : (
|
|
300
|
+
cards.map((card) => (
|
|
301
|
+
<SortableKanbanCard
|
|
302
|
+
key={card.id}
|
|
303
|
+
card={card}
|
|
304
|
+
accent={accent}
|
|
305
|
+
onClick={onCardClick}
|
|
306
|
+
isDraggable={isDraggable}
|
|
307
|
+
dense={dense}
|
|
308
|
+
/>
|
|
309
|
+
))
|
|
310
|
+
)}
|
|
311
|
+
</SortableContext>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{column.footer ? (
|
|
315
|
+
<div className="border-t border-slate-200 px-3 py-2 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
|
316
|
+
{column.footer}
|
|
317
|
+
</div>
|
|
318
|
+
) : null}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export default function KanbanBoard({
|
|
324
|
+
columns = [],
|
|
325
|
+
cards = [],
|
|
326
|
+
title,
|
|
327
|
+
subtitle,
|
|
328
|
+
accent = "default",
|
|
329
|
+
isDraggable = true,
|
|
330
|
+
dense = false,
|
|
331
|
+
showColumnCounts = true,
|
|
332
|
+
showWipLimits = true,
|
|
333
|
+
emptyMessage = "No items",
|
|
334
|
+
loading = false,
|
|
335
|
+
error,
|
|
336
|
+
actions,
|
|
337
|
+
onCardClick,
|
|
338
|
+
onCardMove,
|
|
339
|
+
className = "",
|
|
340
|
+
...cardProps
|
|
341
|
+
}) {
|
|
342
|
+
const [activeId, setActiveId] = useState(null);
|
|
343
|
+
const [internalCards, setInternalCards] = useState(null);
|
|
344
|
+
|
|
345
|
+
const liveCards = internalCards ?? cards;
|
|
346
|
+
|
|
347
|
+
const sensors = useSensors(
|
|
348
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
349
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const cardsByColumn = useMemo(() => {
|
|
353
|
+
const map = {};
|
|
354
|
+
for (const col of columns) map[col.id] = [];
|
|
355
|
+
for (const c of liveCards) {
|
|
356
|
+
const colId = c.columnId;
|
|
357
|
+
if (!map[colId]) map[colId] = [];
|
|
358
|
+
map[colId].push(c);
|
|
359
|
+
}
|
|
360
|
+
return map;
|
|
361
|
+
}, [columns, liveCards]);
|
|
362
|
+
|
|
363
|
+
const findColumnIdForCard = useCallback(
|
|
364
|
+
(cardId) => {
|
|
365
|
+
const card = liveCards.find((c) => c.id === cardId);
|
|
366
|
+
return card?.columnId ?? null;
|
|
367
|
+
},
|
|
368
|
+
[liveCards]
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const resolveColumnId = useCallback(
|
|
372
|
+
(id) => {
|
|
373
|
+
if (!id) return null;
|
|
374
|
+
if (typeof id === "string" && id.startsWith("column:")) return id.slice("column:".length);
|
|
375
|
+
const card = liveCards.find((c) => c.id === id);
|
|
376
|
+
return card?.columnId ?? null;
|
|
377
|
+
},
|
|
378
|
+
[liveCards]
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const handleDragStart = (event) => setActiveId(event.active.id);
|
|
382
|
+
|
|
383
|
+
const handleDragEnd = (event) => {
|
|
384
|
+
setActiveId(null);
|
|
385
|
+
const { active, over } = event;
|
|
386
|
+
if (!over) return;
|
|
387
|
+
|
|
388
|
+
const fromColumnId = findColumnIdForCard(active.id);
|
|
389
|
+
const toColumnId = resolveColumnId(over.id);
|
|
390
|
+
if (!fromColumnId || !toColumnId) return;
|
|
391
|
+
|
|
392
|
+
const fromList = (cardsByColumn[fromColumnId] ?? []).slice();
|
|
393
|
+
const toList =
|
|
394
|
+
fromColumnId === toColumnId ? fromList : (cardsByColumn[toColumnId] ?? []).slice();
|
|
395
|
+
|
|
396
|
+
const fromIndex = fromList.findIndex((c) => c.id === active.id);
|
|
397
|
+
let toIndex;
|
|
398
|
+
|
|
399
|
+
if (over.data?.current?.type === "column" || over.id === `column:${toColumnId}`) {
|
|
400
|
+
toIndex = toList.length;
|
|
401
|
+
} else {
|
|
402
|
+
toIndex = toList.findIndex((c) => c.id === over.id);
|
|
403
|
+
if (toIndex === -1) toIndex = toList.length;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (fromColumnId === toColumnId && fromIndex === toIndex) return;
|
|
407
|
+
|
|
408
|
+
let nextCards;
|
|
409
|
+
if (fromColumnId === toColumnId) {
|
|
410
|
+
const reordered = arrayMove(fromList, fromIndex, toIndex);
|
|
411
|
+
const others = liveCards.filter((c) => c.columnId !== fromColumnId);
|
|
412
|
+
nextCards = [...others, ...reordered];
|
|
413
|
+
} else {
|
|
414
|
+
const moved = { ...fromList[fromIndex], columnId: toColumnId };
|
|
415
|
+
const newFrom = fromList.filter((c) => c.id !== active.id);
|
|
416
|
+
const newTo = toList.slice();
|
|
417
|
+
newTo.splice(toIndex, 0, moved);
|
|
418
|
+
const others = liveCards.filter(
|
|
419
|
+
(c) => c.columnId !== fromColumnId && c.columnId !== toColumnId
|
|
420
|
+
);
|
|
421
|
+
nextCards = [...others, ...newFrom, ...newTo];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (onCardMove) {
|
|
425
|
+
onCardMove({
|
|
426
|
+
cardId: active.id,
|
|
427
|
+
fromColumnId,
|
|
428
|
+
toColumnId,
|
|
429
|
+
fromIndex,
|
|
430
|
+
toIndex,
|
|
431
|
+
});
|
|
432
|
+
} else {
|
|
433
|
+
setInternalCards(nextCards);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const activeCard = activeId ? liveCards.find((c) => c.id === activeId) : null;
|
|
438
|
+
|
|
439
|
+
const header =
|
|
440
|
+
title || subtitle || actions ? (
|
|
441
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
442
|
+
<div className="min-w-0">
|
|
443
|
+
{title ? (
|
|
444
|
+
<UIText as="div" size="sm" weight="medium">
|
|
445
|
+
{title}
|
|
446
|
+
</UIText>
|
|
447
|
+
) : null}
|
|
448
|
+
{subtitle ? (
|
|
449
|
+
<UIText as="div" size="xs" muted className="mt-1">
|
|
450
|
+
{subtitle}
|
|
451
|
+
</UIText>
|
|
452
|
+
) : null}
|
|
453
|
+
</div>
|
|
454
|
+
{actions ? <div className="shrink-0">{actions}</div> : null}
|
|
455
|
+
</div>
|
|
456
|
+
) : null;
|
|
457
|
+
|
|
458
|
+
const board = (
|
|
459
|
+
<div className="flex gap-3 overflow-x-auto pb-1">
|
|
460
|
+
{columns.map((col) => (
|
|
461
|
+
<KanbanColumn
|
|
462
|
+
key={col.id}
|
|
463
|
+
column={col}
|
|
464
|
+
cards={cardsByColumn[col.id] ?? []}
|
|
465
|
+
accent={accent}
|
|
466
|
+
onCardClick={onCardClick}
|
|
467
|
+
isDraggable={isDraggable}
|
|
468
|
+
dense={dense}
|
|
469
|
+
showCounts={showColumnCounts}
|
|
470
|
+
showLimits={showWipLimits}
|
|
471
|
+
emptyMessage={emptyMessage}
|
|
472
|
+
/>
|
|
473
|
+
))}
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<BaseCard
|
|
479
|
+
padding="default"
|
|
480
|
+
isLoading={loading}
|
|
481
|
+
className={className}
|
|
482
|
+
{...cardProps}
|
|
483
|
+
>
|
|
484
|
+
{header}
|
|
485
|
+
{error ? (
|
|
486
|
+
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
|
|
487
|
+
{String(error)}
|
|
488
|
+
</div>
|
|
489
|
+
) : (
|
|
490
|
+
<DndContext
|
|
491
|
+
sensors={sensors}
|
|
492
|
+
collisionDetection={closestCenter}
|
|
493
|
+
onDragStart={handleDragStart}
|
|
494
|
+
onDragEnd={handleDragEnd}
|
|
495
|
+
onDragCancel={() => setActiveId(null)}
|
|
496
|
+
>
|
|
497
|
+
{board}
|
|
498
|
+
<DragOverlay>
|
|
499
|
+
{activeCard ? (
|
|
500
|
+
<KanbanCardBody card={activeCard} accent={accent} dragging dense={dense} />
|
|
501
|
+
) : null}
|
|
502
|
+
</DragOverlay>
|
|
503
|
+
</DndContext>
|
|
504
|
+
)}
|
|
505
|
+
</BaseCard>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
@@ -29,6 +29,7 @@ export { default as ActivityCard } from "./cards/ActivityCard";
|
|
|
29
29
|
export { default as MetricsStrip } from "./cards/MetricsStrip";
|
|
30
30
|
export { default as CalloutCard } from "./cards/CalloutCard";
|
|
31
31
|
export { default as ActionList } from "./cards/ActionList";
|
|
32
|
+
export { default as KanbanBoard } from "./cards/KanbanBoard";
|
|
32
33
|
|
|
33
34
|
// Charts
|
|
34
35
|
export { default as D3Chart } from "./charts/D3Chart";
|