@nextsparkjs/theme-productivity 0.1.0-beta.1
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/README.md +76 -0
- package/about.md +123 -0
- package/components/CardDetailModal.tsx +318 -0
- package/components/KanbanBoard.tsx +612 -0
- package/components/KanbanCard.tsx +218 -0
- package/components/KanbanColumn.tsx +264 -0
- package/components/SortableList.tsx +46 -0
- package/components/index.ts +4 -0
- package/config/app.config.ts +172 -0
- package/config/billing.config.ts +187 -0
- package/config/dashboard.config.ts +357 -0
- package/config/dev.config.ts +55 -0
- package/config/features.config.ts +256 -0
- package/config/flows.config.ts +484 -0
- package/config/permissions.config.ts +167 -0
- package/config/theme.config.ts +106 -0
- package/entities/boards/boards.config.ts +61 -0
- package/entities/boards/boards.fields.ts +154 -0
- package/entities/boards/boards.service.ts +256 -0
- package/entities/boards/boards.types.ts +57 -0
- package/entities/boards/messages/en.json +80 -0
- package/entities/boards/messages/es.json +80 -0
- package/entities/boards/migrations/001_boards_table.sql +83 -0
- package/entities/cards/cards.config.ts +61 -0
- package/entities/cards/cards.fields.ts +242 -0
- package/entities/cards/cards.service.ts +336 -0
- package/entities/cards/cards.types.ts +79 -0
- package/entities/cards/messages/en.json +114 -0
- package/entities/cards/messages/es.json +114 -0
- package/entities/cards/migrations/020_cards_table.sql +92 -0
- package/entities/lists/lists.config.ts +61 -0
- package/entities/lists/lists.fields.ts +105 -0
- package/entities/lists/lists.service.ts +252 -0
- package/entities/lists/lists.types.ts +55 -0
- package/entities/lists/messages/en.json +60 -0
- package/entities/lists/messages/es.json +60 -0
- package/entities/lists/migrations/010_lists_table.sql +79 -0
- package/lib/selectors.ts +206 -0
- package/messages/en.json +79 -0
- package/messages/es.json +79 -0
- package/migrations/999_theme_sample_data.sql +922 -0
- package/migrations/999a_initial_sample_data.sql +377 -0
- package/migrations/999b_abundant_sample_data.sql +346 -0
- package/package.json +17 -0
- package/permissions-matrix.md +122 -0
- package/styles/components.css +460 -0
- package/styles/globals.css +560 -0
- package/templates/dashboard/(main)/boards/[id]/[cardId]/page.tsx +238 -0
- package/templates/dashboard/(main)/boards/[id]/edit/page.tsx +390 -0
- package/templates/dashboard/(main)/boards/[id]/page.tsx +236 -0
- package/templates/dashboard/(main)/boards/create/page.tsx +236 -0
- package/templates/dashboard/(main)/boards/page.tsx +335 -0
- package/templates/dashboard/(main)/layout.tsx +32 -0
- package/templates/dashboard/(main)/page.tsx +592 -0
- package/templates/shared/ProductivityMobileNav.tsx +410 -0
- package/templates/shared/ProductivitySidebar.tsx +538 -0
- package/templates/shared/ProductivityTopBar.tsx +317 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
DragEndEvent,
|
|
7
|
+
DragOverEvent,
|
|
8
|
+
DragStartEvent,
|
|
9
|
+
PointerSensor,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors,
|
|
12
|
+
pointerWithin,
|
|
13
|
+
rectIntersection,
|
|
14
|
+
DragOverlay,
|
|
15
|
+
type CollisionDetection,
|
|
16
|
+
} from '@dnd-kit/core'
|
|
17
|
+
import {
|
|
18
|
+
arrayMove,
|
|
19
|
+
SortableContext,
|
|
20
|
+
horizontalListSortingStrategy,
|
|
21
|
+
} from '@dnd-kit/sortable'
|
|
22
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
23
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
24
|
+
import { Skeleton } from '@nextsparkjs/core/components/ui/skeleton'
|
|
25
|
+
import { Plus, X } from 'lucide-react'
|
|
26
|
+
import { KanbanColumn, type ListData } from './KanbanColumn'
|
|
27
|
+
import { KanbanCard, type CardData } from './KanbanCard'
|
|
28
|
+
import { CardDetailModal } from './CardDetailModal'
|
|
29
|
+
import { SortableList } from './SortableList'
|
|
30
|
+
import { useToast } from '@nextsparkjs/core/hooks/useToast'
|
|
31
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
32
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get headers with x-team-id for API calls
|
|
36
|
+
*/
|
|
37
|
+
function getTeamHeaders(): HeadersInit {
|
|
38
|
+
const headers: HeadersInit = {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
}
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
const teamId = localStorage.getItem('activeTeamId')
|
|
43
|
+
if (teamId) {
|
|
44
|
+
headers['x-team-id'] = teamId
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return headers
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface KanbanBoardProps {
|
|
51
|
+
boardId: string
|
|
52
|
+
/** Optional card ID to open modal on mount (for shareable URLs) */
|
|
53
|
+
initialCardId?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Custom collision detection that prioritizes columns over cards
|
|
58
|
+
* This helps with dropping cards into empty columns or at the end of columns
|
|
59
|
+
*/
|
|
60
|
+
const customCollisionDetection: CollisionDetection = (args) => {
|
|
61
|
+
// First, check for pointer collisions (most precise)
|
|
62
|
+
const pointerCollisions = pointerWithin(args)
|
|
63
|
+
|
|
64
|
+
// If we have a pointer collision, prioritize columns (droppable areas)
|
|
65
|
+
if (pointerCollisions.length > 0) {
|
|
66
|
+
// Sort to prioritize columns over cards
|
|
67
|
+
const sortedCollisions = [...pointerCollisions].sort((a, b) => {
|
|
68
|
+
const aType = a.data?.droppableContainer?.data?.current?.type
|
|
69
|
+
const bType = b.data?.droppableContainer?.data?.current?.type
|
|
70
|
+
|
|
71
|
+
// Columns should come first
|
|
72
|
+
if (aType === 'column' && bType !== 'column') return -1
|
|
73
|
+
if (bType === 'column' && aType !== 'column') return 1
|
|
74
|
+
return 0
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return sortedCollisions
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Fallback to rect intersection
|
|
81
|
+
return rectIntersection(args)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function KanbanBoard({ boardId, initialCardId }: KanbanBoardProps) {
|
|
85
|
+
const [lists, setLists] = useState<ListData[]>([])
|
|
86
|
+
const [cards, setCards] = useState<CardData[]>([])
|
|
87
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
88
|
+
const [activeCard, setActiveCard] = useState<CardData | null>(null)
|
|
89
|
+
const [activeList, setActiveList] = useState<ListData | null>(null)
|
|
90
|
+
const [isAddingList, setIsAddingList] = useState(false)
|
|
91
|
+
const [newListName, setNewListName] = useState('')
|
|
92
|
+
const [selectedCard, setSelectedCard] = useState<CardData | null>(null)
|
|
93
|
+
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
94
|
+
const { toast } = useToast()
|
|
95
|
+
|
|
96
|
+
// Track if we've processed the initial card ID to avoid opening multiple times
|
|
97
|
+
const initialCardProcessed = useRef(false)
|
|
98
|
+
|
|
99
|
+
// Permission checks
|
|
100
|
+
const canMoveCards = usePermission('cards.move')
|
|
101
|
+
const canCreateLists = usePermission('lists.create')
|
|
102
|
+
const canReorderLists = usePermission('lists.reorder')
|
|
103
|
+
|
|
104
|
+
const sensors = useSensors(
|
|
105
|
+
useSensor(PointerSensor, {
|
|
106
|
+
activationConstraint: {
|
|
107
|
+
distance: 8,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Fetch lists and cards
|
|
113
|
+
const fetchData = useCallback(async () => {
|
|
114
|
+
try {
|
|
115
|
+
setIsLoading(true)
|
|
116
|
+
const headers = getTeamHeaders()
|
|
117
|
+
|
|
118
|
+
// Fetch lists for this board
|
|
119
|
+
const listsRes = await fetch(`/api/v1/lists?boardId=${boardId}&limit=100`, { headers })
|
|
120
|
+
const listsData = await listsRes.json()
|
|
121
|
+
const fetchedLists = (listsData.data || []).sort(
|
|
122
|
+
(a: ListData, b: ListData) => (a.position || 0) - (b.position || 0)
|
|
123
|
+
)
|
|
124
|
+
setLists(fetchedLists)
|
|
125
|
+
|
|
126
|
+
// Fetch cards for all lists
|
|
127
|
+
if (fetchedLists.length > 0) {
|
|
128
|
+
const cardsRes = await fetch(`/api/v1/cards?boardId=${boardId}&limit=500`, { headers })
|
|
129
|
+
const cardsData = await cardsRes.json()
|
|
130
|
+
const fetchedCards = (cardsData.data || []).sort(
|
|
131
|
+
(a: CardData, b: CardData) => (a.position || 0) - (b.position || 0)
|
|
132
|
+
)
|
|
133
|
+
setCards(fetchedCards)
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Error fetching board data:', error)
|
|
137
|
+
toast({
|
|
138
|
+
title: 'Error loading board',
|
|
139
|
+
description: 'Could not load board data.',
|
|
140
|
+
variant: 'destructive',
|
|
141
|
+
})
|
|
142
|
+
} finally {
|
|
143
|
+
setIsLoading(false)
|
|
144
|
+
}
|
|
145
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
146
|
+
}, [boardId])
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
fetchData()
|
|
150
|
+
}, [fetchData])
|
|
151
|
+
|
|
152
|
+
// Open card modal when initialCardId is provided and cards are loaded
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (
|
|
155
|
+
initialCardId &&
|
|
156
|
+
!initialCardProcessed.current &&
|
|
157
|
+
cards.length > 0 &&
|
|
158
|
+
!isLoading
|
|
159
|
+
) {
|
|
160
|
+
const cardToOpen = cards.find((c) => c.id === initialCardId)
|
|
161
|
+
if (cardToOpen) {
|
|
162
|
+
setSelectedCard(cardToOpen)
|
|
163
|
+
setIsModalOpen(true)
|
|
164
|
+
initialCardProcessed.current = true
|
|
165
|
+
} else {
|
|
166
|
+
// Card not found - show a toast and update URL
|
|
167
|
+
toast({
|
|
168
|
+
title: 'Card not found',
|
|
169
|
+
description: 'The requested card could not be found.',
|
|
170
|
+
variant: 'destructive',
|
|
171
|
+
})
|
|
172
|
+
// Update URL without page reload
|
|
173
|
+
window.history.replaceState(null, '', `/dashboard/boards/${boardId}`)
|
|
174
|
+
initialCardProcessed.current = true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}, [initialCardId, cards, isLoading, boardId, toast])
|
|
178
|
+
|
|
179
|
+
// Get cards for a specific list
|
|
180
|
+
const getCardsForList = (listId: string) => {
|
|
181
|
+
return cards.filter((card) => card.listId === listId)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle drag start
|
|
185
|
+
const handleDragStart = (event: DragStartEvent) => {
|
|
186
|
+
const { active } = event
|
|
187
|
+
const dragData = active.data.current
|
|
188
|
+
|
|
189
|
+
if (dragData?.type === 'card') {
|
|
190
|
+
// Don't allow card drag if user doesn't have move permission
|
|
191
|
+
if (!canMoveCards) return
|
|
192
|
+
setActiveCard(dragData.card)
|
|
193
|
+
} else if (dragData?.type === 'list') {
|
|
194
|
+
// Don't allow list drag if user doesn't have reorder permission
|
|
195
|
+
if (!canReorderLists) return
|
|
196
|
+
setActiveList(dragData.list)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Handle drag over (for moving between columns)
|
|
201
|
+
const handleDragOver = (event: DragOverEvent) => {
|
|
202
|
+
const { active, over } = event
|
|
203
|
+
if (!over) return
|
|
204
|
+
|
|
205
|
+
const activeId = active.id as string
|
|
206
|
+
const overId = over.id as string
|
|
207
|
+
|
|
208
|
+
if (activeId === overId) return
|
|
209
|
+
|
|
210
|
+
const activeData = active.data.current
|
|
211
|
+
const overData = over.data.current
|
|
212
|
+
|
|
213
|
+
// Only handle card movements
|
|
214
|
+
if (activeData?.type !== 'card') return
|
|
215
|
+
|
|
216
|
+
const activeCard = cards.find((c) => c.id === activeId)
|
|
217
|
+
if (!activeCard) return
|
|
218
|
+
|
|
219
|
+
// Determine target list
|
|
220
|
+
let targetListId: string | null = null
|
|
221
|
+
|
|
222
|
+
// Check for column/list (handles both 'column' from useDroppable and 'list' from useSortable)
|
|
223
|
+
if (overData?.type === 'column' || overData?.type === 'list') {
|
|
224
|
+
// Dropped on a column
|
|
225
|
+
targetListId = overId
|
|
226
|
+
} else if (overData?.type === 'card') {
|
|
227
|
+
// Dropped on another card - get its list
|
|
228
|
+
const overCard = cards.find((c) => c.id === overId)
|
|
229
|
+
if (overCard) {
|
|
230
|
+
targetListId = overCard.listId
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If moving to a different list, update immediately for visual feedback
|
|
235
|
+
if (targetListId && activeCard.listId !== targetListId) {
|
|
236
|
+
setCards((prev) =>
|
|
237
|
+
prev.map((card) =>
|
|
238
|
+
card.id === activeId ? { ...card, listId: targetListId! } : card
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Handle drag end
|
|
245
|
+
const handleDragEnd = async (event: DragEndEvent) => {
|
|
246
|
+
setActiveCard(null)
|
|
247
|
+
setActiveList(null)
|
|
248
|
+
|
|
249
|
+
const { active, over } = event
|
|
250
|
+
if (!over) return
|
|
251
|
+
|
|
252
|
+
const activeId = active.id as string
|
|
253
|
+
const overId = over.id as string
|
|
254
|
+
|
|
255
|
+
const activeData = active.data.current
|
|
256
|
+
const overData = over.data.current
|
|
257
|
+
|
|
258
|
+
// Handle list reordering
|
|
259
|
+
if (activeData?.type === 'list') {
|
|
260
|
+
if (activeId !== overId) {
|
|
261
|
+
const oldIndex = lists.findIndex((l) => l.id === activeId)
|
|
262
|
+
const newIndex = lists.findIndex((l) => l.id === overId)
|
|
263
|
+
|
|
264
|
+
if (oldIndex !== -1 && newIndex !== -1) {
|
|
265
|
+
const reorderedLists = arrayMove(lists, oldIndex, newIndex)
|
|
266
|
+
setLists(reorderedLists)
|
|
267
|
+
|
|
268
|
+
// Update positions on server
|
|
269
|
+
try {
|
|
270
|
+
await fetch(`/api/v1/lists/${activeId}`, {
|
|
271
|
+
method: 'PATCH',
|
|
272
|
+
headers: getTeamHeaders(),
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
position: newIndex,
|
|
275
|
+
}),
|
|
276
|
+
})
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error('Error reordering list:', error)
|
|
279
|
+
fetchData()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (activeData?.type !== 'card') return
|
|
287
|
+
|
|
288
|
+
const activeCard = cards.find((c) => c.id === activeId)
|
|
289
|
+
if (!activeCard) return
|
|
290
|
+
|
|
291
|
+
// Determine final list and position
|
|
292
|
+
let targetListId = activeCard.listId
|
|
293
|
+
let newPosition = activeCard.position || 0
|
|
294
|
+
|
|
295
|
+
// Check if dropped on a column/list (handles both 'column' from useDroppable and 'list' from useSortable)
|
|
296
|
+
if (overData?.type === 'column' || overData?.type === 'list') {
|
|
297
|
+
targetListId = overId
|
|
298
|
+
// Add to end of list
|
|
299
|
+
const listCards = cards.filter((c) => c.listId === overId)
|
|
300
|
+
newPosition = listCards.length
|
|
301
|
+
} else if (overData?.type === 'card') {
|
|
302
|
+
const overCard = cards.find((c) => c.id === overId)
|
|
303
|
+
if (overCard) {
|
|
304
|
+
targetListId = overCard.listId
|
|
305
|
+
|
|
306
|
+
// Reorder within list
|
|
307
|
+
const listCards = cards.filter((c) => c.listId === targetListId)
|
|
308
|
+
const oldIndex = listCards.findIndex((c) => c.id === activeId)
|
|
309
|
+
const newIndex = listCards.findIndex((c) => c.id === overId)
|
|
310
|
+
|
|
311
|
+
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
312
|
+
const reordered = arrayMove(listCards, oldIndex, newIndex)
|
|
313
|
+
setCards((prev) => {
|
|
314
|
+
const otherCards = prev.filter((c) => c.listId !== targetListId)
|
|
315
|
+
return [...otherCards, ...reordered]
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
newPosition = newIndex
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Update on server
|
|
324
|
+
try {
|
|
325
|
+
await fetch(`/api/v1/cards/${activeId}`, {
|
|
326
|
+
method: 'PATCH',
|
|
327
|
+
headers: getTeamHeaders(),
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
listId: targetListId,
|
|
330
|
+
position: newPosition,
|
|
331
|
+
}),
|
|
332
|
+
})
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error('Error moving card:', error)
|
|
335
|
+
// Refetch to sync state
|
|
336
|
+
fetchData()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Add new list
|
|
341
|
+
const handleAddList = async () => {
|
|
342
|
+
if (!newListName.trim()) return
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch('/api/v1/lists', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: getTeamHeaders(),
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
name: newListName.trim(),
|
|
350
|
+
boardId,
|
|
351
|
+
position: lists.length,
|
|
352
|
+
}),
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
if (response.ok) {
|
|
356
|
+
const newList = await response.json()
|
|
357
|
+
setLists((prev) => [...prev, newList.data || newList])
|
|
358
|
+
setNewListName('')
|
|
359
|
+
setIsAddingList(false)
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.error('Error creating list:', error)
|
|
363
|
+
toast({
|
|
364
|
+
title: 'Error',
|
|
365
|
+
description: 'Could not create list.',
|
|
366
|
+
variant: 'destructive',
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add new card
|
|
372
|
+
const handleAddCard = async (listId: string, title: string) => {
|
|
373
|
+
try {
|
|
374
|
+
const listCards = getCardsForList(listId)
|
|
375
|
+
const response = await fetch('/api/v1/cards', {
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: getTeamHeaders(),
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
title,
|
|
380
|
+
listId,
|
|
381
|
+
boardId,
|
|
382
|
+
position: listCards.length,
|
|
383
|
+
}),
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
if (response.ok) {
|
|
387
|
+
const newCard = await response.json()
|
|
388
|
+
setCards((prev) => [...prev, newCard.data || newCard])
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.error('Error creating card:', error)
|
|
392
|
+
toast({
|
|
393
|
+
title: 'Error',
|
|
394
|
+
description: 'Could not create card.',
|
|
395
|
+
variant: 'destructive',
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Handle card click to open modal and update URL
|
|
401
|
+
const handleCardClick = (card: CardData) => {
|
|
402
|
+
setSelectedCard(card)
|
|
403
|
+
setIsModalOpen(true)
|
|
404
|
+
// Update URL without page reload using History API
|
|
405
|
+
window.history.pushState(null, '', `/dashboard/boards/${boardId}/${card.id}`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Handle modal close - update URL back to board
|
|
409
|
+
const handleModalClose = () => {
|
|
410
|
+
setIsModalOpen(false)
|
|
411
|
+
setSelectedCard(null)
|
|
412
|
+
// Update URL without page reload using History API
|
|
413
|
+
window.history.pushState(null, '', `/dashboard/boards/${boardId}`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Handle card update from modal
|
|
417
|
+
const handleCardUpdate = async (updatedCard: CardData) => {
|
|
418
|
+
try {
|
|
419
|
+
const response = await fetch(`/api/v1/cards/${updatedCard.id}`, {
|
|
420
|
+
method: 'PATCH',
|
|
421
|
+
headers: getTeamHeaders(),
|
|
422
|
+
body: JSON.stringify({
|
|
423
|
+
title: updatedCard.title,
|
|
424
|
+
description: updatedCard.description,
|
|
425
|
+
priority: updatedCard.priority,
|
|
426
|
+
dueDate: updatedCard.dueDate,
|
|
427
|
+
}),
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
if (response.ok) {
|
|
431
|
+
// Update local state
|
|
432
|
+
setCards((prev) =>
|
|
433
|
+
prev.map((card) => (card.id === updatedCard.id ? updatedCard : card))
|
|
434
|
+
)
|
|
435
|
+
toast({
|
|
436
|
+
title: 'Card updated',
|
|
437
|
+
description: 'Changes saved successfully.',
|
|
438
|
+
})
|
|
439
|
+
} else {
|
|
440
|
+
throw new Error('Failed to update card')
|
|
441
|
+
}
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error('Error updating card:', error)
|
|
444
|
+
toast({
|
|
445
|
+
title: 'Error',
|
|
446
|
+
description: 'Could not update card.',
|
|
447
|
+
variant: 'destructive',
|
|
448
|
+
})
|
|
449
|
+
throw error
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Handle card delete from modal
|
|
454
|
+
const handleCardDelete = async (cardId: string) => {
|
|
455
|
+
try {
|
|
456
|
+
const response = await fetch(`/api/v1/cards/${cardId}`, {
|
|
457
|
+
method: 'DELETE',
|
|
458
|
+
headers: getTeamHeaders(),
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
if (response.ok) {
|
|
462
|
+
// Remove from local state
|
|
463
|
+
setCards((prev) => prev.filter((card) => card.id !== cardId))
|
|
464
|
+
toast({
|
|
465
|
+
title: 'Card deleted',
|
|
466
|
+
description: 'Card has been removed.',
|
|
467
|
+
})
|
|
468
|
+
} else {
|
|
469
|
+
throw new Error('Failed to delete card')
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
console.error('Error deleting card:', error)
|
|
473
|
+
toast({
|
|
474
|
+
title: 'Error',
|
|
475
|
+
description: 'Could not delete card.',
|
|
476
|
+
variant: 'destructive',
|
|
477
|
+
})
|
|
478
|
+
throw error
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (isLoading) {
|
|
483
|
+
return (
|
|
484
|
+
<div className="flex gap-4 p-4 overflow-x-auto">
|
|
485
|
+
{[1, 2, 3].map((i) => (
|
|
486
|
+
<div key={i} className="w-72 flex-shrink-0">
|
|
487
|
+
<Skeleton className="h-8 w-32 mb-3" />
|
|
488
|
+
<div className="space-y-2">
|
|
489
|
+
<Skeleton className="h-20 w-full" />
|
|
490
|
+
<Skeleton className="h-20 w-full" />
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
))}
|
|
494
|
+
</div>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<DndContext
|
|
500
|
+
sensors={sensors}
|
|
501
|
+
collisionDetection={customCollisionDetection}
|
|
502
|
+
onDragStart={handleDragStart}
|
|
503
|
+
onDragOver={handleDragOver}
|
|
504
|
+
onDragEnd={handleDragEnd}
|
|
505
|
+
>
|
|
506
|
+
<div
|
|
507
|
+
className="flex gap-4 p-4 overflow-x-auto min-h-[calc(100vh-200px)]"
|
|
508
|
+
data-cy="kanban-board"
|
|
509
|
+
>
|
|
510
|
+
{/* Columns with sortable context */}
|
|
511
|
+
<SortableContext
|
|
512
|
+
items={lists.map((l) => l.id)}
|
|
513
|
+
strategy={horizontalListSortingStrategy}
|
|
514
|
+
>
|
|
515
|
+
{lists.map((list) => (
|
|
516
|
+
<SortableList
|
|
517
|
+
key={list.id}
|
|
518
|
+
list={list}
|
|
519
|
+
disabled={!canReorderLists}
|
|
520
|
+
>
|
|
521
|
+
{({ dragHandleProps }) => (
|
|
522
|
+
<KanbanColumn
|
|
523
|
+
list={list}
|
|
524
|
+
cards={getCardsForList(list.id)}
|
|
525
|
+
onAddCard={handleAddCard}
|
|
526
|
+
onCardClick={handleCardClick}
|
|
527
|
+
dragHandleProps={canReorderLists ? dragHandleProps : undefined}
|
|
528
|
+
/>
|
|
529
|
+
)}
|
|
530
|
+
</SortableList>
|
|
531
|
+
))}
|
|
532
|
+
</SortableContext>
|
|
533
|
+
|
|
534
|
+
{/* Add List - Only shown if user has permission */}
|
|
535
|
+
<PermissionGate permission="lists.create">
|
|
536
|
+
<div className="w-72 flex-shrink-0">
|
|
537
|
+
{isAddingList ? (
|
|
538
|
+
<div className="bg-muted/50 rounded-lg p-3 space-y-2" data-cy="lists-add-form">
|
|
539
|
+
<Input
|
|
540
|
+
autoFocus
|
|
541
|
+
placeholder="Enter list name..."
|
|
542
|
+
value={newListName}
|
|
543
|
+
onChange={(e) => setNewListName(e.target.value)}
|
|
544
|
+
onKeyDown={(e) => {
|
|
545
|
+
if (e.key === 'Enter') handleAddList()
|
|
546
|
+
if (e.key === 'Escape') {
|
|
547
|
+
setIsAddingList(false)
|
|
548
|
+
setNewListName('')
|
|
549
|
+
}
|
|
550
|
+
}}
|
|
551
|
+
data-cy="lists-field-name"
|
|
552
|
+
/>
|
|
553
|
+
<div className="flex items-center gap-2">
|
|
554
|
+
<Button size="sm" onClick={handleAddList} disabled={!newListName.trim()} data-cy="lists-form-submit">
|
|
555
|
+
Add List
|
|
556
|
+
</Button>
|
|
557
|
+
<Button
|
|
558
|
+
size="sm"
|
|
559
|
+
variant="ghost"
|
|
560
|
+
onClick={() => {
|
|
561
|
+
setIsAddingList(false)
|
|
562
|
+
setNewListName('')
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
<X className="h-4 w-4" />
|
|
566
|
+
</Button>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
) : (
|
|
570
|
+
<Button
|
|
571
|
+
variant="outline"
|
|
572
|
+
className="w-full justify-start bg-muted/30 hover:bg-muted/50"
|
|
573
|
+
onClick={() => setIsAddingList(true)}
|
|
574
|
+
data-cy="lists-add-column"
|
|
575
|
+
>
|
|
576
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
577
|
+
Add another list
|
|
578
|
+
</Button>
|
|
579
|
+
)}
|
|
580
|
+
</div>
|
|
581
|
+
</PermissionGate>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
{/* Drag Overlay */}
|
|
585
|
+
<DragOverlay>
|
|
586
|
+
{activeCard && (
|
|
587
|
+
<div className="rotate-3 opacity-90">
|
|
588
|
+
<KanbanCard card={activeCard} />
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
591
|
+
{activeList && (
|
|
592
|
+
<div className="rotate-2 opacity-90">
|
|
593
|
+
<KanbanColumn
|
|
594
|
+
list={activeList}
|
|
595
|
+
cards={getCardsForList(activeList.id)}
|
|
596
|
+
/>
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
599
|
+
</DragOverlay>
|
|
600
|
+
|
|
601
|
+
{/* Card Detail Modal */}
|
|
602
|
+
<CardDetailModal
|
|
603
|
+
card={selectedCard}
|
|
604
|
+
isOpen={isModalOpen}
|
|
605
|
+
onClose={handleModalClose}
|
|
606
|
+
onUpdate={handleCardUpdate}
|
|
607
|
+
onDelete={handleCardDelete}
|
|
608
|
+
/>
|
|
609
|
+
</DndContext>
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|