@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,238 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Productivity Theme - Board Detail Page with Card Modal Open
|
|
5
|
+
*
|
|
6
|
+
* This page renders the board with a specific card modal pre-opened.
|
|
7
|
+
* URL pattern: /dashboard/boards/{boardId}/{cardId}
|
|
8
|
+
*
|
|
9
|
+
* This enables shareable URLs for cards within a board.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useEffect, use } from 'react'
|
|
13
|
+
import Link from 'next/link'
|
|
14
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
15
|
+
import { Badge } from '@nextsparkjs/core/components/ui/badge'
|
|
16
|
+
import { Skeleton } from '@nextsparkjs/core/components/ui/skeleton'
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuSeparator,
|
|
22
|
+
DropdownMenuTrigger,
|
|
23
|
+
} from '@nextsparkjs/core/components/ui/dropdown-menu'
|
|
24
|
+
import { ArrowLeft, MoreVertical, Settings, Archive, Trash2 } from 'lucide-react'
|
|
25
|
+
import { KanbanBoard } from '@/themes/productivity/components/KanbanBoard'
|
|
26
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
27
|
+
import { useToast } from '@nextsparkjs/core/hooks/useToast'
|
|
28
|
+
import { useRouter } from 'next/navigation'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get headers with x-team-id for API calls
|
|
32
|
+
*/
|
|
33
|
+
function getTeamHeaders(): HeadersInit {
|
|
34
|
+
const headers: HeadersInit = {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
}
|
|
37
|
+
if (typeof window !== 'undefined') {
|
|
38
|
+
const teamId = localStorage.getItem('activeTeamId')
|
|
39
|
+
if (teamId) {
|
|
40
|
+
headers['x-team-id'] = teamId
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return headers
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Board {
|
|
47
|
+
id: string
|
|
48
|
+
name: string
|
|
49
|
+
description?: string | null
|
|
50
|
+
color?: string | null
|
|
51
|
+
status?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PageProps {
|
|
55
|
+
params: Promise<{ id: string; cardId: string }>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function BoardDetailWithCardPage({ params }: PageProps) {
|
|
59
|
+
const resolvedParams = use(params)
|
|
60
|
+
const boardId = resolvedParams.id
|
|
61
|
+
const cardId = resolvedParams.cardId
|
|
62
|
+
|
|
63
|
+
const [board, setBoard] = useState<Board | null>(null)
|
|
64
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
65
|
+
const { toast } = useToast()
|
|
66
|
+
const router = useRouter()
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const fetchBoard = async () => {
|
|
70
|
+
try {
|
|
71
|
+
setIsLoading(true)
|
|
72
|
+
const response = await fetch(`/api/v1/boards/${boardId}`, {
|
|
73
|
+
headers: getTeamHeaders(),
|
|
74
|
+
})
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error('Board not found')
|
|
77
|
+
}
|
|
78
|
+
const data = await response.json()
|
|
79
|
+
setBoard(data.data || data)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Error fetching board:', error)
|
|
82
|
+
toast({
|
|
83
|
+
title: 'Error',
|
|
84
|
+
description: 'Could not load board.',
|
|
85
|
+
variant: 'destructive',
|
|
86
|
+
})
|
|
87
|
+
} finally {
|
|
88
|
+
setIsLoading(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fetchBoard()
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, [boardId])
|
|
95
|
+
|
|
96
|
+
const handleArchive = async () => {
|
|
97
|
+
try {
|
|
98
|
+
await fetch(`/api/v1/boards/${boardId}`, {
|
|
99
|
+
method: 'PATCH',
|
|
100
|
+
headers: getTeamHeaders(),
|
|
101
|
+
body: JSON.stringify({ status: 'archived' }),
|
|
102
|
+
})
|
|
103
|
+
toast({ title: 'Board archived' })
|
|
104
|
+
router.push('/dashboard/boards')
|
|
105
|
+
} catch (error) {
|
|
106
|
+
toast({
|
|
107
|
+
title: 'Error',
|
|
108
|
+
description: 'Could not archive board.',
|
|
109
|
+
variant: 'destructive',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const handleDelete = async () => {
|
|
115
|
+
if (!confirm('Are you sure you want to delete this board? All lists and cards will be deleted.')) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await fetch(`/api/v1/boards/${boardId}`, {
|
|
121
|
+
method: 'DELETE',
|
|
122
|
+
headers: getTeamHeaders(),
|
|
123
|
+
})
|
|
124
|
+
toast({ title: 'Board deleted' })
|
|
125
|
+
router.push('/dashboard/boards')
|
|
126
|
+
} catch (error) {
|
|
127
|
+
toast({
|
|
128
|
+
title: 'Error',
|
|
129
|
+
description: 'Could not delete board.',
|
|
130
|
+
variant: 'destructive',
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isLoading) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="h-full flex flex-col">
|
|
138
|
+
<div className="flex items-center justify-between p-4 border-b">
|
|
139
|
+
<div className="flex items-center gap-4">
|
|
140
|
+
<Skeleton className="h-8 w-8" />
|
|
141
|
+
<Skeleton className="h-6 w-48" />
|
|
142
|
+
</div>
|
|
143
|
+
<Skeleton className="h-8 w-8" />
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex-1 p-4">
|
|
146
|
+
<div className="flex gap-4">
|
|
147
|
+
{[1, 2, 3].map((i) => (
|
|
148
|
+
<Skeleton key={i} className="w-72 h-96" />
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!board) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex flex-col items-center justify-center h-full">
|
|
159
|
+
<h2 className="text-xl font-semibold mb-2">Board not found</h2>
|
|
160
|
+
<p className="text-muted-foreground mb-4">
|
|
161
|
+
This board may have been deleted or you don't have access.
|
|
162
|
+
</p>
|
|
163
|
+
<Link href="/dashboard/boards">
|
|
164
|
+
<Button variant="outline">
|
|
165
|
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
166
|
+
Back to Boards
|
|
167
|
+
</Button>
|
|
168
|
+
</Link>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="h-full flex flex-col">
|
|
175
|
+
{/* Header */}
|
|
176
|
+
<div className="flex items-center justify-between px-4 py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
177
|
+
<div className="flex items-center gap-4">
|
|
178
|
+
<Link href="/dashboard/boards">
|
|
179
|
+
<Button variant="ghost" size="sm">
|
|
180
|
+
<ArrowLeft className="h-4 w-4" />
|
|
181
|
+
</Button>
|
|
182
|
+
</Link>
|
|
183
|
+
|
|
184
|
+
<div>
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<h1 className="text-xl font-semibold">{board.name}</h1>
|
|
187
|
+
{board.status && board.status !== 'active' && (
|
|
188
|
+
<Badge variant="secondary">{board.status}</Badge>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
{board.description && (
|
|
192
|
+
<p className="text-sm text-muted-foreground">{board.description}</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<DropdownMenu>
|
|
198
|
+
<DropdownMenuTrigger asChild>
|
|
199
|
+
<Button variant="ghost" size="sm">
|
|
200
|
+
<MoreVertical className="h-4 w-4" />
|
|
201
|
+
</Button>
|
|
202
|
+
</DropdownMenuTrigger>
|
|
203
|
+
<DropdownMenuContent align="end">
|
|
204
|
+
<PermissionGate permission="boards.update">
|
|
205
|
+
<DropdownMenuItem asChild>
|
|
206
|
+
<Link href={`/dashboard/boards/${boardId}/edit`}>
|
|
207
|
+
<Settings className="h-4 w-4 mr-2" />
|
|
208
|
+
Settings
|
|
209
|
+
</Link>
|
|
210
|
+
</DropdownMenuItem>
|
|
211
|
+
</PermissionGate>
|
|
212
|
+
<DropdownMenuSeparator />
|
|
213
|
+
<PermissionGate permission="boards.archive">
|
|
214
|
+
<DropdownMenuItem onClick={handleArchive}>
|
|
215
|
+
<Archive className="h-4 w-4 mr-2" />
|
|
216
|
+
Archive Board
|
|
217
|
+
</DropdownMenuItem>
|
|
218
|
+
</PermissionGate>
|
|
219
|
+
<PermissionGate permission="boards.delete">
|
|
220
|
+
<DropdownMenuItem
|
|
221
|
+
className="text-destructive"
|
|
222
|
+
onClick={handleDelete}
|
|
223
|
+
>
|
|
224
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
225
|
+
Delete Board
|
|
226
|
+
</DropdownMenuItem>
|
|
227
|
+
</PermissionGate>
|
|
228
|
+
</DropdownMenuContent>
|
|
229
|
+
</DropdownMenu>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Kanban Board with initial card ID */}
|
|
233
|
+
<div className="flex-1 overflow-hidden">
|
|
234
|
+
<KanbanBoard boardId={boardId} initialCardId={cardId} />
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Productivity Theme - Edit Board Page
|
|
5
|
+
*
|
|
6
|
+
* Form for editing an existing board.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useEffect, use, useRef, useCallback } from 'react'
|
|
10
|
+
import Link from 'next/link'
|
|
11
|
+
import { useRouter } from 'next/navigation'
|
|
12
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
|
|
13
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
14
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
15
|
+
import { Textarea } from '@nextsparkjs/core/components/ui/textarea'
|
|
16
|
+
import { Label } from '@nextsparkjs/core/components/ui/label'
|
|
17
|
+
import { Skeleton } from '@nextsparkjs/core/components/ui/skeleton'
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from '@nextsparkjs/core/components/ui/select'
|
|
25
|
+
import { Switch } from '@nextsparkjs/core/components/ui/switch'
|
|
26
|
+
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
|
27
|
+
import { useToast } from '@nextsparkjs/core/hooks/useToast'
|
|
28
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
29
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
30
|
+
import { NoPermission } from '@nextsparkjs/core/components/permissions/NoPermission'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get headers with x-team-id for API calls
|
|
34
|
+
*/
|
|
35
|
+
function getTeamHeaders(): HeadersInit {
|
|
36
|
+
const headers: HeadersInit = {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
}
|
|
39
|
+
if (typeof window !== 'undefined') {
|
|
40
|
+
const teamId = localStorage.getItem('activeTeamId')
|
|
41
|
+
if (teamId) {
|
|
42
|
+
headers['x-team-id'] = teamId
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return headers
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const colorOptions = [
|
|
49
|
+
{ value: 'blue', label: 'Blue', class: 'bg-blue-500' },
|
|
50
|
+
{ value: 'green', label: 'Green', class: 'bg-green-500' },
|
|
51
|
+
{ value: 'purple', label: 'Purple', class: 'bg-purple-500' },
|
|
52
|
+
{ value: 'orange', label: 'Orange', class: 'bg-orange-500' },
|
|
53
|
+
{ value: 'red', label: 'Red', class: 'bg-red-500' },
|
|
54
|
+
{ value: 'pink', label: 'Pink', class: 'bg-pink-500' },
|
|
55
|
+
{ value: 'gray', label: 'Gray', class: 'bg-gray-500' },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
interface PageProps {
|
|
59
|
+
params: Promise<{ id: string }>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function EditBoardPage({ params }: PageProps) {
|
|
63
|
+
const resolvedParams = use(params)
|
|
64
|
+
const boardId = resolvedParams.id
|
|
65
|
+
|
|
66
|
+
const router = useRouter()
|
|
67
|
+
const { toast } = useToast()
|
|
68
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
69
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
70
|
+
const [formData, setFormData] = useState({
|
|
71
|
+
name: '',
|
|
72
|
+
description: '',
|
|
73
|
+
color: 'blue',
|
|
74
|
+
archived: false,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Permission check for boards.update
|
|
78
|
+
const hasUpdatePermission = usePermission('boards.update')
|
|
79
|
+
|
|
80
|
+
// Use refs for toast and router to avoid useEffect dependency issues
|
|
81
|
+
const toastRef = useRef(toast)
|
|
82
|
+
const routerRef = useRef(router)
|
|
83
|
+
const hasFetchedRef = useRef(false)
|
|
84
|
+
|
|
85
|
+
// Keep refs updated
|
|
86
|
+
toastRef.current = toast
|
|
87
|
+
routerRef.current = router
|
|
88
|
+
|
|
89
|
+
const fetchBoard = useCallback(async () => {
|
|
90
|
+
// Prevent duplicate fetches
|
|
91
|
+
if (hasFetchedRef.current) return
|
|
92
|
+
hasFetchedRef.current = true
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
setIsLoading(true)
|
|
96
|
+
const response = await fetch(`/api/v1/boards/${boardId}`, {
|
|
97
|
+
headers: getTeamHeaders(),
|
|
98
|
+
})
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error('Board not found')
|
|
101
|
+
}
|
|
102
|
+
const data = await response.json()
|
|
103
|
+
const board = data.data || data
|
|
104
|
+
setFormData({
|
|
105
|
+
name: board.name || '',
|
|
106
|
+
description: board.description || '',
|
|
107
|
+
color: board.color || 'blue',
|
|
108
|
+
archived: board.archived || board.status === 'archived' || false,
|
|
109
|
+
})
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Error fetching board:', error)
|
|
112
|
+
toastRef.current({
|
|
113
|
+
title: 'Error',
|
|
114
|
+
description: 'Could not load board.',
|
|
115
|
+
variant: 'destructive',
|
|
116
|
+
})
|
|
117
|
+
routerRef.current.push('/dashboard/boards')
|
|
118
|
+
} finally {
|
|
119
|
+
setIsLoading(false)
|
|
120
|
+
}
|
|
121
|
+
}, [boardId])
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
fetchBoard()
|
|
125
|
+
}, [fetchBoard])
|
|
126
|
+
|
|
127
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
128
|
+
e.preventDefault()
|
|
129
|
+
|
|
130
|
+
if (!formData.name.trim()) {
|
|
131
|
+
toast({
|
|
132
|
+
title: 'Validation Error',
|
|
133
|
+
description: 'Board name is required.',
|
|
134
|
+
variant: 'destructive',
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setIsSubmitting(true)
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(`/api/v1/boards/${boardId}`, {
|
|
143
|
+
method: 'PATCH',
|
|
144
|
+
headers: getTeamHeaders(),
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
name: formData.name,
|
|
147
|
+
description: formData.description,
|
|
148
|
+
color: formData.color,
|
|
149
|
+
status: formData.archived ? 'archived' : 'active',
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const error = await response.json()
|
|
155
|
+
throw new Error(error.message || 'Failed to update board')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
toast({ title: 'Board updated successfully!' })
|
|
159
|
+
router.push(`/dashboard/boards/${boardId}`)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('Error updating board:', error)
|
|
162
|
+
toast({
|
|
163
|
+
title: 'Error',
|
|
164
|
+
description: error instanceof Error ? error.message : 'Could not update board.',
|
|
165
|
+
variant: 'destructive',
|
|
166
|
+
})
|
|
167
|
+
} finally {
|
|
168
|
+
setIsSubmitting(false)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const handleDelete = async () => {
|
|
173
|
+
if (!confirm('Are you sure you want to delete this board? All lists and cards will be deleted.')) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setIsSubmitting(true)
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetch(`/api/v1/boards/${boardId}`, {
|
|
181
|
+
method: 'DELETE',
|
|
182
|
+
headers: getTeamHeaders(),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const error = await response.json()
|
|
187
|
+
throw new Error(error.message || 'Failed to delete board')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
toast({ title: 'Board deleted' })
|
|
191
|
+
router.push('/dashboard/boards')
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error('Error deleting board:', error)
|
|
194
|
+
toast({
|
|
195
|
+
title: 'Error',
|
|
196
|
+
description: error instanceof Error ? error.message : 'Could not delete board.',
|
|
197
|
+
variant: 'destructive',
|
|
198
|
+
})
|
|
199
|
+
} finally {
|
|
200
|
+
setIsSubmitting(false)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (isLoading) {
|
|
205
|
+
return (
|
|
206
|
+
<div className="p-6 max-w-2xl mx-auto">
|
|
207
|
+
<div className="flex items-center gap-4 mb-6">
|
|
208
|
+
<Skeleton className="h-8 w-8" />
|
|
209
|
+
<div>
|
|
210
|
+
<Skeleton className="h-7 w-48 mb-2" />
|
|
211
|
+
<Skeleton className="h-4 w-64" />
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<Card>
|
|
215
|
+
<CardHeader>
|
|
216
|
+
<Skeleton className="h-6 w-32" />
|
|
217
|
+
</CardHeader>
|
|
218
|
+
<CardContent className="space-y-6">
|
|
219
|
+
<Skeleton className="h-10 w-full" />
|
|
220
|
+
<Skeleton className="h-24 w-full" />
|
|
221
|
+
<Skeleton className="h-10 w-full" />
|
|
222
|
+
</CardContent>
|
|
223
|
+
</Card>
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// If user doesn't have permission, show NoPermission component
|
|
229
|
+
if (hasUpdatePermission === false) {
|
|
230
|
+
return (
|
|
231
|
+
<NoPermission
|
|
232
|
+
entityName="Board"
|
|
233
|
+
action="update"
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="p-6 max-w-2xl mx-auto" data-cy="boards-edit-page">
|
|
240
|
+
{/* Header */}
|
|
241
|
+
<div className="flex items-center gap-4 mb-6">
|
|
242
|
+
<Link href={`/dashboard/boards/${boardId}`}>
|
|
243
|
+
<Button variant="ghost" size="sm">
|
|
244
|
+
<ArrowLeft className="h-4 w-4" />
|
|
245
|
+
</Button>
|
|
246
|
+
</Link>
|
|
247
|
+
<div>
|
|
248
|
+
<h1 className="text-2xl font-bold">Edit Board</h1>
|
|
249
|
+
<p className="text-muted-foreground text-sm">
|
|
250
|
+
Update board settings and details
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Form */}
|
|
256
|
+
<Card>
|
|
257
|
+
<CardHeader>
|
|
258
|
+
<CardTitle>Board Details</CardTitle>
|
|
259
|
+
</CardHeader>
|
|
260
|
+
<CardContent>
|
|
261
|
+
<form onSubmit={handleSubmit} className="space-y-6" data-cy="boards-form">
|
|
262
|
+
{/* Name */}
|
|
263
|
+
<div className="space-y-2">
|
|
264
|
+
<Label htmlFor="name">Name *</Label>
|
|
265
|
+
<Input
|
|
266
|
+
id="name"
|
|
267
|
+
placeholder="Enter board name..."
|
|
268
|
+
value={formData.name}
|
|
269
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
270
|
+
disabled={isSubmitting}
|
|
271
|
+
data-cy="boards-field-name"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Description */}
|
|
276
|
+
<div className="space-y-2">
|
|
277
|
+
<Label htmlFor="description">Description</Label>
|
|
278
|
+
<Textarea
|
|
279
|
+
id="description"
|
|
280
|
+
placeholder="Describe what this board is for..."
|
|
281
|
+
value={formData.description}
|
|
282
|
+
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
283
|
+
disabled={isSubmitting}
|
|
284
|
+
rows={3}
|
|
285
|
+
data-cy="boards-field-description"
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Color */}
|
|
290
|
+
<div className="space-y-2">
|
|
291
|
+
<Label htmlFor="color">Color</Label>
|
|
292
|
+
<Select
|
|
293
|
+
value={formData.color}
|
|
294
|
+
onValueChange={(value) => setFormData({ ...formData, color: value })}
|
|
295
|
+
disabled={isSubmitting}
|
|
296
|
+
>
|
|
297
|
+
<SelectTrigger id="color" data-cy="boards-field-color">
|
|
298
|
+
<SelectValue placeholder="Select a color" />
|
|
299
|
+
</SelectTrigger>
|
|
300
|
+
<SelectContent>
|
|
301
|
+
{colorOptions.map((color) => (
|
|
302
|
+
<SelectItem key={color.value} value={color.value}>
|
|
303
|
+
<div className="flex items-center gap-2">
|
|
304
|
+
<div className={`w-4 h-4 rounded ${color.class}`} />
|
|
305
|
+
{color.label}
|
|
306
|
+
</div>
|
|
307
|
+
</SelectItem>
|
|
308
|
+
))}
|
|
309
|
+
</SelectContent>
|
|
310
|
+
</Select>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* Archived */}
|
|
314
|
+
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
315
|
+
<div className="space-y-0.5">
|
|
316
|
+
<Label htmlFor="archived">Archive Board</Label>
|
|
317
|
+
<p className="text-sm text-muted-foreground">
|
|
318
|
+
Archived boards are hidden from the main view
|
|
319
|
+
</p>
|
|
320
|
+
</div>
|
|
321
|
+
<Switch
|
|
322
|
+
id="archived"
|
|
323
|
+
checked={formData.archived}
|
|
324
|
+
onCheckedChange={(checked) => setFormData({ ...formData, archived: checked })}
|
|
325
|
+
disabled={isSubmitting}
|
|
326
|
+
data-cy="boards-field-archived"
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Color Preview */}
|
|
331
|
+
<div className="space-y-2">
|
|
332
|
+
<Label>Preview</Label>
|
|
333
|
+
<div className="border rounded-lg overflow-hidden">
|
|
334
|
+
<div
|
|
335
|
+
className={`h-2 ${colorOptions.find(c => c.value === formData.color)?.class || 'bg-blue-500'}`}
|
|
336
|
+
/>
|
|
337
|
+
<div className="p-4 bg-muted/30">
|
|
338
|
+
<p className="font-medium">{formData.name || 'Board Name'}</p>
|
|
339
|
+
{formData.description && (
|
|
340
|
+
<p className="text-sm text-muted-foreground mt-1">{formData.description}</p>
|
|
341
|
+
)}
|
|
342
|
+
{formData.archived && (
|
|
343
|
+
<span className="inline-block mt-2 text-xs bg-muted px-2 py-1 rounded">
|
|
344
|
+
Archived
|
|
345
|
+
</span>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Actions */}
|
|
352
|
+
<div className="flex items-center justify-between pt-4 border-t">
|
|
353
|
+
<div className="flex items-center gap-4">
|
|
354
|
+
<Button type="submit" disabled={isSubmitting} data-cy="boards-form-submit">
|
|
355
|
+
{isSubmitting ? (
|
|
356
|
+
<>
|
|
357
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
358
|
+
Saving...
|
|
359
|
+
</>
|
|
360
|
+
) : (
|
|
361
|
+
'Save Changes'
|
|
362
|
+
)}
|
|
363
|
+
</Button>
|
|
364
|
+
<Link href={`/dashboard/boards/${boardId}`}>
|
|
365
|
+
<Button type="button" variant="outline" disabled={isSubmitting} data-cy="boards-form-cancel">
|
|
366
|
+
Cancel
|
|
367
|
+
</Button>
|
|
368
|
+
</Link>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
{/* Delete - Only shown if user has permission */}
|
|
372
|
+
<PermissionGate permission="boards.delete">
|
|
373
|
+
<Button
|
|
374
|
+
type="button"
|
|
375
|
+
variant="destructive"
|
|
376
|
+
onClick={handleDelete}
|
|
377
|
+
disabled={isSubmitting}
|
|
378
|
+
data-cy="boards-form-delete"
|
|
379
|
+
>
|
|
380
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
381
|
+
Delete Board
|
|
382
|
+
</Button>
|
|
383
|
+
</PermissionGate>
|
|
384
|
+
</div>
|
|
385
|
+
</form>
|
|
386
|
+
</CardContent>
|
|
387
|
+
</Card>
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
}
|