@moontra/moonui-pro 2.12.0 → 2.14.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/dist/index.d.ts +53 -2
- package/dist/index.mjs +1641 -187
- package/package.json +1 -1
- package/src/components/index.ts +2 -1
- package/src/components/kanban/add-card-modal.tsx +502 -0
- package/src/components/kanban/card-detail-modal.tsx +769 -0
- package/src/components/kanban/index.ts +13 -0
- package/src/components/kanban/{index.tsx → kanban.tsx} +736 -294
- package/src/components/kanban/types.ts +168 -0
- package/src/hooks/use-toast.ts +15 -0
|
@@ -65,119 +65,23 @@ import {
|
|
|
65
65
|
} from 'lucide-react'
|
|
66
66
|
import { cn } from '../../lib/utils'
|
|
67
67
|
import { useSubscription } from '../../hooks/use-subscription'
|
|
68
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog'
|
|
69
|
+
import { ColorPicker } from '../ui/color-picker'
|
|
70
|
+
import { CardDetailModal } from './card-detail-modal'
|
|
71
|
+
import { AddCardModal } from './add-card-modal'
|
|
72
|
+
import { format } from 'date-fns'
|
|
73
|
+
import type {
|
|
74
|
+
KanbanAssignee,
|
|
75
|
+
KanbanLabel,
|
|
76
|
+
KanbanChecklist,
|
|
77
|
+
KanbanActivity,
|
|
78
|
+
KanbanCard,
|
|
79
|
+
KanbanColumn,
|
|
80
|
+
KanbanFilter,
|
|
81
|
+
KanbanProps
|
|
82
|
+
} from './types'
|
|
83
|
+
import { useToast } from '../../hooks/use-toast'
|
|
68
84
|
|
|
69
|
-
// Enhanced types
|
|
70
|
-
interface KanbanAssignee {
|
|
71
|
-
id: string
|
|
72
|
-
name: string
|
|
73
|
-
avatar?: string
|
|
74
|
-
email?: string
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface KanbanLabel {
|
|
78
|
-
id: string
|
|
79
|
-
name: string
|
|
80
|
-
color: string
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface KanbanChecklist {
|
|
84
|
-
id: string
|
|
85
|
-
title: string
|
|
86
|
-
items: {
|
|
87
|
-
id: string
|
|
88
|
-
text: string
|
|
89
|
-
completed: boolean
|
|
90
|
-
}[]
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface KanbanActivity {
|
|
94
|
-
id: string
|
|
95
|
-
user: KanbanAssignee
|
|
96
|
-
action: string
|
|
97
|
-
timestamp: Date
|
|
98
|
-
details?: string
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
interface KanbanCard {
|
|
102
|
-
id: string
|
|
103
|
-
title: string
|
|
104
|
-
description?: string
|
|
105
|
-
coverImage?: string
|
|
106
|
-
assignees?: KanbanAssignee[]
|
|
107
|
-
dueDate?: Date
|
|
108
|
-
startDate?: Date
|
|
109
|
-
priority?: 'low' | 'medium' | 'high' | 'urgent'
|
|
110
|
-
tags?: string[]
|
|
111
|
-
labels?: KanbanLabel[]
|
|
112
|
-
attachments?: {
|
|
113
|
-
id: string
|
|
114
|
-
name: string
|
|
115
|
-
type: string
|
|
116
|
-
url: string
|
|
117
|
-
size: number
|
|
118
|
-
}[]
|
|
119
|
-
comments?: number
|
|
120
|
-
completed?: boolean
|
|
121
|
-
progress?: number
|
|
122
|
-
checklist?: KanbanChecklist
|
|
123
|
-
activities?: KanbanActivity[]
|
|
124
|
-
customFields?: Record<string, any>
|
|
125
|
-
position: number
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
interface KanbanColumn {
|
|
129
|
-
id: string
|
|
130
|
-
title: string
|
|
131
|
-
color?: string
|
|
132
|
-
cards: KanbanCard[]
|
|
133
|
-
limit?: number
|
|
134
|
-
collapsed?: boolean
|
|
135
|
-
locked?: boolean
|
|
136
|
-
template?: 'todo' | 'inProgress' | 'done' | 'custom'
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
interface KanbanFilter {
|
|
140
|
-
id: string
|
|
141
|
-
name: string
|
|
142
|
-
query: string
|
|
143
|
-
assignees?: string[]
|
|
144
|
-
labels?: string[]
|
|
145
|
-
priority?: string[]
|
|
146
|
-
tags?: string[]
|
|
147
|
-
dueDate?: {
|
|
148
|
-
from?: Date
|
|
149
|
-
to?: Date
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
interface KanbanProps {
|
|
154
|
-
columns: KanbanColumn[]
|
|
155
|
-
onCardMove?: (cardId: string, fromColumn: string, toColumn: string, newPosition: number) => void
|
|
156
|
-
onCardClick?: (card: KanbanCard) => void
|
|
157
|
-
onCardEdit?: (card: KanbanCard) => void
|
|
158
|
-
onCardDelete?: (card: KanbanCard) => void
|
|
159
|
-
onCardUpdate?: (card: KanbanCard) => void
|
|
160
|
-
onAddCard?: (columnId: string, card?: Partial<KanbanCard>) => void
|
|
161
|
-
onAddColumn?: (column?: Partial<KanbanColumn>) => void
|
|
162
|
-
onColumnUpdate?: (column: KanbanColumn) => void
|
|
163
|
-
onColumnDelete?: (columnId: string) => void
|
|
164
|
-
onBulkAction?: (action: string, cardIds: string[]) => void
|
|
165
|
-
onExport?: (format: 'json' | 'csv') => void
|
|
166
|
-
className?: string
|
|
167
|
-
showAddColumn?: boolean
|
|
168
|
-
showCardDetails?: boolean
|
|
169
|
-
showFilters?: boolean
|
|
170
|
-
showSearch?: boolean
|
|
171
|
-
enableKeyboardShortcuts?: boolean
|
|
172
|
-
cardTemplates?: Partial<KanbanCard>[]
|
|
173
|
-
columnTemplates?: Partial<KanbanColumn>[]
|
|
174
|
-
filters?: KanbanFilter[]
|
|
175
|
-
defaultFilter?: string
|
|
176
|
-
loading?: boolean
|
|
177
|
-
disabled?: boolean
|
|
178
|
-
labels?: KanbanLabel[]
|
|
179
|
-
users?: KanbanAssignee[]
|
|
180
|
-
}
|
|
181
85
|
|
|
182
86
|
// Constants
|
|
183
87
|
const PRIORITY_CONFIG = {
|
|
@@ -267,38 +171,80 @@ const useAutoScroll = () => {
|
|
|
267
171
|
// Card component
|
|
268
172
|
const KanbanCardComponent = ({
|
|
269
173
|
card,
|
|
174
|
+
column,
|
|
270
175
|
isDragging,
|
|
271
176
|
onEdit,
|
|
272
177
|
onDelete,
|
|
273
178
|
onClick,
|
|
274
179
|
showDetails,
|
|
275
|
-
disabled
|
|
180
|
+
disabled,
|
|
181
|
+
renderCard,
|
|
182
|
+
renderCardPreview,
|
|
183
|
+
renderCardBadge,
|
|
184
|
+
renderCardActions,
|
|
185
|
+
cardCompactMode,
|
|
186
|
+
cardShowCoverImage,
|
|
187
|
+
cardShowAssignees,
|
|
188
|
+
cardShowLabels,
|
|
189
|
+
cardShowProgress,
|
|
190
|
+
cardDateFormat,
|
|
191
|
+
cardMaxAssigneesToShow,
|
|
192
|
+
provided,
|
|
193
|
+
enableAnimations,
|
|
194
|
+
animationDuration,
|
|
195
|
+
cardVariant
|
|
276
196
|
}: {
|
|
277
197
|
card: KanbanCard
|
|
198
|
+
column: KanbanColumn
|
|
278
199
|
isDragging: boolean
|
|
279
200
|
onEdit?: (e: React.MouseEvent) => void
|
|
280
201
|
onDelete?: (e: React.MouseEvent) => void
|
|
281
202
|
onClick?: () => void
|
|
282
203
|
showDetails: boolean
|
|
283
204
|
disabled: boolean
|
|
205
|
+
renderCard?: (card: KanbanCard, column: KanbanColumn, provided: any) => React.ReactNode
|
|
206
|
+
renderCardPreview?: (card: KanbanCard) => React.ReactNode
|
|
207
|
+
renderCardBadge?: (card: KanbanCard) => React.ReactNode
|
|
208
|
+
renderCardActions?: (card: KanbanCard) => React.ReactNode
|
|
209
|
+
cardCompactMode?: boolean
|
|
210
|
+
cardShowCoverImage?: boolean
|
|
211
|
+
cardShowAssignees?: boolean
|
|
212
|
+
cardShowLabels?: boolean
|
|
213
|
+
cardShowProgress?: boolean
|
|
214
|
+
cardDateFormat?: string
|
|
215
|
+
cardMaxAssigneesToShow?: number
|
|
216
|
+
provided?: any
|
|
217
|
+
enableAnimations?: boolean
|
|
218
|
+
animationDuration?: number
|
|
219
|
+
cardVariant?: 'default' | 'bordered' | 'elevated' | 'flat'
|
|
284
220
|
}) => {
|
|
285
221
|
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
|
286
222
|
const [title, setTitle] = useState(card.title)
|
|
287
223
|
const dragControls = useDragControls()
|
|
224
|
+
|
|
225
|
+
// Default values
|
|
226
|
+
const animationsEnabled = enableAnimations ?? true
|
|
227
|
+
const animDuration = animationDuration ?? 0.2
|
|
228
|
+
const variant = cardVariant ?? 'default'
|
|
288
229
|
|
|
289
230
|
const completedChecklistItems = card.checklist?.items.filter(item => item.completed).length || 0
|
|
290
231
|
const totalChecklistItems = card.checklist?.items.length || 0
|
|
291
232
|
const checklistProgress = totalChecklistItems > 0 ? (completedChecklistItems / totalChecklistItems) * 100 : 0
|
|
292
233
|
|
|
234
|
+
// If custom render function is provided, use it
|
|
235
|
+
if (renderCard) {
|
|
236
|
+
return renderCard(card, column, provided || {})
|
|
237
|
+
}
|
|
238
|
+
|
|
293
239
|
return (
|
|
294
240
|
<motion.div
|
|
295
241
|
layout
|
|
296
|
-
initial={{ opacity: 0, y: 20 }}
|
|
242
|
+
initial={{ opacity: animationsEnabled ? 0 : 1, y: animationsEnabled ? 20 : 0 }}
|
|
297
243
|
animate={{ opacity: 1, y: 0 }}
|
|
298
|
-
exit={{ opacity: 0, y: -20 }}
|
|
299
|
-
whileHover={{ scale: 1.02 }}
|
|
300
|
-
whileDrag={{ scale: 1.05, rotate: 3 }}
|
|
301
|
-
transition={{ duration: 0
|
|
244
|
+
exit={{ opacity: animationsEnabled ? 0 : 1, y: animationsEnabled ? -20 : 0 }}
|
|
245
|
+
whileHover={animationsEnabled ? { scale: 1.02 } : {}}
|
|
246
|
+
whileDrag={animationsEnabled ? { scale: 1.05, rotate: 3 } : {}}
|
|
247
|
+
transition={{ duration: animationsEnabled ? animDuration : 0 }}
|
|
302
248
|
className={cn(
|
|
303
249
|
"relative group cursor-pointer select-none",
|
|
304
250
|
isDragging && "z-50"
|
|
@@ -306,7 +252,11 @@ const KanbanCardComponent = ({
|
|
|
306
252
|
>
|
|
307
253
|
<Card
|
|
308
254
|
className={cn(
|
|
309
|
-
"border
|
|
255
|
+
"border transition-all duration-200",
|
|
256
|
+
variant === 'bordered' && "border-2",
|
|
257
|
+
variant === 'elevated' && "shadow-lg hover:shadow-xl",
|
|
258
|
+
variant === 'flat' && "border-0 bg-muted/30",
|
|
259
|
+
variant === 'default' && "hover:shadow-md",
|
|
310
260
|
isDragging && "shadow-2xl ring-2 ring-primary ring-offset-2",
|
|
311
261
|
disabled && "cursor-not-allowed opacity-50"
|
|
312
262
|
)}
|
|
@@ -319,7 +269,7 @@ const KanbanCardComponent = ({
|
|
|
319
269
|
/>
|
|
320
270
|
|
|
321
271
|
{/* Cover image */}
|
|
322
|
-
{card.coverImage && (
|
|
272
|
+
{cardShowCoverImage && card.coverImage && (
|
|
323
273
|
<div className="relative h-32 -mx-px -mt-px rounded-t-lg overflow-hidden">
|
|
324
274
|
<img
|
|
325
275
|
src={card.coverImage}
|
|
@@ -332,7 +282,7 @@ const KanbanCardComponent = ({
|
|
|
332
282
|
|
|
333
283
|
<CardContent className="p-3">
|
|
334
284
|
{/* Labels */}
|
|
335
|
-
{card.labels && card.labels.length > 0 && (
|
|
285
|
+
{cardShowLabels && card.labels && card.labels.length > 0 && (
|
|
336
286
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
337
287
|
{card.labels.map((label) => (
|
|
338
288
|
<div
|
|
@@ -373,27 +323,32 @@ const KanbanCardComponent = ({
|
|
|
373
323
|
</div>
|
|
374
324
|
|
|
375
325
|
{/* Quick actions */}
|
|
376
|
-
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
</
|
|
393
|
-
<
|
|
394
|
-
<
|
|
395
|
-
|
|
396
|
-
|
|
326
|
+
{renderCardActions ? (
|
|
327
|
+
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
|
328
|
+
{renderCardActions(card)}
|
|
329
|
+
</div>
|
|
330
|
+
) : (
|
|
331
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
332
|
+
<DropdownMenu>
|
|
333
|
+
<DropdownMenuTrigger asChild>
|
|
334
|
+
<Button
|
|
335
|
+
variant="ghost"
|
|
336
|
+
size="sm"
|
|
337
|
+
className="h-6 w-6 p-0"
|
|
338
|
+
onClick={(e) => e.stopPropagation()}
|
|
339
|
+
>
|
|
340
|
+
<MoreVertical className="h-3 w-3" />
|
|
341
|
+
</Button>
|
|
342
|
+
</DropdownMenuTrigger>
|
|
343
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
344
|
+
<DropdownMenuItem onClick={onEdit}>
|
|
345
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
346
|
+
Edit
|
|
347
|
+
</DropdownMenuItem>
|
|
348
|
+
<DropdownMenuItem>
|
|
349
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
350
|
+
Duplicate
|
|
351
|
+
</DropdownMenuItem>
|
|
397
352
|
<DropdownMenuItem>
|
|
398
353
|
<Move className="mr-2 h-4 w-4" />
|
|
399
354
|
Move
|
|
@@ -410,20 +365,21 @@ const KanbanCardComponent = ({
|
|
|
410
365
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
411
366
|
Delete
|
|
412
367
|
</DropdownMenuItem>
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
368
|
+
</DropdownMenuContent>
|
|
369
|
+
</DropdownMenu>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
416
372
|
</div>
|
|
417
373
|
|
|
418
374
|
{/* Description */}
|
|
419
|
-
{card.description && (
|
|
375
|
+
{!cardCompactMode && card.description && (
|
|
420
376
|
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">
|
|
421
377
|
{card.description}
|
|
422
378
|
</p>
|
|
423
379
|
)}
|
|
424
380
|
|
|
425
381
|
{/* Progress bar */}
|
|
426
|
-
{(card.progress !== undefined || card.checklist) && (
|
|
382
|
+
{cardShowProgress && (card.progress !== undefined || card.checklist) && (
|
|
427
383
|
<div className="mb-3">
|
|
428
384
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
|
429
385
|
<span>Progress</span>
|
|
@@ -434,7 +390,7 @@ const KanbanCardComponent = ({
|
|
|
434
390
|
)}
|
|
435
391
|
|
|
436
392
|
{/* Tags */}
|
|
437
|
-
{card.tags && card.tags.length > 0 && (
|
|
393
|
+
{!cardCompactMode && card.tags && card.tags.length > 0 && (
|
|
438
394
|
<div className="flex flex-wrap gap-1 mb-3">
|
|
439
395
|
{card.tags.map((tag, index) => (
|
|
440
396
|
<Badge key={index} variant="secondary" className="text-xs px-1.5 py-0">
|
|
@@ -463,7 +419,7 @@ const KanbanCardComponent = ({
|
|
|
463
419
|
isOverdue(card.dueDate) && "text-destructive"
|
|
464
420
|
)}>
|
|
465
421
|
<Calendar className="h-3 w-3" />
|
|
466
|
-
<span>{formatDate(card.dueDate)}</span>
|
|
422
|
+
<span>{cardDateFormat ? format(card.dueDate, cardDateFormat) : formatDate(card.dueDate)}</span>
|
|
467
423
|
</div>
|
|
468
424
|
)}
|
|
469
425
|
|
|
@@ -494,8 +450,8 @@ const KanbanCardComponent = ({
|
|
|
494
450
|
)}
|
|
495
451
|
|
|
496
452
|
{/* Assignees */}
|
|
497
|
-
{card.assignees && card.assignees.length > 0 && (
|
|
498
|
-
<MoonUIAvatarGroupPro max={
|
|
453
|
+
{cardShowAssignees && card.assignees && card.assignees.length > 0 && (
|
|
454
|
+
<MoonUIAvatarGroupPro max={cardMaxAssigneesToShow} size="xs">
|
|
499
455
|
{card.assignees.map((assignee) => (
|
|
500
456
|
<MoonUIAvatarPro key={assignee.id} className="h-5 w-5">
|
|
501
457
|
<MoonUIAvatarImagePro src={assignee.avatar} />
|
|
@@ -542,7 +498,54 @@ export function Kanban({
|
|
|
542
498
|
loading = false,
|
|
543
499
|
disabled = false,
|
|
544
500
|
labels = [],
|
|
545
|
-
users = []
|
|
501
|
+
users = [],
|
|
502
|
+
// Card Render Customization
|
|
503
|
+
renderCard,
|
|
504
|
+
renderCardPreview,
|
|
505
|
+
renderCardBadge,
|
|
506
|
+
renderCardActions,
|
|
507
|
+
cardCompactMode = false,
|
|
508
|
+
cardShowCoverImage = true,
|
|
509
|
+
cardShowAssignees = true,
|
|
510
|
+
cardShowLabels = true,
|
|
511
|
+
cardShowProgress = true,
|
|
512
|
+
cardDateFormat,
|
|
513
|
+
cardMaxAssigneesToShow = 3,
|
|
514
|
+
// Add Card Customization
|
|
515
|
+
renderAddCardButton,
|
|
516
|
+
renderAddCardForm,
|
|
517
|
+
addCardButtonText,
|
|
518
|
+
addCardPosition = 'bottom',
|
|
519
|
+
allowQuickAdd = true,
|
|
520
|
+
quickAddFields = ['title'],
|
|
521
|
+
validateCard,
|
|
522
|
+
onBeforeCardAdd,
|
|
523
|
+
// Column Customization
|
|
524
|
+
renderColumnHeader,
|
|
525
|
+
renderColumnFooter,
|
|
526
|
+
renderEmptyColumn,
|
|
527
|
+
columnMenuActions,
|
|
528
|
+
allowColumnReorder = true,
|
|
529
|
+
columnColorOptions,
|
|
530
|
+
columnDefaultColor = '#6B7280',
|
|
531
|
+
// Drag & Drop Enhancement
|
|
532
|
+
dragDisabled = false,
|
|
533
|
+
dropDisabled = false,
|
|
534
|
+
dragPreview = 'card',
|
|
535
|
+
renderDragPreview,
|
|
536
|
+
canDrop,
|
|
537
|
+
onDragStart,
|
|
538
|
+
onDragEnd,
|
|
539
|
+
// UI/UX Customization
|
|
540
|
+
theme = 'default',
|
|
541
|
+
cardVariant = 'default',
|
|
542
|
+
enableAnimations = true,
|
|
543
|
+
animationDuration = 0.2,
|
|
544
|
+
columnWidth,
|
|
545
|
+
columnGap = 24,
|
|
546
|
+
cardGap = 12,
|
|
547
|
+
showTooltips = true,
|
|
548
|
+
tooltipDelay = 500
|
|
546
549
|
}: KanbanProps) {
|
|
547
550
|
// Check pro access
|
|
548
551
|
const { hasProAccess, isLoading } = useSubscription()
|
|
@@ -584,8 +587,21 @@ export function Kanban({
|
|
|
584
587
|
const [draggedOverColumn, setDraggedOverColumn] = useState<string | null>(null)
|
|
585
588
|
const [isCreatingColumn, setIsCreatingColumn] = useState(false)
|
|
586
589
|
const [newColumnTitle, setNewColumnTitle] = useState('')
|
|
590
|
+
|
|
591
|
+
// Modal states
|
|
592
|
+
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null)
|
|
593
|
+
const [addCardColumnId, setAddCardColumnId] = useState<string | null>(null)
|
|
594
|
+
const [editingColumnId, setEditingColumnId] = useState<string | null>(null)
|
|
595
|
+
const [editingColumnTitle, setEditingColumnTitle] = useState('')
|
|
596
|
+
const [wipLimitModalOpen, setWipLimitModalOpen] = useState(false)
|
|
597
|
+
const [wipLimitColumnId, setWipLimitColumnId] = useState<string | null>(null)
|
|
598
|
+
const [wipLimit, setWipLimit] = useState<number | undefined>()
|
|
599
|
+
const [colorPickerOpen, setColorPickerOpen] = useState(false)
|
|
600
|
+
const [colorPickerColumnId, setColorPickerColumnId] = useState<string | null>(null)
|
|
601
|
+
const [selectedColor, setSelectedColor] = useState('#6B7280')
|
|
587
602
|
|
|
588
603
|
const { scrollRef, startAutoScroll, stopAutoScroll } = useAutoScroll()
|
|
604
|
+
const { toast } = useToast()
|
|
589
605
|
|
|
590
606
|
// Filter cards based on search and filters
|
|
591
607
|
const filteredColumns = useMemo(() => {
|
|
@@ -663,11 +679,22 @@ export function Kanban({
|
|
|
663
679
|
// Drag handlers
|
|
664
680
|
const handleDragStart = (card: KanbanCard, columnId: string) => {
|
|
665
681
|
if (disabled) return
|
|
682
|
+
if (typeof dragDisabled === 'function' && dragDisabled(card)) return
|
|
683
|
+
if (dragDisabled === true) return
|
|
684
|
+
|
|
666
685
|
setDraggedCard(card.id)
|
|
686
|
+
const column = columns.find(col => col.id === columnId)
|
|
687
|
+
if (column && onDragStart) {
|
|
688
|
+
onDragStart(card, column)
|
|
689
|
+
}
|
|
667
690
|
}
|
|
668
691
|
|
|
669
692
|
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
|
670
693
|
if (disabled) return
|
|
694
|
+
const column = columns.find(col => col.id === columnId)
|
|
695
|
+
if (column && typeof dropDisabled === 'function' && dropDisabled(column)) return
|
|
696
|
+
if (dropDisabled === true) return
|
|
697
|
+
|
|
671
698
|
e.preventDefault()
|
|
672
699
|
setDraggedOverColumn(columnId)
|
|
673
700
|
|
|
@@ -688,6 +715,13 @@ export function Kanban({
|
|
|
688
715
|
}
|
|
689
716
|
|
|
690
717
|
const handleDragEnd = () => {
|
|
718
|
+
if (draggedCard) {
|
|
719
|
+
const card = columns.flatMap(col => col.cards).find(c => c.id === draggedCard)
|
|
720
|
+
const column = columns.find(col => col.cards.some(c => c.id === draggedCard))
|
|
721
|
+
if (card && column && onDragEnd) {
|
|
722
|
+
onDragEnd(card, column)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
691
725
|
setDraggedCard(null)
|
|
692
726
|
setDraggedOverColumn(null)
|
|
693
727
|
stopAutoScroll()
|
|
@@ -696,6 +730,13 @@ export function Kanban({
|
|
|
696
730
|
const handleDrop = (e: React.DragEvent, targetColumnId: string, targetIndex: number) => {
|
|
697
731
|
if (disabled || !draggedCard || !onCardMove) return
|
|
698
732
|
e.preventDefault()
|
|
733
|
+
|
|
734
|
+
const targetColumn = columns.find(col => col.id === targetColumnId)
|
|
735
|
+
const draggedCardObj = columns.flatMap(col => col.cards).find(card => card.id === draggedCard)
|
|
736
|
+
|
|
737
|
+
if (targetColumn && draggedCardObj && canDrop && !canDrop(draggedCardObj, targetColumn, targetIndex)) {
|
|
738
|
+
return
|
|
739
|
+
}
|
|
699
740
|
|
|
700
741
|
// Find source column and card
|
|
701
742
|
let sourceColumnId: string | null = null
|
|
@@ -729,20 +770,224 @@ export function Kanban({
|
|
|
729
770
|
const handleColumnAction = (column: KanbanColumn, action: string) => {
|
|
730
771
|
switch (action) {
|
|
731
772
|
case 'rename':
|
|
732
|
-
|
|
773
|
+
setEditingColumnId(column.id)
|
|
774
|
+
setEditingColumnTitle(column.title)
|
|
733
775
|
break
|
|
734
776
|
case 'delete':
|
|
735
777
|
onColumnDelete?.(column.id)
|
|
778
|
+
toast({
|
|
779
|
+
title: "Column deleted",
|
|
780
|
+
description: `"${column.title}" has been deleted`
|
|
781
|
+
})
|
|
736
782
|
break
|
|
737
783
|
case 'collapse':
|
|
738
|
-
|
|
784
|
+
const updatedColumn = { ...column, collapsed: !column.collapsed }
|
|
785
|
+
onColumnUpdate?.(updatedColumn)
|
|
786
|
+
setColumns(columns.map(col => col.id === column.id ? updatedColumn : col))
|
|
739
787
|
break
|
|
740
788
|
case 'setLimit':
|
|
741
|
-
|
|
789
|
+
setWipLimitColumnId(column.id)
|
|
790
|
+
setWipLimit(column.limit)
|
|
791
|
+
setWipLimitModalOpen(true)
|
|
792
|
+
break
|
|
793
|
+
case 'changeColor':
|
|
794
|
+
setColorPickerColumnId(column.id)
|
|
795
|
+
setSelectedColor(column.color || '#6B7280')
|
|
796
|
+
setColorPickerOpen(true)
|
|
797
|
+
break
|
|
798
|
+
case 'sortByPriority':
|
|
799
|
+
const sortedCards = [...column.cards].sort((a, b) => {
|
|
800
|
+
const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 }
|
|
801
|
+
return (priorityOrder[b.priority || 'medium'] || 2) - (priorityOrder[a.priority || 'medium'] || 2)
|
|
802
|
+
})
|
|
803
|
+
const sortedColumn = { ...column, cards: sortedCards }
|
|
804
|
+
onColumnUpdate?.(sortedColumn)
|
|
805
|
+
setColumns(columns.map(col => col.id === column.id ? sortedColumn : col))
|
|
806
|
+
toast({
|
|
807
|
+
title: "Cards sorted",
|
|
808
|
+
description: "Cards sorted by priority"
|
|
809
|
+
})
|
|
810
|
+
break
|
|
811
|
+
case 'sortByDueDate':
|
|
812
|
+
const dateCards = [...column.cards].sort((a, b) => {
|
|
813
|
+
if (!a.dueDate && !b.dueDate) return 0
|
|
814
|
+
if (!a.dueDate) return 1
|
|
815
|
+
if (!b.dueDate) return -1
|
|
816
|
+
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
|
817
|
+
})
|
|
818
|
+
const dateColumn = { ...column, cards: dateCards }
|
|
819
|
+
onColumnUpdate?.(dateColumn)
|
|
820
|
+
setColumns(columns.map(col => col.id === column.id ? dateColumn : col))
|
|
821
|
+
toast({
|
|
822
|
+
title: "Cards sorted",
|
|
823
|
+
description: "Cards sorted by due date"
|
|
824
|
+
})
|
|
825
|
+
break
|
|
826
|
+
case 'sortAlphabetically':
|
|
827
|
+
const alphaCards = [...column.cards].sort((a, b) => a.title.localeCompare(b.title))
|
|
828
|
+
const alphaColumn = { ...column, cards: alphaCards }
|
|
829
|
+
onColumnUpdate?.(alphaColumn)
|
|
830
|
+
setColumns(columns.map(col => col.id === column.id ? alphaColumn : col))
|
|
831
|
+
toast({
|
|
832
|
+
title: "Cards sorted",
|
|
833
|
+
description: "Cards sorted alphabetically"
|
|
834
|
+
})
|
|
742
835
|
break
|
|
743
836
|
}
|
|
744
837
|
}
|
|
745
838
|
|
|
839
|
+
// Card handlers
|
|
840
|
+
const handleCardClick = (card: KanbanCard) => {
|
|
841
|
+
if (onCardClick) {
|
|
842
|
+
onCardClick(card)
|
|
843
|
+
} else {
|
|
844
|
+
setSelectedCard(card)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const handleCardUpdate = (updatedCard: KanbanCard) => {
|
|
849
|
+
onCardUpdate?.(updatedCard)
|
|
850
|
+
setColumns(columns.map(col => ({
|
|
851
|
+
...col,
|
|
852
|
+
cards: col.cards.map(card => card.id === updatedCard.id ? updatedCard : card)
|
|
853
|
+
})))
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const handleAddCard = (columnId: string, newCard?: Partial<KanbanCard>) => {
|
|
857
|
+
if (onAddCard) {
|
|
858
|
+
onAddCard(columnId, newCard)
|
|
859
|
+
} else {
|
|
860
|
+
setAddCardColumnId(columnId)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const handleAddNewCard = (card: Partial<KanbanCard>) => {
|
|
865
|
+
if (!addCardColumnId) return
|
|
866
|
+
|
|
867
|
+
const newCard: KanbanCard = {
|
|
868
|
+
id: Date.now().toString(),
|
|
869
|
+
title: card.title || 'New Card',
|
|
870
|
+
position: Date.now(),
|
|
871
|
+
...card
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
setColumns(columns.map(col => {
|
|
875
|
+
if (col.id === addCardColumnId) {
|
|
876
|
+
return {
|
|
877
|
+
...col,
|
|
878
|
+
cards: [...col.cards, newCard]
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return col
|
|
882
|
+
}))
|
|
883
|
+
|
|
884
|
+
onAddCard?.(addCardColumnId, newCard)
|
|
885
|
+
toast({
|
|
886
|
+
title: "Card added",
|
|
887
|
+
description: `"${newCard.title}" has been added`
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Column updates
|
|
892
|
+
const handleColumnRename = (columnId: string) => {
|
|
893
|
+
const column = columns.find(col => col.id === columnId)
|
|
894
|
+
if (!column || !editingColumnTitle.trim()) return
|
|
895
|
+
|
|
896
|
+
const updatedColumn = { ...column, title: editingColumnTitle.trim() }
|
|
897
|
+
onColumnUpdate?.(updatedColumn)
|
|
898
|
+
setColumns(columns.map(col => col.id === columnId ? updatedColumn : col))
|
|
899
|
+
setEditingColumnId(null)
|
|
900
|
+
setEditingColumnTitle('')
|
|
901
|
+
|
|
902
|
+
toast({
|
|
903
|
+
title: "Column renamed",
|
|
904
|
+
description: `Column renamed to "${editingColumnTitle.trim()}"`
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const handleWipLimitUpdate = () => {
|
|
909
|
+
const column = columns.find(col => col.id === wipLimitColumnId)
|
|
910
|
+
if (!column) return
|
|
911
|
+
|
|
912
|
+
const updatedColumn = { ...column, limit: wipLimit }
|
|
913
|
+
onColumnUpdate?.(updatedColumn)
|
|
914
|
+
setColumns(columns.map(col => col.id === wipLimitColumnId ? updatedColumn : col))
|
|
915
|
+
setWipLimitModalOpen(false)
|
|
916
|
+
|
|
917
|
+
toast({
|
|
918
|
+
title: "WIP limit updated",
|
|
919
|
+
description: wipLimit ? `WIP limit set to ${wipLimit}` : "WIP limit removed"
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const handleColorUpdate = () => {
|
|
924
|
+
const column = columns.find(col => col.id === colorPickerColumnId)
|
|
925
|
+
if (!column) return
|
|
926
|
+
|
|
927
|
+
const updatedColumn = { ...column, color: selectedColor }
|
|
928
|
+
onColumnUpdate?.(updatedColumn)
|
|
929
|
+
setColumns(columns.map(col => col.id === colorPickerColumnId ? updatedColumn : col))
|
|
930
|
+
setColorPickerOpen(false)
|
|
931
|
+
|
|
932
|
+
toast({
|
|
933
|
+
title: "Column color updated",
|
|
934
|
+
description: "Column color has been changed"
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Export functionality
|
|
939
|
+
const handleExport = (format: 'json' | 'csv') => {
|
|
940
|
+
if (onExport) {
|
|
941
|
+
onExport(format)
|
|
942
|
+
} else {
|
|
943
|
+
if (format === 'json') {
|
|
944
|
+
const data = JSON.stringify(columns, null, 2)
|
|
945
|
+
const blob = new Blob([data], { type: 'application/json' })
|
|
946
|
+
const url = URL.createObjectURL(blob)
|
|
947
|
+
const a = document.createElement('a')
|
|
948
|
+
a.href = url
|
|
949
|
+
a.download = 'kanban-board.json'
|
|
950
|
+
document.body.appendChild(a)
|
|
951
|
+
a.click()
|
|
952
|
+
document.body.removeChild(a)
|
|
953
|
+
URL.revokeObjectURL(url)
|
|
954
|
+
|
|
955
|
+
toast({
|
|
956
|
+
title: "Board exported",
|
|
957
|
+
description: "Board exported as JSON file"
|
|
958
|
+
})
|
|
959
|
+
} else if (format === 'csv') {
|
|
960
|
+
let csv = 'Column,Card Title,Description,Priority,Assignees,Due Date,Tags\n'
|
|
961
|
+
columns.forEach(column => {
|
|
962
|
+
column.cards.forEach(card => {
|
|
963
|
+
csv += `"${column.title}",`
|
|
964
|
+
csv += `"${card.title}",`
|
|
965
|
+
csv += `"${card.description || ''}",`
|
|
966
|
+
csv += `"${card.priority || ''}",`
|
|
967
|
+
csv += `"${card.assignees?.map(a => a.name).join(', ') || ''}",`
|
|
968
|
+
csv += `"${card.dueDate ? new Date(card.dueDate).toLocaleDateString() : ''}",`
|
|
969
|
+
csv += `"${card.tags?.join(', ') || ''}"\n`
|
|
970
|
+
})
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
const blob = new Blob([csv], { type: 'text/csv' })
|
|
974
|
+
const url = URL.createObjectURL(blob)
|
|
975
|
+
const a = document.createElement('a')
|
|
976
|
+
a.href = url
|
|
977
|
+
a.download = 'kanban-board.csv'
|
|
978
|
+
document.body.appendChild(a)
|
|
979
|
+
a.click()
|
|
980
|
+
document.body.removeChild(a)
|
|
981
|
+
URL.revokeObjectURL(url)
|
|
982
|
+
|
|
983
|
+
toast({
|
|
984
|
+
title: "Board exported",
|
|
985
|
+
description: "Board exported as CSV file"
|
|
986
|
+
})
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
746
991
|
// Loading state
|
|
747
992
|
if (loading) {
|
|
748
993
|
return (
|
|
@@ -855,8 +1100,7 @@ export function Kanban({
|
|
|
855
1100
|
)}
|
|
856
1101
|
|
|
857
1102
|
{/* Export */}
|
|
858
|
-
|
|
859
|
-
<DropdownMenu>
|
|
1103
|
+
<DropdownMenu>
|
|
860
1104
|
<DropdownMenuTrigger asChild>
|
|
861
1105
|
<Button variant="outline" size="sm">
|
|
862
1106
|
<Download className="mr-2 h-4 w-4" />
|
|
@@ -864,15 +1108,14 @@ export function Kanban({
|
|
|
864
1108
|
</Button>
|
|
865
1109
|
</DropdownMenuTrigger>
|
|
866
1110
|
<DropdownMenuContent align="end">
|
|
867
|
-
<DropdownMenuItem onClick={() =>
|
|
1111
|
+
<DropdownMenuItem onClick={() => handleExport('json')}>
|
|
868
1112
|
Export as JSON
|
|
869
1113
|
</DropdownMenuItem>
|
|
870
|
-
<DropdownMenuItem onClick={() =>
|
|
1114
|
+
<DropdownMenuItem onClick={() => handleExport('csv')}>
|
|
871
1115
|
Export as CSV
|
|
872
1116
|
</DropdownMenuItem>
|
|
873
1117
|
</DropdownMenuContent>
|
|
874
1118
|
</DropdownMenu>
|
|
875
|
-
)}
|
|
876
1119
|
</div>
|
|
877
1120
|
</div>
|
|
878
1121
|
|
|
@@ -899,7 +1142,8 @@ export function Kanban({
|
|
|
899
1142
|
{/* Kanban board */}
|
|
900
1143
|
<div
|
|
901
1144
|
ref={scrollRef}
|
|
902
|
-
className="flex
|
|
1145
|
+
className="flex overflow-x-auto pb-4"
|
|
1146
|
+
style={{ gap: `${columnGap}px` }}
|
|
903
1147
|
onDragOver={(e) => e.preventDefault()}
|
|
904
1148
|
>
|
|
905
1149
|
<AnimatePresence mode="sync">
|
|
@@ -910,14 +1154,19 @@ export function Kanban({
|
|
|
910
1154
|
return (
|
|
911
1155
|
<motion.div
|
|
912
1156
|
key={column.id}
|
|
913
|
-
layout
|
|
914
|
-
initial={{ opacity: 0, x: -20 }}
|
|
1157
|
+
layout={enableAnimations}
|
|
1158
|
+
initial={{ opacity: enableAnimations ? 0 : 1, x: enableAnimations ? -20 : 0 }}
|
|
915
1159
|
animate={{ opacity: 1, x: 0 }}
|
|
916
|
-
exit={{ opacity: 0, x: 20 }}
|
|
1160
|
+
exit={{ opacity: enableAnimations ? 0 : 1, x: enableAnimations ? 20 : 0 }}
|
|
917
1161
|
className={cn(
|
|
918
|
-
"flex-shrink-0
|
|
1162
|
+
"flex-shrink-0 transition-all",
|
|
919
1163
|
isDraggedOver && "scale-105"
|
|
920
1164
|
)}
|
|
1165
|
+
style={{
|
|
1166
|
+
width: columnWidth === 'auto' ? 'auto' : (columnWidth || 320) + 'px',
|
|
1167
|
+
minWidth: columnWidth === 'auto' ? '300px' : undefined,
|
|
1168
|
+
transitionDuration: enableAnimations ? `${animationDuration}s` : '0s'
|
|
1169
|
+
}}
|
|
921
1170
|
onDragOver={(e) => handleDragOver(e, column.id)}
|
|
922
1171
|
onDragLeave={() => setDraggedOverColumn(null)}
|
|
923
1172
|
onDrop={(e) => handleDrop(e, column.id, column.cards.length)}
|
|
@@ -928,114 +1177,178 @@ export function Kanban({
|
|
|
928
1177
|
column.collapsed && "opacity-60"
|
|
929
1178
|
)}>
|
|
930
1179
|
<CardHeader className="pb-3">
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1180
|
+
{renderColumnHeader ? (
|
|
1181
|
+
renderColumnHeader(column)
|
|
1182
|
+
) : (
|
|
1183
|
+
<>
|
|
1184
|
+
<div className="flex items-center justify-between">
|
|
1185
|
+
<div className="flex items-center gap-2">
|
|
1186
|
+
{/* Column color indicator */}
|
|
1187
|
+
{column.color && (
|
|
1188
|
+
<div
|
|
1189
|
+
className="w-3 h-3 rounded-full"
|
|
1190
|
+
style={{ backgroundColor: column.color }}
|
|
1191
|
+
/>
|
|
1192
|
+
)}
|
|
1193
|
+
|
|
1194
|
+
{/* Column title */}
|
|
1195
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
1196
|
+
{editingColumnId === column.id ? (
|
|
1197
|
+
<Input
|
|
1198
|
+
value={editingColumnTitle}
|
|
1199
|
+
onChange={(e) => setEditingColumnTitle(e.target.value)}
|
|
1200
|
+
onBlur={() => handleColumnRename(column.id)}
|
|
1201
|
+
onKeyDown={(e) => {
|
|
1202
|
+
if (e.key === 'Enter') {
|
|
1203
|
+
handleColumnRename(column.id)
|
|
1204
|
+
}
|
|
1205
|
+
if (e.key === 'Escape') {
|
|
1206
|
+
setEditingColumnId(null)
|
|
1207
|
+
setEditingColumnTitle('')
|
|
1208
|
+
}
|
|
1209
|
+
}}
|
|
1210
|
+
className="h-6 w-32 text-sm"
|
|
1211
|
+
autoFocus
|
|
1212
|
+
onClick={(e) => e.stopPropagation()}
|
|
1213
|
+
/>
|
|
1214
|
+
) : (
|
|
1215
|
+
<>
|
|
1216
|
+
{column.title}
|
|
1217
|
+
{column.locked && <Lock className="h-3 w-3" />}
|
|
1218
|
+
</>
|
|
1219
|
+
)}
|
|
1220
|
+
</CardTitle>
|
|
1221
|
+
|
|
1222
|
+
{/* Card count */}
|
|
1223
|
+
<Badge variant="secondary" className="text-xs">
|
|
1224
|
+
{column.cards.length}
|
|
1225
|
+
</Badge>
|
|
1226
|
+
</div>
|
|
1227
|
+
|
|
1228
|
+
{/* Column actions */}
|
|
1229
|
+
<DropdownMenu>
|
|
1230
|
+
<DropdownMenuTrigger asChild>
|
|
1231
|
+
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
1232
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
1233
|
+
</Button>
|
|
1234
|
+
</DropdownMenuTrigger>
|
|
1235
|
+
<DropdownMenuContent align="end">
|
|
1236
|
+
{columnMenuActions ? (
|
|
1237
|
+
columnMenuActions.map((action, index) => {
|
|
1238
|
+
if (action.visible && !action.visible(column)) return null
|
|
1239
|
+
return (
|
|
1240
|
+
<DropdownMenuItem
|
|
1241
|
+
key={index}
|
|
1242
|
+
onClick={() => action.action(column)}
|
|
1243
|
+
>
|
|
1244
|
+
{action.icon || null}
|
|
1245
|
+
{action.label}
|
|
1246
|
+
</DropdownMenuItem>
|
|
1247
|
+
)
|
|
1248
|
+
})
|
|
1249
|
+
) : (
|
|
1250
|
+
<>
|
|
1251
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'rename')}>
|
|
1252
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
1253
|
+
Rename
|
|
1254
|
+
</DropdownMenuItem>
|
|
1255
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'collapse')}>
|
|
1256
|
+
{column.collapsed ? (
|
|
1257
|
+
<>
|
|
1258
|
+
<Eye className="mr-2 h-4 w-4" />
|
|
1259
|
+
Expand
|
|
1260
|
+
</>
|
|
1261
|
+
) : (
|
|
1262
|
+
<>
|
|
1263
|
+
<EyeOff className="mr-2 h-4 w-4" />
|
|
1264
|
+
Collapse
|
|
1265
|
+
</>
|
|
1266
|
+
)}
|
|
1267
|
+
</DropdownMenuItem>
|
|
1268
|
+
<DropdownMenuSeparator />
|
|
1269
|
+
<DropdownMenuSub>
|
|
1270
|
+
<DropdownMenuSubTrigger>
|
|
1271
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
1272
|
+
Settings
|
|
1273
|
+
</DropdownMenuSubTrigger>
|
|
1274
|
+
<DropdownMenuSubContent>
|
|
1275
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'setLimit')}>
|
|
1276
|
+
<Timer className="mr-2 h-4 w-4" />
|
|
1277
|
+
Set WIP limit
|
|
1278
|
+
</DropdownMenuItem>
|
|
1279
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'changeColor')}>
|
|
1280
|
+
<Palette className="mr-2 h-4 w-4" />
|
|
1281
|
+
Change color
|
|
1282
|
+
</DropdownMenuItem>
|
|
1283
|
+
<DropdownMenuSub>
|
|
1284
|
+
<DropdownMenuSubTrigger>
|
|
1285
|
+
<ArrowUpDown className="mr-2 h-4 w-4" />
|
|
1286
|
+
Sort cards
|
|
1287
|
+
</DropdownMenuSubTrigger>
|
|
1288
|
+
<DropdownMenuSubContent>
|
|
1289
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortByPriority')}>
|
|
1290
|
+
By Priority
|
|
1291
|
+
</DropdownMenuItem>
|
|
1292
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortByDueDate')}>
|
|
1293
|
+
By Due Date
|
|
1294
|
+
</DropdownMenuItem>
|
|
1295
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortAlphabetically')}>
|
|
1296
|
+
Alphabetically
|
|
1297
|
+
</DropdownMenuItem>
|
|
1298
|
+
</DropdownMenuSubContent>
|
|
1299
|
+
</DropdownMenuSub>
|
|
1300
|
+
</DropdownMenuSubContent>
|
|
1301
|
+
</DropdownMenuSub>
|
|
1302
|
+
<DropdownMenuSeparator />
|
|
1303
|
+
<DropdownMenuItem
|
|
1304
|
+
onClick={() => handleColumnAction(column, 'delete')}
|
|
1305
|
+
className="text-destructive"
|
|
1306
|
+
>
|
|
1307
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
1308
|
+
Delete column
|
|
1309
|
+
</DropdownMenuItem>
|
|
1310
|
+
</>
|
|
1311
|
+
)}
|
|
1312
|
+
</DropdownMenuContent>
|
|
1313
|
+
</DropdownMenu>
|
|
1314
|
+
</div>
|
|
1315
|
+
|
|
1316
|
+
{/* WIP limit warning */}
|
|
1317
|
+
{column.limit && (
|
|
1318
|
+
<CardDescription className={cn(
|
|
1319
|
+
"text-xs flex items-center gap-1 mt-1",
|
|
1320
|
+
isOverLimit && "text-destructive"
|
|
1321
|
+
)}>
|
|
1322
|
+
{isOverLimit ? (
|
|
967
1323
|
<>
|
|
968
|
-
<
|
|
969
|
-
|
|
1324
|
+
<AlertCircle className="h-3 w-3" />
|
|
1325
|
+
Over WIP limit ({column.cards.length}/{column.limit})
|
|
970
1326
|
</>
|
|
971
1327
|
) : (
|
|
972
|
-
|
|
973
|
-
<EyeOff className="mr-2 h-4 w-4" />
|
|
974
|
-
Collapse
|
|
975
|
-
</>
|
|
1328
|
+
`WIP limit: ${column.cards.length}/${column.limit}`
|
|
976
1329
|
)}
|
|
977
|
-
</
|
|
978
|
-
<DropdownMenuSeparator />
|
|
979
|
-
<DropdownMenuSub>
|
|
980
|
-
<DropdownMenuSubTrigger>
|
|
981
|
-
<Settings className="mr-2 h-4 w-4" />
|
|
982
|
-
Settings
|
|
983
|
-
</DropdownMenuSubTrigger>
|
|
984
|
-
<DropdownMenuSubContent>
|
|
985
|
-
<DropdownMenuItem onClick={() => handleColumnAction(column, 'setLimit')}>
|
|
986
|
-
<Timer className="mr-2 h-4 w-4" />
|
|
987
|
-
Set WIP limit
|
|
988
|
-
</DropdownMenuItem>
|
|
989
|
-
<DropdownMenuItem>
|
|
990
|
-
<Palette className="mr-2 h-4 w-4" />
|
|
991
|
-
Change color
|
|
992
|
-
</DropdownMenuItem>
|
|
993
|
-
<DropdownMenuItem>
|
|
994
|
-
<ArrowUpDown className="mr-2 h-4 w-4" />
|
|
995
|
-
Sort cards
|
|
996
|
-
</DropdownMenuItem>
|
|
997
|
-
</DropdownMenuSubContent>
|
|
998
|
-
</DropdownMenuSub>
|
|
999
|
-
<DropdownMenuSeparator />
|
|
1000
|
-
<DropdownMenuItem
|
|
1001
|
-
onClick={() => handleColumnAction(column, 'delete')}
|
|
1002
|
-
className="text-destructive"
|
|
1003
|
-
>
|
|
1004
|
-
<Trash2 className="mr-2 h-4 w-4" />
|
|
1005
|
-
Delete column
|
|
1006
|
-
</DropdownMenuItem>
|
|
1007
|
-
</DropdownMenuContent>
|
|
1008
|
-
</DropdownMenu>
|
|
1009
|
-
</div>
|
|
1010
|
-
|
|
1011
|
-
{/* WIP limit warning */}
|
|
1012
|
-
{column.limit && (
|
|
1013
|
-
<CardDescription className={cn(
|
|
1014
|
-
"text-xs flex items-center gap-1 mt-1",
|
|
1015
|
-
isOverLimit && "text-destructive"
|
|
1016
|
-
)}>
|
|
1017
|
-
{isOverLimit ? (
|
|
1018
|
-
<>
|
|
1019
|
-
<AlertCircle className="h-3 w-3" />
|
|
1020
|
-
Over WIP limit ({column.cards.length}/{column.limit})
|
|
1021
|
-
</>
|
|
1022
|
-
) : (
|
|
1023
|
-
`WIP limit: ${column.cards.length}/${column.limit}`
|
|
1330
|
+
</CardDescription>
|
|
1024
1331
|
)}
|
|
1025
|
-
|
|
1332
|
+
</>
|
|
1026
1333
|
)}
|
|
1027
1334
|
</CardHeader>
|
|
1028
1335
|
|
|
1029
1336
|
{!column.collapsed && (
|
|
1030
|
-
<CardContent className="space-y-3">
|
|
1337
|
+
<CardContent className="space-y-3" style={{ gap: `${cardGap}px` }}>
|
|
1031
1338
|
<ScrollArea className="h-[calc(100vh-300px)]">
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1339
|
+
{column.cards.length === 0 && renderEmptyColumn ? (
|
|
1340
|
+
renderEmptyColumn(column)
|
|
1341
|
+
) : (
|
|
1342
|
+
<AnimatePresence mode={enableAnimations ? "popLayout" : undefined}>
|
|
1343
|
+
{column.cards
|
|
1344
|
+
.sort((a, b) => a.position - b.position)
|
|
1345
|
+
.map((card, index) => (
|
|
1036
1346
|
<div
|
|
1037
1347
|
key={card.id}
|
|
1038
|
-
draggable={
|
|
1348
|
+
draggable={
|
|
1349
|
+
!disabled &&
|
|
1350
|
+
(typeof dragDisabled === 'function' ? !dragDisabled(card) : !dragDisabled)
|
|
1351
|
+
}
|
|
1039
1352
|
onDragStart={() => handleDragStart(card, column.id)}
|
|
1040
1353
|
onDragEnd={handleDragEnd}
|
|
1041
1354
|
onDrop={(e) => {
|
|
@@ -1047,6 +1360,7 @@ export function Kanban({
|
|
|
1047
1360
|
>
|
|
1048
1361
|
<KanbanCardComponent
|
|
1049
1362
|
card={card}
|
|
1363
|
+
column={column}
|
|
1050
1364
|
isDragging={draggedCard === card.id}
|
|
1051
1365
|
onEdit={(e) => {
|
|
1052
1366
|
e.stopPropagation()
|
|
@@ -1056,48 +1370,69 @@ export function Kanban({
|
|
|
1056
1370
|
e.stopPropagation()
|
|
1057
1371
|
onCardDelete?.(card)
|
|
1058
1372
|
}}
|
|
1059
|
-
onClick={() =>
|
|
1373
|
+
onClick={() => handleCardClick(card)}
|
|
1060
1374
|
showDetails={showCardDetails}
|
|
1061
1375
|
disabled={disabled}
|
|
1376
|
+
renderCard={renderCard}
|
|
1377
|
+
renderCardPreview={renderCardPreview}
|
|
1378
|
+
renderCardBadge={renderCardBadge}
|
|
1379
|
+
renderCardActions={renderCardActions}
|
|
1380
|
+
cardCompactMode={cardCompactMode}
|
|
1381
|
+
cardShowCoverImage={cardShowCoverImage}
|
|
1382
|
+
cardShowAssignees={cardShowAssignees}
|
|
1383
|
+
cardShowLabels={cardShowLabels}
|
|
1384
|
+
cardShowProgress={cardShowProgress}
|
|
1385
|
+
cardDateFormat={cardDateFormat}
|
|
1386
|
+
cardMaxAssigneesToShow={cardMaxAssigneesToShow}
|
|
1387
|
+
enableAnimations={enableAnimations}
|
|
1388
|
+
animationDuration={animationDuration}
|
|
1389
|
+
cardVariant={cardVariant}
|
|
1062
1390
|
/>
|
|
1063
1391
|
</div>
|
|
1064
1392
|
))}
|
|
1065
|
-
|
|
1393
|
+
</AnimatePresence>
|
|
1394
|
+
)}
|
|
1066
1395
|
</ScrollArea>
|
|
1067
1396
|
|
|
1068
1397
|
{/* Add card button */}
|
|
1069
1398
|
{onAddCard && !column.locked && !isOverLimit && (
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
</Button>
|
|
1081
|
-
</DropdownMenuTrigger>
|
|
1082
|
-
<DropdownMenuContent align="start" className="w-48">
|
|
1083
|
-
<DropdownMenuLabel>Card Templates</DropdownMenuLabel>
|
|
1084
|
-
<DropdownMenuSeparator />
|
|
1085
|
-
<DropdownMenuItem onClick={() => onAddCard(column.id)}>
|
|
1086
|
-
<FileText className="mr-2 h-4 w-4" />
|
|
1087
|
-
Blank card
|
|
1088
|
-
</DropdownMenuItem>
|
|
1089
|
-
{cardTemplates.map((template, index) => (
|
|
1090
|
-
<DropdownMenuItem
|
|
1091
|
-
key={index}
|
|
1092
|
-
onClick={() => onAddCard(column.id, template)}
|
|
1399
|
+
renderAddCardButton ? (
|
|
1400
|
+
renderAddCardButton(column.id)
|
|
1401
|
+
) : (
|
|
1402
|
+
<DropdownMenu>
|
|
1403
|
+
<DropdownMenuTrigger asChild>
|
|
1404
|
+
<Button
|
|
1405
|
+
variant="ghost"
|
|
1406
|
+
size="sm"
|
|
1407
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
1408
|
+
disabled={disabled}
|
|
1093
1409
|
>
|
|
1094
|
-
<
|
|
1095
|
-
{
|
|
1410
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
1411
|
+
{typeof addCardButtonText === 'function' ? addCardButtonText(column.id) : (addCardButtonText || 'Add card')}
|
|
1412
|
+
</Button>
|
|
1413
|
+
</DropdownMenuTrigger>
|
|
1414
|
+
<DropdownMenuContent align="start" className="w-48">
|
|
1415
|
+
<DropdownMenuLabel>Card Templates</DropdownMenuLabel>
|
|
1416
|
+
<DropdownMenuSeparator />
|
|
1417
|
+
<DropdownMenuItem onClick={() => handleAddCard(column.id)}>
|
|
1418
|
+
<FileText className="mr-2 h-4 w-4" />
|
|
1419
|
+
Blank card
|
|
1096
1420
|
</DropdownMenuItem>
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1421
|
+
{cardTemplates.map((template, index) => (
|
|
1422
|
+
<DropdownMenuItem
|
|
1423
|
+
key={index}
|
|
1424
|
+
onClick={() => handleAddCard(column.id, template)}
|
|
1425
|
+
>
|
|
1426
|
+
<Star className="mr-2 h-4 w-4" />
|
|
1427
|
+
{template.title || `Template ${index + 1}`}
|
|
1428
|
+
</DropdownMenuItem>
|
|
1429
|
+
))}
|
|
1430
|
+
</DropdownMenuContent>
|
|
1431
|
+
</DropdownMenu>
|
|
1432
|
+
)
|
|
1100
1433
|
)}
|
|
1434
|
+
{/* Column footer */}
|
|
1435
|
+
{renderColumnFooter && renderColumnFooter(column)}
|
|
1101
1436
|
</CardContent>
|
|
1102
1437
|
)}
|
|
1103
1438
|
</Card>
|
|
@@ -1207,8 +1542,115 @@ export function Kanban({
|
|
|
1207
1542
|
</motion.div>
|
|
1208
1543
|
)}
|
|
1209
1544
|
</div>
|
|
1545
|
+
|
|
1546
|
+
{/* Modals */}
|
|
1547
|
+
{/* Card Detail Modal */}
|
|
1548
|
+
{selectedCard && (
|
|
1549
|
+
<CardDetailModal
|
|
1550
|
+
card={selectedCard}
|
|
1551
|
+
isOpen={!!selectedCard}
|
|
1552
|
+
onClose={() => setSelectedCard(null)}
|
|
1553
|
+
onUpdate={handleCardUpdate}
|
|
1554
|
+
onDelete={(card) => {
|
|
1555
|
+
onCardDelete?.(card)
|
|
1556
|
+
toast({
|
|
1557
|
+
title: "Card deleted",
|
|
1558
|
+
description: `"${card.title}" has been deleted`
|
|
1559
|
+
})
|
|
1560
|
+
}}
|
|
1561
|
+
availableAssignees={users}
|
|
1562
|
+
availableLabels={labels}
|
|
1563
|
+
currentColumn={columns.find(col => col.cards.some(c => c.id === selectedCard.id))?.title}
|
|
1564
|
+
availableColumns={columns.map(col => ({ id: col.id, title: col.title }))}
|
|
1565
|
+
/>
|
|
1566
|
+
)}
|
|
1567
|
+
|
|
1568
|
+
{/* Add Card Modal */}
|
|
1569
|
+
{addCardColumnId && (
|
|
1570
|
+
<AddCardModal
|
|
1571
|
+
isOpen={!!addCardColumnId}
|
|
1572
|
+
onClose={() => setAddCardColumnId(null)}
|
|
1573
|
+
onAdd={handleAddNewCard}
|
|
1574
|
+
columnId={addCardColumnId}
|
|
1575
|
+
columnTitle={columns.find(col => col.id === addCardColumnId)?.title || ''}
|
|
1576
|
+
availableAssignees={users}
|
|
1577
|
+
availableLabels={labels}
|
|
1578
|
+
templates={cardTemplates}
|
|
1579
|
+
/>
|
|
1580
|
+
)}
|
|
1581
|
+
|
|
1582
|
+
{/* WIP Limit Modal */}
|
|
1583
|
+
<Dialog open={wipLimitModalOpen} onOpenChange={setWipLimitModalOpen}>
|
|
1584
|
+
<DialogContent className="sm:max-w-md">
|
|
1585
|
+
<DialogHeader>
|
|
1586
|
+
<DialogTitle>Set WIP Limit</DialogTitle>
|
|
1587
|
+
</DialogHeader>
|
|
1588
|
+
<div className="space-y-4 py-4">
|
|
1589
|
+
<div className="space-y-2">
|
|
1590
|
+
<Label htmlFor="wip-limit">Work In Progress Limit</Label>
|
|
1591
|
+
<Input
|
|
1592
|
+
id="wip-limit"
|
|
1593
|
+
type="number"
|
|
1594
|
+
min="0"
|
|
1595
|
+
value={wipLimit || ''}
|
|
1596
|
+
onChange={(e) => setWipLimit(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
1597
|
+
placeholder="Enter a number (leave empty to remove limit)"
|
|
1598
|
+
/>
|
|
1599
|
+
<p className="text-sm text-muted-foreground">
|
|
1600
|
+
Set a maximum number of cards allowed in this column. Leave empty to remove the limit.
|
|
1601
|
+
</p>
|
|
1602
|
+
</div>
|
|
1603
|
+
</div>
|
|
1604
|
+
<DialogFooter>
|
|
1605
|
+
<Button variant="outline" onClick={() => setWipLimitModalOpen(false)}>
|
|
1606
|
+
Cancel
|
|
1607
|
+
</Button>
|
|
1608
|
+
<Button onClick={handleWipLimitUpdate}>
|
|
1609
|
+
Save
|
|
1610
|
+
</Button>
|
|
1611
|
+
</DialogFooter>
|
|
1612
|
+
</DialogContent>
|
|
1613
|
+
</Dialog>
|
|
1614
|
+
|
|
1615
|
+
{/* Color Picker Modal */}
|
|
1616
|
+
<Dialog open={colorPickerOpen} onOpenChange={setColorPickerOpen}>
|
|
1617
|
+
<DialogContent className="sm:max-w-md">
|
|
1618
|
+
<DialogHeader>
|
|
1619
|
+
<DialogTitle>Change Column Color</DialogTitle>
|
|
1620
|
+
</DialogHeader>
|
|
1621
|
+
<div className="space-y-4 py-4">
|
|
1622
|
+
<div className="space-y-2">
|
|
1623
|
+
<Label>Select a color</Label>
|
|
1624
|
+
<div className="grid grid-cols-8 gap-2">
|
|
1625
|
+
{[
|
|
1626
|
+
'#6B7280', '#EF4444', '#F59E0B', '#10B981',
|
|
1627
|
+
'#3B82F6', '#8B5CF6', '#EC4899', '#06B6D4',
|
|
1628
|
+
'#F43F5E', '#84CC16', '#14B8A6', '#6366F1',
|
|
1629
|
+
'#A855F7', '#F472B6', '#0EA5E9', '#22D3EE'
|
|
1630
|
+
].map(color => (
|
|
1631
|
+
<button
|
|
1632
|
+
key={color}
|
|
1633
|
+
className={cn(
|
|
1634
|
+
"w-10 h-10 rounded-md border-2 transition-all",
|
|
1635
|
+
selectedColor === color ? "border-primary scale-110" : "border-transparent"
|
|
1636
|
+
)}
|
|
1637
|
+
style={{ backgroundColor: color }}
|
|
1638
|
+
onClick={() => setSelectedColor(color)}
|
|
1639
|
+
/>
|
|
1640
|
+
))}
|
|
1641
|
+
</div>
|
|
1642
|
+
</div>
|
|
1643
|
+
</div>
|
|
1644
|
+
<DialogFooter>
|
|
1645
|
+
<Button variant="outline" onClick={() => setColorPickerOpen(false)}>
|
|
1646
|
+
Cancel
|
|
1647
|
+
</Button>
|
|
1648
|
+
<Button onClick={handleColorUpdate}>
|
|
1649
|
+
Save
|
|
1650
|
+
</Button>
|
|
1651
|
+
</DialogFooter>
|
|
1652
|
+
</DialogContent>
|
|
1653
|
+
</Dialog>
|
|
1210
1654
|
</div>
|
|
1211
1655
|
)
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
export default Kanban
|
|
1656
|
+
}
|