@mdxui/issues 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-typecheck.log +5 -0
- package/CHANGELOG.md +80 -0
- package/package.json +35 -0
- package/src/components/epic-list.tsx +210 -0
- package/src/components/index.ts +9 -0
- package/src/components/issue-badges.tsx +135 -0
- package/src/components/issue-board.tsx +211 -0
- package/src/components/issue-card.tsx +57 -0
- package/src/components/issue-detail.tsx +390 -0
- package/src/components/issue-dialog.tsx +184 -0
- package/src/components/issue-filters.tsx +180 -0
- package/src/components/issue-list.tsx +142 -0
- package/src/components/issue-row.tsx +56 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-beads.ts +224 -0
- package/src/hooks/use-issue-mutations.ts +260 -0
- package/src/hooks/use-issues-store.ts +210 -0
- package/src/index.ts +20 -0
- package/src/types/index.ts +194 -0
- package/tsconfig.json +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @mdxui/issues
|
|
2
|
+
|
|
3
|
+
## 6.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @mdxui/primitives@6.0.0
|
|
8
|
+
- mdxui@6.0.0
|
|
9
|
+
|
|
10
|
+
## 5.0.2
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- @mdxui/primitives@5.0.2
|
|
15
|
+
- mdxui@5.0.2
|
|
16
|
+
|
|
17
|
+
## 5.0.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- @mdxui/primitives@5.0.1
|
|
22
|
+
- mdxui@5.0.1
|
|
23
|
+
|
|
24
|
+
## 5.0.0
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- @mdxui/primitives@5.0.0
|
|
29
|
+
- mdxui@5.0.0
|
|
30
|
+
|
|
31
|
+
## 4.0.0
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- @mdxui/primitives@4.0.0
|
|
36
|
+
- mdxui@4.0.0
|
|
37
|
+
|
|
38
|
+
## 3.0.1
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- @mdxui/primitives@3.0.1
|
|
43
|
+
- mdxui@3.0.1
|
|
44
|
+
|
|
45
|
+
## 3.0.0
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- @mdxui/primitives@3.0.0
|
|
50
|
+
- mdxui@3.0.0
|
|
51
|
+
|
|
52
|
+
## 2.1.1
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- Updated dependencies
|
|
57
|
+
- mdxui@2.1.1
|
|
58
|
+
- @mdxui/primitives@2.1.1
|
|
59
|
+
|
|
60
|
+
## 2.0.0
|
|
61
|
+
|
|
62
|
+
### Patch Changes
|
|
63
|
+
|
|
64
|
+
- Updated dependencies [8101194]
|
|
65
|
+
- mdxui@2.0.0
|
|
66
|
+
|
|
67
|
+
## 1.0.0
|
|
68
|
+
|
|
69
|
+
### Patch Changes
|
|
70
|
+
|
|
71
|
+
- Updated dependencies [defc863]
|
|
72
|
+
- mdxui@1.0.0
|
|
73
|
+
|
|
74
|
+
## 0.4.1
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [4d0d1a0]
|
|
79
|
+
- mdxui@0.4.1
|
|
80
|
+
- @mdxui/primitives@0.4.1
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mdxui/issues",
|
|
3
|
+
"version": "6.0.0",
|
|
4
|
+
"sideEffects": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./components": "./src/components/index.ts",
|
|
10
|
+
"./hooks": "./src/hooks/index.ts",
|
|
11
|
+
"./types": "./src/types/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/react": "^19.2.7",
|
|
15
|
+
"@types/react-dom": "^19.2.3",
|
|
16
|
+
"typescript": "5.9.3",
|
|
17
|
+
"@mdxui/typescript-config": "6.0.0"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"date-fns": "^4.1.0",
|
|
21
|
+
"lucide-react": "^0.561.0",
|
|
22
|
+
"react": "^19.2.3",
|
|
23
|
+
"zod": "^4.3.5",
|
|
24
|
+
"zustand": "^5.0.9",
|
|
25
|
+
"mdxui": "6.0.0",
|
|
26
|
+
"@mdxui/primitives": "6.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"clean": "rm -rf .turbo node_modules"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@mdxui/primitives/card'
|
|
5
|
+
import { Progress } from '@mdxui/primitives/progress'
|
|
6
|
+
import {
|
|
7
|
+
Collapsible,
|
|
8
|
+
CollapsibleContent,
|
|
9
|
+
CollapsibleTrigger,
|
|
10
|
+
} from '@mdxui/primitives/collapsible'
|
|
11
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
12
|
+
import { ChevronRight, ChevronDown } from 'lucide-react'
|
|
13
|
+
import { TypeBadge, PriorityBadge, StatusBadge } from './issue-badges'
|
|
14
|
+
import { useIssuesStore } from '../hooks/use-issues-store'
|
|
15
|
+
import type { Issue, Epic } from '../types'
|
|
16
|
+
|
|
17
|
+
export interface EpicListProps {
|
|
18
|
+
/** Epics to display */
|
|
19
|
+
epics?: Epic[]
|
|
20
|
+
/** All issues (for calculating epic progress) */
|
|
21
|
+
issues?: Issue[]
|
|
22
|
+
/** Callback when an issue is selected */
|
|
23
|
+
onSelectIssue?: (issue: Issue) => void
|
|
24
|
+
/** Custom class name */
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function EpicList({
|
|
29
|
+
epics: propEpics,
|
|
30
|
+
issues: propIssues,
|
|
31
|
+
onSelectIssue,
|
|
32
|
+
className,
|
|
33
|
+
}: EpicListProps) {
|
|
34
|
+
const storeIssues = useIssuesStore((s) => s.issues)
|
|
35
|
+
const selectIssue = useIssuesStore((s) => s.selectIssue)
|
|
36
|
+
const selectedId = useIssuesStore((s) => s.selectedId)
|
|
37
|
+
|
|
38
|
+
const issues = propIssues ?? storeIssues
|
|
39
|
+
|
|
40
|
+
// Derive epics from issues with type='epic' if not provided
|
|
41
|
+
const epics = React.useMemo(() => {
|
|
42
|
+
if (propEpics) return propEpics
|
|
43
|
+
|
|
44
|
+
// Find all epic-type issues
|
|
45
|
+
const epicIssues = issues.filter((i) => i.type === 'epic')
|
|
46
|
+
|
|
47
|
+
return epicIssues.map((epic) => {
|
|
48
|
+
// Find issues that belong to this epic
|
|
49
|
+
const epicChildIssues = issues.filter((i) => i.epic === epic.id)
|
|
50
|
+
const closedCount = epicChildIssues.filter((i) => i.status === 'closed').length
|
|
51
|
+
const progress =
|
|
52
|
+
epicChildIssues.length > 0 ? Math.round((closedCount / epicChildIssues.length) * 100) : 0
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
id: epic.id,
|
|
56
|
+
title: epic.title,
|
|
57
|
+
description: epic.description,
|
|
58
|
+
issues: epicChildIssues.map((i) => i.id),
|
|
59
|
+
progress,
|
|
60
|
+
createdAt: epic.createdAt,
|
|
61
|
+
updatedAt: epic.updatedAt,
|
|
62
|
+
} satisfies Epic
|
|
63
|
+
})
|
|
64
|
+
}, [propEpics, issues])
|
|
65
|
+
|
|
66
|
+
const handleSelectIssue = (issue: Issue) => {
|
|
67
|
+
selectIssue(issue.id)
|
|
68
|
+
onSelectIssue?.(issue)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const getEpicIssues = (epicId: string): Issue[] => {
|
|
72
|
+
return issues.filter((i) => i.epic === epicId)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('space-y-4', className)}>
|
|
77
|
+
{epics.length === 0 ? (
|
|
78
|
+
<Card>
|
|
79
|
+
<CardContent className="py-8 text-center text-muted-foreground">
|
|
80
|
+
No epics found
|
|
81
|
+
</CardContent>
|
|
82
|
+
</Card>
|
|
83
|
+
) : (
|
|
84
|
+
epics.map((epic) => {
|
|
85
|
+
const epicIssues = getEpicIssues(epic.id)
|
|
86
|
+
const closedCount = epicIssues.filter((i) => i.status === 'closed').length
|
|
87
|
+
const inProgressCount = epicIssues.filter((i) => i.status === 'in_progress').length
|
|
88
|
+
const blockedCount = epicIssues.filter((i) => i.status === 'blocked').length
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<EpicCard
|
|
92
|
+
key={epic.id}
|
|
93
|
+
epic={epic}
|
|
94
|
+
issues={epicIssues}
|
|
95
|
+
stats={{
|
|
96
|
+
total: epicIssues.length,
|
|
97
|
+
closed: closedCount,
|
|
98
|
+
inProgress: inProgressCount,
|
|
99
|
+
blocked: blockedCount,
|
|
100
|
+
}}
|
|
101
|
+
selectedId={selectedId}
|
|
102
|
+
onSelectIssue={handleSelectIssue}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface EpicCardProps {
|
|
112
|
+
epic: Epic
|
|
113
|
+
issues: Issue[]
|
|
114
|
+
stats: {
|
|
115
|
+
total: number
|
|
116
|
+
closed: number
|
|
117
|
+
inProgress: number
|
|
118
|
+
blocked: number
|
|
119
|
+
}
|
|
120
|
+
selectedId: string | null
|
|
121
|
+
onSelectIssue: (issue: Issue) => void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function EpicCard({ epic, issues, stats, selectedId, onSelectIssue }: EpicCardProps) {
|
|
125
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Card>
|
|
129
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
130
|
+
<CollapsibleTrigger asChild>
|
|
131
|
+
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
|
132
|
+
<div className="flex items-start gap-3">
|
|
133
|
+
<div className="mt-1">
|
|
134
|
+
{isOpen ? (
|
|
135
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
136
|
+
) : (
|
|
137
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
<div className="flex-1 space-y-2">
|
|
141
|
+
<div className="flex items-center justify-between">
|
|
142
|
+
<CardTitle className="text-base">{epic.title}</CardTitle>
|
|
143
|
+
<span className="text-sm text-muted-foreground font-mono">{epic.id}</span>
|
|
144
|
+
</div>
|
|
145
|
+
{epic.description && (
|
|
146
|
+
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
147
|
+
{epic.description}
|
|
148
|
+
</p>
|
|
149
|
+
)}
|
|
150
|
+
<div className="flex items-center gap-4">
|
|
151
|
+
<div className="flex-1">
|
|
152
|
+
<Progress value={epic.progress} className="h-2" />
|
|
153
|
+
</div>
|
|
154
|
+
<span className="text-sm font-medium">{epic.progress}%</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="flex gap-4 text-xs text-muted-foreground">
|
|
157
|
+
<span>{stats.total} issues</span>
|
|
158
|
+
<span className="text-green-600">{stats.closed} closed</span>
|
|
159
|
+
<span className="text-yellow-600">{stats.inProgress} in progress</span>
|
|
160
|
+
{stats.blocked > 0 && (
|
|
161
|
+
<span className="text-red-600">{stats.blocked} blocked</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</CardHeader>
|
|
167
|
+
</CollapsibleTrigger>
|
|
168
|
+
|
|
169
|
+
<CollapsibleContent>
|
|
170
|
+
<CardContent className="pt-0">
|
|
171
|
+
<div className="border-t pt-4 space-y-2">
|
|
172
|
+
{issues.length === 0 ? (
|
|
173
|
+
<div className="text-sm text-muted-foreground italic">
|
|
174
|
+
No issues in this epic
|
|
175
|
+
</div>
|
|
176
|
+
) : (
|
|
177
|
+
issues.map((issue) => (
|
|
178
|
+
<div
|
|
179
|
+
key={issue.id}
|
|
180
|
+
role="button"
|
|
181
|
+
tabIndex={0}
|
|
182
|
+
onClick={() => onSelectIssue(issue)}
|
|
183
|
+
onKeyDown={(e) => {
|
|
184
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
onSelectIssue(issue)
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
className={cn(
|
|
190
|
+
'flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 transition-colors',
|
|
191
|
+
issue.id === selectedId && 'bg-muted'
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<span className="font-mono text-xs text-muted-foreground w-24 shrink-0">
|
|
195
|
+
{issue.id}
|
|
196
|
+
</span>
|
|
197
|
+
<TypeBadge type={issue.type} />
|
|
198
|
+
<span className="flex-1 text-sm truncate">{issue.title}</span>
|
|
199
|
+
<StatusBadge status={issue.status} />
|
|
200
|
+
<PriorityBadge priority={issue.priority} />
|
|
201
|
+
</div>
|
|
202
|
+
))
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</CardContent>
|
|
206
|
+
</CollapsibleContent>
|
|
207
|
+
</Collapsible>
|
|
208
|
+
</Card>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './issue-list'
|
|
2
|
+
export * from './issue-board'
|
|
3
|
+
export * from './issue-detail'
|
|
4
|
+
export * from './issue-card'
|
|
5
|
+
export * from './issue-row'
|
|
6
|
+
export * from './issue-dialog'
|
|
7
|
+
export * from './issue-filters'
|
|
8
|
+
export * from './issue-badges'
|
|
9
|
+
export * from './epic-list'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Badge } from '@mdxui/primitives/badge'
|
|
4
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
5
|
+
import type { IssueStatus, IssueType, IssuePriority } from '../types'
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Status Badge
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
const statusConfig: Record<IssueStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
|
12
|
+
open: { label: 'Open', variant: 'secondary' },
|
|
13
|
+
in_progress: { label: 'In Progress', variant: 'default' },
|
|
14
|
+
blocked: { label: 'Blocked', variant: 'destructive' },
|
|
15
|
+
closed: { label: 'Closed', variant: 'outline' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatusBadgeProps {
|
|
19
|
+
status: IssueStatus
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
|
24
|
+
const config = statusConfig[status]
|
|
25
|
+
return (
|
|
26
|
+
<Badge variant={config.variant} className={className}>
|
|
27
|
+
{config.label}
|
|
28
|
+
</Badge>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Type Badge
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
const typeConfig: Record<IssueType, { label: string; color: string }> = {
|
|
37
|
+
task: { label: 'Task', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
|
38
|
+
bug: { label: 'Bug', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
|
|
39
|
+
feature: { label: 'Feature', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
|
40
|
+
epic: { label: 'Epic', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
|
|
41
|
+
story: { label: 'Story', color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200' },
|
|
42
|
+
chore: { label: 'Chore', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TypeBadgeProps {
|
|
46
|
+
type: IssueType
|
|
47
|
+
className?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function TypeBadge({ type, className }: TypeBadgeProps) {
|
|
51
|
+
const config = typeConfig[type]
|
|
52
|
+
return (
|
|
53
|
+
<span
|
|
54
|
+
className={cn(
|
|
55
|
+
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
|
|
56
|
+
config.color,
|
|
57
|
+
className
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{config.label}
|
|
61
|
+
</span>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Priority Badge
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
const priorityConfig: Record<IssuePriority, { label: string; color: string }> = {
|
|
70
|
+
0: { label: 'P0', color: 'bg-red-500 text-white' },
|
|
71
|
+
1: { label: 'P1', color: 'bg-orange-500 text-white' },
|
|
72
|
+
2: { label: 'P2', color: 'bg-yellow-500 text-black' },
|
|
73
|
+
3: { label: 'P3', color: 'bg-blue-500 text-white' },
|
|
74
|
+
4: { label: 'P4', color: 'bg-gray-400 text-white' },
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PriorityBadgeProps {
|
|
78
|
+
priority: IssuePriority
|
|
79
|
+
className?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function PriorityBadge({ priority, className }: PriorityBadgeProps) {
|
|
83
|
+
const config = priorityConfig[priority] ?? priorityConfig[2]
|
|
84
|
+
return (
|
|
85
|
+
<span
|
|
86
|
+
className={cn(
|
|
87
|
+
'inline-flex items-center rounded px-1.5 py-0.5 text-xs font-bold',
|
|
88
|
+
config.color,
|
|
89
|
+
className
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{config.label}
|
|
93
|
+
</span>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Label Badge
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
export interface LabelBadgeProps {
|
|
102
|
+
label: string
|
|
103
|
+
onRemove?: () => void
|
|
104
|
+
className?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function LabelBadge({ label, onRemove, className }: LabelBadgeProps) {
|
|
108
|
+
return (
|
|
109
|
+
<Badge variant="outline" className={cn('gap-1', className)}>
|
|
110
|
+
{label}
|
|
111
|
+
{onRemove && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={onRemove}
|
|
115
|
+
className="ml-1 rounded-full hover:bg-muted p-0.5"
|
|
116
|
+
aria-label={`Remove ${label}`}
|
|
117
|
+
>
|
|
118
|
+
<svg
|
|
119
|
+
className="h-3 w-3"
|
|
120
|
+
fill="none"
|
|
121
|
+
viewBox="0 0 24 24"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
>
|
|
124
|
+
<path
|
|
125
|
+
strokeLinecap="round"
|
|
126
|
+
strokeLinejoin="round"
|
|
127
|
+
strokeWidth={2}
|
|
128
|
+
d="M6 18L18 6M6 6l12 12"
|
|
129
|
+
/>
|
|
130
|
+
</svg>
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
</Badge>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@mdxui/primitives/card'
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from '@mdxui/primitives/select'
|
|
12
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
13
|
+
import { IssueCard } from './issue-card'
|
|
14
|
+
import { useIssuesStore, useFilteredIssues } from '../hooks/use-issues-store'
|
|
15
|
+
import {
|
|
16
|
+
defaultBoardColumns,
|
|
17
|
+
type Issue,
|
|
18
|
+
type BoardColumn,
|
|
19
|
+
type ClosedTimeRange,
|
|
20
|
+
} from '../types'
|
|
21
|
+
|
|
22
|
+
export interface IssueBoardProps {
|
|
23
|
+
/** Issues to display (overrides store) */
|
|
24
|
+
issues?: Issue[]
|
|
25
|
+
/** Board columns configuration */
|
|
26
|
+
columns?: BoardColumn[]
|
|
27
|
+
/** Currently selected issue ID */
|
|
28
|
+
selectedId?: string
|
|
29
|
+
/** Callback when an issue is selected */
|
|
30
|
+
onSelect?: (issue: Issue) => void
|
|
31
|
+
/** Custom class name */
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function IssueBoard({
|
|
36
|
+
issues: propIssues,
|
|
37
|
+
columns = defaultBoardColumns,
|
|
38
|
+
selectedId: propSelectedId,
|
|
39
|
+
onSelect,
|
|
40
|
+
className,
|
|
41
|
+
}: IssueBoardProps) {
|
|
42
|
+
const storeIssues = useFilteredIssues()
|
|
43
|
+
const storeSelectedId = useIssuesStore((s) => s.selectedId)
|
|
44
|
+
const selectIssue = useIssuesStore((s) => s.selectIssue)
|
|
45
|
+
const closedTimeRange = useIssuesStore((s) => s.closedTimeRange)
|
|
46
|
+
const setClosedTimeRange = useIssuesStore((s) => s.setClosedTimeRange)
|
|
47
|
+
|
|
48
|
+
const issues = propIssues ?? storeIssues
|
|
49
|
+
const selectedId = propSelectedId ?? storeSelectedId
|
|
50
|
+
|
|
51
|
+
// Group issues by column
|
|
52
|
+
const getColumnIssues = (column: BoardColumn): Issue[] => {
|
|
53
|
+
let columnIssues = issues.filter((issue) => column.statuses.includes(issue.status))
|
|
54
|
+
|
|
55
|
+
// For closed column, apply time range filter
|
|
56
|
+
if (column.id === 'closed' && closedTimeRange !== 'all') {
|
|
57
|
+
const now = new Date()
|
|
58
|
+
const cutoff = new Date()
|
|
59
|
+
|
|
60
|
+
if (closedTimeRange === 'today') {
|
|
61
|
+
cutoff.setDate(now.getDate() - 1)
|
|
62
|
+
} else if (closedTimeRange === 'last3days') {
|
|
63
|
+
cutoff.setDate(now.getDate() - 3)
|
|
64
|
+
} else if (closedTimeRange === 'last7days') {
|
|
65
|
+
cutoff.setDate(now.getDate() - 7)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
columnIssues = columnIssues.filter((i) => {
|
|
69
|
+
if (i.closedAt) {
|
|
70
|
+
return new Date(i.closedAt) >= cutoff
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Sort: active columns by priority asc, closed by closedAt desc
|
|
77
|
+
if (column.id === 'closed') {
|
|
78
|
+
columnIssues.sort((a, b) => {
|
|
79
|
+
const aDate = a.closedAt ? new Date(a.closedAt).getTime() : 0
|
|
80
|
+
const bDate = b.closedAt ? new Date(b.closedAt).getTime() : 0
|
|
81
|
+
return bDate - aDate
|
|
82
|
+
})
|
|
83
|
+
} else {
|
|
84
|
+
columnIssues.sort((a, b) => {
|
|
85
|
+
if (a.priority !== b.priority) return a.priority - b.priority
|
|
86
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return columnIssues
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const handleSelect = (issue: Issue) => {
|
|
94
|
+
selectIssue(issue.id)
|
|
95
|
+
onSelect?.(issue)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Keyboard navigation
|
|
99
|
+
const handleKeyDown = (
|
|
100
|
+
e: React.KeyboardEvent,
|
|
101
|
+
columnIndex: number,
|
|
102
|
+
issueIndex: number,
|
|
103
|
+
columnIssues: Issue[]
|
|
104
|
+
) => {
|
|
105
|
+
const allColumns = columns.map((col) => getColumnIssues(col))
|
|
106
|
+
|
|
107
|
+
if (e.key === 'ArrowDown' && issueIndex < columnIssues.length - 1) {
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
handleSelect(columnIssues[issueIndex + 1])
|
|
110
|
+
} else if (e.key === 'ArrowUp' && issueIndex > 0) {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
handleSelect(columnIssues[issueIndex - 1])
|
|
113
|
+
} else if (e.key === 'ArrowRight' && columnIndex < columns.length - 1) {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
const nextColIssues = allColumns[columnIndex + 1]
|
|
116
|
+
if (nextColIssues.length > 0) {
|
|
117
|
+
handleSelect(nextColIssues[Math.min(issueIndex, nextColIssues.length - 1)])
|
|
118
|
+
}
|
|
119
|
+
} else if (e.key === 'ArrowLeft' && columnIndex > 0) {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
const prevColIssues = allColumns[columnIndex - 1]
|
|
122
|
+
if (prevColIssues.length > 0) {
|
|
123
|
+
handleSelect(prevColIssues[Math.min(issueIndex, prevColIssues.length - 1)])
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const getColumnColorClass = (color?: string) => {
|
|
129
|
+
switch (color) {
|
|
130
|
+
case 'red':
|
|
131
|
+
return 'border-t-red-500'
|
|
132
|
+
case 'blue':
|
|
133
|
+
return 'border-t-blue-500'
|
|
134
|
+
case 'yellow':
|
|
135
|
+
return 'border-t-yellow-500'
|
|
136
|
+
case 'green':
|
|
137
|
+
return 'border-t-green-500'
|
|
138
|
+
default:
|
|
139
|
+
return 'border-t-muted-foreground'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
|
|
145
|
+
{columns.map((column, columnIndex) => {
|
|
146
|
+
const columnIssues = getColumnIssues(column)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Card
|
|
150
|
+
key={column.id}
|
|
151
|
+
className={cn(
|
|
152
|
+
'flex-shrink-0 w-72 flex flex-col max-h-[calc(100vh-12rem)]',
|
|
153
|
+
'border-t-4',
|
|
154
|
+
getColumnColorClass(column.color)
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<CardHeader className="pb-2">
|
|
158
|
+
<div className="flex items-center justify-between">
|
|
159
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
160
|
+
{column.title}
|
|
161
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
162
|
+
{columnIssues.length}
|
|
163
|
+
</span>
|
|
164
|
+
</CardTitle>
|
|
165
|
+
|
|
166
|
+
{/* Closed column time filter */}
|
|
167
|
+
{column.id === 'closed' && (
|
|
168
|
+
<Select
|
|
169
|
+
value={closedTimeRange}
|
|
170
|
+
onValueChange={(value) => setClosedTimeRange(value as ClosedTimeRange)}
|
|
171
|
+
>
|
|
172
|
+
<SelectTrigger className="h-7 w-28 text-xs">
|
|
173
|
+
<SelectValue />
|
|
174
|
+
</SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
<SelectItem value="today">Today</SelectItem>
|
|
177
|
+
<SelectItem value="last3days">Last 3 days</SelectItem>
|
|
178
|
+
<SelectItem value="last7days">Last 7 days</SelectItem>
|
|
179
|
+
<SelectItem value="all">All time</SelectItem>
|
|
180
|
+
</SelectContent>
|
|
181
|
+
</Select>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</CardHeader>
|
|
185
|
+
<CardContent className="flex-1 overflow-auto p-2">
|
|
186
|
+
<div className="space-y-2 pr-2">
|
|
187
|
+
{columnIssues.length === 0 ? (
|
|
188
|
+
<div className="text-center text-sm text-muted-foreground py-8">
|
|
189
|
+
No issues
|
|
190
|
+
</div>
|
|
191
|
+
) : (
|
|
192
|
+
columnIssues.map((issue, issueIndex) => (
|
|
193
|
+
<IssueCard
|
|
194
|
+
key={issue.id}
|
|
195
|
+
issue={issue}
|
|
196
|
+
isSelected={issue.id === selectedId}
|
|
197
|
+
onClick={() => handleSelect(issue)}
|
|
198
|
+
onKeyDown={(e) =>
|
|
199
|
+
handleKeyDown(e, columnIndex, issueIndex, columnIssues)
|
|
200
|
+
}
|
|
201
|
+
/>
|
|
202
|
+
))
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</CardContent>
|
|
206
|
+
</Card>
|
|
207
|
+
)
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|