Skip to content

Commit 08b1a74

Browse files
committed
[Refactor] 이슈보드를 작업보드로 변경
1 parent c885128 commit 08b1a74

3 files changed

Lines changed: 329 additions & 8 deletions

File tree

src/app/components/Layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { useTheme } from "../contexts/ThemeContext";
2020
const navItems = [
2121
{ path: "/workspace", label: "Dashboard" },
2222
{ path: "/prs", label: "PRs" },
23-
{ path: "/issues", label: "Issues" },
2423
{ path: "/chat", label: "Workspace" },
2524
];
2625

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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+
}

src/app/pages/ChatPage.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Hash, Users, GitPullRequest, Home, CheckSquare, ChevronDown, ChevronRight, GitBranch, Code2, Database, BookOpen, Maximize2, Minimize2, Plus, Pencil, Trash2, MoreVertical, X, LayoutGrid, type LucideIcon } from "lucide-react";
2+
import { WorkBoardPanel } from "../components/WorkBoardPanel";
23
import { ChatPanel } from "../components/ChatPanel";
34
import { PRReviewPanel } from "../components/PRReviewPanel";
45
import { IssuePanel } from "../components/IssuePanel";
@@ -10,7 +11,6 @@ import { APISpecPage } from "./APISpecPage";
1011
import { ERDPage } from "./ERDPage";
1112
import { DocsPage } from "./DocsPage";
1213
import { useEffect, useState } from "react";
13-
import { useNavigate } from "react-router";
1414
import { AnimatePresence, motion } from "motion/react";
1515
import type { MessageAttachment } from "../components/messageAttachments";
1616
import { TeamInviteModal } from "../components/TeamInviteModal";
@@ -388,8 +388,6 @@ export function ChatPage() {
388388
'review-room': 2,
389389
});
390390

391-
const navigate = useNavigate();
392-
393391
const hasRepositories = repositoriesImported && repositories.length > 0;
394392
const currentRepo = repositories.find(repo => repo.id === selectedRepository);
395393

@@ -698,7 +696,7 @@ export function ChatPage() {
698696
setSelectedRepository(importedRepositories[0]?.id ?? "");
699697
setSelectedChannel("general");
700698
setShowRepoDropdown(false);
701-
closeRepositoryForm();
699+
handleCloseRepoForm();
702700
};
703701

704702
const handleMergePR = (messageId: number) => {
@@ -1409,13 +1407,26 @@ export function ChatPage() {
14091407

14101408
<motion.button
14111409
type="button"
1412-
onClick={() => { setSelectedRepository(repo.id); navigate('/issues'); }}
1410+
onClick={() => { setSelectedRepository(repo.id); setSelectedChannel('work-board'); }}
14131411
className="relative isolate flex w-full items-center gap-2 rounded-full border-0 py-2.5 pl-8 pr-3 text-left tracking-tight transition-colors"
14141412
style={{ background: 'transparent', cursor: 'pointer' }}
14151413
whileTap={{ scale: 0.99 }}
14161414
>
1417-
<LayoutGrid size={14} style={{ color: 'var(--muted)', flexShrink: 0, position: 'relative', zIndex: 1 }} />
1418-
<span className="relative z-10 flex-1 tracking-tight" style={{ fontSize: '13px', fontWeight: 800, color: 'var(--muted)' }}>
1415+
{selectedChannel === 'work-board' && selectedRepository === repo.id && (
1416+
<motion.div
1417+
layoutId="workspaceSidebarActiveTab"
1418+
className="absolute inset-0 rounded-full"
1419+
style={{
1420+
background: 'linear-gradient(135deg, rgba(32, 227, 255, 0.18), rgba(234, 247, 255, 0.045)), rgba(11, 22, 40, 0.52)',
1421+
border: '1px solid rgba(32, 227, 255, 0.30)',
1422+
boxShadow: '0 0 24px rgba(32, 227, 255, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.12)',
1423+
backdropFilter: 'blur(14px) saturate(180%)'
1424+
}}
1425+
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
1426+
/>
1427+
)}
1428+
<LayoutGrid size={14} style={{ color: selectedChannel === 'work-board' && selectedRepository === repo.id ? 'var(--neon-cyan)' : 'var(--muted)', flexShrink: 0, position: 'relative', zIndex: 1 }} />
1429+
<span className="relative z-10 flex-1 tracking-tight" style={{ fontSize: '13px', fontWeight: selectedChannel === 'work-board' && selectedRepository === repo.id ? 900 : 800, color: selectedChannel === 'work-board' && selectedRepository === repo.id ? 'var(--white)' : 'var(--muted)' }}>
14191430
작업 보드
14201431
</span>
14211432
</motion.button>
@@ -1507,6 +1518,11 @@ export function ChatPage() {
15071518
onOpenThread={handleOpenThread}
15081519
onOpenInvite={() => setTeamInviteOpen(true)}
15091520
/>
1521+
) : selectedChannel === 'work-board' ? (
1522+
<WorkBoardPanel
1523+
repositoryName={currentRepo?.name}
1524+
onViewIssue={handleViewIssue}
1525+
/>
15101526
) : selectedChannel === 'team' ? (
15111527
<TeamPanel
15121528
onInvite={() => setTeamInviteOpen(true)}

0 commit comments

Comments
 (0)