@moontra/moonui-pro 2.11.4 → 2.13.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 +80 -11
- package/dist/index.mjs +2314 -376
- package/package.json +1 -1
- package/src/components/file-upload/index.tsx +95 -40
- 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/kanban.tsx +1474 -0
- package/src/components/kanban/types.ts +111 -0
- package/src/components/ui/avatar.tsx +34 -19
- package/src/hooks/use-toast.ts +15 -0
- package/src/components/kanban/index.tsx +0 -434
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, Reorder, useDragControls } from 'framer-motion'
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
|
6
|
+
import { Button } from '../ui/button'
|
|
7
|
+
import { MoonUIBadgePro as Badge } from '../ui/badge'
|
|
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'
|
|
20
|
+
import {
|
|
21
|
+
Plus,
|
|
22
|
+
MoreHorizontal,
|
|
23
|
+
User,
|
|
24
|
+
Calendar,
|
|
25
|
+
MessageCircle,
|
|
26
|
+
Paperclip,
|
|
27
|
+
Edit,
|
|
28
|
+
Trash2,
|
|
29
|
+
GripVertical,
|
|
30
|
+
Lock,
|
|
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
|
|
65
|
+
} from 'lucide-react'
|
|
66
|
+
import { cn } from '../../lib/utils'
|
|
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 type {
|
|
73
|
+
KanbanAssignee,
|
|
74
|
+
KanbanLabel,
|
|
75
|
+
KanbanChecklist,
|
|
76
|
+
KanbanActivity,
|
|
77
|
+
KanbanCard,
|
|
78
|
+
KanbanColumn,
|
|
79
|
+
KanbanFilter,
|
|
80
|
+
KanbanProps
|
|
81
|
+
} from './types'
|
|
82
|
+
import { useToast } from '../../hooks/use-toast'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
// Constants
|
|
86
|
+
const PRIORITY_CONFIG = {
|
|
87
|
+
low: {
|
|
88
|
+
color: 'bg-green-100 text-green-800 border-green-200',
|
|
89
|
+
dot: 'bg-green-500',
|
|
90
|
+
icon: ArrowDown
|
|
91
|
+
},
|
|
92
|
+
medium: {
|
|
93
|
+
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
94
|
+
dot: 'bg-yellow-500',
|
|
95
|
+
icon: ArrowUp
|
|
96
|
+
},
|
|
97
|
+
high: {
|
|
98
|
+
color: 'bg-orange-100 text-orange-800 border-orange-200',
|
|
99
|
+
dot: 'bg-orange-500',
|
|
100
|
+
icon: Zap
|
|
101
|
+
},
|
|
102
|
+
urgent: {
|
|
103
|
+
color: 'bg-red-100 text-red-800 border-red-200',
|
|
104
|
+
dot: 'bg-red-500',
|
|
105
|
+
icon: AlertCircle
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const COLUMN_TEMPLATES = {
|
|
110
|
+
todo: { title: 'To Do', color: '#6B7280' },
|
|
111
|
+
inProgress: { title: 'In Progress', color: '#3B82F6' },
|
|
112
|
+
done: { title: 'Done', color: '#10B981' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Helper functions
|
|
116
|
+
const formatDate = (date: Date) => {
|
|
117
|
+
return date.toLocaleDateString('en-US', {
|
|
118
|
+
month: 'short',
|
|
119
|
+
day: 'numeric',
|
|
120
|
+
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isOverdue = (dueDate: Date) => {
|
|
125
|
+
return dueDate < new Date()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const getInitials = (name: string) => {
|
|
129
|
+
return name.split(' ').map(n => n[0]).join('').toUpperCase()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const formatFileSize = (bytes: number) => {
|
|
133
|
+
if (bytes === 0) return '0 Bytes'
|
|
134
|
+
const k = 1024
|
|
135
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
136
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
137
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Custom hook for auto-scroll while dragging
|
|
141
|
+
const useAutoScroll = () => {
|
|
142
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
143
|
+
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
144
|
+
|
|
145
|
+
const startAutoScroll = useCallback((direction: 'left' | 'right') => {
|
|
146
|
+
if (scrollIntervalRef.current) return
|
|
147
|
+
|
|
148
|
+
scrollIntervalRef.current = setInterval(() => {
|
|
149
|
+
if (scrollRef.current) {
|
|
150
|
+
const scrollAmount = direction === 'left' ? -10 : 10
|
|
151
|
+
scrollRef.current.scrollLeft += scrollAmount
|
|
152
|
+
}
|
|
153
|
+
}, 20)
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
const stopAutoScroll = useCallback(() => {
|
|
157
|
+
if (scrollIntervalRef.current) {
|
|
158
|
+
clearInterval(scrollIntervalRef.current)
|
|
159
|
+
scrollIntervalRef.current = null
|
|
160
|
+
}
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
return () => stopAutoScroll()
|
|
165
|
+
}, [stopAutoScroll])
|
|
166
|
+
|
|
167
|
+
return { scrollRef, startAutoScroll, stopAutoScroll }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Card component
|
|
171
|
+
const KanbanCardComponent = ({
|
|
172
|
+
card,
|
|
173
|
+
isDragging,
|
|
174
|
+
onEdit,
|
|
175
|
+
onDelete,
|
|
176
|
+
onClick,
|
|
177
|
+
showDetails,
|
|
178
|
+
disabled
|
|
179
|
+
}: {
|
|
180
|
+
card: KanbanCard
|
|
181
|
+
isDragging: boolean
|
|
182
|
+
onEdit?: (e: React.MouseEvent) => void
|
|
183
|
+
onDelete?: (e: React.MouseEvent) => void
|
|
184
|
+
onClick?: () => void
|
|
185
|
+
showDetails: boolean
|
|
186
|
+
disabled: boolean
|
|
187
|
+
}) => {
|
|
188
|
+
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
|
189
|
+
const [title, setTitle] = useState(card.title)
|
|
190
|
+
const dragControls = useDragControls()
|
|
191
|
+
|
|
192
|
+
const completedChecklistItems = card.checklist?.items.filter(item => item.completed).length || 0
|
|
193
|
+
const totalChecklistItems = card.checklist?.items.length || 0
|
|
194
|
+
const checklistProgress = totalChecklistItems > 0 ? (completedChecklistItems / totalChecklistItems) * 100 : 0
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<motion.div
|
|
198
|
+
layout
|
|
199
|
+
initial={{ opacity: 0, y: 20 }}
|
|
200
|
+
animate={{ opacity: 1, y: 0 }}
|
|
201
|
+
exit={{ opacity: 0, y: -20 }}
|
|
202
|
+
whileHover={{ scale: 1.02 }}
|
|
203
|
+
whileDrag={{ scale: 1.05, rotate: 3 }}
|
|
204
|
+
transition={{ duration: 0.2 }}
|
|
205
|
+
className={cn(
|
|
206
|
+
"relative group cursor-pointer select-none",
|
|
207
|
+
isDragging && "z-50"
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
<Card
|
|
211
|
+
className={cn(
|
|
212
|
+
"border hover:shadow-md transition-all duration-200",
|
|
213
|
+
isDragging && "shadow-2xl ring-2 ring-primary ring-offset-2",
|
|
214
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
215
|
+
)}
|
|
216
|
+
onClick={onClick}
|
|
217
|
+
>
|
|
218
|
+
{/* Drag handle */}
|
|
219
|
+
<div
|
|
220
|
+
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"
|
|
221
|
+
onPointerDown={(e) => dragControls.start(e)}
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
{/* Cover image */}
|
|
225
|
+
{card.coverImage && (
|
|
226
|
+
<div className="relative h-32 -mx-px -mt-px rounded-t-lg overflow-hidden">
|
|
227
|
+
<img
|
|
228
|
+
src={card.coverImage}
|
|
229
|
+
alt=""
|
|
230
|
+
className="w-full h-full object-cover"
|
|
231
|
+
/>
|
|
232
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
<CardContent className="p-3">
|
|
237
|
+
{/* Labels */}
|
|
238
|
+
{card.labels && card.labels.length > 0 && (
|
|
239
|
+
<div className="flex flex-wrap gap-1 mb-2">
|
|
240
|
+
{card.labels.map((label) => (
|
|
241
|
+
<div
|
|
242
|
+
key={label.id}
|
|
243
|
+
className="h-2 w-12 rounded-full"
|
|
244
|
+
style={{ backgroundColor: label.color }}
|
|
245
|
+
title={label.name}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{/* Title and actions */}
|
|
252
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
253
|
+
<div className="flex-1">
|
|
254
|
+
{isEditingTitle ? (
|
|
255
|
+
<Input
|
|
256
|
+
value={title}
|
|
257
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
258
|
+
onBlur={() => setIsEditingTitle(false)}
|
|
259
|
+
onKeyDown={(e) => {
|
|
260
|
+
if (e.key === 'Enter') {
|
|
261
|
+
setIsEditingTitle(false)
|
|
262
|
+
// Call update handler
|
|
263
|
+
}
|
|
264
|
+
if (e.key === 'Escape') {
|
|
265
|
+
setTitle(card.title)
|
|
266
|
+
setIsEditingTitle(false)
|
|
267
|
+
}
|
|
268
|
+
}}
|
|
269
|
+
className="h-6 px-1 py-0 text-sm font-medium"
|
|
270
|
+
autoFocus
|
|
271
|
+
onClick={(e) => e.stopPropagation()}
|
|
272
|
+
/>
|
|
273
|
+
) : (
|
|
274
|
+
<h4 className="font-medium text-sm line-clamp-2">{card.title}</h4>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Quick actions */}
|
|
279
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
280
|
+
<DropdownMenu>
|
|
281
|
+
<DropdownMenuTrigger asChild>
|
|
282
|
+
<Button
|
|
283
|
+
variant="ghost"
|
|
284
|
+
size="sm"
|
|
285
|
+
className="h-6 w-6 p-0"
|
|
286
|
+
onClick={(e) => e.stopPropagation()}
|
|
287
|
+
>
|
|
288
|
+
<MoreVertical className="h-3 w-3" />
|
|
289
|
+
</Button>
|
|
290
|
+
</DropdownMenuTrigger>
|
|
291
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
292
|
+
<DropdownMenuItem onClick={onEdit}>
|
|
293
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
294
|
+
Edit
|
|
295
|
+
</DropdownMenuItem>
|
|
296
|
+
<DropdownMenuItem>
|
|
297
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
298
|
+
Duplicate
|
|
299
|
+
</DropdownMenuItem>
|
|
300
|
+
<DropdownMenuItem>
|
|
301
|
+
<Move className="mr-2 h-4 w-4" />
|
|
302
|
+
Move
|
|
303
|
+
</DropdownMenuItem>
|
|
304
|
+
<DropdownMenuItem>
|
|
305
|
+
<Archive className="mr-2 h-4 w-4" />
|
|
306
|
+
Archive
|
|
307
|
+
</DropdownMenuItem>
|
|
308
|
+
<DropdownMenuSeparator />
|
|
309
|
+
<DropdownMenuItem
|
|
310
|
+
onClick={onDelete}
|
|
311
|
+
className="text-destructive"
|
|
312
|
+
>
|
|
313
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
314
|
+
Delete
|
|
315
|
+
</DropdownMenuItem>
|
|
316
|
+
</DropdownMenuContent>
|
|
317
|
+
</DropdownMenu>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{/* Description */}
|
|
322
|
+
{card.description && (
|
|
323
|
+
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">
|
|
324
|
+
{card.description}
|
|
325
|
+
</p>
|
|
326
|
+
)}
|
|
327
|
+
|
|
328
|
+
{/* Progress bar */}
|
|
329
|
+
{(card.progress !== undefined || card.checklist) && (
|
|
330
|
+
<div className="mb-3">
|
|
331
|
+
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
|
332
|
+
<span>Progress</span>
|
|
333
|
+
<span>{Math.round(card.progress || checklistProgress)}%</span>
|
|
334
|
+
</div>
|
|
335
|
+
<Progress value={card.progress || checklistProgress} className="h-1" />
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{/* Tags */}
|
|
340
|
+
{card.tags && card.tags.length > 0 && (
|
|
341
|
+
<div className="flex flex-wrap gap-1 mb-3">
|
|
342
|
+
{card.tags.map((tag, index) => (
|
|
343
|
+
<Badge key={index} variant="secondary" className="text-xs px-1.5 py-0">
|
|
344
|
+
{tag}
|
|
345
|
+
</Badge>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* Card details */}
|
|
351
|
+
{showDetails && (
|
|
352
|
+
<div className="flex items-center justify-between text-xs">
|
|
353
|
+
<div className="flex items-center gap-2">
|
|
354
|
+
{/* Priority */}
|
|
355
|
+
{card.priority && (
|
|
356
|
+
<div className="flex items-center gap-1">
|
|
357
|
+
<div className={cn("w-2 h-2 rounded-full", PRIORITY_CONFIG[card.priority].dot)} />
|
|
358
|
+
<span className="capitalize">{card.priority}</span>
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
|
|
362
|
+
{/* Due date */}
|
|
363
|
+
{card.dueDate && (
|
|
364
|
+
<div className={cn(
|
|
365
|
+
"flex items-center gap-1",
|
|
366
|
+
isOverdue(card.dueDate) && "text-destructive"
|
|
367
|
+
)}>
|
|
368
|
+
<Calendar className="h-3 w-3" />
|
|
369
|
+
<span>{formatDate(card.dueDate)}</span>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{/* Checklist */}
|
|
374
|
+
{card.checklist && (
|
|
375
|
+
<div className="flex items-center gap-1">
|
|
376
|
+
<CheckSquare className="h-3 w-3" />
|
|
377
|
+
<span>{completedChecklistItems}/{totalChecklistItems}</span>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div className="flex items-center gap-2">
|
|
383
|
+
{/* Comments */}
|
|
384
|
+
{card.comments && card.comments > 0 && (
|
|
385
|
+
<div className="flex items-center gap-1">
|
|
386
|
+
<MessageCircle className="h-3 w-3" />
|
|
387
|
+
<span>{card.comments}</span>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Attachments */}
|
|
392
|
+
{card.attachments && card.attachments.length > 0 && (
|
|
393
|
+
<div className="flex items-center gap-1">
|
|
394
|
+
<Paperclip className="h-3 w-3" />
|
|
395
|
+
<span>{card.attachments.length}</span>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Assignees */}
|
|
400
|
+
{card.assignees && card.assignees.length > 0 && (
|
|
401
|
+
<MoonUIAvatarGroupPro max={3} size="xs">
|
|
402
|
+
{card.assignees.map((assignee) => (
|
|
403
|
+
<MoonUIAvatarPro key={assignee.id} className="h-5 w-5">
|
|
404
|
+
<MoonUIAvatarImagePro src={assignee.avatar} />
|
|
405
|
+
<MoonUIAvatarFallbackPro className="text-xs">
|
|
406
|
+
{getInitials(assignee.name)}
|
|
407
|
+
</MoonUIAvatarFallbackPro>
|
|
408
|
+
</MoonUIAvatarPro>
|
|
409
|
+
))}
|
|
410
|
+
</MoonUIAvatarGroupPro>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
</CardContent>
|
|
416
|
+
</Card>
|
|
417
|
+
</motion.div>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Main Kanban component
|
|
422
|
+
export function Kanban({
|
|
423
|
+
columns: initialColumns,
|
|
424
|
+
onCardMove,
|
|
425
|
+
onCardClick,
|
|
426
|
+
onCardEdit,
|
|
427
|
+
onCardDelete,
|
|
428
|
+
onCardUpdate,
|
|
429
|
+
onAddCard,
|
|
430
|
+
onAddColumn,
|
|
431
|
+
onColumnUpdate,
|
|
432
|
+
onColumnDelete,
|
|
433
|
+
onBulkAction,
|
|
434
|
+
onExport,
|
|
435
|
+
className,
|
|
436
|
+
showAddColumn = true,
|
|
437
|
+
showCardDetails = true,
|
|
438
|
+
showFilters = true,
|
|
439
|
+
showSearch = true,
|
|
440
|
+
enableKeyboardShortcuts = true,
|
|
441
|
+
cardTemplates = [],
|
|
442
|
+
columnTemplates = [],
|
|
443
|
+
filters = [],
|
|
444
|
+
defaultFilter,
|
|
445
|
+
loading = false,
|
|
446
|
+
disabled = false,
|
|
447
|
+
labels = [],
|
|
448
|
+
users = []
|
|
449
|
+
}: KanbanProps) {
|
|
450
|
+
// Check pro access
|
|
451
|
+
const { hasProAccess, isLoading } = useSubscription()
|
|
452
|
+
|
|
453
|
+
if (!isLoading && !hasProAccess) {
|
|
454
|
+
return (
|
|
455
|
+
<Card className={cn("w-full", className)}>
|
|
456
|
+
<CardContent className="py-12 text-center">
|
|
457
|
+
<div className="max-w-md mx-auto space-y-4">
|
|
458
|
+
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
459
|
+
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
460
|
+
</div>
|
|
461
|
+
<div>
|
|
462
|
+
<h3 className="font-semibold text-lg mb-2">Pro Feature</h3>
|
|
463
|
+
<p className="text-muted-foreground text-sm mb-4">
|
|
464
|
+
Kanban Board is available exclusively to MoonUI Pro subscribers.
|
|
465
|
+
</p>
|
|
466
|
+
<div className="flex gap-3 justify-center">
|
|
467
|
+
<a href="/pricing">
|
|
468
|
+
<Button size="sm">
|
|
469
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
470
|
+
Upgrade to Pro
|
|
471
|
+
</Button>
|
|
472
|
+
</a>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</CardContent>
|
|
477
|
+
</Card>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// State
|
|
482
|
+
const [columns, setColumns] = useState(initialColumns)
|
|
483
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
484
|
+
const [activeFilter, setActiveFilter] = useState(defaultFilter)
|
|
485
|
+
const [selectedCards, setSelectedCards] = useState<string[]>([])
|
|
486
|
+
const [draggedCard, setDraggedCard] = useState<string | null>(null)
|
|
487
|
+
const [draggedOverColumn, setDraggedOverColumn] = useState<string | null>(null)
|
|
488
|
+
const [isCreatingColumn, setIsCreatingColumn] = useState(false)
|
|
489
|
+
const [newColumnTitle, setNewColumnTitle] = useState('')
|
|
490
|
+
|
|
491
|
+
// Modal states
|
|
492
|
+
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null)
|
|
493
|
+
const [addCardColumnId, setAddCardColumnId] = useState<string | null>(null)
|
|
494
|
+
const [editingColumnId, setEditingColumnId] = useState<string | null>(null)
|
|
495
|
+
const [editingColumnTitle, setEditingColumnTitle] = useState('')
|
|
496
|
+
const [wipLimitModalOpen, setWipLimitModalOpen] = useState(false)
|
|
497
|
+
const [wipLimitColumnId, setWipLimitColumnId] = useState<string | null>(null)
|
|
498
|
+
const [wipLimit, setWipLimit] = useState<number | undefined>()
|
|
499
|
+
const [colorPickerOpen, setColorPickerOpen] = useState(false)
|
|
500
|
+
const [colorPickerColumnId, setColorPickerColumnId] = useState<string | null>(null)
|
|
501
|
+
const [selectedColor, setSelectedColor] = useState('#6B7280')
|
|
502
|
+
|
|
503
|
+
const { scrollRef, startAutoScroll, stopAutoScroll } = useAutoScroll()
|
|
504
|
+
const { toast } = useToast()
|
|
505
|
+
|
|
506
|
+
// Filter cards based on search and filters
|
|
507
|
+
const filteredColumns = useMemo(() => {
|
|
508
|
+
if (!searchQuery && !activeFilter) return columns
|
|
509
|
+
|
|
510
|
+
return columns.map(column => ({
|
|
511
|
+
...column,
|
|
512
|
+
cards: column.cards.filter(card => {
|
|
513
|
+
// Search filter
|
|
514
|
+
if (searchQuery) {
|
|
515
|
+
const query = searchQuery.toLowerCase()
|
|
516
|
+
const matchesSearch =
|
|
517
|
+
card.title.toLowerCase().includes(query) ||
|
|
518
|
+
card.description?.toLowerCase().includes(query) ||
|
|
519
|
+
card.tags?.some(tag => tag.toLowerCase().includes(query)) ||
|
|
520
|
+
card.assignees?.some(a => a.name.toLowerCase().includes(query))
|
|
521
|
+
|
|
522
|
+
if (!matchesSearch) return false
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Active filter
|
|
526
|
+
if (activeFilter) {
|
|
527
|
+
const filter = filters.find(f => f.id === activeFilter)
|
|
528
|
+
if (filter) {
|
|
529
|
+
// Apply filter logic here
|
|
530
|
+
// This is a simplified example
|
|
531
|
+
if (filter.assignees?.length && !card.assignees?.some(a => filter.assignees!.includes(a.id))) {
|
|
532
|
+
return false
|
|
533
|
+
}
|
|
534
|
+
if (filter.priority?.length && !filter.priority.includes(card.priority || '')) {
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
if (filter.labels?.length && !card.labels?.some(l => filter.labels!.includes(l.id))) {
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return true
|
|
544
|
+
})
|
|
545
|
+
}))
|
|
546
|
+
}, [columns, searchQuery, activeFilter, filters])
|
|
547
|
+
|
|
548
|
+
// Keyboard shortcuts
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
if (!enableKeyboardShortcuts) return
|
|
551
|
+
|
|
552
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
553
|
+
// Cmd/Ctrl + F: Focus search
|
|
554
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
|
555
|
+
e.preventDefault()
|
|
556
|
+
document.getElementById('kanban-search')?.focus()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Cmd/Ctrl + N: Add new card
|
|
560
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
|
|
561
|
+
e.preventDefault()
|
|
562
|
+
// Add to first column by default
|
|
563
|
+
const firstColumn = columns[0]
|
|
564
|
+
if (firstColumn && onAddCard) {
|
|
565
|
+
onAddCard(firstColumn.id)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Escape: Clear selection
|
|
570
|
+
if (e.key === 'Escape') {
|
|
571
|
+
setSelectedCards([])
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
576
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
577
|
+
}, [enableKeyboardShortcuts, columns, onAddCard])
|
|
578
|
+
|
|
579
|
+
// Drag handlers
|
|
580
|
+
const handleDragStart = (card: KanbanCard, columnId: string) => {
|
|
581
|
+
if (disabled) return
|
|
582
|
+
setDraggedCard(card.id)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
|
586
|
+
if (disabled) return
|
|
587
|
+
e.preventDefault()
|
|
588
|
+
setDraggedOverColumn(columnId)
|
|
589
|
+
|
|
590
|
+
// Auto-scroll logic
|
|
591
|
+
const container = scrollRef.current
|
|
592
|
+
if (!container) return
|
|
593
|
+
|
|
594
|
+
const rect = container.getBoundingClientRect()
|
|
595
|
+
const x = e.clientX
|
|
596
|
+
|
|
597
|
+
if (x < rect.left + 100) {
|
|
598
|
+
startAutoScroll('left')
|
|
599
|
+
} else if (x > rect.right - 100) {
|
|
600
|
+
startAutoScroll('right')
|
|
601
|
+
} else {
|
|
602
|
+
stopAutoScroll()
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const handleDragEnd = () => {
|
|
607
|
+
setDraggedCard(null)
|
|
608
|
+
setDraggedOverColumn(null)
|
|
609
|
+
stopAutoScroll()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const handleDrop = (e: React.DragEvent, targetColumnId: string, targetIndex: number) => {
|
|
613
|
+
if (disabled || !draggedCard || !onCardMove) return
|
|
614
|
+
e.preventDefault()
|
|
615
|
+
|
|
616
|
+
// Find source column and card
|
|
617
|
+
let sourceColumnId: string | null = null
|
|
618
|
+
let sourceCard: KanbanCard | null = null
|
|
619
|
+
|
|
620
|
+
for (const column of columns) {
|
|
621
|
+
const card = column.cards.find(c => c.id === draggedCard)
|
|
622
|
+
if (card) {
|
|
623
|
+
sourceColumnId = column.id
|
|
624
|
+
sourceCard = card
|
|
625
|
+
break
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (sourceColumnId && sourceCard) {
|
|
630
|
+
onCardMove(draggedCard, sourceColumnId, targetColumnId, targetIndex)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
handleDragEnd()
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Bulk actions
|
|
637
|
+
const handleBulkAction = (action: string) => {
|
|
638
|
+
if (onBulkAction && selectedCards.length > 0) {
|
|
639
|
+
onBulkAction(action, selectedCards)
|
|
640
|
+
setSelectedCards([])
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Column actions
|
|
645
|
+
const handleColumnAction = (column: KanbanColumn, action: string) => {
|
|
646
|
+
switch (action) {
|
|
647
|
+
case 'rename':
|
|
648
|
+
setEditingColumnId(column.id)
|
|
649
|
+
setEditingColumnTitle(column.title)
|
|
650
|
+
break
|
|
651
|
+
case 'delete':
|
|
652
|
+
onColumnDelete?.(column.id)
|
|
653
|
+
toast({
|
|
654
|
+
title: "Column deleted",
|
|
655
|
+
description: `"${column.title}" has been deleted`
|
|
656
|
+
})
|
|
657
|
+
break
|
|
658
|
+
case 'collapse':
|
|
659
|
+
const updatedColumn = { ...column, collapsed: !column.collapsed }
|
|
660
|
+
onColumnUpdate?.(updatedColumn)
|
|
661
|
+
setColumns(columns.map(col => col.id === column.id ? updatedColumn : col))
|
|
662
|
+
break
|
|
663
|
+
case 'setLimit':
|
|
664
|
+
setWipLimitColumnId(column.id)
|
|
665
|
+
setWipLimit(column.limit)
|
|
666
|
+
setWipLimitModalOpen(true)
|
|
667
|
+
break
|
|
668
|
+
case 'changeColor':
|
|
669
|
+
setColorPickerColumnId(column.id)
|
|
670
|
+
setSelectedColor(column.color || '#6B7280')
|
|
671
|
+
setColorPickerOpen(true)
|
|
672
|
+
break
|
|
673
|
+
case 'sortByPriority':
|
|
674
|
+
const sortedCards = [...column.cards].sort((a, b) => {
|
|
675
|
+
const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 }
|
|
676
|
+
return (priorityOrder[b.priority || 'medium'] || 2) - (priorityOrder[a.priority || 'medium'] || 2)
|
|
677
|
+
})
|
|
678
|
+
const sortedColumn = { ...column, cards: sortedCards }
|
|
679
|
+
onColumnUpdate?.(sortedColumn)
|
|
680
|
+
setColumns(columns.map(col => col.id === column.id ? sortedColumn : col))
|
|
681
|
+
toast({
|
|
682
|
+
title: "Cards sorted",
|
|
683
|
+
description: "Cards sorted by priority"
|
|
684
|
+
})
|
|
685
|
+
break
|
|
686
|
+
case 'sortByDueDate':
|
|
687
|
+
const dateCards = [...column.cards].sort((a, b) => {
|
|
688
|
+
if (!a.dueDate && !b.dueDate) return 0
|
|
689
|
+
if (!a.dueDate) return 1
|
|
690
|
+
if (!b.dueDate) return -1
|
|
691
|
+
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
|
692
|
+
})
|
|
693
|
+
const dateColumn = { ...column, cards: dateCards }
|
|
694
|
+
onColumnUpdate?.(dateColumn)
|
|
695
|
+
setColumns(columns.map(col => col.id === column.id ? dateColumn : col))
|
|
696
|
+
toast({
|
|
697
|
+
title: "Cards sorted",
|
|
698
|
+
description: "Cards sorted by due date"
|
|
699
|
+
})
|
|
700
|
+
break
|
|
701
|
+
case 'sortAlphabetically':
|
|
702
|
+
const alphaCards = [...column.cards].sort((a, b) => a.title.localeCompare(b.title))
|
|
703
|
+
const alphaColumn = { ...column, cards: alphaCards }
|
|
704
|
+
onColumnUpdate?.(alphaColumn)
|
|
705
|
+
setColumns(columns.map(col => col.id === column.id ? alphaColumn : col))
|
|
706
|
+
toast({
|
|
707
|
+
title: "Cards sorted",
|
|
708
|
+
description: "Cards sorted alphabetically"
|
|
709
|
+
})
|
|
710
|
+
break
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Card handlers
|
|
715
|
+
const handleCardClick = (card: KanbanCard) => {
|
|
716
|
+
if (onCardClick) {
|
|
717
|
+
onCardClick(card)
|
|
718
|
+
} else {
|
|
719
|
+
setSelectedCard(card)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const handleCardUpdate = (updatedCard: KanbanCard) => {
|
|
724
|
+
onCardUpdate?.(updatedCard)
|
|
725
|
+
setColumns(columns.map(col => ({
|
|
726
|
+
...col,
|
|
727
|
+
cards: col.cards.map(card => card.id === updatedCard.id ? updatedCard : card)
|
|
728
|
+
})))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const handleAddCard = (columnId: string, newCard?: Partial<KanbanCard>) => {
|
|
732
|
+
if (onAddCard) {
|
|
733
|
+
onAddCard(columnId, newCard)
|
|
734
|
+
} else {
|
|
735
|
+
setAddCardColumnId(columnId)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const handleAddNewCard = (card: Partial<KanbanCard>) => {
|
|
740
|
+
if (!addCardColumnId) return
|
|
741
|
+
|
|
742
|
+
const newCard: KanbanCard = {
|
|
743
|
+
id: Date.now().toString(),
|
|
744
|
+
title: card.title || 'New Card',
|
|
745
|
+
position: Date.now(),
|
|
746
|
+
...card
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
setColumns(columns.map(col => {
|
|
750
|
+
if (col.id === addCardColumnId) {
|
|
751
|
+
return {
|
|
752
|
+
...col,
|
|
753
|
+
cards: [...col.cards, newCard]
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return col
|
|
757
|
+
}))
|
|
758
|
+
|
|
759
|
+
onAddCard?.(addCardColumnId, newCard)
|
|
760
|
+
toast({
|
|
761
|
+
title: "Card added",
|
|
762
|
+
description: `"${newCard.title}" has been added`
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Column updates
|
|
767
|
+
const handleColumnRename = (columnId: string) => {
|
|
768
|
+
const column = columns.find(col => col.id === columnId)
|
|
769
|
+
if (!column || !editingColumnTitle.trim()) return
|
|
770
|
+
|
|
771
|
+
const updatedColumn = { ...column, title: editingColumnTitle.trim() }
|
|
772
|
+
onColumnUpdate?.(updatedColumn)
|
|
773
|
+
setColumns(columns.map(col => col.id === columnId ? updatedColumn : col))
|
|
774
|
+
setEditingColumnId(null)
|
|
775
|
+
setEditingColumnTitle('')
|
|
776
|
+
|
|
777
|
+
toast({
|
|
778
|
+
title: "Column renamed",
|
|
779
|
+
description: `Column renamed to "${editingColumnTitle.trim()}"`
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const handleWipLimitUpdate = () => {
|
|
784
|
+
const column = columns.find(col => col.id === wipLimitColumnId)
|
|
785
|
+
if (!column) return
|
|
786
|
+
|
|
787
|
+
const updatedColumn = { ...column, limit: wipLimit }
|
|
788
|
+
onColumnUpdate?.(updatedColumn)
|
|
789
|
+
setColumns(columns.map(col => col.id === wipLimitColumnId ? updatedColumn : col))
|
|
790
|
+
setWipLimitModalOpen(false)
|
|
791
|
+
|
|
792
|
+
toast({
|
|
793
|
+
title: "WIP limit updated",
|
|
794
|
+
description: wipLimit ? `WIP limit set to ${wipLimit}` : "WIP limit removed"
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const handleColorUpdate = () => {
|
|
799
|
+
const column = columns.find(col => col.id === colorPickerColumnId)
|
|
800
|
+
if (!column) return
|
|
801
|
+
|
|
802
|
+
const updatedColumn = { ...column, color: selectedColor }
|
|
803
|
+
onColumnUpdate?.(updatedColumn)
|
|
804
|
+
setColumns(columns.map(col => col.id === colorPickerColumnId ? updatedColumn : col))
|
|
805
|
+
setColorPickerOpen(false)
|
|
806
|
+
|
|
807
|
+
toast({
|
|
808
|
+
title: "Column color updated",
|
|
809
|
+
description: "Column color has been changed"
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Export functionality
|
|
814
|
+
const handleExport = (format: 'json' | 'csv') => {
|
|
815
|
+
if (onExport) {
|
|
816
|
+
onExport(format)
|
|
817
|
+
} else {
|
|
818
|
+
if (format === 'json') {
|
|
819
|
+
const data = JSON.stringify(columns, null, 2)
|
|
820
|
+
const blob = new Blob([data], { type: 'application/json' })
|
|
821
|
+
const url = URL.createObjectURL(blob)
|
|
822
|
+
const a = document.createElement('a')
|
|
823
|
+
a.href = url
|
|
824
|
+
a.download = 'kanban-board.json'
|
|
825
|
+
document.body.appendChild(a)
|
|
826
|
+
a.click()
|
|
827
|
+
document.body.removeChild(a)
|
|
828
|
+
URL.revokeObjectURL(url)
|
|
829
|
+
|
|
830
|
+
toast({
|
|
831
|
+
title: "Board exported",
|
|
832
|
+
description: "Board exported as JSON file"
|
|
833
|
+
})
|
|
834
|
+
} else if (format === 'csv') {
|
|
835
|
+
let csv = 'Column,Card Title,Description,Priority,Assignees,Due Date,Tags\n'
|
|
836
|
+
columns.forEach(column => {
|
|
837
|
+
column.cards.forEach(card => {
|
|
838
|
+
csv += `"${column.title}",`
|
|
839
|
+
csv += `"${card.title}",`
|
|
840
|
+
csv += `"${card.description || ''}",`
|
|
841
|
+
csv += `"${card.priority || ''}",`
|
|
842
|
+
csv += `"${card.assignees?.map(a => a.name).join(', ') || ''}",`
|
|
843
|
+
csv += `"${card.dueDate ? new Date(card.dueDate).toLocaleDateString() : ''}",`
|
|
844
|
+
csv += `"${card.tags?.join(', ') || ''}"\n`
|
|
845
|
+
})
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const blob = new Blob([csv], { type: 'text/csv' })
|
|
849
|
+
const url = URL.createObjectURL(blob)
|
|
850
|
+
const a = document.createElement('a')
|
|
851
|
+
a.href = url
|
|
852
|
+
a.download = 'kanban-board.csv'
|
|
853
|
+
document.body.appendChild(a)
|
|
854
|
+
a.click()
|
|
855
|
+
document.body.removeChild(a)
|
|
856
|
+
URL.revokeObjectURL(url)
|
|
857
|
+
|
|
858
|
+
toast({
|
|
859
|
+
title: "Board exported",
|
|
860
|
+
description: "Board exported as CSV file"
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Loading state
|
|
867
|
+
if (loading) {
|
|
868
|
+
return (
|
|
869
|
+
<div className={cn("w-full", className)}>
|
|
870
|
+
<div className="flex gap-6 overflow-x-auto pb-4">
|
|
871
|
+
{[1, 2, 3].map((i) => (
|
|
872
|
+
<div key={i} className="flex-shrink-0 w-80">
|
|
873
|
+
<Card>
|
|
874
|
+
<CardHeader>
|
|
875
|
+
<Skeleton className="h-4 w-24" />
|
|
876
|
+
</CardHeader>
|
|
877
|
+
<CardContent className="space-y-3">
|
|
878
|
+
{[1, 2, 3].map((j) => (
|
|
879
|
+
<Skeleton key={j} className="h-24 w-full" />
|
|
880
|
+
))}
|
|
881
|
+
</CardContent>
|
|
882
|
+
</Card>
|
|
883
|
+
</div>
|
|
884
|
+
))}
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return (
|
|
891
|
+
<div className={cn("w-full", className)}>
|
|
892
|
+
{/* Header with search and filters */}
|
|
893
|
+
{(showSearch || showFilters) && (
|
|
894
|
+
<div className="mb-6 space-y-4">
|
|
895
|
+
<div className="flex items-center justify-between gap-4">
|
|
896
|
+
{/* Search */}
|
|
897
|
+
{showSearch && (
|
|
898
|
+
<div className="relative flex-1 max-w-md">
|
|
899
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
900
|
+
<Input
|
|
901
|
+
id="kanban-search"
|
|
902
|
+
placeholder="Search cards..."
|
|
903
|
+
value={searchQuery}
|
|
904
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
905
|
+
className="pl-9"
|
|
906
|
+
/>
|
|
907
|
+
</div>
|
|
908
|
+
)}
|
|
909
|
+
|
|
910
|
+
{/* Actions */}
|
|
911
|
+
<div className="flex items-center gap-2">
|
|
912
|
+
{/* Filters */}
|
|
913
|
+
{showFilters && filters.length > 0 && (
|
|
914
|
+
<DropdownMenu>
|
|
915
|
+
<DropdownMenuTrigger asChild>
|
|
916
|
+
<Button variant="outline" size="sm">
|
|
917
|
+
<Filter className="mr-2 h-4 w-4" />
|
|
918
|
+
Filter
|
|
919
|
+
{activeFilter && (
|
|
920
|
+
<Badge variant="secondary" className="ml-2">
|
|
921
|
+
{filters.find(f => f.id === activeFilter)?.name}
|
|
922
|
+
</Badge>
|
|
923
|
+
)}
|
|
924
|
+
</Button>
|
|
925
|
+
</DropdownMenuTrigger>
|
|
926
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
927
|
+
<DropdownMenuLabel>Quick Filters</DropdownMenuLabel>
|
|
928
|
+
<DropdownMenuSeparator />
|
|
929
|
+
<DropdownMenuItem onClick={() => setActiveFilter(undefined)}>
|
|
930
|
+
<X className="mr-2 h-4 w-4" />
|
|
931
|
+
Clear filter
|
|
932
|
+
</DropdownMenuItem>
|
|
933
|
+
{filters.map((filter) => (
|
|
934
|
+
<DropdownMenuItem
|
|
935
|
+
key={filter.id}
|
|
936
|
+
onClick={() => setActiveFilter(filter.id)}
|
|
937
|
+
>
|
|
938
|
+
<Check
|
|
939
|
+
className={cn(
|
|
940
|
+
"mr-2 h-4 w-4",
|
|
941
|
+
activeFilter === filter.id ? "opacity-100" : "opacity-0"
|
|
942
|
+
)}
|
|
943
|
+
/>
|
|
944
|
+
{filter.name}
|
|
945
|
+
</DropdownMenuItem>
|
|
946
|
+
))}
|
|
947
|
+
</DropdownMenuContent>
|
|
948
|
+
</DropdownMenu>
|
|
949
|
+
)}
|
|
950
|
+
|
|
951
|
+
{/* Bulk actions */}
|
|
952
|
+
{selectedCards.length > 0 && (
|
|
953
|
+
<DropdownMenu>
|
|
954
|
+
<DropdownMenuTrigger asChild>
|
|
955
|
+
<Button variant="outline" size="sm">
|
|
956
|
+
<span className="mr-2">{selectedCards.length} selected</span>
|
|
957
|
+
<ChevronDown className="h-4 w-4" />
|
|
958
|
+
</Button>
|
|
959
|
+
</DropdownMenuTrigger>
|
|
960
|
+
<DropdownMenuContent align="end">
|
|
961
|
+
<DropdownMenuItem onClick={() => handleBulkAction('move')}>
|
|
962
|
+
<Move className="mr-2 h-4 w-4" />
|
|
963
|
+
Move cards
|
|
964
|
+
</DropdownMenuItem>
|
|
965
|
+
<DropdownMenuItem onClick={() => handleBulkAction('archive')}>
|
|
966
|
+
<Archive className="mr-2 h-4 w-4" />
|
|
967
|
+
Archive cards
|
|
968
|
+
</DropdownMenuItem>
|
|
969
|
+
<DropdownMenuItem onClick={() => handleBulkAction('delete')}>
|
|
970
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
971
|
+
Delete cards
|
|
972
|
+
</DropdownMenuItem>
|
|
973
|
+
</DropdownMenuContent>
|
|
974
|
+
</DropdownMenu>
|
|
975
|
+
)}
|
|
976
|
+
|
|
977
|
+
{/* Export */}
|
|
978
|
+
<DropdownMenu>
|
|
979
|
+
<DropdownMenuTrigger asChild>
|
|
980
|
+
<Button variant="outline" size="sm">
|
|
981
|
+
<Download className="mr-2 h-4 w-4" />
|
|
982
|
+
Export
|
|
983
|
+
</Button>
|
|
984
|
+
</DropdownMenuTrigger>
|
|
985
|
+
<DropdownMenuContent align="end">
|
|
986
|
+
<DropdownMenuItem onClick={() => handleExport('json')}>
|
|
987
|
+
Export as JSON
|
|
988
|
+
</DropdownMenuItem>
|
|
989
|
+
<DropdownMenuItem onClick={() => handleExport('csv')}>
|
|
990
|
+
Export as CSV
|
|
991
|
+
</DropdownMenuItem>
|
|
992
|
+
</DropdownMenuContent>
|
|
993
|
+
</DropdownMenu>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
|
|
997
|
+
{/* Active filters display */}
|
|
998
|
+
{activeFilter && (
|
|
999
|
+
<div className="flex items-center gap-2">
|
|
1000
|
+
<span className="text-sm text-muted-foreground">Active filters:</span>
|
|
1001
|
+
<Badge variant="secondary">
|
|
1002
|
+
{filters.find(f => f.id === activeFilter)?.name}
|
|
1003
|
+
<Button
|
|
1004
|
+
variant="ghost"
|
|
1005
|
+
size="sm"
|
|
1006
|
+
className="ml-1 h-auto p-0"
|
|
1007
|
+
onClick={() => setActiveFilter(undefined)}
|
|
1008
|
+
>
|
|
1009
|
+
<X className="h-3 w-3" />
|
|
1010
|
+
</Button>
|
|
1011
|
+
</Badge>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
1014
|
+
</div>
|
|
1015
|
+
)}
|
|
1016
|
+
|
|
1017
|
+
{/* Kanban board */}
|
|
1018
|
+
<div
|
|
1019
|
+
ref={scrollRef}
|
|
1020
|
+
className="flex gap-6 overflow-x-auto pb-4"
|
|
1021
|
+
onDragOver={(e) => e.preventDefault()}
|
|
1022
|
+
>
|
|
1023
|
+
<AnimatePresence mode="sync">
|
|
1024
|
+
{filteredColumns.map((column) => {
|
|
1025
|
+
const isOverLimit = column.limit && column.cards.length >= column.limit
|
|
1026
|
+
const isDraggedOver = draggedOverColumn === column.id
|
|
1027
|
+
|
|
1028
|
+
return (
|
|
1029
|
+
<motion.div
|
|
1030
|
+
key={column.id}
|
|
1031
|
+
layout
|
|
1032
|
+
initial={{ opacity: 0, x: -20 }}
|
|
1033
|
+
animate={{ opacity: 1, x: 0 }}
|
|
1034
|
+
exit={{ opacity: 0, x: 20 }}
|
|
1035
|
+
className={cn(
|
|
1036
|
+
"flex-shrink-0 w-80 transition-all duration-200",
|
|
1037
|
+
isDraggedOver && "scale-105"
|
|
1038
|
+
)}
|
|
1039
|
+
onDragOver={(e) => handleDragOver(e, column.id)}
|
|
1040
|
+
onDragLeave={() => setDraggedOverColumn(null)}
|
|
1041
|
+
onDrop={(e) => handleDrop(e, column.id, column.cards.length)}
|
|
1042
|
+
>
|
|
1043
|
+
<Card className={cn(
|
|
1044
|
+
"h-full transition-all duration-200",
|
|
1045
|
+
isDraggedOver && "ring-2 ring-primary ring-offset-2 bg-primary/5",
|
|
1046
|
+
column.collapsed && "opacity-60"
|
|
1047
|
+
)}>
|
|
1048
|
+
<CardHeader className="pb-3">
|
|
1049
|
+
<div className="flex items-center justify-between">
|
|
1050
|
+
<div className="flex items-center gap-2">
|
|
1051
|
+
{/* Column color indicator */}
|
|
1052
|
+
{column.color && (
|
|
1053
|
+
<div
|
|
1054
|
+
className="w-3 h-3 rounded-full"
|
|
1055
|
+
style={{ backgroundColor: column.color }}
|
|
1056
|
+
/>
|
|
1057
|
+
)}
|
|
1058
|
+
|
|
1059
|
+
{/* Column title */}
|
|
1060
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
1061
|
+
{editingColumnId === column.id ? (
|
|
1062
|
+
<Input
|
|
1063
|
+
value={editingColumnTitle}
|
|
1064
|
+
onChange={(e) => setEditingColumnTitle(e.target.value)}
|
|
1065
|
+
onBlur={() => handleColumnRename(column.id)}
|
|
1066
|
+
onKeyDown={(e) => {
|
|
1067
|
+
if (e.key === 'Enter') {
|
|
1068
|
+
handleColumnRename(column.id)
|
|
1069
|
+
}
|
|
1070
|
+
if (e.key === 'Escape') {
|
|
1071
|
+
setEditingColumnId(null)
|
|
1072
|
+
setEditingColumnTitle('')
|
|
1073
|
+
}
|
|
1074
|
+
}}
|
|
1075
|
+
className="h-6 w-32 text-sm"
|
|
1076
|
+
autoFocus
|
|
1077
|
+
onClick={(e) => e.stopPropagation()}
|
|
1078
|
+
/>
|
|
1079
|
+
) : (
|
|
1080
|
+
<>
|
|
1081
|
+
{column.title}
|
|
1082
|
+
{column.locked && <Lock className="h-3 w-3" />}
|
|
1083
|
+
</>
|
|
1084
|
+
)}
|
|
1085
|
+
</CardTitle>
|
|
1086
|
+
|
|
1087
|
+
{/* Card count */}
|
|
1088
|
+
<Badge variant="secondary" className="text-xs">
|
|
1089
|
+
{column.cards.length}
|
|
1090
|
+
</Badge>
|
|
1091
|
+
</div>
|
|
1092
|
+
|
|
1093
|
+
{/* Column actions */}
|
|
1094
|
+
<DropdownMenu>
|
|
1095
|
+
<DropdownMenuTrigger asChild>
|
|
1096
|
+
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
1097
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
1098
|
+
</Button>
|
|
1099
|
+
</DropdownMenuTrigger>
|
|
1100
|
+
<DropdownMenuContent align="end">
|
|
1101
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'rename')}>
|
|
1102
|
+
<Edit className="mr-2 h-4 w-4" />
|
|
1103
|
+
Rename
|
|
1104
|
+
</DropdownMenuItem>
|
|
1105
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'collapse')}>
|
|
1106
|
+
{column.collapsed ? (
|
|
1107
|
+
<>
|
|
1108
|
+
<Eye className="mr-2 h-4 w-4" />
|
|
1109
|
+
Expand
|
|
1110
|
+
</>
|
|
1111
|
+
) : (
|
|
1112
|
+
<>
|
|
1113
|
+
<EyeOff className="mr-2 h-4 w-4" />
|
|
1114
|
+
Collapse
|
|
1115
|
+
</>
|
|
1116
|
+
)}
|
|
1117
|
+
</DropdownMenuItem>
|
|
1118
|
+
<DropdownMenuSeparator />
|
|
1119
|
+
<DropdownMenuSub>
|
|
1120
|
+
<DropdownMenuSubTrigger>
|
|
1121
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
1122
|
+
Settings
|
|
1123
|
+
</DropdownMenuSubTrigger>
|
|
1124
|
+
<DropdownMenuSubContent>
|
|
1125
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'setLimit')}>
|
|
1126
|
+
<Timer className="mr-2 h-4 w-4" />
|
|
1127
|
+
Set WIP limit
|
|
1128
|
+
</DropdownMenuItem>
|
|
1129
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'changeColor')}>
|
|
1130
|
+
<Palette className="mr-2 h-4 w-4" />
|
|
1131
|
+
Change color
|
|
1132
|
+
</DropdownMenuItem>
|
|
1133
|
+
<DropdownMenuSub>
|
|
1134
|
+
<DropdownMenuSubTrigger>
|
|
1135
|
+
<ArrowUpDown className="mr-2 h-4 w-4" />
|
|
1136
|
+
Sort cards
|
|
1137
|
+
</DropdownMenuSubTrigger>
|
|
1138
|
+
<DropdownMenuSubContent>
|
|
1139
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortByPriority')}>
|
|
1140
|
+
By Priority
|
|
1141
|
+
</DropdownMenuItem>
|
|
1142
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortByDueDate')}>
|
|
1143
|
+
By Due Date
|
|
1144
|
+
</DropdownMenuItem>
|
|
1145
|
+
<DropdownMenuItem onClick={() => handleColumnAction(column, 'sortAlphabetically')}>
|
|
1146
|
+
Alphabetically
|
|
1147
|
+
</DropdownMenuItem>
|
|
1148
|
+
</DropdownMenuSubContent>
|
|
1149
|
+
</DropdownMenuSub>
|
|
1150
|
+
</DropdownMenuSubContent>
|
|
1151
|
+
</DropdownMenuSub>
|
|
1152
|
+
<DropdownMenuSeparator />
|
|
1153
|
+
<DropdownMenuItem
|
|
1154
|
+
onClick={() => handleColumnAction(column, 'delete')}
|
|
1155
|
+
className="text-destructive"
|
|
1156
|
+
>
|
|
1157
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
1158
|
+
Delete column
|
|
1159
|
+
</DropdownMenuItem>
|
|
1160
|
+
</DropdownMenuContent>
|
|
1161
|
+
</DropdownMenu>
|
|
1162
|
+
</div>
|
|
1163
|
+
|
|
1164
|
+
{/* WIP limit warning */}
|
|
1165
|
+
{column.limit && (
|
|
1166
|
+
<CardDescription className={cn(
|
|
1167
|
+
"text-xs flex items-center gap-1 mt-1",
|
|
1168
|
+
isOverLimit && "text-destructive"
|
|
1169
|
+
)}>
|
|
1170
|
+
{isOverLimit ? (
|
|
1171
|
+
<>
|
|
1172
|
+
<AlertCircle className="h-3 w-3" />
|
|
1173
|
+
Over WIP limit ({column.cards.length}/{column.limit})
|
|
1174
|
+
</>
|
|
1175
|
+
) : (
|
|
1176
|
+
`WIP limit: ${column.cards.length}/${column.limit}`
|
|
1177
|
+
)}
|
|
1178
|
+
</CardDescription>
|
|
1179
|
+
)}
|
|
1180
|
+
</CardHeader>
|
|
1181
|
+
|
|
1182
|
+
{!column.collapsed && (
|
|
1183
|
+
<CardContent className="space-y-3">
|
|
1184
|
+
<ScrollArea className="h-[calc(100vh-300px)]">
|
|
1185
|
+
<AnimatePresence mode="popLayout">
|
|
1186
|
+
{column.cards
|
|
1187
|
+
.sort((a, b) => a.position - b.position)
|
|
1188
|
+
.map((card, index) => (
|
|
1189
|
+
<div
|
|
1190
|
+
key={card.id}
|
|
1191
|
+
draggable={!disabled}
|
|
1192
|
+
onDragStart={() => handleDragStart(card, column.id)}
|
|
1193
|
+
onDragEnd={handleDragEnd}
|
|
1194
|
+
onDrop={(e) => {
|
|
1195
|
+
e.preventDefault()
|
|
1196
|
+
e.stopPropagation()
|
|
1197
|
+
handleDrop(e, column.id, index)
|
|
1198
|
+
}}
|
|
1199
|
+
className="mb-3"
|
|
1200
|
+
>
|
|
1201
|
+
<KanbanCardComponent
|
|
1202
|
+
card={card}
|
|
1203
|
+
isDragging={draggedCard === card.id}
|
|
1204
|
+
onEdit={(e) => {
|
|
1205
|
+
e.stopPropagation()
|
|
1206
|
+
onCardEdit?.(card)
|
|
1207
|
+
}}
|
|
1208
|
+
onDelete={(e) => {
|
|
1209
|
+
e.stopPropagation()
|
|
1210
|
+
onCardDelete?.(card)
|
|
1211
|
+
}}
|
|
1212
|
+
onClick={() => handleCardClick(card)}
|
|
1213
|
+
showDetails={showCardDetails}
|
|
1214
|
+
disabled={disabled}
|
|
1215
|
+
/>
|
|
1216
|
+
</div>
|
|
1217
|
+
))}
|
|
1218
|
+
</AnimatePresence>
|
|
1219
|
+
</ScrollArea>
|
|
1220
|
+
|
|
1221
|
+
{/* Add card button */}
|
|
1222
|
+
{onAddCard && !column.locked && !isOverLimit && (
|
|
1223
|
+
<DropdownMenu>
|
|
1224
|
+
<DropdownMenuTrigger asChild>
|
|
1225
|
+
<Button
|
|
1226
|
+
variant="ghost"
|
|
1227
|
+
size="sm"
|
|
1228
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
1229
|
+
disabled={disabled}
|
|
1230
|
+
>
|
|
1231
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
1232
|
+
Add card
|
|
1233
|
+
</Button>
|
|
1234
|
+
</DropdownMenuTrigger>
|
|
1235
|
+
<DropdownMenuContent align="start" className="w-48">
|
|
1236
|
+
<DropdownMenuLabel>Card Templates</DropdownMenuLabel>
|
|
1237
|
+
<DropdownMenuSeparator />
|
|
1238
|
+
<DropdownMenuItem onClick={() => handleAddCard(column.id)}>
|
|
1239
|
+
<FileText className="mr-2 h-4 w-4" />
|
|
1240
|
+
Blank card
|
|
1241
|
+
</DropdownMenuItem>
|
|
1242
|
+
{cardTemplates.map((template, index) => (
|
|
1243
|
+
<DropdownMenuItem
|
|
1244
|
+
key={index}
|
|
1245
|
+
onClick={() => handleAddCard(column.id, template)}
|
|
1246
|
+
>
|
|
1247
|
+
<Star className="mr-2 h-4 w-4" />
|
|
1248
|
+
{template.title || `Template ${index + 1}`}
|
|
1249
|
+
</DropdownMenuItem>
|
|
1250
|
+
))}
|
|
1251
|
+
</DropdownMenuContent>
|
|
1252
|
+
</DropdownMenu>
|
|
1253
|
+
)}
|
|
1254
|
+
</CardContent>
|
|
1255
|
+
)}
|
|
1256
|
+
</Card>
|
|
1257
|
+
</motion.div>
|
|
1258
|
+
)
|
|
1259
|
+
})}
|
|
1260
|
+
</AnimatePresence>
|
|
1261
|
+
|
|
1262
|
+
{/* Add column */}
|
|
1263
|
+
{showAddColumn && onAddColumn && (
|
|
1264
|
+
<motion.div
|
|
1265
|
+
initial={{ opacity: 0 }}
|
|
1266
|
+
animate={{ opacity: 1 }}
|
|
1267
|
+
className="flex-shrink-0 w-80"
|
|
1268
|
+
>
|
|
1269
|
+
{isCreatingColumn ? (
|
|
1270
|
+
<Card>
|
|
1271
|
+
<CardHeader>
|
|
1272
|
+
<Input
|
|
1273
|
+
placeholder="Enter column title..."
|
|
1274
|
+
value={newColumnTitle}
|
|
1275
|
+
onChange={(e) => setNewColumnTitle(e.target.value)}
|
|
1276
|
+
onKeyDown={(e) => {
|
|
1277
|
+
if (e.key === 'Enter' && newColumnTitle) {
|
|
1278
|
+
onAddColumn({ title: newColumnTitle })
|
|
1279
|
+
setNewColumnTitle('')
|
|
1280
|
+
setIsCreatingColumn(false)
|
|
1281
|
+
}
|
|
1282
|
+
if (e.key === 'Escape') {
|
|
1283
|
+
setNewColumnTitle('')
|
|
1284
|
+
setIsCreatingColumn(false)
|
|
1285
|
+
}
|
|
1286
|
+
}}
|
|
1287
|
+
autoFocus
|
|
1288
|
+
/>
|
|
1289
|
+
</CardHeader>
|
|
1290
|
+
<CardContent>
|
|
1291
|
+
<div className="flex gap-2">
|
|
1292
|
+
<Button
|
|
1293
|
+
size="sm"
|
|
1294
|
+
onClick={() => {
|
|
1295
|
+
if (newColumnTitle) {
|
|
1296
|
+
onAddColumn({ title: newColumnTitle })
|
|
1297
|
+
setNewColumnTitle('')
|
|
1298
|
+
setIsCreatingColumn(false)
|
|
1299
|
+
}
|
|
1300
|
+
}}
|
|
1301
|
+
>
|
|
1302
|
+
Add column
|
|
1303
|
+
</Button>
|
|
1304
|
+
<Button
|
|
1305
|
+
size="sm"
|
|
1306
|
+
variant="ghost"
|
|
1307
|
+
onClick={() => {
|
|
1308
|
+
setNewColumnTitle('')
|
|
1309
|
+
setIsCreatingColumn(false)
|
|
1310
|
+
}}
|
|
1311
|
+
>
|
|
1312
|
+
Cancel
|
|
1313
|
+
</Button>
|
|
1314
|
+
</div>
|
|
1315
|
+
</CardContent>
|
|
1316
|
+
</Card>
|
|
1317
|
+
) : (
|
|
1318
|
+
<DropdownMenu>
|
|
1319
|
+
<DropdownMenuTrigger asChild>
|
|
1320
|
+
<Button
|
|
1321
|
+
variant="outline"
|
|
1322
|
+
className="w-full h-full min-h-[200px] border-dashed justify-center items-center"
|
|
1323
|
+
disabled={disabled}
|
|
1324
|
+
>
|
|
1325
|
+
<Plus className="h-6 w-6 mr-2" />
|
|
1326
|
+
Add column
|
|
1327
|
+
</Button>
|
|
1328
|
+
</DropdownMenuTrigger>
|
|
1329
|
+
<DropdownMenuContent align="start" className="w-48">
|
|
1330
|
+
<DropdownMenuLabel>Column Templates</DropdownMenuLabel>
|
|
1331
|
+
<DropdownMenuSeparator />
|
|
1332
|
+
<DropdownMenuItem onClick={() => setIsCreatingColumn(true)}>
|
|
1333
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
1334
|
+
Blank column
|
|
1335
|
+
</DropdownMenuItem>
|
|
1336
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.todo)}>
|
|
1337
|
+
<Square className="mr-2 h-4 w-4" />
|
|
1338
|
+
To Do
|
|
1339
|
+
</DropdownMenuItem>
|
|
1340
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.inProgress)}>
|
|
1341
|
+
<Clock className="mr-2 h-4 w-4" />
|
|
1342
|
+
In Progress
|
|
1343
|
+
</DropdownMenuItem>
|
|
1344
|
+
<DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.done)}>
|
|
1345
|
+
<CheckSquare className="mr-2 h-4 w-4" />
|
|
1346
|
+
Done
|
|
1347
|
+
</DropdownMenuItem>
|
|
1348
|
+
{columnTemplates.map((template, index) => (
|
|
1349
|
+
<DropdownMenuItem
|
|
1350
|
+
key={index}
|
|
1351
|
+
onClick={() => onAddColumn(template)}
|
|
1352
|
+
>
|
|
1353
|
+
<Star className="mr-2 h-4 w-4" />
|
|
1354
|
+
{template.title || `Template ${index + 1}`}
|
|
1355
|
+
</DropdownMenuItem>
|
|
1356
|
+
))}
|
|
1357
|
+
</DropdownMenuContent>
|
|
1358
|
+
</DropdownMenu>
|
|
1359
|
+
)}
|
|
1360
|
+
</motion.div>
|
|
1361
|
+
)}
|
|
1362
|
+
</div>
|
|
1363
|
+
|
|
1364
|
+
{/* Modals */}
|
|
1365
|
+
{/* Card Detail Modal */}
|
|
1366
|
+
{selectedCard && (
|
|
1367
|
+
<CardDetailModal
|
|
1368
|
+
card={selectedCard}
|
|
1369
|
+
isOpen={!!selectedCard}
|
|
1370
|
+
onClose={() => setSelectedCard(null)}
|
|
1371
|
+
onUpdate={handleCardUpdate}
|
|
1372
|
+
onDelete={(card) => {
|
|
1373
|
+
onCardDelete?.(card)
|
|
1374
|
+
toast({
|
|
1375
|
+
title: "Card deleted",
|
|
1376
|
+
description: `"${card.title}" has been deleted`
|
|
1377
|
+
})
|
|
1378
|
+
}}
|
|
1379
|
+
availableAssignees={users}
|
|
1380
|
+
availableLabels={labels}
|
|
1381
|
+
currentColumn={columns.find(col => col.cards.some(c => c.id === selectedCard.id))?.title}
|
|
1382
|
+
availableColumns={columns.map(col => ({ id: col.id, title: col.title }))}
|
|
1383
|
+
/>
|
|
1384
|
+
)}
|
|
1385
|
+
|
|
1386
|
+
{/* Add Card Modal */}
|
|
1387
|
+
{addCardColumnId && (
|
|
1388
|
+
<AddCardModal
|
|
1389
|
+
isOpen={!!addCardColumnId}
|
|
1390
|
+
onClose={() => setAddCardColumnId(null)}
|
|
1391
|
+
onAdd={handleAddNewCard}
|
|
1392
|
+
columnId={addCardColumnId}
|
|
1393
|
+
columnTitle={columns.find(col => col.id === addCardColumnId)?.title || ''}
|
|
1394
|
+
availableAssignees={users}
|
|
1395
|
+
availableLabels={labels}
|
|
1396
|
+
templates={cardTemplates}
|
|
1397
|
+
/>
|
|
1398
|
+
)}
|
|
1399
|
+
|
|
1400
|
+
{/* WIP Limit Modal */}
|
|
1401
|
+
<Dialog open={wipLimitModalOpen} onOpenChange={setWipLimitModalOpen}>
|
|
1402
|
+
<DialogContent className="sm:max-w-md">
|
|
1403
|
+
<DialogHeader>
|
|
1404
|
+
<DialogTitle>Set WIP Limit</DialogTitle>
|
|
1405
|
+
</DialogHeader>
|
|
1406
|
+
<div className="space-y-4 py-4">
|
|
1407
|
+
<div className="space-y-2">
|
|
1408
|
+
<Label htmlFor="wip-limit">Work In Progress Limit</Label>
|
|
1409
|
+
<Input
|
|
1410
|
+
id="wip-limit"
|
|
1411
|
+
type="number"
|
|
1412
|
+
min="0"
|
|
1413
|
+
value={wipLimit || ''}
|
|
1414
|
+
onChange={(e) => setWipLimit(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
1415
|
+
placeholder="Enter a number (leave empty to remove limit)"
|
|
1416
|
+
/>
|
|
1417
|
+
<p className="text-sm text-muted-foreground">
|
|
1418
|
+
Set a maximum number of cards allowed in this column. Leave empty to remove the limit.
|
|
1419
|
+
</p>
|
|
1420
|
+
</div>
|
|
1421
|
+
</div>
|
|
1422
|
+
<DialogFooter>
|
|
1423
|
+
<Button variant="outline" onClick={() => setWipLimitModalOpen(false)}>
|
|
1424
|
+
Cancel
|
|
1425
|
+
</Button>
|
|
1426
|
+
<Button onClick={handleWipLimitUpdate}>
|
|
1427
|
+
Save
|
|
1428
|
+
</Button>
|
|
1429
|
+
</DialogFooter>
|
|
1430
|
+
</DialogContent>
|
|
1431
|
+
</Dialog>
|
|
1432
|
+
|
|
1433
|
+
{/* Color Picker Modal */}
|
|
1434
|
+
<Dialog open={colorPickerOpen} onOpenChange={setColorPickerOpen}>
|
|
1435
|
+
<DialogContent className="sm:max-w-md">
|
|
1436
|
+
<DialogHeader>
|
|
1437
|
+
<DialogTitle>Change Column Color</DialogTitle>
|
|
1438
|
+
</DialogHeader>
|
|
1439
|
+
<div className="space-y-4 py-4">
|
|
1440
|
+
<div className="space-y-2">
|
|
1441
|
+
<Label>Select a color</Label>
|
|
1442
|
+
<div className="grid grid-cols-8 gap-2">
|
|
1443
|
+
{[
|
|
1444
|
+
'#6B7280', '#EF4444', '#F59E0B', '#10B981',
|
|
1445
|
+
'#3B82F6', '#8B5CF6', '#EC4899', '#06B6D4',
|
|
1446
|
+
'#F43F5E', '#84CC16', '#14B8A6', '#6366F1',
|
|
1447
|
+
'#A855F7', '#F472B6', '#0EA5E9', '#22D3EE'
|
|
1448
|
+
].map(color => (
|
|
1449
|
+
<button
|
|
1450
|
+
key={color}
|
|
1451
|
+
className={cn(
|
|
1452
|
+
"w-10 h-10 rounded-md border-2 transition-all",
|
|
1453
|
+
selectedColor === color ? "border-primary scale-110" : "border-transparent"
|
|
1454
|
+
)}
|
|
1455
|
+
style={{ backgroundColor: color }}
|
|
1456
|
+
onClick={() => setSelectedColor(color)}
|
|
1457
|
+
/>
|
|
1458
|
+
))}
|
|
1459
|
+
</div>
|
|
1460
|
+
</div>
|
|
1461
|
+
</div>
|
|
1462
|
+
<DialogFooter>
|
|
1463
|
+
<Button variant="outline" onClick={() => setColorPickerOpen(false)}>
|
|
1464
|
+
Cancel
|
|
1465
|
+
</Button>
|
|
1466
|
+
<Button onClick={handleColorUpdate}>
|
|
1467
|
+
Save
|
|
1468
|
+
</Button>
|
|
1469
|
+
</DialogFooter>
|
|
1470
|
+
</DialogContent>
|
|
1471
|
+
</Dialog>
|
|
1472
|
+
</div>
|
|
1473
|
+
)
|
|
1474
|
+
}
|