@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.
@@ -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
+ }