From dd441b167a668b3e08790920f5a6723a0424ee20 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 8 Jul 2026 16:35:06 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=C2=B7?= =?UTF-8?q?=EB=B7=B0=20=EA=B3=A8=EA=B2=A9=20=EB=B0=8F=20=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/sprint-board/page.tsx | 8 ++++ src/features/sprint-board/index.ts | 2 + .../model/sprint-board-columns.ts | 27 ++++++++++++ src/features/sprint-board/ui/SprintBoard.tsx | 44 +++++++++++++++++++ src/views/side-project/sprint-board/index.ts | 2 + .../sprint-board/ui/SprintBoardPage.tsx | 19 ++++++++ 6 files changed, 102 insertions(+) create mode 100644 src/app/workspaces/[workspaceId]/sprint-board/page.tsx create mode 100644 src/features/sprint-board/index.ts create mode 100644 src/features/sprint-board/model/sprint-board-columns.ts create mode 100644 src/features/sprint-board/ui/SprintBoard.tsx create mode 100644 src/views/side-project/sprint-board/index.ts create mode 100644 src/views/side-project/sprint-board/ui/SprintBoardPage.tsx diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx new file mode 100644 index 0000000..9d220be --- /dev/null +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -0,0 +1,8 @@ +// 스프린트 보드 라우트 진입점 — 사이드 프로젝트 워크스페이스 전용. +// 목 단계에서는 데이터가 단일 currentSprint 기준이라 workspaceId를 사용하지 않는다. +// (실 API 전환 시 workspaceId로 스프린트/업무를 조회하도록 확장) +import { SprintBoardPage } from '@/views/side-project/sprint-board'; + +export default function SprintBoardRoute() { + return ; +} diff --git a/src/features/sprint-board/index.ts b/src/features/sprint-board/index.ts new file mode 100644 index 0000000..5426856 --- /dev/null +++ b/src/features/sprint-board/index.ts @@ -0,0 +1,2 @@ +// sprint-board 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD 포함) +export { SprintBoard } from './ui/SprintBoard'; diff --git a/src/features/sprint-board/model/sprint-board-columns.ts b/src/features/sprint-board/model/sprint-board-columns.ts new file mode 100644 index 0000000..495006c --- /dev/null +++ b/src/features/sprint-board/model/sprint-board-columns.ts @@ -0,0 +1,27 @@ +// 스프린트 보드 칸반 컬럼 파생 로직 — 업무 목록을 상태(대기/진행 중/완료)별로 그룹핑한다. +// 컬럼별 포인트 합계까지 함께 계산해, 컬럼 헤더의 "13pt" 같은 표시에 그대로 쓴다. +// 상태 순서/라벨은 entities의 TASK_STATUS를 단일 출처로 삼는다. +import { type Task, type TaskStatus, TASK_STATUS } from '@/entities/side-project/task'; + +export interface SprintColumn { + id: TaskStatus; + title: string; + tasks: Task[]; + /** 컬럼에 속한 업무 포인트 합계 */ + totalPoints: number; +} + +// 칸반 컬럼 노출 순서 (대기 → 진행 중 → 완료) +const COLUMN_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done']; + +export function groupTasksByStatus(tasks: Task[]): SprintColumn[] { + return COLUMN_ORDER.map((status) => { + const columnTasks = tasks.filter((task) => task.status === status); + return { + id: status, + title: TASK_STATUS[status].label, + tasks: columnTasks, + totalPoints: columnTasks.reduce((sum, task) => sum + task.point, 0), + }; + }); +} diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/sprint-board/ui/SprintBoard.tsx new file mode 100644 index 0000000..6aae568 --- /dev/null +++ b/src/features/sprint-board/ui/SprintBoard.tsx @@ -0,0 +1,44 @@ +'use client'; + +// 스프린트 보드 — 칸반(대기/진행 중/완료) + 백로그를 담는 상호작용 컨테이너. +// 목데이터를 로컬 상태로 seed하고, 업무 CRUD·드래그 이동을 여기서 관리한다(Epic E). +// (백엔드 전환 시 seed 소스만 서버 조회로 교체하면 되고, 상태 구조는 유지된다.) +import { useState } from 'react'; + +import { currentSprint } from '@/entities/side-project/sprint'; +import { getBacklogTasks, getSprintTasks, type Task } from '@/entities/side-project/task'; + +import { groupTasksByStatus } from '../model/sprint-board-columns'; + +export function SprintBoard() { + // 스프린트 편입 업무 — 목데이터 seed 후 로컬 상태로 관리(setter는 Epic E에서 사용). + const [sprintTasks] = useState(() => getSprintTasks(currentSprint.id)); + // 백로그(스프린트 미편입) 업무 + const [backlogTasks] = useState(() => getBacklogTasks(currentSprint.workspaceId)); + + // 상태별 컬럼 + 컬럼 포인트 합계 (Epic C에서 카드 렌더에 사용) + const columns = groupTasksByStatus(sprintTasks); + + return ( +
+ {/* TODO(Epic C): 칸반 컬럼·업무 카드 렌더 */} +
+ {columns.map((column) => ( +
+

+ {column.title} · {column.tasks.length}건 · {column.totalPoints}pt +

+
+ ))} +
+ + {/* TODO(Epic D): 백로그 섹션 */} +
+

백로그 · {backlogTasks.length}개

+
+
+ ); +} diff --git a/src/views/side-project/sprint-board/index.ts b/src/views/side-project/sprint-board/index.ts new file mode 100644 index 0000000..cee3a85 --- /dev/null +++ b/src/views/side-project/sprint-board/index.ts @@ -0,0 +1,2 @@ +// 스프린트 보드 뷰의 Public API +export { default as SprintBoardPage } from './ui/SprintBoardPage'; diff --git a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx new file mode 100644 index 0000000..4a42784 --- /dev/null +++ b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx @@ -0,0 +1,19 @@ +// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고 실제 보드는 feature(SprintBoard)가 담당한다. +// 순수 표시용 요약 헤더(Epic B)는 여기(뷰)에서 조립하고, 상호작용이 있는 보드/백로그는 feature에 둔다. +import { Plus_Jakarta_Sans } from 'next/font/google'; + +import { SprintBoard } from '@/features/sprint-board'; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], +}); + +export default function SprintBoardPage() { + return ( +
+ {/* TODO(Epic B): */} + +
+ ); +} From 528e1b9eed4290bc1d02a22e3b597ed33f1ac30a Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 8 Jul 2026 16:38:19 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=94=EC=95=BD=20=ED=97=A4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sprint-board/ui/SprintBoardPage.tsx | 4 +- .../sprint-board/ui/SprintSummaryHeader.tsx | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx diff --git a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx index 4a42784..a2dd9e4 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx @@ -4,6 +4,8 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import { SprintBoard } from '@/features/sprint-board'; +import SprintSummaryHeader from './SprintSummaryHeader'; + const jakarta = Plus_Jakarta_Sans({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'], @@ -12,7 +14,7 @@ const jakarta = Plus_Jakarta_Sans({ export default function SprintBoardPage() { return (
- {/* TODO(Epic B): */} +
); diff --git a/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx new file mode 100644 index 0000000..f145899 --- /dev/null +++ b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx @@ -0,0 +1,50 @@ +// 스프린트 요약 헤더 — 현재 스프린트 메타(기간·진행률·남은 일수/포인트)를 그라데이션 배너로 표시. +// 순수 표시용이라 currentSprint를 직접 읽는다(상호작용 없음). 진행률은 완료/총 포인트에서 파생. +import { currentSprint } from '@/entities/side-project/sprint'; + +// 'YYYY-MM-DD' → 'M/D' +const monthDay = (iso: string) => { + const [, month, day] = iso.split('-'); + return `${Number(month)}/${Number(day)}`; +}; + +export default function SprintSummaryHeader() { + const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = currentSprint; + const remainingPoints = totalPoints - completedPoints; + const percent = totalPoints === 0 ? 0 : Math.round((completedPoints / totalPoints) * 100); + const period = `${monthDay(startDate)} – ${monthDay(endDate)}`; + + return ( +
+
+

현재 스프린트

+

+ {name} · {period} +

+ + {/* 진행률 바 */} +
+
+
+

+ {completedPoints}/{totalPoints}pt 완료 ({percent}%) +

+
+ + {/* 남은 일수 · 남은 포인트 */} +
+
+

{daysLeft}일

+

남은 일수

+
+
+

{remainingPoints}pt

+

남은 포인트

+
+
+
+ ); +} From b5382a107f23f1baba79b46cf674f563c1ad5950 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 8 Jul 2026 16:54:00 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=20feat:=20=EC=B9=B8=EB=B0=98=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C,=EC=BB=AC=EB=9F=BC,=EB=B0=B1=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/side-project/task/index.ts | 1 + .../side-project/task/lib/avatar-color.ts | 19 +++++++ .../side-project/task/ui/TaskCard.tsx | 41 +++++++++++++ src/features/sprint-board/ui/BacklogRow.tsx | 31 ++++++++++ .../sprint-board/ui/BacklogSection.tsx | 57 +++++++++++++++++++ src/features/sprint-board/ui/SprintBoard.tsx | 23 +++----- src/features/sprint-board/ui/SprintColumn.tsx | 30 ++++++++++ 7 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 src/entities/side-project/task/lib/avatar-color.ts create mode 100644 src/entities/side-project/task/ui/TaskCard.tsx create mode 100644 src/features/sprint-board/ui/BacklogRow.tsx create mode 100644 src/features/sprint-board/ui/BacklogSection.tsx create mode 100644 src/features/sprint-board/ui/SprintColumn.tsx diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 0e905b4..3a89e87 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -12,3 +12,4 @@ export { } from './model/task.types'; export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; +export { TaskCard } from './ui/TaskCard'; diff --git a/src/entities/side-project/task/lib/avatar-color.ts b/src/entities/side-project/task/lib/avatar-color.ts new file mode 100644 index 0000000..aa661ee --- /dev/null +++ b/src/entities/side-project/task/lib/avatar-color.ts @@ -0,0 +1,19 @@ +// 담당자 아바타 배경색 — Task 모델에는 색상 필드가 없어, 표시명 기반으로 결정론적 색을 고른다. +// 같은 담당자는 항상 같은 색이 나오도록 간단한 해시로 팔레트를 선택한다. +const AVATAR_PALETTE = [ + '#00C950', + '#FE9A00', + '#615FFF', + '#00B8DB', + '#2B7FFF', + '#F6339A', + '#7E22CE', +]; + +export function getAvatarColor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; +} diff --git a/src/entities/side-project/task/ui/TaskCard.tsx b/src/entities/side-project/task/ui/TaskCard.tsx new file mode 100644 index 0000000..83144a2 --- /dev/null +++ b/src/entities/side-project/task/ui/TaskCard.tsx @@ -0,0 +1,41 @@ +// 사이드 프로젝트 업무 카드 — 카테고리 태그 · 포인트 · 제목 · 담당자를 표시하는 Task의 시각 표현. +// 색상 토큰(TASK_CATEGORY)은 같은 엔티티를 단일 출처로 사용한다. +// 상호작용(수정/삭제)은 상위(피처)에서 콜백으로 주입하는 구조로 확장한다. +import { getAvatarColor } from '../lib/avatar-color'; +import { type Task, TASK_CATEGORY } from '../model/task.types'; + +export function TaskCard({ task }: { task: Task }) { + const category = task.category ? TASK_CATEGORY[task.category] : null; + + return ( +
+
+ {category ? ( + + {category.label} + + ) : ( + + )} + {task.point}pt +
+ +

{task.title}

+ + {task.assignee && ( +
+ + {task.assignee.avatarLabel} + + {task.assignee.name} +
+ )} +
+ ); +} diff --git a/src/features/sprint-board/ui/BacklogRow.tsx b/src/features/sprint-board/ui/BacklogRow.tsx new file mode 100644 index 0000000..2f4842b --- /dev/null +++ b/src/features/sprint-board/ui/BacklogRow.tsx @@ -0,0 +1,31 @@ +// 백로그 행 — 우선순위 점 · 제목 · 포인트 · 우선순위 배지를 한 줄로 표시. +// 우선순위 색(TASK_PRIORITY)은 entities를 단일 출처로 사용한다. +// '낮음'은 지정색(#d1d5dc)이 옅어 배지 텍스트로 쓰면 대비가 부족하므로 뮤트 그레이로 대체한다. +import { type Task, TASK_PRIORITY } from '@/entities/side-project/task'; + +export function BacklogRow({ task }: { task: Task }) { + const priority = TASK_PRIORITY[task.priority]; + const isLow = task.priority === 'low'; + + return ( +
+ + + {task.title} + + {task.point}pt + + {priority.label} + +
+ ); +} diff --git a/src/features/sprint-board/ui/BacklogSection.tsx b/src/features/sprint-board/ui/BacklogSection.tsx new file mode 100644 index 0000000..8fe41c8 --- /dev/null +++ b/src/features/sprint-board/ui/BacklogSection.tsx @@ -0,0 +1,57 @@ +'use client'; + +// 백로그 섹션 — 접기/펼치기 가능한 패널. 헤더(아이콘 + 제목 + 개수 배지)와 항목 목록, 추가 버튼으로 구성. +// 펼침 여부는 로컬 상태로 관리한다(상호작용). 항목 추가 동작은 Epic E에서 연결한다. +import { useState } from 'react'; + +import { ChevronDown, Package, Plus } from 'lucide-react'; + +import type { Task } from '@/entities/side-project/task'; + +import { BacklogRow } from './BacklogRow'; + +export function BacklogSection({ tasks }: { tasks: Task[] }) { + const [open, setOpen] = useState(true); + + return ( +
+ + + {open && ( +
+
+ {tasks.map((task) => ( + + ))} +
+ + {/* TODO(Epic E): 백로그 항목 추가 동작 연결 */} + +
+ )} +
+ ); +} diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/sprint-board/ui/SprintBoard.tsx index 6aae568..86d1665 100644 --- a/src/features/sprint-board/ui/SprintBoard.tsx +++ b/src/features/sprint-board/ui/SprintBoard.tsx @@ -9,6 +9,8 @@ import { currentSprint } from '@/entities/side-project/sprint'; import { getBacklogTasks, getSprintTasks, type Task } from '@/entities/side-project/task'; import { groupTasksByStatus } from '../model/sprint-board-columns'; +import { BacklogSection } from './BacklogSection'; +import { SprintColumn } from './SprintColumn'; export function SprintBoard() { // 스프린트 편입 업무 — 목데이터 seed 후 로컬 상태로 관리(setter는 Epic E에서 사용). @@ -18,27 +20,18 @@ export function SprintBoard() { // 상태별 컬럼 + 컬럼 포인트 합계 (Epic C에서 카드 렌더에 사용) const columns = groupTasksByStatus(sprintTasks); - +console.log(columns) return (
- {/* TODO(Epic C): 칸반 컬럼·업무 카드 렌더 */} -
+ {/* 칸반 보드 — 상태별 컬럼 */} +
{columns.map((column) => ( -
-

- {column.title} · {column.tasks.length}건 · {column.totalPoints}pt -

-
+ ))}
- {/* TODO(Epic D): 백로그 섹션 */} -
-

백로그 · {backlogTasks.length}개

-
+ {/* 백로그 섹션 */} +
); } diff --git a/src/features/sprint-board/ui/SprintColumn.tsx b/src/features/sprint-board/ui/SprintColumn.tsx new file mode 100644 index 0000000..5146a21 --- /dev/null +++ b/src/features/sprint-board/ui/SprintColumn.tsx @@ -0,0 +1,30 @@ +// 스프린트 칸반 컬럼 — 상태 헤더(점 + 라벨 + 포인트 합계) + 업무 카드 목록. +// 상태 점 색은 TASK_STATUS를 단일 출처로 사용한다. 빈 컬럼은 안내 문구를 표시한다. +import { TASK_STATUS, TaskCard } from '@/entities/side-project/task'; + +import type { SprintColumn as SprintColumnData } from '../model/sprint-board-columns'; + +export function SprintColumn({ column }: { column: SprintColumnData }) { + const status = TASK_STATUS[column.id]; + + return ( +
+
+
+ + {column.title} +
+ {column.totalPoints}pt +
+ +
+ {column.tasks.map((task) => ( + + ))} + {column.tasks.length === 0 && ( +

업무 없음

+ )} +
+
+ ); +} From c84010b26a4f64f222525342d996e759dbaba81f Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 8 Jul 2026 18:10:40 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EB=A0=89=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/sprint-board/page.tsx | 37 ++- src/entities/side-project/task/index.ts | 1 - .../side-project/task/ui/TaskCard.tsx | 41 ---- .../sprint-board}/lib/avatar-color.ts | 0 src/features/sprint-board/model/task-form.ts | 60 +++++ .../sprint-board/model/use-sprint-board.ts | 88 +++++++ .../sprint-board/model/use-task-dnd.ts | 44 ++++ src/features/sprint-board/ui/BacklogRow.tsx | 38 ++- .../sprint-board/ui/BacklogSection.tsx | 18 +- src/features/sprint-board/ui/SprintBoard.tsx | 100 ++++++-- src/features/sprint-board/ui/SprintColumn.tsx | 58 ++++- src/features/sprint-board/ui/TaskCard.tsx | 95 ++++++++ .../sprint-board/ui/TaskFormDialog.tsx | 217 ++++++++++++++++++ .../sprint-board/ui/SprintBoardPage.tsx | 35 ++- .../sprint-board/ui/SprintSummaryHeader.tsx | 10 +- 15 files changed, 753 insertions(+), 89 deletions(-) delete mode 100644 src/entities/side-project/task/ui/TaskCard.tsx rename src/{entities/side-project/task => features/sprint-board}/lib/avatar-color.ts (100%) create mode 100644 src/features/sprint-board/model/task-form.ts create mode 100644 src/features/sprint-board/model/use-sprint-board.ts create mode 100644 src/features/sprint-board/model/use-task-dnd.ts create mode 100644 src/features/sprint-board/ui/TaskCard.tsx create mode 100644 src/features/sprint-board/ui/TaskFormDialog.tsx diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 9d220be..56b2e14 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,8 +1,35 @@ -// 스프린트 보드 라우트 진입점 — 사이드 프로젝트 워크스페이스 전용. -// 목 단계에서는 데이터가 단일 currentSprint 기준이라 workspaceId를 사용하지 않는다. -// (실 API 전환 시 workspaceId로 스프린트/업무를 조회하도록 확장) +// 스프린트 보드 라우트 — 초기 데이터는 서버(RSC)에서 조회해 뷰에 주입한다. +// 이후 변경(추가/수정/삭제/이동)은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. +// 목 단계: 단일 currentSprint 기준. 실 API 전환 시 아래 조회부만 교체한다: +// const sprint = await getCurrentSprint(workspaceId); +// const [initialTasks, initialBacklog] = await Promise.all([ +// getSprintTasks(sprint.id), getBacklogTasks(workspaceId), +// ]); +import { currentSprint } from '@/entities/side-project/sprint'; +import { getBacklogTasks, getSprintTasks } from '@/entities/side-project/task'; +import { mockWorkspaceMembers } from '@/entities/workspace-member'; import { SprintBoardPage } from '@/views/side-project/sprint-board'; -export default function SprintBoardRoute() { - return ; +interface SprintBoardRouteProps { + params: Promise<{ workspaceId: string }>; +} + +export default async function SprintBoardRoute({ params }: SprintBoardRouteProps) { + const { workspaceId } = await params; + + const sprint = currentSprint; + const initialTasks = getSprintTasks(sprint.id); + const initialBacklog = getBacklogTasks(sprint.workspaceId); + // 담당자 후보 — 목 단계는 전체 멤버. 실 API: getWorkspaceMembers(workspaceId) + + + return ( + + ); } diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 3a89e87..0e905b4 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -12,4 +12,3 @@ export { } from './model/task.types'; export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; -export { TaskCard } from './ui/TaskCard'; diff --git a/src/entities/side-project/task/ui/TaskCard.tsx b/src/entities/side-project/task/ui/TaskCard.tsx deleted file mode 100644 index 83144a2..0000000 --- a/src/entities/side-project/task/ui/TaskCard.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// 사이드 프로젝트 업무 카드 — 카테고리 태그 · 포인트 · 제목 · 담당자를 표시하는 Task의 시각 표현. -// 색상 토큰(TASK_CATEGORY)은 같은 엔티티를 단일 출처로 사용한다. -// 상호작용(수정/삭제)은 상위(피처)에서 콜백으로 주입하는 구조로 확장한다. -import { getAvatarColor } from '../lib/avatar-color'; -import { type Task, TASK_CATEGORY } from '../model/task.types'; - -export function TaskCard({ task }: { task: Task }) { - const category = task.category ? TASK_CATEGORY[task.category] : null; - - return ( -
-
- {category ? ( - - {category.label} - - ) : ( - - )} - {task.point}pt -
- -

{task.title}

- - {task.assignee && ( -
- - {task.assignee.avatarLabel} - - {task.assignee.name} -
- )} -
- ); -} diff --git a/src/entities/side-project/task/lib/avatar-color.ts b/src/features/sprint-board/lib/avatar-color.ts similarity index 100% rename from src/entities/side-project/task/lib/avatar-color.ts rename to src/features/sprint-board/lib/avatar-color.ts diff --git a/src/features/sprint-board/model/task-form.ts b/src/features/sprint-board/model/task-form.ts new file mode 100644 index 0000000..9cd18f1 --- /dev/null +++ b/src/features/sprint-board/model/task-form.ts @@ -0,0 +1,60 @@ +// 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ Task 변환 헬퍼. +// 담당자는 워크스페이스 멤버에서 선택하며, 선택 결과를 Task.assignee 형태({name, avatarLabel})로 그대로 담는다. +import type { Task, TaskAssignee, TaskCategory, TaskPriority } from '@/entities/side-project/task'; + +export interface TaskFormValues { + title: string; + point: number; + category: TaskCategory | null; + priority: TaskPriority; + assignee: TaskAssignee | null; +} + +export const EMPTY_TASK_FORM: TaskFormValues = { + title: '', + point: 1, + category: null, + priority: 'medium', + assignee: null, +}; + +// 기존 Task를 편집 폼 초기값으로 변환 +export function valuesFromTask(task: Task): TaskFormValues { + return { + title: task.title, + point: task.point, + category: task.category, + priority: task.priority, + assignee: task.assignee, + }; +} + +// 폼 값을 기존 Task에 반영(id/workspaceId/sprintId/status는 유지) +export function applyValuesToTask(task: Task, values: TaskFormValues): Task { + return { + ...task, + title: values.title.trim(), + point: values.point, + category: values.category, + priority: values.priority, + assignee: values.assignee, + }; +} + +// 폼 값으로 새 Task 생성. 배치(스프린트 편입/백로그, 상태)는 호출부가 결정한다. +export function createTaskFromValues( + values: TaskFormValues, + placement: { workspaceId: string; sprintId: string | null; status: Task['status'] }, +): Task { + return { + id: crypto.randomUUID(), + workspaceId: placement.workspaceId, + sprintId: placement.sprintId, + title: values.title.trim(), + point: values.point, + status: placement.status, + priority: values.priority, + category: values.category, + assignee: values.assignee, + }; +} diff --git a/src/features/sprint-board/model/use-sprint-board.ts b/src/features/sprint-board/model/use-sprint-board.ts new file mode 100644 index 0000000..3eeedc7 --- /dev/null +++ b/src/features/sprint-board/model/use-sprint-board.ts @@ -0,0 +1,88 @@ +// 스프린트 보드 상태 훅 — 서버에서 받은 초기 데이터를 로컬 상태로 seed하고, +// 이후 CRUD/드래그 이동을 낙관적으로 로컬에서 처리한다(재조회 없음). +// 실 API 전환 시: 각 액션 안에서 서버 저장을 호출하고, 실패 시 이전 상태로 롤백한다. +// - 추가: 임시 카드를 먼저 그린 뒤, 서버가 준 실제 id로 교체 +// - 그 외: setState 뒤 저장 호출, 실패 시 prev로 복원 +// 컴포넌트/DnD는 이 훅이 주는 값만 소비하므로, 위 교체 시에도 UI는 그대로 유지된다. +import { useCallback, useState } from 'react'; + +import { type Task, type TaskStatus } from '@/entities/side-project/task'; + +import { groupTasksByStatus } from './sprint-board-columns'; +import { applyValuesToTask, createTaskFromValues, type TaskFormValues } from './task-form'; +import { useTaskDnd } from './use-task-dnd'; + +interface UseSprintBoardParams { + /** 새 업무를 편입할 현재 스프린트 id */ + sprintId: string; + /** 새 업무가 속할 워크스페이스 id */ + workspaceId: string; + initialTasks: Task[]; + initialBacklog: Task[]; +} + +export function useSprintBoard({ + sprintId, + workspaceId, + initialTasks, + initialBacklog, +}: UseSprintBoardParams) { + // 서버에서 받은 초기 데이터로 seed + const [sprintTasks, setSprintTasks] = useState(initialTasks); + const [backlogTasks, setBacklogTasks] = useState(initialBacklog); + + // 스프린트에 새 업무 추가(기본 상태: 대기) + const addSprintTask = useCallback( + (values: TaskFormValues) => { + setSprintTasks((prev) => [ + ...prev, + createTaskFromValues(values, { workspaceId, sprintId, status: 'todo' }), + ]); + }, + [workspaceId, sprintId], + ); + + // 백로그에 새 항목 추가(스프린트 미편입) + const addBacklogTask = useCallback( + (values: TaskFormValues) => { + setBacklogTasks((prev) => [ + ...prev, + createTaskFromValues(values, { workspaceId, sprintId: null, status: 'todo' }), + ]); + }, + [workspaceId], + ); + + // 수정/삭제는 업무가 어느 목록에 있든 처리(스프린트·백로그 공통) + const updateTask = useCallback((id: string, values: TaskFormValues) => { + const patch = (list: Task[]) => list.map((t) => (t.id === id ? applyValuesToTask(t, values) : t)); + setSprintTasks(patch); + setBacklogTasks(patch); + }, []); + + const deleteTask = useCallback((id: string) => { + const remove = (list: Task[]) => list.filter((t) => t.id !== id); + setSprintTasks(remove); + setBacklogTasks(remove); + }, []); + + // 드래그로 컬럼(상태) 이동 — 스프린트 업무에만 적용 + const moveTask = useCallback((id: string, status: TaskStatus) => { + setSprintTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t))); + }, []); + + const dnd = useTaskDnd(moveTask); + const columns = groupTasksByStatus(sprintTasks); + + return { + columns, + backlogTasks, + addSprintTask, + addBacklogTask, + updateTask, + deleteTask, + dragProps: dnd.dragProps, + dropProps: dnd.dropProps, + dragOverStatus: dnd.dragOverStatus, + }; +} diff --git a/src/features/sprint-board/model/use-task-dnd.ts b/src/features/sprint-board/model/use-task-dnd.ts new file mode 100644 index 0000000..37a1778 --- /dev/null +++ b/src/features/sprint-board/model/use-task-dnd.ts @@ -0,0 +1,44 @@ +// 칸반 드래그앤드롭 훅 — 네이티브 HTML5 DnD의 보일러플레이트를 캡슐화한다. +// 컴포넌트는 카드에 dragProps(taskId), 컬럼에 dropProps(status)만 스프레드하면 된다. +// dragOverStatus로 "지금 드롭하면 들어갈 컬럼"을 알려, 컬럼에서 하이라이트를 그릴 수 있게 한다. +// 컬럼 내 재정렬은 하지 않고(디자인 요구 없음), 드롭 대상 컬럼의 상태로 이동만 시킨다. +import { useState, type DragEvent } from 'react'; + +import type { TaskStatus } from '@/entities/side-project/task'; + +export function useTaskDnd(onMove: (taskId: string, status: TaskStatus) => void) { + const [draggingId, setDraggingId] = useState(null); + // 현재 드래그가 올라와 있는 컬럼(=드롭 시 들어갈 곳). 하이라이트 표시에 사용. + const [dragOverStatus, setDragOverStatus] = useState(null); + + const reset = () => { + setDraggingId(null); + setDragOverStatus(null); + }; + + const dragProps = (taskId: string) => ({ + draggable: true, + onDragStart: (event: DragEvent) => { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', taskId); + setDraggingId(taskId); + }, + onDragEnd: reset, + }); + + const dropProps = (status: TaskStatus) => ({ + onDragOver: (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (dragOverStatus !== status) setDragOverStatus(status); + }, + onDrop: (event: DragEvent) => { + event.preventDefault(); + const taskId = event.dataTransfer.getData('text/plain') || draggingId; + if (taskId) onMove(taskId, status); + reset(); + }, + }); + + return { draggingId, dragOverStatus, dragProps, dropProps }; +} diff --git a/src/features/sprint-board/ui/BacklogRow.tsx b/src/features/sprint-board/ui/BacklogRow.tsx index 2f4842b..ee46784 100644 --- a/src/features/sprint-board/ui/BacklogRow.tsx +++ b/src/features/sprint-board/ui/BacklogRow.tsx @@ -1,18 +1,23 @@ -// 백로그 행 — 우선순위 점 · 제목 · 포인트 · 우선순위 배지를 한 줄로 표시. +// 백로그 행 — 우선순위 점 · 제목 · 포인트 · 우선순위 배지 + (호버 시) 수정/삭제 액션. // 우선순위 색(TASK_PRIORITY)은 entities를 단일 출처로 사용한다. // '낮음'은 지정색(#d1d5dc)이 옅어 배지 텍스트로 쓰면 대비가 부족하므로 뮤트 그레이로 대체한다. +import { Pencil, Trash2 } from 'lucide-react'; + import { type Task, TASK_PRIORITY } from '@/entities/side-project/task'; -export function BacklogRow({ task }: { task: Task }) { +interface BacklogRowProps { + task: Task; + onEdit: () => void; + onDelete: () => void; +} + +export function BacklogRow({ task, onEdit, onDelete }: BacklogRowProps) { const priority = TASK_PRIORITY[task.priority]; const isLow = task.priority === 'low'; return ( -
- +
+ {task.title} @@ -26,6 +31,25 @@ export function BacklogRow({ task }: { task: Task }) { > {priority.label} + +
+ + +
); } diff --git a/src/features/sprint-board/ui/BacklogSection.tsx b/src/features/sprint-board/ui/BacklogSection.tsx index 8fe41c8..c08064c 100644 --- a/src/features/sprint-board/ui/BacklogSection.tsx +++ b/src/features/sprint-board/ui/BacklogSection.tsx @@ -10,7 +10,14 @@ import type { Task } from '@/entities/side-project/task'; import { BacklogRow } from './BacklogRow'; -export function BacklogSection({ tasks }: { tasks: Task[] }) { +interface BacklogSectionProps { + tasks: Task[]; + onAdd: () => void; + onEdit: (task: Task) => void; + onDelete: (taskId: string) => void; +} + +export function BacklogSection({ tasks, onAdd, onEdit, onDelete }: BacklogSectionProps) { const [open, setOpen] = useState(true); return ( @@ -38,13 +45,18 @@ export function BacklogSection({ tasks }: { tasks: Task[] }) {
{tasks.map((task) => ( - + onEdit(task)} + onDelete={() => onDelete(task.id)} + /> ))}
- {/* TODO(Epic E): 백로그 항목 추가 동작 연결 */} +
+ {/* 칸반 보드 — 상태별 컬럼 */}
{columns.map((column) => ( - + setDialog({ mode: 'edit', task })} + onDeleteTask={deleteTask} + /> ))}
{/* 백로그 섹션 */} - + setDialog({ mode: 'add-backlog' })} + onEdit={(task) => setDialog({ mode: 'edit', task })} + onDelete={deleteTask} + /> + + {dialog && ( + setDialog(null)} + onSubmit={handleSubmit} + /> + )}
); } diff --git a/src/features/sprint-board/ui/SprintColumn.tsx b/src/features/sprint-board/ui/SprintColumn.tsx index 5146a21..40434a6 100644 --- a/src/features/sprint-board/ui/SprintColumn.tsx +++ b/src/features/sprint-board/ui/SprintColumn.tsx @@ -1,10 +1,43 @@ -// 스프린트 칸반 컬럼 — 상태 헤더(점 + 라벨 + 포인트 합계) + 업무 카드 목록. -// 상태 점 색은 TASK_STATUS를 단일 출처로 사용한다. 빈 컬럼은 안내 문구를 표시한다. -import { TASK_STATUS, TaskCard } from '@/entities/side-project/task'; +// 스프린트 칸반 컬럼 — 상태 헤더(점 + 라벨 + 포인트 합계) + 업무 카드 목록 + 드롭 영역. +// 상태 점 색은 TASK_STATUS를 단일 출처로 사용한다. 카드 드래그/카드 액션은 상위에서 주입한다. +import type { DragEventHandler } from 'react'; + +import { TASK_STATUS, type Task } from '@/entities/side-project/task'; import type { SprintColumn as SprintColumnData } from '../model/sprint-board-columns'; +import { TaskCard } from './TaskCard'; + +interface DropProps { + onDragOver: DragEventHandler; + onDrop: DragEventHandler; +} + +interface DragProps { + draggable: boolean; + onDragStart: DragEventHandler; + onDragEnd: DragEventHandler; +} + +interface SprintColumnProps { + column: SprintColumnData; + /** 드래그가 이 컬럼 위에 올라와 있는지(드롭 대상 하이라이트) */ + isDragOver: boolean; + /** 이 컬럼(상태)의 드롭 핸들러 */ + dropProps: DropProps; + /** 카드 id별 드래그 핸들러 생성기 */ + getDragProps: (taskId: string) => DragProps; + onEditTask: (task: Task) => void; + onDeleteTask: (taskId: string) => void; +} -export function SprintColumn({ column }: { column: SprintColumnData }) { +export function SprintColumn({ + column, + isDragOver, + dropProps, + getDragProps, + onEditTask, + onDeleteTask, +}: SprintColumnProps) { const status = TASK_STATUS[column.id]; return ( @@ -17,9 +50,22 @@ export function SprintColumn({ column }: { column: SprintColumnData }) { {column.totalPoints}pt -
+ {/* 드롭 영역 — 빈 컬럼도 드롭받을 수 있도록 최소 높이를 준다. + 드래그가 올라오면 링/배경으로 "여기에 들어감"을 표시한다. */} +
{column.tasks.map((task) => ( - + onEditTask(task)} + onDelete={() => onDeleteTask(task.id)} + {...getDragProps(task.id)} + /> ))} {column.tasks.length === 0 && (

업무 없음

diff --git a/src/features/sprint-board/ui/TaskCard.tsx b/src/features/sprint-board/ui/TaskCard.tsx new file mode 100644 index 0000000..a9e66b3 --- /dev/null +++ b/src/features/sprint-board/ui/TaskCard.tsx @@ -0,0 +1,95 @@ +// 스프린트 업무 카드 — 카테고리 태그 · 포인트 · 제목 · 담당자를 표시하고, +// 호버 시 수정/삭제 버튼과 드래그 어피던스를 제공하는 상호작용 카드(→ 피처에 위치). +// 색상 토큰(TASK_CATEGORY)은 entities를 단일 출처로 사용한다. +import type { DragEventHandler } from 'react'; + +import { Pencil, Trash2 } from 'lucide-react'; + +import { type Task, TASK_CATEGORY } from '@/entities/side-project/task'; + +import { getAvatarColor } from '../lib/avatar-color'; + +interface TaskCardProps { + task: Task; + onEdit?: () => void; + onDelete?: () => void; + draggable?: boolean; + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; +} + +export function TaskCard({ + task, + onEdit, + onDelete, + draggable, + onDragStart, + onDragEnd, +}: TaskCardProps) { + const category = task.category ? TASK_CATEGORY[task.category] : null; + const hasActions = Boolean(onEdit || onDelete); + + return ( +
+
+ {category ? ( + + {category.label} + + ) : ( + + )} + {task.point}pt +
+ +

{task.title}

+ + {task.assignee && ( +
+ + {task.assignee.avatarLabel} + + {task.assignee.name} +
+ )} + + {hasActions && ( +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ ); +} diff --git a/src/features/sprint-board/ui/TaskFormDialog.tsx b/src/features/sprint-board/ui/TaskFormDialog.tsx new file mode 100644 index 0000000..7c7407f --- /dev/null +++ b/src/features/sprint-board/ui/TaskFormDialog.tsx @@ -0,0 +1,217 @@ +'use client'; + +// 업무 추가/수정 모달 — 자료실(ResourceAddDialog)과 동일한 템플릿 톤(rounded-2xl 패널, 슬레이트 오버레이, +// 브랜드 퍼플 버튼, 슬레이트 입력, 브랜드 pill 토글)으로 통일한다. +// 열림/닫힘은 부모의 조건부 마운트로 제어하므로, 마운트 시 초기값으로 seed되어 별도 리셋 로직이 필요 없다. +import { useEffect, useState, type FormEvent } from 'react'; + +import { X } from 'lucide-react'; + +import { + TASK_CATEGORY, + TASK_PRIORITY, + type TaskCategory, + type TaskPriority, +} from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { cn } from '@/shared/lib/utils'; + +import { getAvatarColor } from '../lib/avatar-color'; +import { EMPTY_TASK_FORM, type TaskFormValues } from '../model/task-form'; + +const inputClass = + 'h-11 w-full rounded-xl bg-slate-100 px-4 text-sm font-medium text-slate-800 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300'; +const pillClass = + 'flex h-9 items-center gap-1.5 rounded-2xl border border-indigo-100 px-3 text-sm font-bold text-slate-500'; +const pillActiveClass = 'border-[var(--color-brand)] text-[var(--color-brand)]'; +const fieldLabelClass = 'mb-1.5 text-sm font-bold text-slate-700'; + +const priorityOptions = Object.entries(TASK_PRIORITY) as [ + TaskPriority, + { label: string; color: string }, +][]; +const categoryOptions = Object.entries(TASK_CATEGORY) as [TaskCategory, { label: string }][]; + +interface TaskFormDialogProps { + title: string; + /** 담당자 후보 — 워크스페이스 멤버 */ + members: WorkspaceMember[]; + /** 수정 모드일 때의 초기값. 없으면 빈 폼(추가 모드) */ + initialValues?: TaskFormValues; + onClose: () => void; + onSubmit: (values: TaskFormValues) => void; +} + +export function TaskFormDialog({ + title, + members, + initialValues, + onClose, + onSubmit, +}: TaskFormDialogProps) { + const [values, setValues] = useState(initialValues ?? EMPTY_TASK_FORM); + const canSubmit = values.title.trim().length > 0; + + // Escape로 닫기 + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + onSubmit(values); + onClose(); + }; + + return ( +
+
+
+

+ {title} +

+ +
+ +
+ setValues((v) => ({ ...v, title: event.target.value }))} + placeholder="업무 제목" + className={inputClass} + /> + +
+

포인트

+ + setValues((v) => ({ ...v, point: Number(event.target.value) || 0 })) + } + className={inputClass} + /> +
+ +
+

우선순위

+
+ {priorityOptions.map(([key, { label, color }]) => ( + + ))} +
+
+ +
+

카테고리

+
+ + {categoryOptions.map(([key, { label }]) => ( + + ))} +
+
+ +
+

담당자

+
+ + {members.map((member) => { + const selected = values.assignee?.name === member.workspaceNickname; + return ( + + ); + })} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx index a2dd9e4..dc187eb 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx @@ -1,7 +1,10 @@ -// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고 실제 보드는 feature(SprintBoard)가 담당한다. -// 순수 표시용 요약 헤더(Epic B)는 여기(뷰)에서 조립하고, 상호작용이 있는 보드/백로그는 feature에 둔다. +// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고, 서버에서 받은 초기 데이터를 하위로 전달한다. +// 순수 표시용 요약 헤더는 여기(뷰)에서 sprint를 받아 렌더하고, 상호작용 보드/백로그는 feature에 위임한다. import { Plus_Jakarta_Sans } from 'next/font/google'; +import type { Sprint } from '@/entities/side-project/sprint'; +import type { Task } from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import { SprintBoard } from '@/features/sprint-board'; import SprintSummaryHeader from './SprintSummaryHeader'; @@ -11,11 +14,31 @@ const jakarta = Plus_Jakarta_Sans({ weight: ['400', '500', '600', '700', '800'], }); -export default function SprintBoardPage() { +interface SprintBoardPageProps { + workspaceId: string; + sprint: Sprint; + initialTasks: Task[]; + initialBacklog: Task[]; + members: WorkspaceMember[]; +} + +export default function SprintBoardPage({ + workspaceId, + sprint, + initialTasks, + initialBacklog, + members, +}: SprintBoardPageProps) { return ( -
- - +
+ +
); } diff --git a/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx index f145899..2857910 100644 --- a/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx +++ b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx @@ -1,6 +1,6 @@ -// 스프린트 요약 헤더 — 현재 스프린트 메타(기간·진행률·남은 일수/포인트)를 그라데이션 배너로 표시. -// 순수 표시용이라 currentSprint를 직접 읽는다(상호작용 없음). 진행률은 완료/총 포인트에서 파생. -import { currentSprint } from '@/entities/side-project/sprint'; +// 스프린트 요약 헤더 — 스프린트 메타(기간·진행률·남은 일수/포인트)를 그라데이션 배너로 표시. +// 순수 표시용이라 sprint를 props로 받는다(상호작용 없음). 진행률은 완료/총 포인트에서 파생. +import type { Sprint } from '@/entities/side-project/sprint'; // 'YYYY-MM-DD' → 'M/D' const monthDay = (iso: string) => { @@ -8,8 +8,8 @@ const monthDay = (iso: string) => { return `${Number(month)}/${Number(day)}`; }; -export default function SprintSummaryHeader() { - const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = currentSprint; +export default function SprintSummaryHeader({ sprint }: { sprint: Sprint }) { + const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = sprint; const remainingPoints = totalPoints - completedPoints; const percent = totalPoints === 0 ? 0 : Math.round((completedPoints / totalPoints) * 100); const period = `${monthDay(startDate)} – ${monthDay(endDate)}`; From ebf45d601a4e57b44cac98d4267ef44c54559fbf Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 10:59:22 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20URL=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=A6=B0=ED=8A=B8=20=EC=A0=84=ED=99=98=20+?= =?UTF-8?q?=20Sprint=201=20=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/sprint-board/page.tsx | 41 ++++++++++++------- .../side-project/sprint/api/get-sprints.ts | 9 ++++ src/entities/side-project/sprint/index.ts | 9 +++- .../side-project/sprint/model/sprint.mock.ts | 24 +++++++++-- .../sprint/model/sprint.selectors.ts | 14 +++++++ .../sprint-board/model/use-sprint-board.ts | 3 +- src/features/sprint-board/ui/SprintBoard.tsx | 5 +-- src/views/side-project/sprint-board/index.ts | 2 +- ...printBoardPage.tsx => SprintBoardView.tsx} | 12 ++++-- .../sprint-board/ui/SprintSelector.tsx | 36 ++++++++++++++++ 10 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 src/entities/side-project/sprint/api/get-sprints.ts create mode 100644 src/entities/side-project/sprint/model/sprint.selectors.ts rename src/views/side-project/sprint-board/ui/{SprintBoardPage.tsx => SprintBoardView.tsx} (76%) create mode 100644 src/views/side-project/sprint-board/ui/SprintSelector.tsx diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 56b2e14..b10048a 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,32 +1,43 @@ -// 스프린트 보드 라우트 — 초기 데이터는 서버(RSC)에서 조회해 뷰에 주입한다. -// 이후 변경(추가/수정/삭제/이동)은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. -// 목 단계: 단일 currentSprint 기준. 실 API 전환 시 아래 조회부만 교체한다: -// const sprint = await getCurrentSprint(workspaceId); -// const [initialTasks, initialBacklog] = await Promise.all([ -// getSprintTasks(sprint.id), getBacklogTasks(workspaceId), -// ]); -import { currentSprint } from '@/entities/side-project/sprint'; +// 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어, RSC에서 해당 스프린트 데이터를 조회해 주입한다. +// 스프린트 전환 = URL 변경(네비게이션) → 이 RSC가 다시 실행되어 새 스프린트로 seed된다(클라 페칭 없음). +// 이후 태스크 변경은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. +// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. +import { getSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; import { getBacklogTasks, getSprintTasks } from '@/entities/side-project/task'; import { mockWorkspaceMembers } from '@/entities/workspace-member'; -import { SprintBoardPage } from '@/views/side-project/sprint-board'; +import SprintBoardView from '@/views/side-project/sprint-board/ui/SprintBoardView'; interface SprintBoardRouteProps { params: Promise<{ workspaceId: string }>; + searchParams: Promise<{ sprint?: string | string[] }>; } -export default async function SprintBoardRoute({ params }: SprintBoardRouteProps) { +export default async function SprintBoardPage({ params, searchParams }: SprintBoardRouteProps) { const { workspaceId } = await params; + const { sprint: sprintParam } = await searchParams; - const sprint = currentSprint; - const initialTasks = getSprintTasks(sprint.id); - const initialBacklog = getBacklogTasks(sprint.workspaceId); - // 담당자 후보 — 목 단계는 전체 멤버. 실 API: getWorkspaceMembers(workspaceId) + const sprints = getSprints(workspaceId); + const selectedId = typeof sprintParam === 'string' ? sprintParam : undefined; + // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) + const sprint = sprints.find((item) => item.id === selectedId) ?? resolveCurrentSprint(sprints); + + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 + if (!sprint) { + return ( +
+ 아직 생성된 스프린트가 없습니다. +
+ ); + } + const initialTasks = getSprintTasks(sprint.id); + const initialBacklog = getBacklogTasks(workspaceId); return ( - sprint.workspaceId === workspaceId); +} diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 07f1ede..0a88b89 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,4 +1,11 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { currentSprint, sprintVelocity, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; +export { + currentSprint, + mockSprints, + sprintVelocity, + SIDE_PROJECT_WORKSPACE_ID, +} from './model/sprint.mock'; +export { getSprints } from './api/get-sprints'; +export { resolveCurrentSprint } from './model/sprint.selectors'; diff --git a/src/entities/side-project/sprint/model/sprint.mock.ts b/src/entities/side-project/sprint/model/sprint.mock.ts index 6663c98..95bf205 100644 --- a/src/entities/side-project/sprint/model/sprint.mock.ts +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -1,10 +1,23 @@ -// 스프린트 목데이터 — 현재 스프린트 메타 + 벨로시티 -// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts / task.selectors.ts 참고 +// 스프린트 목데이터 — 워크스페이스의 스프린트 목록 + 벨로시티 +// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts 참고. 백로그는 스프린트와 무관하게 워크스페이스 공통. import type { Sprint, VelocityPoint } from './sprint.types'; -// 사이드 프로젝트 데모 워크스페이스 id — 스프린트·업무 목데이터가 공유하는 소유 워크스페이스 -export const SIDE_PROJECT_WORKSPACE_ID = 'ws-side-project'; +// 사이드 프로젝트 데모 워크스페이스 id — 실제 워크스페이스(mock-workspace)의 'side-workspace'와 일치시킨다. +export const SIDE_PROJECT_WORKSPACE_ID = 'side-workspace'; +// 과거(완료된) 스프린트 +const sprint1: Sprint = { + id: 'sprint-1', + workspaceId: SIDE_PROJECT_WORKSPACE_ID, + name: 'Sprint 1', + startDate: '2025-06-17', + endDate: '2025-06-30', + daysLeft: 0, + totalPoints: 38, + completedPoints: 34, +}; + +// 현재 진행 중 스프린트 export const currentSprint: Sprint = { id: 'sprint-2', workspaceId: SIDE_PROJECT_WORKSPACE_ID, @@ -16,6 +29,9 @@ export const currentSprint: Sprint = { completedPoints: 28, }; +// 워크스페이스의 스프린트 목록(선택기용) — 시간순 +export const mockSprints: Sprint[] = [sprint1, currentSprint]; + /** 스프린트별 계획/완료 포인트 추이 */ export const sprintVelocity: VelocityPoint[] = [ { sprint: 'S1', planned: 38, completed: 34 }, diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts new file mode 100644 index 0000000..aac2467 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -0,0 +1,14 @@ +// 스프린트 선택 로직 — 데이터에서 "현재 스프린트"를 판정한다(하드코딩 상수에 의존하지 않음). +// 진행 중(오늘이 기간 안) 스프린트를 우선하고, 없으면 가장 최근 시작한 스프린트를 고른다. +// 실 DB에서는 이 규칙이 `where start_date<=now()<=end_date` → 없으면 `order by start_date desc limit 1`에 대응한다. +import type { Sprint } from './sprint.types'; + +export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + + const ongoing = sprints.find((sprint) => sprint.startDate <= today && today <= sprint.endDate); + if (ongoing) return ongoing; + + // 진행 중이 없으면 가장 최근 시작한 스프린트 (원본 불변) + return [...sprints].sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; +} diff --git a/src/features/sprint-board/model/use-sprint-board.ts b/src/features/sprint-board/model/use-sprint-board.ts index 3eeedc7..8ddb521 100644 --- a/src/features/sprint-board/model/use-sprint-board.ts +++ b/src/features/sprint-board/model/use-sprint-board.ts @@ -55,7 +55,8 @@ export function useSprintBoard({ // 수정/삭제는 업무가 어느 목록에 있든 처리(스프린트·백로그 공통) const updateTask = useCallback((id: string, values: TaskFormValues) => { - const patch = (list: Task[]) => list.map((t) => (t.id === id ? applyValuesToTask(t, values) : t)); + const patch = (list: Task[]) => + list.map((t) => (t.id === id ? applyValuesToTask(t, values) : t)); setSprintTasks(patch); setBacklogTasks(patch); }, []); diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/sprint-board/ui/SprintBoard.tsx index 74f75da..1621d96 100644 --- a/src/features/sprint-board/ui/SprintBoard.tsx +++ b/src/features/sprint-board/ui/SprintBoard.tsx @@ -17,10 +17,7 @@ import { TaskFormDialog } from './TaskFormDialog'; // 다이얼로그 상태 — 스프린트 추가 / 백로그 추가 / 수정(대상 Task) / 닫힘 type DialogState = - | { mode: 'add-sprint' } - | { mode: 'add-backlog' } - | { mode: 'edit'; task: Task } - | null; + { mode: 'add-sprint' } | { mode: 'add-backlog' } | { mode: 'edit'; task: Task } | null; interface SprintBoardProps { sprintId: string; diff --git a/src/views/side-project/sprint-board/index.ts b/src/views/side-project/sprint-board/index.ts index cee3a85..16e1e72 100644 --- a/src/views/side-project/sprint-board/index.ts +++ b/src/views/side-project/sprint-board/index.ts @@ -1,2 +1,2 @@ // 스프린트 보드 뷰의 Public API -export { default as SprintBoardPage } from './ui/SprintBoardPage'; +export { default as SprintBoardPage } from './ui/SprintBoardView'; diff --git a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx similarity index 76% rename from src/views/side-project/sprint-board/ui/SprintBoardPage.tsx rename to src/views/side-project/sprint-board/ui/SprintBoardView.tsx index dc187eb..6f48af9 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardPage.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -7,6 +7,7 @@ import type { Task } from '@/entities/side-project/task'; import type { WorkspaceMember } from '@/entities/workspace-member'; import { SprintBoard } from '@/features/sprint-board'; +import SprintSelector from './SprintSelector'; import SprintSummaryHeader from './SprintSummaryHeader'; const jakarta = Plus_Jakarta_Sans({ @@ -14,25 +15,30 @@ const jakarta = Plus_Jakarta_Sans({ weight: ['400', '500', '600', '700', '800'], }); -interface SprintBoardPageProps { +interface SprintBoardViewProps { workspaceId: string; sprint: Sprint; + sprints: Sprint[]; initialTasks: Task[]; initialBacklog: Task[]; members: WorkspaceMember[]; } -export default function SprintBoardPage({ +export default function SprintBoardView({ workspaceId, sprint, + sprints, initialTasks, initialBacklog, members, -}: SprintBoardPageProps) { +}: SprintBoardViewProps) { return (
+ + {/* key={sprint.id}: 스프린트 전환 시 보드를 리마운트해 초기 데이터로 다시 seed한다 */} + {sprints.map((sprint) => { + const isActive = sprint.id === currentSprintId; + return ( + + {sprint.name} + + ); + })} +
+ ); +} From 3735d1ce3b78890de9d89ef2ef1b61a1610f695c Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 11:29:54 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=20=EB=B7=B0=20=EA=B3=B5=EA=B0=9C=20=EB=B0=B0=EB=9F=B4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/workspaces/[workspaceId]/sprint-board/page.tsx | 2 +- src/views/side-project/sprint-board/index.ts | 2 +- src/views/side-project/sprint-board/ui/SprintBoardView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index b10048a..12e7c03 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -5,7 +5,7 @@ import { getSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; import { getBacklogTasks, getSprintTasks } from '@/entities/side-project/task'; import { mockWorkspaceMembers } from '@/entities/workspace-member'; -import SprintBoardView from '@/views/side-project/sprint-board/ui/SprintBoardView'; +import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { params: Promise<{ workspaceId: string }>; diff --git a/src/views/side-project/sprint-board/index.ts b/src/views/side-project/sprint-board/index.ts index 16e1e72..72a3003 100644 --- a/src/views/side-project/sprint-board/index.ts +++ b/src/views/side-project/sprint-board/index.ts @@ -1,2 +1,2 @@ // 스프린트 보드 뷰의 Public API -export { default as SprintBoardPage } from './ui/SprintBoardView'; +export { SprintBoardView } from './ui/SprintBoardView'; diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 6f48af9..b9cb5db 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -24,7 +24,7 @@ interface SprintBoardViewProps { members: WorkspaceMember[]; } -export default function SprintBoardView({ +export function SprintBoardView({ workspaceId, sprint, sprints, From 75fb39aabf6063d2473b83696713ad52de520b8e Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 11:34:56 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=A6=B0=ED=8A=B8?= =?UTF-8?q?=20=EC=85=80=EB=A0=89=ED=84=B0=20=EB=82=A0=EC=A7=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/side-project/sprint/model/sprint.selectors.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts index aac2467..751aca7 100644 --- a/src/entities/side-project/sprint/model/sprint.selectors.ts +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -4,8 +4,10 @@ import type { Sprint } from './sprint.types'; export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String( + now.getDate(), + ).padStart(2, '0')}`; // 로컬 타임존 기준 YYYY-MM-DD const ongoing = sprints.find((sprint) => sprint.startDate <= today && today <= sprint.endDate); if (ongoing) return ongoing; From 792fa45a658e95e438228aea4d2b289228756ca7 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 9 Jul 2026 11:43:26 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=97=85=EB=AC=B4=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20dialog=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sprint-board/ui/TaskFormDialog.tsx | 267 +++++++++--------- 1 file changed, 136 insertions(+), 131 deletions(-) diff --git a/src/features/sprint-board/ui/TaskFormDialog.tsx b/src/features/sprint-board/ui/TaskFormDialog.tsx index 7c7407f..b3addea 100644 --- a/src/features/sprint-board/ui/TaskFormDialog.tsx +++ b/src/features/sprint-board/ui/TaskFormDialog.tsx @@ -1,9 +1,9 @@ 'use client'; -// 업무 추가/수정 모달 — 자료실(ResourceAddDialog)과 동일한 템플릿 톤(rounded-2xl 패널, 슬레이트 오버레이, -// 브랜드 퍼플 버튼, 슬레이트 입력, 브랜드 pill 토글)으로 통일한다. +// 업무 추가/수정 모달 — 네이티브 .showModal()을 사용해 포커스 트랩·Escape·백드롭을 브라우저가 제공한다. +// 톤은 자료실(ResourceAddDialog)과 동일(rounded-2xl 패널, 브랜드 퍼플 버튼, 슬레이트 입력, 브랜드 pill 토글). // 열림/닫힘은 부모의 조건부 마운트로 제어하므로, 마운트 시 초기값으로 seed되어 별도 리셋 로직이 필요 없다. -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; import { X } from 'lucide-react'; @@ -49,16 +49,24 @@ export function TaskFormDialog({ onClose, onSubmit, }: TaskFormDialogProps) { + const dialogRef = useRef(null); const [values, setValues] = useState(initialValues ?? EMPTY_TASK_FORM); const canSubmit = values.title.trim().length > 0; - // Escape로 닫기 + // 네이티브 모달로 열기 — showModal()이 포커스 트랩·백드롭·Escape를 제공한다. + // Escape(cancel 이벤트)는 네이티브 닫힘 대신 부모 언마운트(onClose)로 통일한다. useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); + const dialog = dialogRef.current; + if (!dialog) return undefined; + + dialog.showModal(); + + const handleCancel = (event: Event) => { + event.preventDefault(); + onClose(); }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + dialog.addEventListener('cancel', handleCancel); + return () => dialog.removeEventListener('cancel', handleCancel); }, [onClose]); const handleSubmit = (event: FormEvent) => { @@ -69,149 +77,146 @@ export function TaskFormDialog({ }; return ( -
-
-
-

- {title} -

- -
- -
+ +
+

+ {title} +

+ +
+ + + setValues((v) => ({ ...v, title: event.target.value }))} + placeholder="업무 제목" + className={inputClass} + /> + +
+

포인트

setValues((v) => ({ ...v, title: event.target.value }))} - placeholder="업무 제목" + type="number" + min={0} + aria-label="포인트" + value={values.point} + onChange={(event) => + setValues((v) => ({ ...v, point: Number(event.target.value) || 0 })) + } className={inputClass} /> +
-
-

포인트

- - setValues((v) => ({ ...v, point: Number(event.target.value) || 0 })) - } - className={inputClass} - /> -
- -
-

우선순위

-
- {priorityOptions.map(([key, { label, color }]) => ( - - ))} -
-
- -
-

카테고리

-
+
+

우선순위

+
+ {priorityOptions.map(([key, { label, color }]) => ( - {categoryOptions.map(([key, { label }]) => ( - - ))} -
+ ))}
+
-
-

담당자

-
+
+

카테고리

+
+ + {categoryOptions.map(([key, { label }]) => ( - {members.map((member) => { - const selected = values.assignee?.name === member.workspaceNickname; - return ( - - ); - })} -
+ ))}
+
-
+
+

담당자

+
- + {members.map((member) => { + const selected = values.assignee?.name === member.workspaceNickname; + return ( + + ); + })}
- -
-
+
+ +
+ + +
+ + ); }