|
| 1 | +import { Clock, AlertCircle, CheckCircle2, XCircle, User } from "lucide-react"; |
| 2 | + |
| 3 | +interface WorkBoardIssue { |
| 4 | + id: number; |
| 5 | + title: string; |
| 6 | + priority: 'high' | 'medium' | 'low'; |
| 7 | + assignee: string | null; |
| 8 | + relatedPR: number | null; |
| 9 | +} |
| 10 | + |
| 11 | +interface WorkBoardPanelProps { |
| 12 | + repositoryName?: string; |
| 13 | + onViewIssue?: (issueData: any) => void; |
| 14 | +} |
| 15 | + |
| 16 | +const COLUMN_STATUS_MAP: Record<string, 'open' | 'in_progress' | 'closed'> = { |
| 17 | + todo: 'open', |
| 18 | + in_progress: 'in_progress', |
| 19 | + review: 'in_progress', |
| 20 | + done: 'closed', |
| 21 | + blocked: 'open', |
| 22 | +}; |
| 23 | + |
| 24 | +const COLUMN_LABEL_MAP: Record<string, { name: string; color: string }[]> = { |
| 25 | + todo: [{ name: '할 일', color: '#6B7280' }], |
| 26 | + in_progress: [{ name: '진행 중', color: '#20E3FF' }], |
| 27 | + review: [{ name: '검토 중', color: '#A8E6CF' }], |
| 28 | + done: [{ name: '완료', color: '#39FF88' }], |
| 29 | + blocked: [{ name: '막힘', color: '#FF6B6B' }], |
| 30 | +}; |
| 31 | + |
| 32 | +function buildIssueData(issue: WorkBoardIssue, columnId: string) { |
| 33 | + const priorityLabelMap: Record<string, { name: string; color: string }> = { |
| 34 | + high: { name: 'priority: high', color: '#FF6B6B' }, |
| 35 | + medium: { name: 'priority: medium', color: '#F59E0B' }, |
| 36 | + low: { name: 'priority: low', color: '#22C55E' }, |
| 37 | + }; |
| 38 | + |
| 39 | + return { |
| 40 | + issueNumber: issue.id, |
| 41 | + issueTitle: issue.title, |
| 42 | + issueStatus: COLUMN_STATUS_MAP[columnId] ?? 'open', |
| 43 | + issueAuthor: issue.assignee ?? '미할당', |
| 44 | + issueLabels: [ |
| 45 | + ...COLUMN_LABEL_MAP[columnId] ?? [], |
| 46 | + priorityLabelMap[issue.priority], |
| 47 | + ].filter(Boolean), |
| 48 | + issueAssignees: issue.assignee ? [issue.assignee] : [], |
| 49 | + issuePriority: issue.priority, |
| 50 | + issueType: columnId === 'blocked' ? 'Blocked' : columnId === 'done' ? 'Completed' : 'Task', |
| 51 | + issueBody: `## 이슈 제목\n${issue.title}${issue.relatedPR ? `\n\n## 연관 PR\nPR #${issue.relatedPR}과 연결되어 있습니다.` : ''}`, |
| 52 | + issueHistory: [ |
| 53 | + { |
| 54 | + id: 'h1', |
| 55 | + actor: issue.assignee ?? '시스템', |
| 56 | + action: '이슈가 생성되었습니다', |
| 57 | + time: '작업 보드', |
| 58 | + eventType: 'created' as const, |
| 59 | + }, |
| 60 | + ...(issue.assignee ? [{ |
| 61 | + id: 'h2', |
| 62 | + actor: issue.assignee, |
| 63 | + action: `${issue.assignee}님이 담당자로 지정되었습니다`, |
| 64 | + time: '작업 보드', |
| 65 | + eventType: 'assigned' as const, |
| 66 | + }] : []), |
| 67 | + ], |
| 68 | + }; |
| 69 | +} |
| 70 | + |
| 71 | +export function WorkBoardPanel({ repositoryName, onViewIssue }: WorkBoardPanelProps) { |
| 72 | + const columns = [ |
| 73 | + { id: 'todo', title: '할 일', color: 'var(--muted)' }, |
| 74 | + { id: 'in_progress', title: '진행 중', color: 'var(--neon-cyan)' }, |
| 75 | + { id: 'review', title: '검토 중', color: 'var(--soft-mint)' }, |
| 76 | + { id: 'done', title: '완료', color: 'var(--matrix-green)' }, |
| 77 | + { id: 'blocked', title: '막힘', color: '#FF6B6B' } |
| 78 | + ]; |
| 79 | + |
| 80 | + const issues = { |
| 81 | + todo: [ |
| 82 | + { id: 145, title: 'refresh API 요청 제한이 작동하지 않음', priority: 'high', assignee: '김진필', relatedPR: null }, |
| 83 | + { id: 144, title: '비밀번호 재설정 이메일 템플릿 추가', priority: 'medium', assignee: '김준우', relatedPR: null }, |
| 84 | + { id: 143, title: 'v2 API 문서 업데이트', priority: 'low', assignee: null, relatedPR: null } |
| 85 | + ], |
| 86 | + in_progress: [ |
| 87 | + { id: 142, title: 'JWT refresh token rotation 구현', priority: 'high', assignee: '김진필', relatedPR: 234 }, |
| 88 | + { id: 141, title: '인증 실패 로그 추가', priority: 'medium', assignee: '김진현', relatedPR: 233 } |
| 89 | + ], |
| 90 | + review: [ |
| 91 | + { id: 140, title: '운영 환경 CORS 설정 수정', priority: 'high', assignee: '김진현', relatedPR: 232 }, |
| 92 | + { id: 139, title: '데이터베이스 쿼리 성능 개선', priority: 'medium', assignee: '김재준', relatedPR: 231 } |
| 93 | + ], |
| 94 | + done: [ |
| 95 | + { id: 138, title: '사용자 프로필 API 엔드포인트 추가', priority: 'medium', assignee: '김준우', relatedPR: 230 }, |
| 96 | + { id: 137, title: 'CI/CD 파이프라인 설정', priority: 'high', assignee: '김재준', relatedPR: 229 } |
| 97 | + ], |
| 98 | + blocked: [ |
| 99 | + { id: 136, title: '새 데이터베이스 스키마로 이전', priority: 'high', assignee: '김재준', relatedPR: null } |
| 100 | + ] |
| 101 | + }; |
| 102 | + |
| 103 | + const getPriorityColor = (priority: string) => { |
| 104 | + switch (priority) { |
| 105 | + case 'high': return '#FF6B6B'; |
| 106 | + case 'medium': return '#FFD93D'; |
| 107 | + case 'low': return '#6BCF7F'; |
| 108 | + default: return 'var(--muted)'; |
| 109 | + } |
| 110 | + }; |
| 111 | + |
| 112 | + const getPriorityIcon = (priority: string) => { |
| 113 | + switch (priority) { |
| 114 | + case 'high': return <AlertCircle size={14} />; |
| 115 | + case 'medium': return <Clock size={14} />; |
| 116 | + case 'low': return <CheckCircle2 size={14} />; |
| 117 | + default: return null; |
| 118 | + } |
| 119 | + }; |
| 120 | + |
| 121 | + const getPriorityLabel = (priority: string) => { |
| 122 | + switch (priority) { |
| 123 | + case 'high': return '높음'; |
| 124 | + case 'medium': return '보통'; |
| 125 | + case 'low': return '낮음'; |
| 126 | + default: return '미정'; |
| 127 | + } |
| 128 | + }; |
| 129 | + |
| 130 | + return ( |
| 131 | + <div className="h-full overflow-y-auto"> |
| 132 | + <div className="w-full px-8 py-10 pb-20"> |
| 133 | + <div className="mb-8"> |
| 134 | + <h1 className="m-0 mb-2 leading-[0.9] tracking-[-0.08em]" style={{ |
| 135 | + fontSize: 'clamp(36px, 4vw, 56px)', |
| 136 | + fontWeight: 950, |
| 137 | + color: 'var(--white)', |
| 138 | + textShadow: '0 0 22px rgba(32, 227, 255, 0.18)' |
| 139 | + }}> |
| 140 | + 작업 보드 |
| 141 | + </h1> |
| 142 | + <p className="m-0 tracking-tight" style={{ |
| 143 | + fontSize: '15px', |
| 144 | + fontWeight: 700, |
| 145 | + color: 'var(--muted)' |
| 146 | + }}> |
| 147 | + {repositoryName ? `${repositoryName} · ` : ''}칸반 보드로 작업을 관리합니다 |
| 148 | + </p> |
| 149 | + </div> |
| 150 | + |
| 151 | + <div className="grid grid-cols-5 gap-4 mb-8"> |
| 152 | + {[ |
| 153 | + { label: '할 일', value: issues.todo.length, color: 'var(--muted)', icon: Clock }, |
| 154 | + { label: '진행 중', value: issues.in_progress.length, color: 'var(--neon-cyan)', icon: Clock }, |
| 155 | + { label: '검토 중', value: issues.review.length, color: 'var(--soft-mint)', icon: Clock }, |
| 156 | + { label: '완료', value: issues.done.length, color: 'var(--matrix-green)', icon: CheckCircle2 }, |
| 157 | + { label: '막힘', value: issues.blocked.length, color: '#FF6B6B', icon: XCircle } |
| 158 | + ].map((stat) => { |
| 159 | + const Icon = stat.icon; |
| 160 | + return ( |
| 161 | + <div key={stat.label} className="px-5 py-5 rounded-3xl" style={{ |
| 162 | + background: 'rgba(11, 22, 40, 0.82)', |
| 163 | + border: '1px solid rgba(32, 227, 255, 0.16)', |
| 164 | + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.32)', |
| 165 | + backdropFilter: 'blur(16px)' |
| 166 | + }}> |
| 167 | + <Icon size={20} style={{ color: stat.color, marginBottom: '8px' }} /> |
| 168 | + <p className="m-0 mb-2 tracking-tight" style={{ |
| 169 | + color: 'var(--muted)', |
| 170 | + fontSize: '12px', |
| 171 | + fontWeight: 900 |
| 172 | + }}> |
| 173 | + {stat.label} |
| 174 | + </p> |
| 175 | + <p className="m-0 tracking-[-0.06em]" style={{ |
| 176 | + fontSize: '32px', |
| 177 | + fontWeight: 950, |
| 178 | + color: stat.color |
| 179 | + }}> |
| 180 | + {stat.value} |
| 181 | + </p> |
| 182 | + </div> |
| 183 | + ); |
| 184 | + })} |
| 185 | + </div> |
| 186 | + |
| 187 | + <div className="grid grid-cols-5 gap-4"> |
| 188 | + {columns.map((column) => ( |
| 189 | + <div key={column.id} className="flex flex-col"> |
| 190 | + <div className="px-5 py-4 rounded-t-3xl" style={{ |
| 191 | + background: 'rgba(11, 22, 40, 0.95)', |
| 192 | + border: '1px solid rgba(32, 227, 255, 0.16)', |
| 193 | + borderBottom: 'none', |
| 194 | + backdropFilter: 'blur(16px)' |
| 195 | + }}> |
| 196 | + <div className="flex items-center justify-between"> |
| 197 | + <h2 className="m-0 tracking-[-0.065em]" style={{ |
| 198 | + fontSize: '18px', |
| 199 | + fontWeight: 950, |
| 200 | + color: column.color |
| 201 | + }}> |
| 202 | + {column.title} |
| 203 | + </h2> |
| 204 | + <span className="px-2 py-1 rounded-full tracking-tight" style={{ |
| 205 | + background: `${column.color}22`, |
| 206 | + fontSize: '12px', |
| 207 | + fontWeight: 900, |
| 208 | + color: column.color |
| 209 | + }}> |
| 210 | + {issues[column.id as keyof typeof issues].length} |
| 211 | + </span> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + |
| 215 | + <div className="px-4 py-4 rounded-b-3xl flex-1" style={{ |
| 216 | + background: 'rgba(11, 22, 40, 0.82)', |
| 217 | + border: '1px solid rgba(32, 227, 255, 0.16)', |
| 218 | + borderTop: 'none', |
| 219 | + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.32)', |
| 220 | + backdropFilter: 'blur(16px)' |
| 221 | + }}> |
| 222 | + <div className="grid gap-3"> |
| 223 | + {issues[column.id as keyof typeof issues].map((issue) => ( |
| 224 | + <div |
| 225 | + key={issue.id} |
| 226 | + onClick={() => onViewIssue?.(buildIssueData(issue, column.id))} |
| 227 | + className="px-4 py-4 rounded-2xl cursor-pointer transition-all hover:scale-[1.02]" |
| 228 | + style={{ |
| 229 | + background: 'rgba(234, 247, 255, 0.055)', |
| 230 | + border: '1px solid rgba(32, 227, 255, 0.14)', |
| 231 | + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.22)' |
| 232 | + }} |
| 233 | + > |
| 234 | + <div className="flex items-start justify-between gap-2 mb-3"> |
| 235 | + <span className="tracking-tight" style={{ |
| 236 | + fontSize: '12px', |
| 237 | + fontWeight: 900, |
| 238 | + color: 'var(--neon-cyan)' |
| 239 | + }}> |
| 240 | + #{issue.id} |
| 241 | + </span> |
| 242 | + <div className="flex items-center gap-1 px-2 py-0.5 rounded-full" style={{ |
| 243 | + background: `${getPriorityColor(issue.priority)}22`, |
| 244 | + border: `1px solid ${getPriorityColor(issue.priority)}`, |
| 245 | + fontSize: '11px', |
| 246 | + fontWeight: 900, |
| 247 | + color: getPriorityColor(issue.priority) |
| 248 | + }}> |
| 249 | + {getPriorityIcon(issue.priority)} |
| 250 | + {getPriorityLabel(issue.priority)} |
| 251 | + </div> |
| 252 | + </div> |
| 253 | + |
| 254 | + <h3 className="m-0 mb-3 leading-[1.3] tracking-tight" style={{ |
| 255 | + fontSize: '14px', |
| 256 | + fontWeight: 900, |
| 257 | + color: 'var(--white)' |
| 258 | + }}> |
| 259 | + {issue.title} |
| 260 | + </h3> |
| 261 | + |
| 262 | + <div className="flex items-center justify-between gap-2"> |
| 263 | + {issue.assignee ? ( |
| 264 | + <div className="flex items-center gap-2"> |
| 265 | + <User size={14} style={{ color: 'var(--matrix-green)' }} /> |
| 266 | + <span className="tracking-tight" style={{ |
| 267 | + fontSize: '12px', |
| 268 | + fontWeight: 800, |
| 269 | + color: 'var(--muted)' |
| 270 | + }}> |
| 271 | + {issue.assignee} |
| 272 | + </span> |
| 273 | + </div> |
| 274 | + ) : ( |
| 275 | + <span className="tracking-tight" style={{ |
| 276 | + fontSize: '12px', |
| 277 | + fontWeight: 800, |
| 278 | + color: 'var(--muted)' |
| 279 | + }}> |
| 280 | + 미할당 |
| 281 | + </span> |
| 282 | + )} |
| 283 | + |
| 284 | + {issue.relatedPR && ( |
| 285 | + <span className="px-2 py-0.5 rounded tracking-tight" style={{ |
| 286 | + background: 'rgba(57, 255, 136, 0.15)', |
| 287 | + border: '1px solid rgba(57, 255, 136, 0.3)', |
| 288 | + fontSize: '11px', |
| 289 | + fontWeight: 900, |
| 290 | + color: 'var(--matrix-green)' |
| 291 | + }}> |
| 292 | + PR #{issue.relatedPR} |
| 293 | + </span> |
| 294 | + )} |
| 295 | + </div> |
| 296 | + </div> |
| 297 | + ))} |
| 298 | + </div> |
| 299 | + </div> |
| 300 | + </div> |
| 301 | + ))} |
| 302 | + </div> |
| 303 | + </div> |
| 304 | + </div> |
| 305 | + ); |
| 306 | +} |
0 commit comments