@moontra/moonui-pro 2.11.3 → 2.12.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 +78 -10
- package/dist/index.mjs +980 -360
- package/package.json +1 -1
- package/src/components/file-upload/index.tsx +95 -39
- package/src/components/kanban/index.tsx +1068 -288
- package/src/components/ui/avatar.tsx +34 -19
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React from 'react'
|
|
3
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, Reorder, useDragControls } from 'framer-motion'
|
|
4
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
|
5
6
|
import { Button } from '../ui/button'
|
|
6
7
|
import { MoonUIBadgePro as Badge } from '../ui/badge'
|
|
7
|
-
import { MoonUIAvatarPro, MoonUIAvatarFallbackPro, MoonUIAvatarImagePro } from '../ui/avatar'
|
|
8
|
+
import { MoonUIAvatarPro, MoonUIAvatarFallbackPro, MoonUIAvatarImagePro, AvatarGroup as MoonUIAvatarGroupPro } from '../ui/avatar'
|
|
9
|
+
import { Input } from '../ui/input'
|
|
10
|
+
import { Textarea } from '../ui/textarea'
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
|
12
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../ui/dropdown-menu'
|
|
13
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../ui/command'
|
|
14
|
+
import { ScrollArea } from '../ui/scroll-area'
|
|
15
|
+
import { Skeleton } from '../ui/skeleton'
|
|
16
|
+
import { Switch } from '../ui/switch'
|
|
17
|
+
import { Label } from '../ui/label'
|
|
18
|
+
import { Progress } from '../ui/progress'
|
|
19
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
|
8
20
|
import {
|
|
9
21
|
Plus,
|
|
10
22
|
MoreHorizontal,
|
|
@@ -16,27 +28,101 @@ import {
|
|
|
16
28
|
Trash2,
|
|
17
29
|
GripVertical,
|
|
18
30
|
Lock,
|
|
19
|
-
Sparkles
|
|
31
|
+
Sparkles,
|
|
32
|
+
Search,
|
|
33
|
+
Filter,
|
|
34
|
+
Download,
|
|
35
|
+
Upload,
|
|
36
|
+
Copy,
|
|
37
|
+
Move,
|
|
38
|
+
Archive,
|
|
39
|
+
Eye,
|
|
40
|
+
EyeOff,
|
|
41
|
+
ChevronDown,
|
|
42
|
+
ChevronRight,
|
|
43
|
+
X,
|
|
44
|
+
Check,
|
|
45
|
+
Clock,
|
|
46
|
+
AlertCircle,
|
|
47
|
+
Tag,
|
|
48
|
+
Users,
|
|
49
|
+
FileText,
|
|
50
|
+
Image,
|
|
51
|
+
Link2,
|
|
52
|
+
Activity,
|
|
53
|
+
Settings,
|
|
54
|
+
Palette,
|
|
55
|
+
Star,
|
|
56
|
+
Flag,
|
|
57
|
+
CheckSquare,
|
|
58
|
+
Square,
|
|
59
|
+
MoreVertical,
|
|
60
|
+
ArrowUpDown,
|
|
61
|
+
ArrowUp,
|
|
62
|
+
ArrowDown,
|
|
63
|
+
Zap,
|
|
64
|
+
Timer
|
|
20
65
|
} from 'lucide-react'
|
|
21
66
|
import { cn } from '../../lib/utils'
|
|
22
|
-
// Note: DocsProAccess should be handled by consuming application
|
|
23
67
|
import { useSubscription } from '../../hooks/use-subscription'
|
|
24
68
|
|
|
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
|
+
|
|
25
101
|
interface KanbanCard {
|
|
26
102
|
id: string
|
|
27
103
|
title: string
|
|
28
104
|
description?: string
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
avatar?: string
|
|
32
|
-
email?: string
|
|
33
|
-
}
|
|
105
|
+
coverImage?: string
|
|
106
|
+
assignees?: KanbanAssignee[]
|
|
34
107
|
dueDate?: Date
|
|
108
|
+
startDate?: Date
|
|
35
109
|
priority?: 'low' | 'medium' | 'high' | 'urgent'
|
|
36
110
|
tags?: string[]
|
|
37
|
-
|
|
111
|
+
labels?: KanbanLabel[]
|
|
112
|
+
attachments?: {
|
|
113
|
+
id: string
|
|
114
|
+
name: string
|
|
115
|
+
type: string
|
|
116
|
+
url: string
|
|
117
|
+
size: number
|
|
118
|
+
}[]
|
|
38
119
|
comments?: number
|
|
39
120
|
completed?: boolean
|
|
121
|
+
progress?: number
|
|
122
|
+
checklist?: KanbanChecklist
|
|
123
|
+
activities?: KanbanActivity[]
|
|
124
|
+
customFields?: Record<string, any>
|
|
125
|
+
position: number
|
|
40
126
|
}
|
|
41
127
|
|
|
42
128
|
interface KanbanColumn {
|
|
@@ -45,55 +131,422 @@ interface KanbanColumn {
|
|
|
45
131
|
color?: string
|
|
46
132
|
cards: KanbanCard[]
|
|
47
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
|
+
}
|
|
48
151
|
}
|
|
49
152
|
|
|
50
153
|
interface KanbanProps {
|
|
51
154
|
columns: KanbanColumn[]
|
|
52
|
-
onCardMove?: (cardId: string, fromColumn: string, toColumn: string,
|
|
155
|
+
onCardMove?: (cardId: string, fromColumn: string, toColumn: string, newPosition: number) => void
|
|
53
156
|
onCardClick?: (card: KanbanCard) => void
|
|
54
157
|
onCardEdit?: (card: KanbanCard) => void
|
|
55
158
|
onCardDelete?: (card: KanbanCard) => void
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
166
|
className?: string
|
|
59
167
|
showAddColumn?: boolean
|
|
60
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
|
|
61
177
|
disabled?: boolean
|
|
178
|
+
labels?: KanbanLabel[]
|
|
179
|
+
users?: KanbanAssignee[]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Constants
|
|
183
|
+
const PRIORITY_CONFIG = {
|
|
184
|
+
low: {
|
|
185
|
+
color: 'bg-green-100 text-green-800 border-green-200',
|
|
186
|
+
dot: 'bg-green-500',
|
|
187
|
+
icon: ArrowDown
|
|
188
|
+
},
|
|
189
|
+
medium: {
|
|
190
|
+
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
191
|
+
dot: 'bg-yellow-500',
|
|
192
|
+
icon: ArrowUp
|
|
193
|
+
},
|
|
194
|
+
high: {
|
|
195
|
+
color: 'bg-orange-100 text-orange-800 border-orange-200',
|
|
196
|
+
dot: 'bg-orange-500',
|
|
197
|
+
icon: Zap
|
|
198
|
+
},
|
|
199
|
+
urgent: {
|
|
200
|
+
color: 'bg-red-100 text-red-800 border-red-200',
|
|
201
|
+
dot: 'bg-red-500',
|
|
202
|
+
icon: AlertCircle
|
|
203
|
+
}
|
|
62
204
|
}
|
|
63
205
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
urgent: 'bg-red-100 text-red-800 border-red-200'
|
|
206
|
+
const COLUMN_TEMPLATES = {
|
|
207
|
+
todo: { title: 'To Do', color: '#6B7280' },
|
|
208
|
+
inProgress: { title: 'In Progress', color: '#3B82F6' },
|
|
209
|
+
done: { title: 'Done', color: '#10B981' }
|
|
69
210
|
}
|
|
70
211
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
212
|
+
// Helper functions
|
|
213
|
+
const formatDate = (date: Date) => {
|
|
214
|
+
return date.toLocaleDateString('en-US', {
|
|
215
|
+
month: 'short',
|
|
216
|
+
day: 'numeric',
|
|
217
|
+
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isOverdue = (dueDate: Date) => {
|
|
222
|
+
return dueDate < new Date()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const getInitials = (name: string) => {
|
|
226
|
+
return name.split(' ').map(n => n[0]).join('').toUpperCase()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const formatFileSize = (bytes: number) => {
|
|
230
|
+
if (bytes === 0) return '0 Bytes'
|
|
231
|
+
const k = 1024
|
|
232
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
233
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
234
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Custom hook for auto-scroll while dragging
|
|
238
|
+
const useAutoScroll = () => {
|
|
239
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
240
|
+
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
241
|
+
|
|
242
|
+
const startAutoScroll = useCallback((direction: 'left' | 'right') => {
|
|
243
|
+
if (scrollIntervalRef.current) return
|
|
244
|
+
|
|
245
|
+
scrollIntervalRef.current = setInterval(() => {
|
|
246
|
+
if (scrollRef.current) {
|
|
247
|
+
const scrollAmount = direction === 'left' ? -10 : 10
|
|
248
|
+
scrollRef.current.scrollLeft += scrollAmount
|
|
249
|
+
}
|
|
250
|
+
}, 20)
|
|
251
|
+
}, [])
|
|
252
|
+
|
|
253
|
+
const stopAutoScroll = useCallback(() => {
|
|
254
|
+
if (scrollIntervalRef.current) {
|
|
255
|
+
clearInterval(scrollIntervalRef.current)
|
|
256
|
+
scrollIntervalRef.current = null
|
|
257
|
+
}
|
|
258
|
+
}, [])
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
return () => stopAutoScroll()
|
|
262
|
+
}, [stopAutoScroll])
|
|
263
|
+
|
|
264
|
+
return { scrollRef, startAutoScroll, stopAutoScroll }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Card component
|
|
268
|
+
const KanbanCardComponent = ({
|
|
269
|
+
card,
|
|
270
|
+
isDragging,
|
|
271
|
+
onEdit,
|
|
272
|
+
onDelete,
|
|
273
|
+
onClick,
|
|
274
|
+
showDetails,
|
|
275
|
+
disabled
|
|
276
|
+
}: {
|
|
277
|
+
card: KanbanCard
|
|
278
|
+
isDragging: boolean
|
|
279
|
+
onEdit?: (e: React.MouseEvent) => void
|
|
280
|
+
onDelete?: (e: React.MouseEvent) => void
|
|
281
|
+
onClick?: () => void
|
|
282
|
+
showDetails: boolean
|
|
283
|
+
disabled: boolean
|
|
284
|
+
}) => {
|
|
285
|
+
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
|
286
|
+
const [title, setTitle] = useState(card.title)
|
|
287
|
+
const dragControls = useDragControls()
|
|
288
|
+
|
|
289
|
+
const completedChecklistItems = card.checklist?.items.filter(item => item.completed).length || 0
|
|
290
|
+
const totalChecklistItems = card.checklist?.items.length || 0
|
|
291
|
+
const checklistProgress = totalChecklistItems > 0 ? (completedChecklistItems / totalChecklistItems) * 100 : 0
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<motion.div
|
|
295
|
+
layout
|
|
296
|
+
initial={{ opacity: 0, y: 20 }}
|
|
297
|
+
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.2 }}
|
|
302
|
+
className={cn(
|
|
303
|
+
"relative group cursor-pointer select-none",
|
|
304
|
+
isDragging && "z-50"
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
<Card
|
|
308
|
+
className={cn(
|
|
309
|
+
"border hover:shadow-md transition-all duration-200",
|
|
310
|
+
isDragging && "shadow-2xl ring-2 ring-primary ring-offset-2",
|
|
311
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
312
|
+
)}
|
|
313
|
+
onClick={onClick}
|
|
314
|
+
>
|
|
315
|
+
{/* Drag handle */}
|
|
316
|
+
<div
|
|
317
|
+
className="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 to-primary/10 opacity-0 group-hover:opacity-100 transition-opacity cursor-move"
|
|
318
|
+
onPointerDown={(e) => dragControls.start(e)}
|
|
319
|
+
/>
|
|
320
|
+
|
|
321
|
+
{/* Cover image */}
|
|
322
|
+
{card.coverImage && (
|
|
323
|
+
<div className="relative h-32 -mx-px -mt-px rounded-t-lg overflow-hidden">
|
|
324
|
+
<img
|
|
325
|
+
src={card.coverImage}
|
|
326
|
+
alt=""
|
|
327
|
+
className="w-full h-full object-cover"
|
|
328
|
+
/>
|
|
329
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
|
|
333
|
+
<CardContent className="p-3">
|
|
334
|
+
{/* Labels */}
|
|
335
|
+
{card.labels && card.labels.length > 0 && (
|
|
336
|
+
<div className="flex flex-wrap gap-1 mb-2">
|
|
337
|
+
{card.labels.map((label) => (
|
|
338
|
+
<div
|
|
339
|
+
key={label.id}
|
|
340
|
+
className="h-2 w-12 rounded-full"
|
|
341
|
+
style={{ backgroundColor: label.color }}
|
|
342
|
+
title={label.name}
|
|
343
|
+
/>
|
|
344
|
+
))}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{/* Title and actions */}
|
|
349
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
350
|
+
<div className="flex-1">
|
|
351
|
+
{isEditingTitle ? (
|
|
352
|
+
<Input
|
|
353
|
+
value={title}
|
|
354
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
355
|
+
onBlur={() => setIsEditingTitle(false)}
|
|
356
|
+
onKeyDown={(e) => {
|
|
357
|
+
if (e.key === 'Enter') {
|
|
358
|
+
setIsEditingTitle(false)
|
|
359
|
+
// Call update handler
|
|
360
|
+
}
|
|
361
|
+
if (e.key === 'Escape') {
|
|
362
|
+
setTitle(card.title)
|
|
363
|
+
setIsEditingTitle(false)
|
|
364
|
+
}
|
|
365
|
+
}}
|
|
366
|
+
className="h-6 px-1 py-0 text-sm font-medium"
|
|
367
|
+
autoFocus
|
|
368
|
+
onClick={(e) => e.stopPropagation()}
|
|
369
|
+
/>
|
|
370
|
+
) : (
|
|
371
|
+
<h4 className="font-medium text-sm line-clamp-2">{card.title}</h4>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
{/* Quick actions */}
|
|
376
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
377
|
+
<DropdownMenu>
|
|
378
|
+
<DropdownMenuTrigger asChild>
|
|
379
|
+
<Button
|
|
380
|
+
variant="ghost"
|
|
381
|
+
size="sm"
|
|
382
|
+
className="h-6 w-6 p-0"
|
|
383
|
+
onClick={(e) => e.stopPropagation()}
|
|
384
|
+
>
|
|
385
|
+
<MoreVertical className="h-3 w-3" />
|
|
386
|
+
</Button>
|
|
387
|
+
</DropdownMenuTrigger>
|
|
388
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
389
|
+
<DropdownMenuItem onClick={onEdit}>
|
|
390
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
391
|
+
Edit
|
|
392
|
+
</DropdownMenuItem>
|
|
393
|
+
<DropdownMenuItem>
|
|
394
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
395
|
+
Duplicate
|
|
396
|
+
</DropdownMenuItem>
|
|
397
|
+
<DropdownMenuItem>
|
|
398
|
+
<Move className="mr-2 h-4 w-4" />
|
|
399
|
+
Move
|
|
400
|
+
</DropdownMenuItem>
|
|
401
|
+
<DropdownMenuItem>
|
|
402
|
+
<Archive className="mr-2 h-4 w-4" />
|
|
403
|
+
Archive
|
|
404
|
+
</DropdownMenuItem>
|
|
405
|
+
<DropdownMenuSeparator />
|
|
406
|
+
<DropdownMenuItem
|
|
407
|
+
onClick={onDelete}
|
|
408
|
+
className="text-destructive"
|
|
409
|
+
>
|
|
410
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
411
|
+
Delete
|
|
412
|
+
</DropdownMenuItem>
|
|
413
|
+
</DropdownMenuContent>
|
|
414
|
+
</DropdownMenu>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
{/* Description */}
|
|
419
|
+
{card.description && (
|
|
420
|
+
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">
|
|
421
|
+
{card.description}
|
|
422
|
+
</p>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{/* Progress bar */}
|
|
426
|
+
{(card.progress !== undefined || card.checklist) && (
|
|
427
|
+
<div className="mb-3">
|
|
428
|
+
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
|
429
|
+
<span>Progress</span>
|
|
430
|
+
<span>{Math.round(card.progress || checklistProgress)}%</span>
|
|
431
|
+
</div>
|
|
432
|
+
<Progress value={card.progress || checklistProgress} className="h-1" />
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{/* Tags */}
|
|
437
|
+
{card.tags && card.tags.length > 0 && (
|
|
438
|
+
<div className="flex flex-wrap gap-1 mb-3">
|
|
439
|
+
{card.tags.map((tag, index) => (
|
|
440
|
+
<Badge key={index} variant="secondary" className="text-xs px-1.5 py-0">
|
|
441
|
+
{tag}
|
|
442
|
+
</Badge>
|
|
443
|
+
))}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{/* Card details */}
|
|
448
|
+
{showDetails && (
|
|
449
|
+
<div className="flex items-center justify-between text-xs">
|
|
450
|
+
<div className="flex items-center gap-2">
|
|
451
|
+
{/* Priority */}
|
|
452
|
+
{card.priority && (
|
|
453
|
+
<div className="flex items-center gap-1">
|
|
454
|
+
<div className={cn("w-2 h-2 rounded-full", PRIORITY_CONFIG[card.priority].dot)} />
|
|
455
|
+
<span className="capitalize">{card.priority}</span>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{/* Due date */}
|
|
460
|
+
{card.dueDate && (
|
|
461
|
+
<div className={cn(
|
|
462
|
+
"flex items-center gap-1",
|
|
463
|
+
isOverdue(card.dueDate) && "text-destructive"
|
|
464
|
+
)}>
|
|
465
|
+
<Calendar className="h-3 w-3" />
|
|
466
|
+
<span>{formatDate(card.dueDate)}</span>
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
|
|
470
|
+
{/* Checklist */}
|
|
471
|
+
{card.checklist && (
|
|
472
|
+
<div className="flex items-center gap-1">
|
|
473
|
+
<CheckSquare className="h-3 w-3" />
|
|
474
|
+
<span>{completedChecklistItems}/{totalChecklistItems}</span>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div className="flex items-center gap-2">
|
|
480
|
+
{/* Comments */}
|
|
481
|
+
{card.comments && card.comments > 0 && (
|
|
482
|
+
<div className="flex items-center gap-1">
|
|
483
|
+
<MessageCircle className="h-3 w-3" />
|
|
484
|
+
<span>{card.comments}</span>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
{/* Attachments */}
|
|
489
|
+
{card.attachments && card.attachments.length > 0 && (
|
|
490
|
+
<div className="flex items-center gap-1">
|
|
491
|
+
<Paperclip className="h-3 w-3" />
|
|
492
|
+
<span>{card.attachments.length}</span>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{/* Assignees */}
|
|
497
|
+
{card.assignees && card.assignees.length > 0 && (
|
|
498
|
+
<MoonUIAvatarGroupPro max={3} size="xs">
|
|
499
|
+
{card.assignees.map((assignee) => (
|
|
500
|
+
<MoonUIAvatarPro key={assignee.id} className="h-5 w-5">
|
|
501
|
+
<MoonUIAvatarImagePro src={assignee.avatar} />
|
|
502
|
+
<MoonUIAvatarFallbackPro className="text-xs">
|
|
503
|
+
{getInitials(assignee.name)}
|
|
504
|
+
</MoonUIAvatarFallbackPro>
|
|
505
|
+
</MoonUIAvatarPro>
|
|
506
|
+
))}
|
|
507
|
+
</MoonUIAvatarGroupPro>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
)}
|
|
512
|
+
</CardContent>
|
|
513
|
+
</Card>
|
|
514
|
+
</motion.div>
|
|
515
|
+
)
|
|
76
516
|
}
|
|
77
517
|
|
|
518
|
+
// Main Kanban component
|
|
78
519
|
export function Kanban({
|
|
79
|
-
columns,
|
|
520
|
+
columns: initialColumns,
|
|
80
521
|
onCardMove,
|
|
81
522
|
onCardClick,
|
|
82
523
|
onCardEdit,
|
|
83
524
|
onCardDelete,
|
|
525
|
+
onCardUpdate,
|
|
84
526
|
onAddCard,
|
|
85
527
|
onAddColumn,
|
|
528
|
+
onColumnUpdate,
|
|
529
|
+
onColumnDelete,
|
|
530
|
+
onBulkAction,
|
|
531
|
+
onExport,
|
|
86
532
|
className,
|
|
87
533
|
showAddColumn = true,
|
|
88
534
|
showCardDetails = true,
|
|
89
|
-
|
|
535
|
+
showFilters = true,
|
|
536
|
+
showSearch = true,
|
|
537
|
+
enableKeyboardShortcuts = true,
|
|
538
|
+
cardTemplates = [],
|
|
539
|
+
columnTemplates = [],
|
|
540
|
+
filters = [],
|
|
541
|
+
defaultFilter,
|
|
542
|
+
loading = false,
|
|
543
|
+
disabled = false,
|
|
544
|
+
labels = [],
|
|
545
|
+
users = []
|
|
90
546
|
}: KanbanProps) {
|
|
91
|
-
// Check
|
|
547
|
+
// Check pro access
|
|
92
548
|
const { hasProAccess, isLoading } = useSubscription()
|
|
93
549
|
|
|
94
|
-
// In docs mode, always show the component
|
|
95
|
-
|
|
96
|
-
// If not in docs mode and no pro access, show upgrade prompt
|
|
97
550
|
if (!isLoading && !hasProAccess) {
|
|
98
551
|
return (
|
|
99
552
|
<Card className={cn("w-full", className)}>
|
|
@@ -121,310 +574,637 @@ export function Kanban({
|
|
|
121
574
|
</Card>
|
|
122
575
|
)
|
|
123
576
|
}
|
|
124
|
-
|
|
125
|
-
const [draggedCard, setDraggedCard] = React.useState<string | null>(null)
|
|
126
|
-
const [draggedOverColumn, setDraggedOverColumn] = React.useState<string | null>(null)
|
|
127
|
-
const [draggedFromColumn, setDraggedFromColumn] = React.useState<string | null>(null)
|
|
128
577
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
e.dataTransfer.setData('text/plain', cardId)
|
|
141
|
-
|
|
142
|
-
// Add visual feedback
|
|
143
|
-
e.currentTarget.classList.add('opacity-50')
|
|
144
|
-
}
|
|
578
|
+
// State
|
|
579
|
+
const [columns, setColumns] = useState(initialColumns)
|
|
580
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
581
|
+
const [activeFilter, setActiveFilter] = useState(defaultFilter)
|
|
582
|
+
const [selectedCards, setSelectedCards] = useState<string[]>([])
|
|
583
|
+
const [draggedCard, setDraggedCard] = useState<string | null>(null)
|
|
584
|
+
const [draggedOverColumn, setDraggedOverColumn] = useState<string | null>(null)
|
|
585
|
+
const [isCreatingColumn, setIsCreatingColumn] = useState(false)
|
|
586
|
+
const [newColumnTitle, setNewColumnTitle] = useState('')
|
|
587
|
+
|
|
588
|
+
const { scrollRef, startAutoScroll, stopAutoScroll } = useAutoScroll()
|
|
145
589
|
|
|
146
|
-
|
|
590
|
+
// Filter cards based on search and filters
|
|
591
|
+
const filteredColumns = useMemo(() => {
|
|
592
|
+
if (!searchQuery && !activeFilter) return columns
|
|
593
|
+
|
|
594
|
+
return columns.map(column => ({
|
|
595
|
+
...column,
|
|
596
|
+
cards: column.cards.filter(card => {
|
|
597
|
+
// Search filter
|
|
598
|
+
if (searchQuery) {
|
|
599
|
+
const query = searchQuery.toLowerCase()
|
|
600
|
+
const matchesSearch =
|
|
601
|
+
card.title.toLowerCase().includes(query) ||
|
|
602
|
+
card.description?.toLowerCase().includes(query) ||
|
|
603
|
+
card.tags?.some(tag => tag.toLowerCase().includes(query)) ||
|
|
604
|
+
card.assignees?.some(a => a.name.toLowerCase().includes(query))
|
|
605
|
+
|
|
606
|
+
if (!matchesSearch) return false
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Active filter
|
|
610
|
+
if (activeFilter) {
|
|
611
|
+
const filter = filters.find(f => f.id === activeFilter)
|
|
612
|
+
if (filter) {
|
|
613
|
+
// Apply filter logic here
|
|
614
|
+
// This is a simplified example
|
|
615
|
+
if (filter.assignees?.length && !card.assignees?.some(a => filter.assignees!.includes(a.id))) {
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
if (filter.priority?.length && !filter.priority.includes(card.priority || '')) {
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
if (filter.labels?.length && !card.labels?.some(l => filter.labels!.includes(l.id))) {
|
|
622
|
+
return false
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return true
|
|
628
|
+
})
|
|
629
|
+
}))
|
|
630
|
+
}, [columns, searchQuery, activeFilter, filters])
|
|
631
|
+
|
|
632
|
+
// Keyboard shortcuts
|
|
633
|
+
useEffect(() => {
|
|
634
|
+
if (!enableKeyboardShortcuts) return
|
|
635
|
+
|
|
636
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
637
|
+
// Cmd/Ctrl + F: Focus search
|
|
638
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
|
639
|
+
e.preventDefault()
|
|
640
|
+
document.getElementById('kanban-search')?.focus()
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Cmd/Ctrl + N: Add new card
|
|
644
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
|
|
645
|
+
e.preventDefault()
|
|
646
|
+
// Add to first column by default
|
|
647
|
+
const firstColumn = columns[0]
|
|
648
|
+
if (firstColumn && onAddCard) {
|
|
649
|
+
onAddCard(firstColumn.id)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Escape: Clear selection
|
|
654
|
+
if (e.key === 'Escape') {
|
|
655
|
+
setSelectedCards([])
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
660
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
661
|
+
}, [enableKeyboardShortcuts, columns, onAddCard])
|
|
662
|
+
|
|
663
|
+
// Drag handlers
|
|
664
|
+
const handleDragStart = (card: KanbanCard, columnId: string) => {
|
|
147
665
|
if (disabled) return
|
|
148
|
-
|
|
149
|
-
// Reset all states
|
|
150
|
-
setDraggedCard(null)
|
|
151
|
-
setDraggedOverColumn(null)
|
|
152
|
-
setDraggedFromColumn(null)
|
|
153
|
-
|
|
154
|
-
// Remove visual feedback
|
|
155
|
-
e.currentTarget.classList.remove('opacity-50')
|
|
666
|
+
setDraggedCard(card.id)
|
|
156
667
|
}
|
|
157
668
|
|
|
158
669
|
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
|
159
670
|
if (disabled) return
|
|
160
671
|
e.preventDefault()
|
|
161
|
-
e.dataTransfer.dropEffect = 'move'
|
|
162
672
|
setDraggedOverColumn(columnId)
|
|
163
|
-
}
|
|
164
673
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
setDraggedOverColumn(columnId)
|
|
169
|
-
}
|
|
674
|
+
// Auto-scroll logic
|
|
675
|
+
const container = scrollRef.current
|
|
676
|
+
if (!container) return
|
|
170
677
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
e.clientY < rect.top ||
|
|
181
|
-
e.clientY > rect.bottom
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
if (isLeavingColumn) {
|
|
185
|
-
setDraggedOverColumn(null)
|
|
678
|
+
const rect = container.getBoundingClientRect()
|
|
679
|
+
const x = e.clientX
|
|
680
|
+
|
|
681
|
+
if (x < rect.left + 100) {
|
|
682
|
+
startAutoScroll('left')
|
|
683
|
+
} else if (x > rect.right - 100) {
|
|
684
|
+
startAutoScroll('right')
|
|
685
|
+
} else {
|
|
686
|
+
stopAutoScroll()
|
|
186
687
|
}
|
|
187
688
|
}
|
|
188
689
|
|
|
189
|
-
const
|
|
190
|
-
if (disabled) return
|
|
191
|
-
e.preventDefault()
|
|
192
|
-
|
|
193
|
-
const cardId = e.dataTransfer.getData('text/plain') || draggedCard
|
|
194
|
-
|
|
195
|
-
if (cardId && onCardMove && draggedFromColumn && draggedFromColumn !== columnId) {
|
|
196
|
-
const targetColumn = columns.find(col => col.id === columnId)
|
|
197
|
-
const newIndex = targetColumn?.cards.length || 0
|
|
198
|
-
onCardMove(cardId, draggedFromColumn, columnId, newIndex)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Reset all states
|
|
690
|
+
const handleDragEnd = () => {
|
|
202
691
|
setDraggedCard(null)
|
|
203
692
|
setDraggedOverColumn(null)
|
|
204
|
-
|
|
693
|
+
stopAutoScroll()
|
|
205
694
|
}
|
|
206
695
|
|
|
207
|
-
const
|
|
208
|
-
if (disabled) return
|
|
209
|
-
|
|
210
|
-
}
|
|
696
|
+
const handleDrop = (e: React.DragEvent, targetColumnId: string, targetIndex: number) => {
|
|
697
|
+
if (disabled || !draggedCard || !onCardMove) return
|
|
698
|
+
e.preventDefault()
|
|
211
699
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
onCardEdit?.(card)
|
|
216
|
-
}
|
|
700
|
+
// Find source column and card
|
|
701
|
+
let sourceColumnId: string | null = null
|
|
702
|
+
let sourceCard: KanbanCard | null = null
|
|
217
703
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
704
|
+
for (const column of columns) {
|
|
705
|
+
const card = column.cards.find(c => c.id === draggedCard)
|
|
706
|
+
if (card) {
|
|
707
|
+
sourceColumnId = column.id
|
|
708
|
+
sourceCard = card
|
|
709
|
+
break
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (sourceColumnId && sourceCard) {
|
|
714
|
+
onCardMove(draggedCard, sourceColumnId, targetColumnId, targetIndex)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
handleDragEnd()
|
|
222
718
|
}
|
|
223
719
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
720
|
+
// Bulk actions
|
|
721
|
+
const handleBulkAction = (action: string) => {
|
|
722
|
+
if (onBulkAction && selectedCards.length > 0) {
|
|
723
|
+
onBulkAction(action, selectedCards)
|
|
724
|
+
setSelectedCards([])
|
|
725
|
+
}
|
|
229
726
|
}
|
|
230
727
|
|
|
231
|
-
|
|
232
|
-
|
|
728
|
+
// Column actions
|
|
729
|
+
const handleColumnAction = (column: KanbanColumn, action: string) => {
|
|
730
|
+
switch (action) {
|
|
731
|
+
case 'rename':
|
|
732
|
+
// Implement inline rename
|
|
733
|
+
break
|
|
734
|
+
case 'delete':
|
|
735
|
+
onColumnDelete?.(column.id)
|
|
736
|
+
break
|
|
737
|
+
case 'collapse':
|
|
738
|
+
onColumnUpdate?.({ ...column, collapsed: !column.collapsed })
|
|
739
|
+
break
|
|
740
|
+
case 'setLimit':
|
|
741
|
+
// Implement WIP limit dialog
|
|
742
|
+
break
|
|
743
|
+
}
|
|
233
744
|
}
|
|
234
745
|
|
|
235
|
-
|
|
236
|
-
|
|
746
|
+
// Loading state
|
|
747
|
+
if (loading) {
|
|
748
|
+
return (
|
|
749
|
+
<div className={cn("w-full", className)}>
|
|
750
|
+
<div className="flex gap-6 overflow-x-auto pb-4">
|
|
751
|
+
{[1, 2, 3].map((i) => (
|
|
752
|
+
<div key={i} className="flex-shrink-0 w-80">
|
|
753
|
+
<Card>
|
|
754
|
+
<CardHeader>
|
|
755
|
+
<Skeleton className="h-4 w-24" />
|
|
756
|
+
</CardHeader>
|
|
757
|
+
<CardContent className="space-y-3">
|
|
758
|
+
{[1, 2, 3].map((j) => (
|
|
759
|
+
<Skeleton key={j} className="h-24 w-full" />
|
|
760
|
+
))}
|
|
761
|
+
</CardContent>
|
|
762
|
+
</Card>
|
|
763
|
+
</div>
|
|
764
|
+
))}
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
)
|
|
237
768
|
}
|
|
238
769
|
|
|
239
770
|
return (
|
|
240
771
|
<div className={cn("w-full", className)}>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
772
|
+
{/* Header with search and filters */}
|
|
773
|
+
{(showSearch || showFilters) && (
|
|
774
|
+
<div className="mb-6 space-y-4">
|
|
775
|
+
<div className="flex items-center justify-between gap-4">
|
|
776
|
+
{/* Search */}
|
|
777
|
+
{showSearch && (
|
|
778
|
+
<div className="relative flex-1 max-w-md">
|
|
779
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
780
|
+
<Input
|
|
781
|
+
id="kanban-search"
|
|
782
|
+
placeholder="Search cards..."
|
|
783
|
+
value={searchQuery}
|
|
784
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
785
|
+
className="pl-9"
|
|
786
|
+
/>
|
|
787
|
+
</div>
|
|
788
|
+
)}
|
|
789
|
+
|
|
790
|
+
{/* Actions */}
|
|
791
|
+
<div className="flex items-center gap-2">
|
|
792
|
+
{/* Filters */}
|
|
793
|
+
{showFilters && filters.length > 0 && (
|
|
794
|
+
<DropdownMenu>
|
|
795
|
+
<DropdownMenuTrigger asChild>
|
|
796
|
+
<Button variant="outline" size="sm">
|
|
797
|
+
<Filter className="mr-2 h-4 w-4" />
|
|
798
|
+
Filter
|
|
799
|
+
{activeFilter && (
|
|
800
|
+
<Badge variant="secondary" className="ml-2">
|
|
801
|
+
{filters.find(f => f.id === activeFilter)?.name}
|
|
802
|
+
</Badge>
|
|
267
803
|
)}
|
|
268
|
-
<CardTitle className="text-sm font-medium">
|
|
269
|
-
{column.title}
|
|
270
|
-
</CardTitle>
|
|
271
|
-
<Badge variant="secondary" className="text-xs">
|
|
272
|
-
{column.cards.length}
|
|
273
|
-
</Badge>
|
|
274
|
-
</div>
|
|
275
|
-
<Button variant="ghost" size="sm">
|
|
276
|
-
<MoreHorizontal className="h-4 w-4" />
|
|
277
804
|
</Button>
|
|
278
|
-
</
|
|
279
|
-
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
</
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
onDragStart={(e) => handleDragStart(e, card.id)}
|
|
296
|
-
onDragEnd={handleDragEnd}
|
|
297
|
-
onClick={() => handleCardClick(card)}
|
|
298
|
-
className={cn(
|
|
299
|
-
"p-3 bg-background border rounded-lg cursor-pointer hover:shadow-md transition-all duration-200",
|
|
300
|
-
"group relative select-none",
|
|
301
|
-
draggedCard === card.id && "opacity-50 scale-95",
|
|
302
|
-
disabled && "cursor-not-allowed"
|
|
303
|
-
)}
|
|
304
|
-
>
|
|
305
|
-
<div className="flex items-start justify-between gap-2">
|
|
306
|
-
<div className="flex-1">
|
|
307
|
-
<h4 className="font-medium text-sm mb-1">{card.title}</h4>
|
|
308
|
-
{card.description && (
|
|
309
|
-
<p className="text-xs text-muted-foreground mb-2 line-clamp-2">
|
|
310
|
-
{card.description}
|
|
311
|
-
</p>
|
|
805
|
+
</DropdownMenuTrigger>
|
|
806
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
807
|
+
<DropdownMenuLabel>Quick Filters</DropdownMenuLabel>
|
|
808
|
+
<DropdownMenuSeparator />
|
|
809
|
+
<DropdownMenuItem onClick={() => setActiveFilter(undefined)}>
|
|
810
|
+
<X className="mr-2 h-4 w-4" />
|
|
811
|
+
Clear filter
|
|
812
|
+
</DropdownMenuItem>
|
|
813
|
+
{filters.map((filter) => (
|
|
814
|
+
<DropdownMenuItem
|
|
815
|
+
key={filter.id}
|
|
816
|
+
onClick={() => setActiveFilter(filter.id)}
|
|
817
|
+
>
|
|
818
|
+
<Check
|
|
819
|
+
className={cn(
|
|
820
|
+
"mr-2 h-4 w-4",
|
|
821
|
+
activeFilter === filter.id ? "opacity-100" : "opacity-0"
|
|
312
822
|
)}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
>
|
|
321
|
-
<Edit className="h-3 w-3" />
|
|
322
|
-
</Button>
|
|
323
|
-
<Button
|
|
324
|
-
variant="ghost"
|
|
325
|
-
size="sm"
|
|
326
|
-
className="h-6 w-6 p-0"
|
|
327
|
-
onClick={(e) => handleCardDelete(card, e)}
|
|
328
|
-
>
|
|
329
|
-
<Trash2 className="h-3 w-3" />
|
|
330
|
-
</Button>
|
|
331
|
-
<div className="cursor-move">
|
|
332
|
-
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
|
333
|
-
</div>
|
|
334
|
-
</div>
|
|
335
|
-
</div>
|
|
823
|
+
/>
|
|
824
|
+
{filter.name}
|
|
825
|
+
</DropdownMenuItem>
|
|
826
|
+
))}
|
|
827
|
+
</DropdownMenuContent>
|
|
828
|
+
</DropdownMenu>
|
|
829
|
+
)}
|
|
336
830
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
831
|
+
{/* Bulk actions */}
|
|
832
|
+
{selectedCards.length > 0 && (
|
|
833
|
+
<DropdownMenu>
|
|
834
|
+
<DropdownMenuTrigger asChild>
|
|
835
|
+
<Button variant="outline" size="sm">
|
|
836
|
+
<span className="mr-2">{selectedCards.length} selected</span>
|
|
837
|
+
<ChevronDown className="h-4 w-4" />
|
|
838
|
+
</Button>
|
|
839
|
+
</DropdownMenuTrigger>
|
|
840
|
+
<DropdownMenuContent align="end">
|
|
841
|
+
<DropdownMenuItem onClick={() => handleBulkAction('move')}>
|
|
842
|
+
<Move className="mr-2 h-4 w-4" />
|
|
843
|
+
Move cards
|
|
844
|
+
</DropdownMenuItem>
|
|
845
|
+
<DropdownMenuItem onClick={() => handleBulkAction('archive')}>
|
|
846
|
+
<Archive className="mr-2 h-4 w-4" />
|
|
847
|
+
Archive cards
|
|
848
|
+
</DropdownMenuItem>
|
|
849
|
+
<DropdownMenuItem onClick={() => handleBulkAction('delete')}>
|
|
850
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
851
|
+
Delete cards
|
|
852
|
+
</DropdownMenuItem>
|
|
853
|
+
</DropdownMenuContent>
|
|
854
|
+
</DropdownMenu>
|
|
855
|
+
)}
|
|
347
856
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
857
|
+
{/* Export */}
|
|
858
|
+
{onExport && (
|
|
859
|
+
<DropdownMenu>
|
|
860
|
+
<DropdownMenuTrigger asChild>
|
|
861
|
+
<Button variant="outline" size="sm">
|
|
862
|
+
<Download className="mr-2 h-4 w-4" />
|
|
863
|
+
Export
|
|
864
|
+
</Button>
|
|
865
|
+
</DropdownMenuTrigger>
|
|
866
|
+
<DropdownMenuContent align="end">
|
|
867
|
+
<DropdownMenuItem onClick={() => onExport('json')}>
|
|
868
|
+
Export as JSON
|
|
869
|
+
</DropdownMenuItem>
|
|
870
|
+
<DropdownMenuItem onClick={() => onExport('csv')}>
|
|
871
|
+
Export as CSV
|
|
872
|
+
</DropdownMenuItem>
|
|
873
|
+
</DropdownMenuContent>
|
|
874
|
+
</DropdownMenu>
|
|
875
|
+
)}
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
{/* Active filters display */}
|
|
880
|
+
{activeFilter && (
|
|
881
|
+
<div className="flex items-center gap-2">
|
|
882
|
+
<span className="text-sm text-muted-foreground">Active filters:</span>
|
|
883
|
+
<Badge variant="secondary">
|
|
884
|
+
{filters.find(f => f.id === activeFilter)?.name}
|
|
885
|
+
<Button
|
|
886
|
+
variant="ghost"
|
|
887
|
+
size="sm"
|
|
888
|
+
className="ml-1 h-auto p-0"
|
|
889
|
+
onClick={() => setActiveFilter(undefined)}
|
|
890
|
+
>
|
|
891
|
+
<X className="h-3 w-3" />
|
|
892
|
+
</Button>
|
|
893
|
+
</Badge>
|
|
894
|
+
</div>
|
|
895
|
+
)}
|
|
896
|
+
</div>
|
|
897
|
+
)}
|
|
898
|
+
|
|
899
|
+
{/* Kanban board */}
|
|
900
|
+
<div
|
|
901
|
+
ref={scrollRef}
|
|
902
|
+
className="flex gap-6 overflow-x-auto pb-4"
|
|
903
|
+
onDragOver={(e) => e.preventDefault()}
|
|
904
|
+
>
|
|
905
|
+
<AnimatePresence mode="sync">
|
|
906
|
+
{filteredColumns.map((column) => {
|
|
907
|
+
const isOverLimit = column.limit && column.cards.length >= column.limit
|
|
908
|
+
const isDraggedOver = draggedOverColumn === column.id
|
|
909
|
+
|
|
910
|
+
return (
|
|
911
|
+
<motion.div
|
|
912
|
+
key={column.id}
|
|
913
|
+
layout
|
|
914
|
+
initial={{ opacity: 0, x: -20 }}
|
|
915
|
+
animate={{ opacity: 1, x: 0 }}
|
|
916
|
+
exit={{ opacity: 0, x: 20 }}
|
|
917
|
+
className={cn(
|
|
918
|
+
"flex-shrink-0 w-80 transition-all duration-200",
|
|
919
|
+
isDraggedOver && "scale-105"
|
|
920
|
+
)}
|
|
921
|
+
onDragOver={(e) => handleDragOver(e, column.id)}
|
|
922
|
+
onDragLeave={() => setDraggedOverColumn(null)}
|
|
923
|
+
onDrop={(e) => handleDrop(e, column.id, column.cards.length)}
|
|
924
|
+
>
|
|
925
|
+
<Card className={cn(
|
|
926
|
+
"h-full transition-all duration-200",
|
|
927
|
+
isDraggedOver && "ring-2 ring-primary ring-offset-2 bg-primary/5",
|
|
928
|
+
column.collapsed && "opacity-60"
|
|
929
|
+
)}>
|
|
930
|
+
<CardHeader className="pb-3">
|
|
931
|
+
<div className="flex items-center justify-between">
|
|
932
|
+
<div className="flex items-center gap-2">
|
|
933
|
+
{/* Column color indicator */}
|
|
934
|
+
{column.color && (
|
|
935
|
+
<div
|
|
936
|
+
className="w-3 h-3 rounded-full"
|
|
937
|
+
style={{ backgroundColor: column.color }}
|
|
938
|
+
/>
|
|
939
|
+
)}
|
|
940
|
+
|
|
941
|
+
{/* Column title */}
|
|
942
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
943
|
+
{column.title}
|
|
944
|
+
{column.locked && <Lock className="h-3 w-3" />}
|
|
945
|
+
</CardTitle>
|
|
946
|
+
|
|
947
|
+
{/* Card count */}
|
|
948
|
+
<Badge variant="secondary" className="text-xs">
|
|
949
|
+
{column.cards.length}
|
|
950
|
+
</Badge>
|
|
951
|
+
</div>
|
|
952
|
+
|
|
953
|
+
{/* Column actions */}
|
|
954
|
+
<DropdownMenu>
|
|
955
|
+
<DropdownMenuTrigger asChild>
|
|
956
|
+
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
957
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
958
|
+
</Button>
|
|
959
|
+
</DropdownMenuTrigger>
|
|
960
|
+
<DropdownMenuContent align="end">
|
|
961
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'rename')}>
|
|
962
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
963
|
+
Rename
|
|
964
|
+
</DropdownMenuItem>
|
|
965
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'collapse')}>
|
|
966
|
+
{column.collapsed ? (
|
|
967
|
+
<>
|
|
968
|
+
<Eye className="mr-2 h-4 w-4" />
|
|
969
|
+
Expand
|
|
970
|
+
</>
|
|
971
|
+
) : (
|
|
972
|
+
<>
|
|
973
|
+
<EyeOff className="mr-2 h-4 w-4" />
|
|
974
|
+
Collapse
|
|
975
|
+
</>
|
|
375
976
|
)}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
977
|
+
</DropdownMenuItem>
|
|
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}`
|
|
1024
|
+
)}
|
|
1025
|
+
</CardDescription>
|
|
1026
|
+
)}
|
|
1027
|
+
</CardHeader>
|
|
1028
|
+
|
|
1029
|
+
{!column.collapsed && (
|
|
1030
|
+
<CardContent className="space-y-3">
|
|
1031
|
+
<ScrollArea className="h-[calc(100vh-300px)]">
|
|
1032
|
+
<AnimatePresence mode="popLayout">
|
|
1033
|
+
{column.cards
|
|
1034
|
+
.sort((a, b) => a.position - b.position)
|
|
1035
|
+
.map((card, index) => (
|
|
1036
|
+
<div
|
|
1037
|
+
key={card.id}
|
|
1038
|
+
draggable={!disabled}
|
|
1039
|
+
onDragStart={() => handleDragStart(card, column.id)}
|
|
1040
|
+
onDragEnd={handleDragEnd}
|
|
1041
|
+
onDrop={(e) => {
|
|
1042
|
+
e.preventDefault()
|
|
1043
|
+
e.stopPropagation()
|
|
1044
|
+
handleDrop(e, column.id, index)
|
|
1045
|
+
}}
|
|
1046
|
+
className="mb-3"
|
|
1047
|
+
>
|
|
1048
|
+
<KanbanCardComponent
|
|
1049
|
+
card={card}
|
|
1050
|
+
isDragging={draggedCard === card.id}
|
|
1051
|
+
onEdit={(e) => {
|
|
1052
|
+
e.stopPropagation()
|
|
1053
|
+
onCardEdit?.(card)
|
|
1054
|
+
}}
|
|
1055
|
+
onDelete={(e) => {
|
|
1056
|
+
e.stopPropagation()
|
|
1057
|
+
onCardDelete?.(card)
|
|
1058
|
+
}}
|
|
1059
|
+
onClick={() => onCardClick?.(card)}
|
|
1060
|
+
showDetails={showCardDetails}
|
|
1061
|
+
disabled={disabled}
|
|
1062
|
+
/>
|
|
380
1063
|
</div>
|
|
381
|
-
)}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1064
|
+
))}
|
|
1065
|
+
</AnimatePresence>
|
|
1066
|
+
</ScrollArea>
|
|
1067
|
+
|
|
1068
|
+
{/* Add card button */}
|
|
1069
|
+
{onAddCard && !column.locked && !isOverLimit && (
|
|
1070
|
+
<DropdownMenu>
|
|
1071
|
+
<DropdownMenuTrigger asChild>
|
|
1072
|
+
<Button
|
|
1073
|
+
variant="ghost"
|
|
1074
|
+
size="sm"
|
|
1075
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
1076
|
+
disabled={disabled}
|
|
1077
|
+
>
|
|
1078
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
1079
|
+
Add card
|
|
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)}
|
|
1093
|
+
>
|
|
1094
|
+
<Star className="mr-2 h-4 w-4" />
|
|
1095
|
+
{template.title || `Template ${index + 1}`}
|
|
1096
|
+
</DropdownMenuItem>
|
|
1097
|
+
))}
|
|
1098
|
+
</DropdownMenuContent>
|
|
1099
|
+
</DropdownMenu>
|
|
392
1100
|
)}
|
|
393
|
-
</
|
|
394
|
-
)
|
|
1101
|
+
</CardContent>
|
|
1102
|
+
)}
|
|
1103
|
+
</Card>
|
|
1104
|
+
</motion.div>
|
|
1105
|
+
)
|
|
1106
|
+
})}
|
|
1107
|
+
</AnimatePresence>
|
|
395
1108
|
|
|
396
|
-
|
|
397
|
-
|
|
1109
|
+
{/* Add column */}
|
|
1110
|
+
{showAddColumn && onAddColumn && (
|
|
1111
|
+
<motion.div
|
|
1112
|
+
initial={{ opacity: 0 }}
|
|
1113
|
+
animate={{ opacity: 1 }}
|
|
1114
|
+
className="flex-shrink-0 w-80"
|
|
1115
|
+
>
|
|
1116
|
+
{isCreatingColumn ? (
|
|
1117
|
+
<Card>
|
|
1118
|
+
<CardHeader>
|
|
1119
|
+
<Input
|
|
1120
|
+
placeholder="Enter column title..."
|
|
1121
|
+
value={newColumnTitle}
|
|
1122
|
+
onChange={(e) => setNewColumnTitle(e.target.value)}
|
|
1123
|
+
onKeyDown={(e) => {
|
|
1124
|
+
if (e.key === 'Enter' && newColumnTitle) {
|
|
1125
|
+
onAddColumn({ title: newColumnTitle })
|
|
1126
|
+
setNewColumnTitle('')
|
|
1127
|
+
setIsCreatingColumn(false)
|
|
1128
|
+
}
|
|
1129
|
+
if (e.key === 'Escape') {
|
|
1130
|
+
setNewColumnTitle('')
|
|
1131
|
+
setIsCreatingColumn(false)
|
|
1132
|
+
}
|
|
1133
|
+
}}
|
|
1134
|
+
autoFocus
|
|
1135
|
+
/>
|
|
1136
|
+
</CardHeader>
|
|
1137
|
+
<CardContent>
|
|
1138
|
+
<div className="flex gap-2">
|
|
398
1139
|
<Button
|
|
399
|
-
variant="ghost"
|
|
400
1140
|
size="sm"
|
|
401
|
-
onClick={() =>
|
|
402
|
-
|
|
403
|
-
|
|
1141
|
+
onClick={() => {
|
|
1142
|
+
if (newColumnTitle) {
|
|
1143
|
+
onAddColumn({ title: newColumnTitle })
|
|
1144
|
+
setNewColumnTitle('')
|
|
1145
|
+
setIsCreatingColumn(false)
|
|
1146
|
+
}
|
|
1147
|
+
}}
|
|
404
1148
|
>
|
|
405
|
-
|
|
406
|
-
Add card
|
|
1149
|
+
Add column
|
|
407
1150
|
</Button>
|
|
408
|
-
|
|
1151
|
+
<Button
|
|
1152
|
+
size="sm"
|
|
1153
|
+
variant="ghost"
|
|
1154
|
+
onClick={() => {
|
|
1155
|
+
setNewColumnTitle('')
|
|
1156
|
+
setIsCreatingColumn(false)
|
|
1157
|
+
}}
|
|
1158
|
+
>
|
|
1159
|
+
Cancel
|
|
1160
|
+
</Button>
|
|
1161
|
+
</div>
|
|
409
1162
|
</CardContent>
|
|
410
1163
|
</Card>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1164
|
+
) : (
|
|
1165
|
+
<DropdownMenu>
|
|
1166
|
+
<DropdownMenuTrigger asChild>
|
|
1167
|
+
<Button
|
|
1168
|
+
variant="outline"
|
|
1169
|
+
className="w-full h-full min-h-[200px] border-dashed justify-center items-center"
|
|
1170
|
+
disabled={disabled}
|
|
1171
|
+
>
|
|
1172
|
+
<Plus className="h-6 w-6 mr-2" />
|
|
1173
|
+
Add column
|
|
1174
|
+
</Button>
|
|
1175
|
+
</DropdownMenuTrigger>
|
|
1176
|
+
<DropdownMenuContent align="start" className="w-48">
|
|
1177
|
+
<DropdownMenuLabel>Column Templates</DropdownMenuLabel>
|
|
1178
|
+
<DropdownMenuSeparator />
|
|
1179
|
+
<DropdownMenuItem onClick={() => setIsCreatingColumn(true)}>
|
|
1180
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
1181
|
+
Blank column
|
|
1182
|
+
</DropdownMenuItem>
|
|
1183
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.todo)}>
|
|
1184
|
+
<Square className="mr-2 h-4 w-4" />
|
|
1185
|
+
To Do
|
|
1186
|
+
</DropdownMenuItem>
|
|
1187
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.inProgress)}>
|
|
1188
|
+
<Clock className="mr-2 h-4 w-4" />
|
|
1189
|
+
In Progress
|
|
1190
|
+
</DropdownMenuItem>
|
|
1191
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.done)}>
|
|
1192
|
+
<CheckSquare className="mr-2 h-4 w-4" />
|
|
1193
|
+
Done
|
|
1194
|
+
</DropdownMenuItem>
|
|
1195
|
+
{columnTemplates.map((template, index) => (
|
|
1196
|
+
<DropdownMenuItem
|
|
1197
|
+
key={index}
|
|
1198
|
+
onClick={() => onAddColumn(template)}
|
|
1199
|
+
>
|
|
1200
|
+
<Star className="mr-2 h-4 w-4" />
|
|
1201
|
+
{template.title || `Template ${index + 1}`}
|
|
1202
|
+
</DropdownMenuItem>
|
|
1203
|
+
))}
|
|
1204
|
+
</DropdownMenuContent>
|
|
1205
|
+
</DropdownMenu>
|
|
1206
|
+
)}
|
|
1207
|
+
</motion.div>
|
|
428
1208
|
)}
|
|
429
1209
|
</div>
|
|
430
1210
|
</div>
|