From 81f4f091681be28fb0926ff2d9af667186bc55a4 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 10:25:59 +0900 Subject: [PATCH 01/23] =?UTF-8?q?refactor:=20=EB=B2=A8=EB=A1=9C=EC=8B=9C?= =?UTF-8?q?=ED=8B=B0=20=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=A6=B0=ED=8A=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=83=9D=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/side-project/sprint/index.ts | 3 +-- .../side-project/sprint/model/sprint.mock.ts | 11 +++-------- .../side-project/sprint/model/sprint.selectors.ts | 14 +++++++++++++- .../dashboard-velocity/ui/Velocity.tsx | 4 +++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 0a88b89..3f56cf7 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -4,8 +4,7 @@ export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.ty export { currentSprint, mockSprints, - sprintVelocity, SIDE_PROJECT_WORKSPACE_ID, } from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; -export { resolveCurrentSprint } from './model/sprint.selectors'; +export { resolveCurrentSprint, selectVelocity } 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 95bf205..bcbcebd 100644 --- a/src/entities/side-project/sprint/model/sprint.mock.ts +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -1,6 +1,7 @@ -// 스프린트 목데이터 — 워크스페이스의 스프린트 목록 + 벨로시티 +// 스프린트 목데이터 — 워크스페이스의 스프린트 목록. +// 벨로시티는 이 목록에서 파생한다(selectVelocity) → 중복 상수를 두지 않는다. // 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts 참고. 백로그는 스프린트와 무관하게 워크스페이스 공통. -import type { Sprint, VelocityPoint } from './sprint.types'; +import type { Sprint } from './sprint.types'; // 사이드 프로젝트 데모 워크스페이스 id — 실제 워크스페이스(mock-workspace)의 'side-workspace'와 일치시킨다. export const SIDE_PROJECT_WORKSPACE_ID = 'side-workspace'; @@ -31,9 +32,3 @@ export const currentSprint: Sprint = { // 워크스페이스의 스프린트 목록(선택기용) — 시간순 export const mockSprints: Sprint[] = [sprint1, currentSprint]; - -/** 스프린트별 계획/완료 포인트 추이 */ -export const sprintVelocity: VelocityPoint[] = [ - { sprint: 'S1', planned: 38, completed: 34 }, - { sprint: 'S2', planned: 42, completed: 28 }, -]; diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts index 751aca7..714e3c4 100644 --- a/src/entities/side-project/sprint/model/sprint.selectors.ts +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -1,7 +1,19 @@ // 스프린트 선택 로직 — 데이터에서 "현재 스프린트"를 판정한다(하드코딩 상수에 의존하지 않음). // 진행 중(오늘이 기간 안) 스프린트를 우선하고, 없으면 가장 최근 시작한 스프린트를 고른다. // 실 DB에서는 이 규칙이 `where start_date<=now()<=end_date` → 없으면 `order by start_date desc limit 1`에 대응한다. -import type { Sprint } from './sprint.types'; +import type { Sprint, VelocityPoint } from './sprint.types'; + +/** + * 스프린트 목록에서 벨로시티 추이를 파생한다 — 벨로시티는 sprints 집계이므로 별도 상수를 두지 않는다. + * 실 DB에서는 `select name, total_points, completed_points from sprints order by start_date`에 대응한다. + */ +export function selectVelocity(sprints: Sprint[]): VelocityPoint[] { + return sprints.map((sprint) => ({ + sprint: sprint.name, + planned: sprint.totalPoints, + completed: sprint.completedPoints, + })); +} export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { const now = new Date(); diff --git a/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx index 20916ef..62bed42 100644 --- a/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx +++ b/src/widgets/side-project/dashboard-velocity/ui/Velocity.tsx @@ -1,9 +1,11 @@ // 벨로시티 위젯 — 스프린트별 계획/완료 포인트를 막대로 비교 // 막대가 2그룹뿐이라 별도 차트 라이브러리 없이 순수 CSS(div height %)로 구현한다. -import { sprintVelocity, VELOCITY_MAX } from '@/entities/side-project/sprint'; +import { mockSprints, selectVelocity, VELOCITY_MAX } from '@/entities/side-project/sprint'; import { WidgetCard, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; export default function Velocity() { + const sprintVelocity = selectVelocity(mockSprints); + return ( From 1f1b60589dc7b820d03c504b629749c984305b3f Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 10:30:27 +0900 Subject: [PATCH 02/23] =?UTF-8?q?=20feat:=20=ED=83=9C=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B6=84=ED=8F=AC=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EC=85=80=EB=A0=89=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../side-project/task/api/get-backlog-tasks.ts | 2 +- .../side-project/task/api/get-sprint-tasks.ts | 2 +- src/entities/side-project/task/index.ts | 1 + .../side-project/task/{api => model}/task.mock.ts | 2 +- .../side-project/task/model/task.selectors.ts | 14 ++++++++++++++ 5 files changed, 18 insertions(+), 3 deletions(-) rename src/entities/side-project/task/{api => model}/task.mock.ts (98%) create mode 100644 src/entities/side-project/task/model/task.selectors.ts diff --git a/src/entities/side-project/task/api/get-backlog-tasks.ts b/src/entities/side-project/task/api/get-backlog-tasks.ts index d540e09..53eb25b 100644 --- a/src/entities/side-project/task/api/get-backlog-tasks.ts +++ b/src/entities/side-project/task/api/get-backlog-tasks.ts @@ -2,7 +2,7 @@ // 백엔드 준비 시 supabase.from('tasks').select().eq('workspace_id', workspaceId).is('sprint_id', null) 로 교체한다. // TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. import type { Task } from '../model/task.types'; -import { mockTasks } from './task.mock'; +import { mockTasks } from '../model/task.mock'; export function getBacklogTasks(workspaceId: string): Task[] { return mockTasks.filter((task) => task.workspaceId === workspaceId && task.sprintId === null); diff --git a/src/entities/side-project/task/api/get-sprint-tasks.ts b/src/entities/side-project/task/api/get-sprint-tasks.ts index d478b65..99e0115 100644 --- a/src/entities/side-project/task/api/get-sprint-tasks.ts +++ b/src/entities/side-project/task/api/get-sprint-tasks.ts @@ -2,7 +2,7 @@ // 백엔드 준비 시 supabase.from('tasks').select().eq('sprint_id', sprintId) 로 교체한다. // TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. import type { Task } from '../model/task.types'; -import { mockTasks } from './task.mock'; +import { mockTasks } from '../model/task.mock'; export function getSprintTasks(sprintId: string): Task[] { return mockTasks.filter((task) => task.sprintId === sprintId); diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 0e905b4..51496dd 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 { countByStatus } from './model/task.selectors'; diff --git a/src/entities/side-project/task/api/task.mock.ts b/src/entities/side-project/task/model/task.mock.ts similarity index 98% rename from src/entities/side-project/task/api/task.mock.ts rename to src/entities/side-project/task/model/task.mock.ts index e30421a..3b52106 100644 --- a/src/entities/side-project/task/api/task.mock.ts +++ b/src/entities/side-project/task/model/task.mock.ts @@ -4,7 +4,7 @@ // Task → Sprint 방향의 의도된 교차 참조(FK 방향과 일치, 비순환): 목 id를 sprint 슬라이스와 동기화한다. import { currentSprint, SIDE_PROJECT_WORKSPACE_ID } from '@/entities/side-project/sprint'; -import type { Task } from '../model/task.types'; +import type { Task } from './task.types'; const workspaceId = SIDE_PROJECT_WORKSPACE_ID; const sprintId = currentSprint.id; diff --git a/src/entities/side-project/task/model/task.selectors.ts b/src/entities/side-project/task/model/task.selectors.ts new file mode 100644 index 0000000..3ad0ac8 --- /dev/null +++ b/src/entities/side-project/task/model/task.selectors.ts @@ -0,0 +1,14 @@ +// 업무(Task) 파생 셀렉터 — 목록에서 화면용 집계값을 계산한다. +import type { Task, TaskStatus } from './task.types'; + +/** + * 상태별 업무 건수를 집계한다(진행률 차트의 상태 분포용). + * 모든 상태 키를 항상 0으로 초기화해, 해당 상태가 없어도 키가 누락되지 않도록 한다. + */ +export function countByStatus(tasks: Task[]): Record { + const counts: Record = { todo: 0, in_progress: 0, done: 0 }; + for (const task of tasks) { + counts[task.status] += 1; + } + return counts; +} From 58e3c88d8e725ba43b1c27b11590e1c04c6b79d1 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 10:37:18 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EB=B7=B0=20=EB=B0=8F=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../side-project/progress-chart/index.ts | 2 + .../progress-chart/ui/ProgressChartView.tsx | 43 +++++++++++ .../progress-chart/ui/ProgressStatRow.tsx | 38 ++++++++++ .../progress-chart/ui/SprintProgressCard.tsx | 35 +++++++++ .../progress-chart/ui/StatusDonutChart.tsx | 74 +++++++++++++++++++ .../progress-chart/ui/VelocityChart.tsx | 57 ++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 src/views/side-project/progress-chart/index.ts create mode 100644 src/views/side-project/progress-chart/ui/ProgressChartView.tsx create mode 100644 src/views/side-project/progress-chart/ui/ProgressStatRow.tsx create mode 100644 src/views/side-project/progress-chart/ui/SprintProgressCard.tsx create mode 100644 src/views/side-project/progress-chart/ui/StatusDonutChart.tsx create mode 100644 src/views/side-project/progress-chart/ui/VelocityChart.tsx diff --git a/src/views/side-project/progress-chart/index.ts b/src/views/side-project/progress-chart/index.ts new file mode 100644 index 0000000..a4aa4d0 --- /dev/null +++ b/src/views/side-project/progress-chart/index.ts @@ -0,0 +1,2 @@ +// 진행률 차트 뷰의 Public API +export { ProgressChartView } from './ui/ProgressChartView'; diff --git a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx new file mode 100644 index 0000000..2acb29d --- /dev/null +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -0,0 +1,43 @@ +// 진행률 차트 페이지 셸 — 폰트/배경만 잡고, 서버에서 파생·주입된 데이터를 하위 표현 컴포넌트에 전달한다. +// 상호작용이 없어 전부 표현용(서버 컴포넌트)이다. +import { Plus_Jakarta_Sans } from 'next/font/google'; + +import type { Sprint, VelocityPoint } from '@/entities/side-project/sprint'; +import type { TaskStatus } from '@/entities/side-project/task'; + +import ProgressStatRow from './ProgressStatRow'; +import SprintProgressCard from './SprintProgressCard'; +import StatusDonutChart from './StatusDonutChart'; +import VelocityChart from './VelocityChart'; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], +}); + +interface ProgressChartViewProps { + sprint: Sprint; + velocity: VelocityPoint[]; + statusCounts: Record; +} + +export function ProgressChartView({ sprint, velocity, statusCounts }: ProgressChartViewProps) { + return ( +
+
+ + +
+ {/* 좌: 진행률 바(짧음) + 상태 도넛(김) 세로 스택 */} +
+ + +
+ + {/* 우: 벨로시티 막대(좌측 컬럼 전체 높이) */} + +
+
+
+ ); +} diff --git a/src/views/side-project/progress-chart/ui/ProgressStatRow.tsx b/src/views/side-project/progress-chart/ui/ProgressStatRow.tsx new file mode 100644 index 0000000..6eb7991 --- /dev/null +++ b/src/views/side-project/progress-chart/ui/ProgressStatRow.tsx @@ -0,0 +1,38 @@ +// 진행률 차트 상단 통계 3종 — 완료/남은 포인트, 스프린트 진행률(%) +// 모두 currentSprint의 포인트 메타에서 파생한다(별도 데이터 없음). +import type { Sprint } from '@/entities/side-project/sprint'; +import { WidgetCard } from '@/shared/dashboard/ui/widget-card'; + +interface ProgressStat { + id: string; + /** 표시 문자열 — 포인트는 숫자, 진행률은 '%'까지 포함 */ + display: string; + label: string; + color: string; +} + +export default function ProgressStatRow({ sprint }: { sprint: Sprint }) { + const { totalPoints, completedPoints } = sprint; + const remaining = totalPoints - completedPoints; + // 계획이 0pt인 스프린트(엣지 케이스)에서 NaN이 되지 않도록 방어 + const progress = totalPoints > 0 ? Math.round((completedPoints / totalPoints) * 100) : 0; + + const stats: ProgressStat[] = [ + { id: 'completed', display: `${completedPoints}`, label: '완료 포인트', color: '#00a63e' }, + { id: 'remaining', display: `${remaining}`, label: '남은 포인트', color: '#e17100' }, + { id: 'progress', display: `${progress}%`, label: '스프린트 진행률', color: '#155dfc' }, + ]; + + return ( +
+ {stats.map((stat) => ( + +

+ {stat.display} +

+

{stat.label}

+
+ ))} +
+ ); +} diff --git a/src/views/side-project/progress-chart/ui/SprintProgressCard.tsx b/src/views/side-project/progress-chart/ui/SprintProgressCard.tsx new file mode 100644 index 0000000..31aa965 --- /dev/null +++ b/src/views/side-project/progress-chart/ui/SprintProgressCard.tsx @@ -0,0 +1,35 @@ +// 스프린트 진행률 프로그레스 바 — 완료/계획 포인트 대비 소진율을 그라데이션 바로 표시 +import type { Sprint } from '@/entities/side-project/sprint'; +import { cn } from '@/shared/lib/utils'; +import { WidgetCard } from '@/shared/dashboard/ui/widget-card'; + +export default function SprintProgressCard({ + sprint, + className, +}: { + sprint: Sprint; + className?: string; +}) { + const { totalPoints, completedPoints } = sprint; + const progress = totalPoints > 0 ? Math.round((completedPoints / totalPoints) * 100) : 0; + + return ( + +

스프린트 진행률

+

+ {completedPoints} / {totalPoints}pt 완료 +

+
+
+ {progress}% +
+
+
+ ); +} diff --git a/src/views/side-project/progress-chart/ui/StatusDonutChart.tsx b/src/views/side-project/progress-chart/ui/StatusDonutChart.tsx new file mode 100644 index 0000000..9b469a8 --- /dev/null +++ b/src/views/side-project/progress-chart/ui/StatusDonutChart.tsx @@ -0,0 +1,74 @@ +// 상태 분포 도넛 차트 — 현재 스프린트 태스크의 status(완료/진행 중/대기) 건수 비율 +// 차트 라이브러리 없이 SVG stroke-dasharray로 도넛을 그린다. r을 15.915로 두면 원둘레 ≈ 100 이라 +// dasharray를 백분율 그대로 쓸 수 있다. +import { TASK_STATUS, type TaskStatus } from '@/entities/side-project/task'; +import { cn } from '@/shared/lib/utils'; +import { WidgetCard } from '@/shared/dashboard/ui/widget-card'; + +// 도넛/범례 표시 순서 + 색상(Figma 지정값). 라벨은 TASK_STATUS에서 재사용. +const SEGMENTS: { status: TaskStatus; color: string }[] = [ + { status: 'done', color: '#5b4ee8' }, + { status: 'in_progress', color: '#7c6ff7' }, + { status: 'todo', color: '#e2e0fb' }, +]; + +const RADIUS = 15.915; // 원둘레 ≈ 100 → dasharray를 % 단위로 사용 + +export default function StatusDonutChart({ + counts, + className, +}: { + counts: Record; + className?: string; +}) { + const total = SEGMENTS.reduce((sum, { status }) => sum + counts[status], 0); + + // 세그먼트를 누적 오프셋으로 이어 그린다(12시 방향 시작 = offset 25). + let accumulated = 0; + const arcs = SEGMENTS.map(({ status, color }) => { + const percent = total > 0 ? (counts[status] / total) * 100 : 0; + const arc = { status, color, percent, offset: 25 - accumulated }; + accumulated += percent; + return arc; + }); + + return ( + +

상태 분포

+
+ + {/* 트랙 */} + + {total > 0 && + arcs.map((arc) => ( + + ))} + + +
    + {SEGMENTS.map(({ status, color }) => ( +
  • + + {TASK_STATUS[status].label} + {counts[status]}건 +
  • + ))} +
+
+
+ ); +} diff --git a/src/views/side-project/progress-chart/ui/VelocityChart.tsx b/src/views/side-project/progress-chart/ui/VelocityChart.tsx new file mode 100644 index 0000000..cb64416 --- /dev/null +++ b/src/views/side-project/progress-chart/ui/VelocityChart.tsx @@ -0,0 +1,57 @@ +// 스프린트별 벨로시티 막대 차트 — 스프린트마다 완료 포인트를 막대로 비교 +// 막대 수가 적어 차트 라이브러리 없이 CSS(div height %)로 구현한다(대시보드 Velocity 위젯과 동일 방침). +import type { VelocityPoint } from '@/entities/side-project/sprint'; +import { cn } from '@/shared/lib/utils'; +import { WidgetCard } from '@/shared/dashboard/ui/widget-card'; + +// Y축 눈금 개수(0 포함 5단계) — 상단값을 4로 나눠 균등 배치한다. +const TICK_STEPS = 4; + +export default function VelocityChart({ + data, + className, +}: { + data: VelocityPoint[]; + className?: string; +}) { + const maxCompleted = Math.max(1, ...data.map((point) => point.completed)); + // 상단 눈금값을 4의 배수로 올림 → 0/¼/½/¾/max 눈금이 정수로 떨어진다. + const top = Math.ceil(maxCompleted / TICK_STEPS) * TICK_STEPS; + // 위에서 아래로 그리기 위해 큰 값부터 나열 + const ticks = Array.from({ length: TICK_STEPS + 1 }, (_, i) => (top / TICK_STEPS) * (TICK_STEPS - i)); + + return ( + +

스프린트별 벨로시티

+
+ {/* Y축 눈금 */} +
+ {ticks.map((tick) => ( + {tick} + ))} +
+ +
+
+ {data.map((point) => ( +
+
+
+ ))} +
+
+ {data.map((point) => ( + + {point.sprint} + + ))} +
+
+
+ + ); +} From 9e2d2ed593cfe7e4cdf2d5000037a16eeae3d2c9 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 10:46:50 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0,=20=EC=B0=A8=ED=8A=B8=20=EB=B7=B0=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/progress-chart/page.tsx | 32 +++++++++++++++++++ .../progress-chart/ui/ProgressChartView.tsx | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/app/workspaces/[workspaceId]/progress-chart/page.tsx diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx new file mode 100644 index 0000000..e89e6c5 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -0,0 +1,32 @@ +// 진행률 차트 라우트 — 현재 스프린트를 판정해, 통계/벨로시티/상태 분포를 파생해 뷰에 주입한다. +// 상호작용이 없어 클라이언트 페칭 없이 RSC에서 데이터를 조립한다. +// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. +import { getSprints, resolveCurrentSprint, selectVelocity } from '@/entities/side-project/sprint'; +import { countByStatus, getSprintTasks } from '@/entities/side-project/task'; +import { ProgressChartView } from '@/views/side-project/progress-chart'; + +interface ProgressChartRouteProps { + params: Promise<{ workspaceId: string }>; +} + +export default async function ProgressChartPage({ params }: ProgressChartRouteProps) { + const { workspaceId } = await params; + + const sprints = getSprints(workspaceId); + // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신 + const sprint = resolveCurrentSprint(sprints); + + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 + if (!sprint) { + return ( +
+ 아직 생성된 스프린트가 없습니다. +
+ ); + } + + const velocity = selectVelocity(sprints); + const statusCounts = countByStatus(getSprintTasks(sprint.id)); + + return ; +} diff --git a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx index 2acb29d..7257ef1 100644 --- a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -23,7 +23,7 @@ interface ProgressChartViewProps { export function ProgressChartView({ sprint, velocity, statusCounts }: ProgressChartViewProps) { return ( -
+
From 9e23b564f8788c86b0b7cf542d1a3ce01da4dea9 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 10:48:17 +0900 Subject: [PATCH 05/23] style: prettier --- .../[workspaceId]/calendar/page.tsx | 4 +- .../model/calendar-event.types.ts | 3 +- src/entities/side-project/sprint/index.ts | 6 +- .../manage-calendar/ui/CalendarView.tsx | 70 ++++++++++--------- .../progress-chart/ui/ProgressChartView.tsx | 2 +- .../progress-chart/ui/VelocityChart.tsx | 5 +- 6 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/calendar/page.tsx b/src/app/workspaces/[workspaceId]/calendar/page.tsx index 4df5a6d..f2b8bdf 100644 --- a/src/app/workspaces/[workspaceId]/calendar/page.tsx +++ b/src/app/workspaces/[workspaceId]/calendar/page.tsx @@ -6,9 +6,7 @@ interface WorkspaceCalendarPageProps { }>; } -export default async function WorkspaceCalendarPage({ - params, -}: WorkspaceCalendarPageProps) { +export default async function WorkspaceCalendarPage({ params }: WorkspaceCalendarPageProps) { const { workspaceId } = await params; return ; diff --git a/src/entities/calendar-event/model/calendar-event.types.ts b/src/entities/calendar-event/model/calendar-event.types.ts index c62aec1..9a4014e 100644 --- a/src/entities/calendar-event/model/calendar-event.types.ts +++ b/src/entities/calendar-event/model/calendar-event.types.ts @@ -1,4 +1,5 @@ -export type CalendarEventColor = 'violet' | 'purple' | 'blue' | 'green' | 'amber' | 'coral' | 'pink'; +export type CalendarEventColor = + 'violet' | 'purple' | 'blue' | 'green' | 'amber' | 'coral' | 'pink'; export interface CalendarEvent { id: string; diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 3f56cf7..7176341 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,10 +1,6 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { - currentSprint, - mockSprints, - SIDE_PROJECT_WORKSPACE_ID, -} from './model/sprint.mock'; +export { currentSprint, mockSprints, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; diff --git a/src/features/manage-calendar/ui/CalendarView.tsx b/src/features/manage-calendar/ui/CalendarView.tsx index 6f37795..0a80550 100644 --- a/src/features/manage-calendar/ui/CalendarView.tsx +++ b/src/features/manage-calendar/ui/CalendarView.tsx @@ -76,7 +76,9 @@ export function CalendarView({ workspaceId }: CalendarViewProps) { const initializeWorkspace = useCalendarEventsStore((state) => state.initializeWorkspace); const addCalendarEvent = useCalendarEventsStore((state) => state.addCalendarEvent); const removeCalendarEvent = useCalendarEventsStore((state) => state.removeCalendarEvent); - const storedEvents = useCalendarEventsStore((state) => state.calendarEventsByWorkspaceId[workspaceId]); + const storedEvents = useCalendarEventsStore( + (state) => state.calendarEventsByWorkspaceId[workspaceId], + ); useEffect(() => { initializeWorkspace(workspaceId, initialEvents); @@ -96,10 +98,13 @@ export function CalendarView({ workspaceId }: CalendarViewProps) { const modalDateLabel = `${year}년 ${Number(month)}월 ${Number(day)}일 일정 추가`; const selectedDateLabel = formatSelectedDateLabel(selectedDate); - const eventsByDate = calendarEvents.reduce>((accumulator, event) => { - accumulator[event.date] = [...(accumulator[event.date] ?? []), event]; - return accumulator; - }, {}); + const eventsByDate = calendarEvents.reduce>( + (accumulator, event) => { + accumulator[event.date] = [...(accumulator[event.date] ?? []), event]; + return accumulator; + }, + {}, + ); const selectedDateEvents = eventsByDate[selectedDate] ?? []; const openAddEventModal = (isoDate: string) => { @@ -135,7 +140,7 @@ export function CalendarView({ workspaceId }: CalendarViewProps) {
-
+
diff --git a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx index 7257ef1..89b86f3 100644 --- a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -23,7 +23,7 @@ interface ProgressChartViewProps { export function ProgressChartView({ sprint, velocity, statusCounts }: ProgressChartViewProps) { return ( -
+
diff --git a/src/views/side-project/progress-chart/ui/VelocityChart.tsx b/src/views/side-project/progress-chart/ui/VelocityChart.tsx index cb64416..65fa94e 100644 --- a/src/views/side-project/progress-chart/ui/VelocityChart.tsx +++ b/src/views/side-project/progress-chart/ui/VelocityChart.tsx @@ -18,7 +18,10 @@ export default function VelocityChart({ // 상단 눈금값을 4의 배수로 올림 → 0/¼/½/¾/max 눈금이 정수로 떨어진다. const top = Math.ceil(maxCompleted / TICK_STEPS) * TICK_STEPS; // 위에서 아래로 그리기 위해 큰 값부터 나열 - const ticks = Array.from({ length: TICK_STEPS + 1 }, (_, i) => (top / TICK_STEPS) * (TICK_STEPS - i)); + const ticks = Array.from( + { length: TICK_STEPS + 1 }, + (_, i) => (top / TICK_STEPS) * (TICK_STEPS - i), + ); return ( From 7f6e8c00f293b0ac47d1d77160d50c23fb6c6dfd Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Fri, 10 Jul 2026 15:50:47 +0900 Subject: [PATCH 06/23] =?UTF-8?q?refactor:=20=EC=A7=84=ED=96=89=EB=A5=A0?= =?UTF-8?q?=20=EC=B0=A8=ED=8A=B8=20=EB=B6=84=EA=B8=B0=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/progress-chart/page.tsx | 36 +++++++++++-------- .../[workspaceId]/sprint-board/page.tsx | 6 ++-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index e89e6c5..e938433 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -4,6 +4,8 @@ import { getSprints, resolveCurrentSprint, selectVelocity } from '@/entities/side-project/sprint'; import { countByStatus, getSprintTasks } from '@/entities/side-project/task'; import { ProgressChartView } from '@/views/side-project/progress-chart'; +import { notFound } from 'next/navigation'; +import { getMockWorkspaceById } from '@/entities/workspace'; interface ProgressChartRouteProps { params: Promise<{ workspaceId: string }>; @@ -12,21 +14,27 @@ interface ProgressChartRouteProps { export default async function ProgressChartPage({ params }: ProgressChartRouteProps) { const { workspaceId } = await params; - const sprints = getSprints(workspaceId); - // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신 - const sprint = resolveCurrentSprint(sprints); + const workspace = getMockWorkspaceById(workspaceId); + if(!workspace) return notFound() + if (workspace.purpose === 'side-project') { + const sprints = getSprints(workspaceId); + // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신 + const sprint = resolveCurrentSprint(sprints); - // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 - if (!sprint) { - return ( -
- 아직 생성된 스프린트가 없습니다. -
- ); - } + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 + if (!sprint) { + return ( +
+ 아직 생성된 스프린트가 없습니다. +
+ ); + } - const velocity = selectVelocity(sprints); - const statusCounts = countByStatus(getSprintTasks(sprint.id)); + const velocity = selectVelocity(sprints); + const statusCounts = countByStatus(getSprintTasks(sprint.id)); - return ; + return ; + } + //TODO: + return <>; } diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 12e7c03..cc91560 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -4,7 +4,7 @@ // 실 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 { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers } from '@/entities/workspace-member'; import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { @@ -32,7 +32,7 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo const initialTasks = getSprintTasks(sprint.id); const initialBacklog = getBacklogTasks(workspaceId); - + const members=getMockWorkspaceMembersByWorkspaceId(workspaceId) return ( ); } From 5a43052828c876af105ab970ee1ec1f2d37423d0 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Sun, 12 Jul 2026 22:11:17 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20get=5Fsprints=20RPC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260712220809_create_sprint_rpcs.sql | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 supabase/migrations/20260712220809_create_sprint_rpcs.sql diff --git a/supabase/migrations/20260712220809_create_sprint_rpcs.sql b/supabase/migrations/20260712220809_create_sprint_rpcs.sql new file mode 100644 index 0000000..3b2ba34 --- /dev/null +++ b/supabase/migrations/20260712220809_create_sprint_rpcs.sql @@ -0,0 +1,42 @@ +-- sprint 도메인 RPC +-- 공통: auth 연동 전이므로 p_workspace_id 파라미터로 스코프를 받는다 (연동 후 멤버십 검증은 RLS/auth.uid()에 위임) + +-- 워크스페이스의 스프린트 목록 — 카드/벨로시티에 필요한 집계를 단일 쿼리로 반환 (스프린트 수와 무관하게 쿼리 1회, N+1 없음) +-- 파생값은 tasks에서 집계하며 sprints 테이블에 저장하지 않는다: +-- total_points/completed_points = 해당 스프린트 tasks의 point 합(완료는 status='done' 필터) +-- days_left = 마감일까지 남은 일수(지난 스프린트는 0) +create or replace function public.get_sprints(p_workspace_id uuid) +returns table ( + id uuid, + workspace_id uuid, + name text, + start_date date, + end_date date, + total_points int, + completed_points int, + days_left int +) +language sql +stable +set search_path = public, pg_temp +as $$ + select + s.id, + s.workspace_id, + s.name, + s.start_date, + s.end_date, + p.total_points, + p.completed_points, + greatest(0, (s.end_date - current_date))::int as days_left + from sprints s + cross join lateral ( + select + coalesce(sum(t.point), 0)::int as total_points, + coalesce(sum(t.point) filter (where t.status = 'done'), 0)::int as completed_points + from tasks t + where t.sprint_id = s.id + ) p + where s.workspace_id = p_workspace_id + order by s.start_date; +$$; From 2d024b883ec0c328de4c76cebbd0bf9e90b2de52 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Sun, 12 Jul 2026 22:25:35 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat:=20tasks/sprints=20DB=20=EB=A7=A4?= =?UTF-8?q?=ED=8D=BC=20=EB=B0=8F=20enum=20=ED=8C=8C=EC=83=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/side-project/sprint/index.ts | 2 + .../sprint/model/sprint.db.types.ts | 12 +++ .../sprint/model/sprint.mapper.ts | 19 ++++ src/entities/side-project/task/index.ts | 2 + .../side-project/task/model/task.db.types.ts | 15 ++++ .../side-project/task/model/task.mapper.ts | 33 +++++++ .../side-project/task/model/task.types.ts | 9 +- src/shared/model/database.types.ts | 87 ++++++++++++++++++- 8 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/entities/side-project/sprint/model/sprint.db.types.ts create mode 100644 src/entities/side-project/sprint/model/sprint.mapper.ts create mode 100644 src/entities/side-project/task/model/task.db.types.ts create mode 100644 src/entities/side-project/task/model/task.mapper.ts diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 7176341..c306de7 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -4,3 +4,5 @@ export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.ty export { currentSprint, mockSprints, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; +export { toSprint } from './model/sprint.mapper'; +export type { SprintRow, SprintRpcRow } from './model/sprint.db.types'; diff --git a/src/entities/side-project/sprint/model/sprint.db.types.ts b/src/entities/side-project/sprint/model/sprint.db.types.ts new file mode 100644 index 0000000..655de1c --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.db.types.ts @@ -0,0 +1,12 @@ +// sprint 도메인 DB 타입 — 자동 생성 스키마(database.types)에서 파생한다. +// 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. +import type { GenericFunctionReturns, GenericTables } from '@/shared/model/supabase.types'; + +/** sprints 테이블 Row — 직접 select 시 결과(파생 집계 없음) */ +export type SprintRow = GenericTables<'sprints'>; + +/** + * get_sprints RPC 반환 행 — 스프린트 메타 + 집계(total/completed_points) + days_left를 SQL에서 계산해 반환. + * 스프린트 조회는 이 RPC를 쓰므로 매퍼(toSprint)의 입력은 SprintRow가 아니라 이 타입이다. + */ +export type SprintRpcRow = GenericFunctionReturns<'get_sprints'>[number]; diff --git a/src/entities/side-project/sprint/model/sprint.mapper.ts b/src/entities/side-project/sprint/model/sprint.mapper.ts new file mode 100644 index 0000000..7867b94 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.mapper.ts @@ -0,0 +1,19 @@ +// get_sprints RPC row → Sprint 엔티티 매퍼. +// 파생값(daysLeft, total/completedPoints)은 RPC가 SQL에서 이미 계산해 반환하므로, +// 매퍼는 snake_case→camelCase 필드 변환만 담당한다(순수 함수). +import type { Sprint } from './sprint.types'; +import type { SprintRpcRow } from './sprint.db.types'; + +/** get_sprints RPC row → Sprint 엔티티. */ +export function toSprint(row: SprintRpcRow): Sprint { + return { + id: row.id, + workspaceId: row.workspace_id, + name: row.name, + startDate: row.start_date, + endDate: row.end_date, + daysLeft: row.days_left, + totalPoints: row.total_points, + completedPoints: row.completed_points, + }; +} diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 51496dd..705e49d 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -13,3 +13,5 @@ export { export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; export { countByStatus } from './model/task.selectors'; +export { toTask } from './model/task.mapper'; +export type { TaskRow, TaskWithAssigneeRow } from './model/task.db.types'; diff --git a/src/entities/side-project/task/model/task.db.types.ts b/src/entities/side-project/task/model/task.db.types.ts new file mode 100644 index 0000000..cb1645c --- /dev/null +++ b/src/entities/side-project/task/model/task.db.types.ts @@ -0,0 +1,15 @@ +// task 도메인 DB 타입 — 자동 생성 스키마(database.types)에서 파생한다. +// 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. +import type { GenericTables } from '@/shared/model/supabase.types'; + +/** tasks 테이블 Row — select 결과 */ +export type TaskRow = GenericTables<'tasks'>; + +/** + * 담당자 조인을 포함한 tasks Row. + * Supabase 쿼리 `.select('*, assignee:profiles(real_name)')`의 반환 형태에 대응한다. + * assignee_id가 null이거나 조인이 비면 assignee = null. + */ +export type TaskWithAssigneeRow = TaskRow & { + assignee: { real_name: string } | null; +}; diff --git a/src/entities/side-project/task/model/task.mapper.ts b/src/entities/side-project/task/model/task.mapper.ts new file mode 100644 index 0000000..cb2bf26 --- /dev/null +++ b/src/entities/side-project/task/model/task.mapper.ts @@ -0,0 +1,33 @@ +// DB row → Task 엔티티 매퍼 — snake_case→camelCase, null 흡수, 담당자 조인 변환을 한곳에 모은다. +// DB enum(task_status/priority/category)은 엔티티 유니온과 값이 동일해 캐스팅 없이 대입된다. +import type { Task, TaskAssignee } from './task.types'; +import type { TaskWithAssigneeRow } from './task.db.types'; + +/** + * 조인된 profiles row → 화면용 담당자(TaskAssignee). + * 미배정(백로그 등)이면 null. avatarLabel은 표시명 첫 글자(성)로, 빈 이름은 '?'로 방어한다. + */ +function toAssignee(profile: { real_name: string } | null): TaskAssignee | null { + if (!profile) return null; + const name = profile.real_name.trim(); + return { name, avatarLabel: name.charAt(0) || '?' }; +} + +/** + * tasks row(담당자 조인 포함) → Task 엔티티. + * point는 DB에서 null 허용(미산정)이나 엔티티는 숫자를 보장하므로 0으로 흡수한다. + * sprintId는 null을 유지한다(null → 백로그). + */ +export function toTask(row: TaskWithAssigneeRow): Task { + return { + id: row.id, + workspaceId: row.workspace_id, + sprintId: row.sprint_id, + title: row.title, + point: row.point ?? 0, + status: row.status, + priority: row.priority, + category: row.category, + assignee: toAssignee(row.assignee), + }; +} diff --git a/src/entities/side-project/task/model/task.types.ts b/src/entities/side-project/task/model/task.types.ts index 8d0b92b..9519b7a 100644 --- a/src/entities/side-project/task/model/task.types.ts +++ b/src/entities/side-project/task/model/task.types.ts @@ -1,9 +1,12 @@ // 업무(Task) 도메인 모델 — 워크스페이스가 소유하고, 선택적으로 스프린트에 편입된다. // status는 진행 상태(대기/진행 중/완료)만 나타낸다. // 백로그 여부는 status가 아니라 sprintId로 판별한다(sprintId === null → 백로그). -export type TaskStatus = 'todo' | 'in_progress' | 'done'; -export type TaskPriority = 'high' | 'medium' | 'low'; -export type TaskCategory = 'design' | 'frontend' | 'backend' | 'planning'; +// enum 리터럴은 DB 네이티브 ENUM에서 파생한다(중복 정의 금지) — supabase-convention §6. +import type { GenericEnums } from '@/shared/model/supabase.types'; + +export type TaskStatus = GenericEnums<'task_status'>; +export type TaskPriority = GenericEnums<'task_priority'>; +export type TaskCategory = GenericEnums<'task_category'>; interface StatusStyle { label: string; diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index d3cc1c2..8acb007 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -509,7 +509,7 @@ export type Database = { created_by: string | null id: string note: string | null - shift_type: string + shift_type_id: string updated_at: string user_id: string work_date: string @@ -520,7 +520,7 @@ export type Database = { created_by?: string | null id?: string note?: string | null - shift_type: string + shift_type_id: string updated_at?: string user_id: string work_date: string @@ -531,7 +531,7 @@ export type Database = { created_by?: string | null id?: string note?: string | null - shift_type?: string + shift_type_id?: string updated_at?: string user_id?: string work_date?: string @@ -552,6 +552,13 @@ export type Database = { referencedRelation: "workspace_members" referencedColumns: ["workspace_id", "user_id"] }, + { + foreignKeyName: "work_schedule_entries_shift_type_id_fkey" + columns: ["shift_type_id"] + isOneToOne: false + referencedRelation: "work_shift_types" + referencedColumns: ["id"] + }, { foreignKeyName: "work_schedule_entries_user_id_fkey" columns: ["user_id"] @@ -568,6 +575,59 @@ export type Database = { }, ] } + work_shift_types: { + Row: { + code: string + color: string + created_at: string + end_time: string | null + ends_next_day: boolean + id: string + is_off: boolean + name: string + sort_order: number + start_time: string | null + updated_at: string + workspace_id: string + } + Insert: { + code: string + color: string + created_at?: string + end_time?: string | null + ends_next_day?: boolean + id?: string + is_off?: boolean + name: string + sort_order?: number + start_time?: string | null + updated_at?: string + workspace_id: string + } + Update: { + code?: string + color?: string + created_at?: string + end_time?: string | null + ends_next_day?: boolean + id?: string + is_off?: boolean + name?: string + sort_order?: number + start_time?: string | null + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "work_shift_types_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } workspace_members: { Row: { created_at: string @@ -738,6 +798,27 @@ export type Database = { updated_at: string }[] } + get_sprints: { + Args: { p_workspace_id: string } + Returns: { + completed_points: number + days_left: number + end_date: string + id: string + name: string + start_date: string + total_points: number + workspace_id: string + }[] + } + replace_and_delete_work_shift_type: { + Args: { + p_deleted_shift_type_id: string + p_replacement_shift_type_id: string + p_workspace_id: string + } + Returns: undefined + } } Enums: { calendar_event_type: "meeting" | "deadline" From fe1324540895f2cb8a147519713a3133b70a695d Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Sun, 12 Jul 2026 22:38:08 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8/=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=9D=BD=EA=B8=B0=20fe?= =?UTF-8?q?tcher=20Supabase=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/progress-chart/page.tsx | 9 ++++--- .../[workspaceId]/sprint-board/page.tsx | 11 ++++---- .../side-project/sprint/api/get-sprints.ts | 18 ++++++++----- src/entities/side-project/sprint/index.ts | 7 ++++- .../side-project/sprint/model/sprint.mock.ts | 6 +++++ .../task/api/get-backlog-tasks.ts | 26 ++++++++++++++----- .../side-project/task/api/get-sprint-tasks.ts | 25 +++++++++++++----- src/entities/side-project/task/index.ts | 1 + .../side-project/task/model/task.mock.ts | 10 +++++++ .../dashboard-backlog/ui/Backlog.tsx | 4 +-- .../dashboard-my-tasks/ui/MyTasks.tsx | 4 +-- 11 files changed, 89 insertions(+), 32 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index e938433..7f91c65 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,8 +1,9 @@ // 진행률 차트 라우트 — 현재 스프린트를 판정해, 통계/벨로시티/상태 분포를 파생해 뷰에 주입한다. // 상호작용이 없어 클라이언트 페칭 없이 RSC에서 데이터를 조립한다. // 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. -import { getSprints, resolveCurrentSprint, selectVelocity } from '@/entities/side-project/sprint'; -import { countByStatus, getSprintTasks } from '@/entities/side-project/task'; +// TODO(Epic 4): getMock* → useQuery 훅(client 전환)으로 교체. 현재는 async fetcher 전환 중 임시 mock. +import { getMockSprints, resolveCurrentSprint, selectVelocity } from '@/entities/side-project/sprint'; +import { countByStatus, getMockSprintTasks } from '@/entities/side-project/task'; import { ProgressChartView } from '@/views/side-project/progress-chart'; import { notFound } from 'next/navigation'; import { getMockWorkspaceById } from '@/entities/workspace'; @@ -17,7 +18,7 @@ export default async function ProgressChartPage({ params }: ProgressChartRoutePr const workspace = getMockWorkspaceById(workspaceId); if(!workspace) return notFound() if (workspace.purpose === 'side-project') { - const sprints = getSprints(workspaceId); + const sprints = getMockSprints(workspaceId); // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신 const sprint = resolveCurrentSprint(sprints); @@ -31,7 +32,7 @@ export default async function ProgressChartPage({ params }: ProgressChartRoutePr } const velocity = selectVelocity(sprints); - const statusCounts = countByStatus(getSprintTasks(sprint.id)); + const statusCounts = countByStatus(getMockSprintTasks(sprint.id)); return ; } diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index cc91560..96d088d 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -2,8 +2,9 @@ // 스프린트 전환 = URL 변경(네비게이션) → 이 RSC가 다시 실행되어 새 스프린트로 seed된다(클라 페칭 없음). // 이후 태스크 변경은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. // 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. -import { getSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; -import { getBacklogTasks, getSprintTasks } from '@/entities/side-project/task'; +// TODO(Epic 4): getMock* → useQuery 훅(client 전환)으로 교체. 현재는 async fetcher 전환 중 임시 mock. +import { getMockSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; +import { getMockBacklogTasks, getMockSprintTasks } from '@/entities/side-project/task'; import { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers } from '@/entities/workspace-member'; import { SprintBoardView } from '@/views/side-project/sprint-board'; @@ -16,7 +17,7 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo const { workspaceId } = await params; const { sprint: sprintParam } = await searchParams; - const sprints = getSprints(workspaceId); + const sprints = getMockSprints(workspaceId); const selectedId = typeof sprintParam === 'string' ? sprintParam : undefined; // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) const sprint = sprints.find((item) => item.id === selectedId) ?? resolveCurrentSprint(sprints); @@ -30,8 +31,8 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo ); } - const initialTasks = getSprintTasks(sprint.id); - const initialBacklog = getBacklogTasks(workspaceId); + const initialTasks = getMockSprintTasks(sprint.id); + const initialBacklog = getMockBacklogTasks(workspaceId); const members=getMockWorkspaceMembersByWorkspaceId(workspaceId) return ( sprint.workspaceId === workspaceId); +export async function getSprints(workspaceId: string): Promise { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase.rpc('get_sprints', { p_workspace_id: workspaceId }); + + if (error) { + throw new Error(`스프린트 목록 조회에 실패했습니다: ${error.message}`); + } + + return (data ?? []).map(toSprint); } diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index c306de7..b59bc9e 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,7 +1,12 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { currentSprint, mockSprints, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; +export { + currentSprint, + getMockSprints, + mockSprints, + SIDE_PROJECT_WORKSPACE_ID, +} from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; export { toSprint } from './model/sprint.mapper'; diff --git a/src/entities/side-project/sprint/model/sprint.mock.ts b/src/entities/side-project/sprint/model/sprint.mock.ts index bcbcebd..a245a87 100644 --- a/src/entities/side-project/sprint/model/sprint.mock.ts +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -32,3 +32,9 @@ export const currentSprint: Sprint = { // 워크스페이스의 스프린트 목록(선택기용) — 시간순 export const mockSprints: Sprint[] = [sprint1, currentSprint]; + +// 대시보드 위젯 전용 동기 mock 접근자 — api/get-sprints.ts가 async(Supabase)로 전환되어 분리한다. +// 대시보드 실 연동 시 이 접근자와 mock 데이터를 함께 제거한다. +export function getMockSprints(workspaceId: string): Sprint[] { + return mockSprints.filter((sprint) => sprint.workspaceId === workspaceId); +} diff --git a/src/entities/side-project/task/api/get-backlog-tasks.ts b/src/entities/side-project/task/api/get-backlog-tasks.ts index 53eb25b..4472e3b 100644 --- a/src/entities/side-project/task/api/get-backlog-tasks.ts +++ b/src/entities/side-project/task/api/get-backlog-tasks.ts @@ -1,9 +1,23 @@ -// 백로그(스프린트 미편입) 업무 조회 — Mock 구현. -// 백엔드 준비 시 supabase.from('tasks').select().eq('workspace_id', workspaceId).is('sprint_id', null) 로 교체한다. -// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +// 백로그(스프린트 미편입) 업무 조회 — workspace 범위 + sprint_id is null 필터 + 담당자 조인 +// profiles FK가 2개(assignee_id/created_by)라 조인 모호성 회피 위해 FK명(tasks_assignee_id_fkey)을 명시한다. +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import { toTask } from '../model/task.mapper'; import type { Task } from '../model/task.types'; -import { mockTasks } from '../model/task.mock'; -export function getBacklogTasks(workspaceId: string): Task[] { - return mockTasks.filter((task) => task.workspaceId === workspaceId && task.sprintId === null); +const TASK_SELECT = '*, assignee:profiles!tasks_assignee_id_fkey(real_name)'; + +export async function getBacklogTasks(workspaceId: string): Promise { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase + .from('tasks') + .select(TASK_SELECT) + .eq('workspace_id', workspaceId) + .is('sprint_id', null) + .order('sort_order'); + + if (error) { + throw new Error(`백로그 조회에 실패했습니다: ${error.message}`); + } + + return (data ?? []).map(toTask); } diff --git a/src/entities/side-project/task/api/get-sprint-tasks.ts b/src/entities/side-project/task/api/get-sprint-tasks.ts index 99e0115..9fb239a 100644 --- a/src/entities/side-project/task/api/get-sprint-tasks.ts +++ b/src/entities/side-project/task/api/get-sprint-tasks.ts @@ -1,9 +1,22 @@ -// 특정 스프린트에 편입된 업무 조회 — Mock 구현. -// 백엔드 준비 시 supabase.from('tasks').select().eq('sprint_id', sprintId) 로 교체한다. -// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +// 특정 스프린트에 편입된 업무 조회 — tasks 단순 필터 + 담당자 조인(집계 아님 → 직접 쿼리) +// profiles FK가 2개(assignee_id/created_by)라 조인 모호성 회피 위해 FK명(tasks_assignee_id_fkey)을 명시한다. +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import { toTask } from '../model/task.mapper'; import type { Task } from '../model/task.types'; -import { mockTasks } from '../model/task.mock'; -export function getSprintTasks(sprintId: string): Task[] { - return mockTasks.filter((task) => task.sprintId === sprintId); +const TASK_SELECT = '*, assignee:profiles!tasks_assignee_id_fkey(real_name)'; + +export async function getSprintTasks(sprintId: string): Promise { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase + .from('tasks') + .select(TASK_SELECT) + .eq('sprint_id', sprintId) + .order('sort_order'); + + if (error) { + throw new Error(`스프린트 업무 조회에 실패했습니다: ${error.message}`); + } + + return (data ?? []).map(toTask); } diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 705e49d..f19f8f3 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -12,6 +12,7 @@ export { } from './model/task.types'; export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; +export { getMockBacklogTasks, getMockSprintTasks } from './model/task.mock'; export { countByStatus } from './model/task.selectors'; export { toTask } from './model/task.mapper'; export type { TaskRow, TaskWithAssigneeRow } from './model/task.db.types'; diff --git a/src/entities/side-project/task/model/task.mock.ts b/src/entities/side-project/task/model/task.mock.ts index 3b52106..1ba7fc8 100644 --- a/src/entities/side-project/task/model/task.mock.ts +++ b/src/entities/side-project/task/model/task.mock.ts @@ -147,3 +147,13 @@ export const mockTasks: Task[] = [ assignee: null, }, ]; + +// 대시보드 위젯 전용 동기 mock 접근자 — api/get-*-tasks.ts가 async(Supabase)로 전환되어 분리한다. +// 대시보드 실 연동 시 이 접근자와 mock 데이터를 함께 제거한다. +export function getMockSprintTasks(sprintId: string): Task[] { + return mockTasks.filter((task) => task.sprintId === sprintId); +} + +export function getMockBacklogTasks(workspaceId: string): Task[] { + return mockTasks.filter((task) => task.workspaceId === workspaceId && task.sprintId === null); +} diff --git a/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx b/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx index 845bbbf..8a13e01 100644 --- a/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx +++ b/src/widgets/side-project/dashboard-backlog/ui/Backlog.tsx @@ -3,7 +3,7 @@ // · md/lg: 우선순위 점 + 항목 + 포인트 리스트(넘치면 스크롤) // 워크스페이스의 백로그(스프린트 미편입) 업무를 셀렉터로 가져온다. import { currentSprint } from '@/entities/side-project/sprint'; -import { getBacklogTasks, type Task, TASK_PRIORITY } from '@/entities/side-project/task'; +import { getMockBacklogTasks, type Task, TASK_PRIORITY } from '@/entities/side-project/task'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; @@ -11,7 +11,7 @@ const header = ( 보드} /> ); -const backlogItems: Task[] = getBacklogTasks(currentSprint.workspaceId); +const backlogItems: Task[] = getMockBacklogTasks(currentSprint.workspaceId); export default function Backlog({ size = 'md' }: { size?: WidgetSize }) { if (size === 'sm') { diff --git a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx index 9d783d4..2929fb0 100644 --- a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx +++ b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx @@ -4,7 +4,7 @@ // · lg: 상태별 카운트 요약 + task 리스트 // 현재 스프린트에 편입된 업무를 셀렉터로 가져온다(백로그는 애초에 포함되지 않음). import { currentSprint } from '@/entities/side-project/sprint'; -import { getSprintTasks, Task, TASK_STATUS, type TaskStatus } from '@/entities/side-project/task'; +import { getMockSprintTasks, Task, TASK_STATUS, type TaskStatus } from '@/entities/side-project/task'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; @@ -13,7 +13,7 @@ const header = ( ); // 현재 스프린트 편입 업무 -const sprintTasks: Task[] = getSprintTasks(currentSprint.id); +const sprintTasks: Task[] = getMockSprintTasks(currentSprint.id); const countBy = (status: TaskStatus) => sprintTasks.filter((task) => task.status === status).length; export default function MyTasks({ size = 'md' }: { size?: WidgetSize }) { From 1c9cd0fc6a8c800541ed0c100d6bb87feefce06a Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Sun, 12 Jul 2026 23:12:43 +0900 Subject: [PATCH 10/23] =?UTF-8?q?=20feat:=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=C2=B7=EC=8A=A4=ED=94=84=EB=A6=B0=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=8B=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(useQuery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/progress-chart/page.tsx | 33 ++-------- .../[workspaceId]/sprint-board/page.tsx | 39 ++--------- .../side-project/sprint/api/use-sprints.ts | 15 +++++ src/entities/side-project/sprint/index.ts | 8 +-- .../side-project/sprint/model/sprint.mock.ts | 6 -- .../task/api/use-backlog-tasks.ts | 16 +++++ .../side-project/task/api/use-sprint-tasks.ts | 17 +++++ src/entities/side-project/task/index.ts | 2 + .../progress-chart/ui/ProgressChartView.tsx | 39 ++++++++--- .../sprint-board/ui/SprintBoardView.tsx | 64 +++++++++++++------ 10 files changed, 136 insertions(+), 103 deletions(-) create mode 100644 src/entities/side-project/sprint/api/use-sprints.ts create mode 100644 src/entities/side-project/task/api/use-backlog-tasks.ts create mode 100644 src/entities/side-project/task/api/use-sprint-tasks.ts diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index 7f91c65..ffe17d8 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,12 +1,7 @@ -// 진행률 차트 라우트 — 현재 스프린트를 판정해, 통계/벨로시티/상태 분포를 파생해 뷰에 주입한다. -// 상호작용이 없어 클라이언트 페칭 없이 RSC에서 데이터를 조립한다. -// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. -// TODO(Epic 4): getMock* → useQuery 훅(client 전환)으로 교체. 현재는 async fetcher 전환 중 임시 mock. -import { getMockSprints, resolveCurrentSprint, selectVelocity } from '@/entities/side-project/sprint'; -import { countByStatus, getMockSprintTasks } from '@/entities/side-project/task'; -import { ProgressChartView } from '@/views/side-project/progress-chart'; +// 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. import { notFound } from 'next/navigation'; import { getMockWorkspaceById } from '@/entities/workspace'; +import { ProgressChartView } from '@/views/side-project/progress-chart'; interface ProgressChartRouteProps { params: Promise<{ workspaceId: string }>; @@ -16,26 +11,12 @@ export default async function ProgressChartPage({ params }: ProgressChartRoutePr const { workspaceId } = await params; const workspace = getMockWorkspaceById(workspaceId); - if(!workspace) return notFound() - if (workspace.purpose === 'side-project') { - const sprints = getMockSprints(workspaceId); - // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신 - const sprint = resolveCurrentSprint(sprints); - - // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 - if (!sprint) { - return ( -
- 아직 생성된 스프린트가 없습니다. -
- ); - } + if (!workspace) return notFound(); - const velocity = selectVelocity(sprints); - const statusCounts = countByStatus(getMockSprintTasks(sprint.id)); - - return ; + if (workspace.purpose === 'side-project') { + return ; } - //TODO: + + //TODO: 다른 워크스페이스 용도(team-project 등)별 진행률 뷰 return <>; } diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 96d088d..6266d98 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,11 +1,5 @@ -// 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어, RSC에서 해당 스프린트 데이터를 조회해 주입한다. -// 스프린트 전환 = URL 변경(네비게이션) → 이 RSC가 다시 실행되어 새 스프린트로 seed된다(클라 페칭 없음). -// 이후 태스크 변경은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. -// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. -// TODO(Epic 4): getMock* → useQuery 훅(client 전환)으로 교체. 현재는 async fetcher 전환 중 임시 mock. -import { getMockSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; -import { getMockBacklogTasks, getMockSprintTasks } from '@/entities/side-project/task'; -import { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers } from '@/entities/workspace-member'; +// 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다. +// 데이터 조회(useQuery)와 스프린트 판정은 컨테이너가 담당하고, 태스크 변경은 보드의 클라이언트 낙관적 업데이트로 처리한다. import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { @@ -16,32 +10,7 @@ interface SprintBoardRouteProps { export default async function SprintBoardPage({ params, searchParams }: SprintBoardRouteProps) { const { workspaceId } = await params; const { sprint: sprintParam } = await searchParams; + const selectedSprintId = typeof sprintParam === 'string' ? sprintParam : undefined; - const sprints = getMockSprints(workspaceId); - const selectedId = typeof sprintParam === 'string' ? sprintParam : undefined; - // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) - const sprint = sprints.find((item) => item.id === selectedId) ?? resolveCurrentSprint(sprints); - - // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 - if (!sprint) { - return ( -
- 아직 생성된 스프린트가 없습니다. -
- ); - } - - const initialTasks = getMockSprintTasks(sprint.id); - const initialBacklog = getMockBacklogTasks(workspaceId); - const members=getMockWorkspaceMembersByWorkspaceId(workspaceId) - return ( - - ); + return ; } diff --git a/src/entities/side-project/sprint/api/use-sprints.ts b/src/entities/side-project/sprint/api/use-sprints.ts new file mode 100644 index 0000000..5f50520 --- /dev/null +++ b/src/entities/side-project/sprint/api/use-sprints.ts @@ -0,0 +1,15 @@ +'use client'; + +// 워크스페이스 스프린트 목록 쿼리 훅 — GET은 tanstack-query 컨벤션(§5) +import { useQuery } from '@tanstack/react-query'; +import { getSprints } from './get-sprints'; + +// 쓰기 후 invalidateQueries({ queryKey: ['sprints'] })로 무효화한다 +export const sprintsQueryKey = (workspaceId: string) => ['sprints', workspaceId] as const; + +export function useSprints(workspaceId: string) { + return useQuery({ + queryKey: sprintsQueryKey(workspaceId), + queryFn: () => getSprints(workspaceId), + }); +} diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index b59bc9e..e9e3ea7 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,13 +1,9 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { - currentSprint, - getMockSprints, - mockSprints, - SIDE_PROJECT_WORKSPACE_ID, -} from './model/sprint.mock'; +export { currentSprint, mockSprints, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; +export { sprintsQueryKey, useSprints } from './api/use-sprints'; export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; export { toSprint } from './model/sprint.mapper'; export type { SprintRow, SprintRpcRow } from './model/sprint.db.types'; diff --git a/src/entities/side-project/sprint/model/sprint.mock.ts b/src/entities/side-project/sprint/model/sprint.mock.ts index a245a87..bcbcebd 100644 --- a/src/entities/side-project/sprint/model/sprint.mock.ts +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -32,9 +32,3 @@ export const currentSprint: Sprint = { // 워크스페이스의 스프린트 목록(선택기용) — 시간순 export const mockSprints: Sprint[] = [sprint1, currentSprint]; - -// 대시보드 위젯 전용 동기 mock 접근자 — api/get-sprints.ts가 async(Supabase)로 전환되어 분리한다. -// 대시보드 실 연동 시 이 접근자와 mock 데이터를 함께 제거한다. -export function getMockSprints(workspaceId: string): Sprint[] { - return mockSprints.filter((sprint) => sprint.workspaceId === workspaceId); -} diff --git a/src/entities/side-project/task/api/use-backlog-tasks.ts b/src/entities/side-project/task/api/use-backlog-tasks.ts new file mode 100644 index 0000000..b662c0d --- /dev/null +++ b/src/entities/side-project/task/api/use-backlog-tasks.ts @@ -0,0 +1,16 @@ +'use client'; + +// 백로그(스프린트 미편입) 업무 쿼리 훅 — GET은 tanstack-query 컨벤션(§5) +import { useQuery } from '@tanstack/react-query'; +import { getBacklogTasks } from './get-backlog-tasks'; + +// 쓰기 후 invalidateQueries({ queryKey: ['tasks'] })로 무효화한다 +export const backlogTasksQueryKey = (workspaceId: string) => + ['tasks', 'backlog', workspaceId] as const; + +export function useBacklogTasks(workspaceId: string) { + return useQuery({ + queryKey: backlogTasksQueryKey(workspaceId), + queryFn: () => getBacklogTasks(workspaceId), + }); +} diff --git a/src/entities/side-project/task/api/use-sprint-tasks.ts b/src/entities/side-project/task/api/use-sprint-tasks.ts new file mode 100644 index 0000000..f89f8e7 --- /dev/null +++ b/src/entities/side-project/task/api/use-sprint-tasks.ts @@ -0,0 +1,17 @@ +'use client'; + +// 스프린트 편입 업무 쿼리 훅 — GET은 tanstack-query 컨벤션(§5) +// sprintId가 아직 정해지지 않았으면(상위 스프린트 로딩 중) enabled=false로 대기한다. +import { useQuery } from '@tanstack/react-query'; +import { getSprintTasks } from './get-sprint-tasks'; + +// 쓰기 후 invalidateQueries({ queryKey: ['tasks'] })로 무효화한다 +export const sprintTasksQueryKey = (sprintId: string) => ['tasks', 'sprint', sprintId] as const; + +export function useSprintTasks(sprintId: string | undefined) { + return useQuery({ + queryKey: sprintTasksQueryKey(sprintId ?? ''), + queryFn: () => getSprintTasks(sprintId as string), + enabled: !!sprintId, + }); +} diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index f19f8f3..a414857 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -12,6 +12,8 @@ export { } from './model/task.types'; export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; +export { backlogTasksQueryKey, useBacklogTasks } from './api/use-backlog-tasks'; +export { sprintTasksQueryKey, useSprintTasks } from './api/use-sprint-tasks'; export { getMockBacklogTasks, getMockSprintTasks } from './model/task.mock'; export { countByStatus } from './model/task.selectors'; export { toTask } from './model/task.mapper'; diff --git a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx index 89b86f3..cf5b1a0 100644 --- a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -1,9 +1,11 @@ -// 진행률 차트 페이지 셸 — 폰트/배경만 잡고, 서버에서 파생·주입된 데이터를 하위 표현 컴포넌트에 전달한다. -// 상호작용이 없어 전부 표현용(서버 컴포넌트)이다. +'use client'; + +// 진행률 차트 페이지 뷰 — useQuery로 스프린트/업무를 조회하고 파생값을 계산해 렌더한다(GET 컨벤션 §5). +// 로딩/에러/빈 상태를 여기서 분기하고, 하위 차트 컴포넌트는 순수 표현만 담당한다. import { Plus_Jakarta_Sans } from 'next/font/google'; -import type { Sprint, VelocityPoint } from '@/entities/side-project/sprint'; -import type { TaskStatus } from '@/entities/side-project/task'; +import { resolveCurrentSprint, selectVelocity, useSprints } from '@/entities/side-project/sprint'; +import { countByStatus, useSprintTasks } from '@/entities/side-project/task'; import ProgressStatRow from './ProgressStatRow'; import SprintProgressCard from './SprintProgressCard'; @@ -15,13 +17,32 @@ const jakarta = Plus_Jakarta_Sans({ weight: ['400', '500', '600', '700', '800'], }); -interface ProgressChartViewProps { - sprint: Sprint; - velocity: VelocityPoint[]; - statusCounts: Record; +function CenteredMessage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); } -export function ProgressChartView({ sprint, velocity, statusCounts }: ProgressChartViewProps) { +export function ProgressChartView({ workspaceId }: { workspaceId: string }) { + const sprintsQuery = useSprints(workspaceId); + // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신. 로딩 중이면 undefined. + const sprint = sprintsQuery.data ? resolveCurrentSprint(sprintsQuery.data) : undefined; + const tasksQuery = useSprintTasks(sprint?.id); + + if (sprintsQuery.isPending) return 불러오는 중…; + if (sprintsQuery.isError) return 진행률을 불러오지 못했습니다.; + + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 + if (!sprint) return 아직 생성된 스프린트가 없습니다.; + + if (tasksQuery.isPending) return 불러오는 중…; + if (tasksQuery.isError) return 업무를 불러오지 못했습니다.; + + const velocity = selectVelocity(sprintsQuery.data); + const statusCounts = countByStatus(tasksQuery.data); + return (
diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index b9cb5db..c889f15 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -1,10 +1,14 @@ -// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고, 서버에서 받은 초기 데이터를 하위로 전달한다. -// 순수 표시용 요약 헤더는 여기(뷰)에서 sprint를 받아 렌더하고, 상호작용 보드/백로그는 feature에 위임한다. +'use client'; + +// 스프린트 보드 페이지 뷰 — useQuery로 스프린트/업무/백로그를 조회해 렌더한다(GET 컨벤션 §5). +// 선택 스프린트는 URL(?sprint=id)에서 온 selectedSprintId로 판정하고, 없으면 현재 스프린트로 폴백한다. +// 로딩/에러/빈 상태를 여기서 분기하고, 상호작용 보드/백로그는 feature에 위임한다. +// members는 아직 mock 유지(workspace-member 엔티티 api 준비 시 연동). 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 { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; +import { useBacklogTasks, useSprintTasks } from '@/entities/side-project/task'; +import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member'; import { SprintBoard } from '@/features/sprint-board'; import SprintSelector from './SprintSelector'; @@ -15,34 +19,52 @@ const jakarta = Plus_Jakarta_Sans({ weight: ['400', '500', '600', '700', '800'], }); +function CenteredMessage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + interface SprintBoardViewProps { workspaceId: string; - sprint: Sprint; - sprints: Sprint[]; - initialTasks: Task[]; - initialBacklog: Task[]; - members: WorkspaceMember[]; + selectedSprintId?: string; } -export function SprintBoardView({ - workspaceId, - sprint, - sprints, - initialTasks, - initialBacklog, - members, -}: SprintBoardViewProps) { +export function SprintBoardView({ workspaceId, selectedSprintId }: SprintBoardViewProps) { + const sprintsQuery = useSprints(workspaceId); + // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) + const sprint = sprintsQuery.data + ? (sprintsQuery.data.find((item) => item.id === selectedSprintId) ?? + resolveCurrentSprint(sprintsQuery.data)) + : undefined; + const tasksQuery = useSprintTasks(sprint?.id); + const backlogQuery = useBacklogTasks(workspaceId); + const members = getMockWorkspaceMembersByWorkspaceId(workspaceId); + + if (sprintsQuery.isPending) return 불러오는 중…; + if (sprintsQuery.isError) return 스프린트를 불러오지 못했습니다.; + + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 + if (!sprint) return 아직 생성된 스프린트가 없습니다.; + + if (tasksQuery.isPending || backlogQuery.isPending) + return 불러오는 중…; + if (tasksQuery.isError || backlogQuery.isError) + return 업무를 불러오지 못했습니다.; + return (
- + {/* key={sprint.id}: 스프린트 전환 시 보드를 리마운트해 초기 데이터로 다시 seed한다 */}
From bb935f3a07925200deae7421f14ee7b3c6d0b536 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 09:46:12 +0900 Subject: [PATCH 11/23] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20+=20DnD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../side-project/task/api/create-task.ts | 35 +++++++ .../side-project/task/api/delete-task.ts | 14 +++ .../task/api/update-task-status.ts | 12 +++ .../side-project/task/api/update-task.ts | 26 ++++++ .../side-project/task/api/use-create-task.ts | 23 +++++ .../side-project/task/api/use-delete-task.ts | 20 ++++ .../task/api/use-update-task-status.ts | 22 +++++ .../side-project/task/api/use-update-task.ts | 21 +++++ src/entities/side-project/task/index.ts | 5 + .../side-project/task/model/task.mapper.ts | 51 +++++++++-- .../side-project/task/model/task.mock.ts | 16 ++-- .../side-project/task/model/task.schema.ts | 19 ++++ .../side-project/task/model/task.types.ts | 2 + src/features/sprint-board/model/task-form.ts | 37 +++----- .../sprint-board/model/use-sprint-board.ts | 91 ++++++++----------- src/features/sprint-board/ui/SprintBoard.tsx | 14 +-- .../sprint-board/ui/TaskFormDialog.tsx | 3 +- .../sprint-board/ui/SprintBoardView.tsx | 7 +- .../dashboard-my-tasks/ui/MyTasks.tsx | 7 +- 19 files changed, 319 insertions(+), 106 deletions(-) create mode 100644 src/entities/side-project/task/api/create-task.ts create mode 100644 src/entities/side-project/task/api/delete-task.ts create mode 100644 src/entities/side-project/task/api/update-task-status.ts create mode 100644 src/entities/side-project/task/api/update-task.ts create mode 100644 src/entities/side-project/task/api/use-create-task.ts create mode 100644 src/entities/side-project/task/api/use-delete-task.ts create mode 100644 src/entities/side-project/task/api/use-update-task-status.ts create mode 100644 src/entities/side-project/task/api/use-update-task.ts create mode 100644 src/entities/side-project/task/model/task.schema.ts diff --git a/src/entities/side-project/task/api/create-task.ts b/src/entities/side-project/task/api/create-task.ts new file mode 100644 index 0000000..f711595 --- /dev/null +++ b/src/entities/side-project/task/api/create-task.ts @@ -0,0 +1,35 @@ +'use server'; + +// 업무 생성 서버액션 — 검증 후 tasks insert (created_by는 서버에서 현재 유저로 채운다) +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { DEV_USER_ID } from '@/shared/config/dev-user'; +import { toTaskInsert } from '../model/task.mapper'; +import { taskInputSchema, type TaskInput } from '../model/task.schema'; + +interface CreateTaskParams { + input: TaskInput; + workspaceId: string; + /** 편입할 스프린트 id. null이면 백로그로 생성 */ + sprintId: string | null; +} + +export async function createTask({ + input, + workspaceId, + sprintId, +}: CreateTaskParams): Promise { + // 클라이언트 검증과 별개로 서버에서 재검증한다 + const parsed = taskInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const payload = toTaskInsert(parsed.data, { workspaceId, sprintId, createdBy: DEV_USER_ID }); + const { error } = await supabase.from('tasks').insert(payload); + + if (error) { + console.error('[createTask] insert 실패:', error); + throw new Error('업무 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/task/api/delete-task.ts b/src/entities/side-project/task/api/delete-task.ts new file mode 100644 index 0000000..91610f6 --- /dev/null +++ b/src/entities/side-project/task/api/delete-task.ts @@ -0,0 +1,14 @@ +'use server'; + +// 업무 삭제 서버액션 +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +export async function deleteTask(id: string): Promise { + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from('tasks').delete().eq('id', id); + + if (error) { + console.error('[deleteTask] delete 실패:', error); + throw new Error('업무 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/task/api/update-task-status.ts b/src/entities/side-project/task/api/update-task-status.ts new file mode 100644 index 0000000..ef139cc --- /dev/null +++ b/src/entities/side-project/task/api/update-task-status.ts @@ -0,0 +1,12 @@ +// 칸반 DnD 상태 이동 — 단일 컬럼 부분 수정이라 클라이언트 직접 update(supabase-convention §4) +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import type { TaskStatus } from '../model/task.types'; + +export async function updateTaskStatus(id: string, status: TaskStatus): Promise { + const supabase = getSupabaseBrowserClient(); + const { error } = await supabase.from('tasks').update({ status }).eq('id', id); + + if (error) { + throw new Error(`업무 상태 변경에 실패했습니다: ${error.message}`); + } +} diff --git a/src/entities/side-project/task/api/update-task.ts b/src/entities/side-project/task/api/update-task.ts new file mode 100644 index 0000000..95a459a --- /dev/null +++ b/src/entities/side-project/task/api/update-task.ts @@ -0,0 +1,26 @@ +'use server'; + +// 업무 수정 서버액션 — 검증 후 편집 가능 필드만 update +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { toTaskUpdate } from '../model/task.mapper'; +import { taskInputSchema, type TaskInput } from '../model/task.schema'; + +interface UpdateTaskParams { + id: string; + input: TaskInput; +} + +export async function updateTask({ id, input }: UpdateTaskParams): Promise { + const parsed = taskInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from('tasks').update(toTaskUpdate(parsed.data)).eq('id', id); + + if (error) { + console.error('[updateTask] update 실패:', error); + throw new Error('업무 수정에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/task/api/use-create-task.ts b/src/entities/side-project/task/api/use-create-task.ts new file mode 100644 index 0000000..b87e7f4 --- /dev/null +++ b/src/entities/side-project/task/api/use-create-task.ts @@ -0,0 +1,23 @@ +'use client'; + +// 업무 생성 뮤테이션 — 서버액션을 mutationFn으로 감싸고, 성공 시 tasks/sprints 쿼리를 무효화한다. +// (생성은 스프린트 포인트 집계도 바꾸므로 sprints도 무효화) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createTask } from './create-task'; +import type { TaskInput } from '../model/task.schema'; + +export function useCreateTask(workspaceId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { input: TaskInput; sprintId: string | null }) => + createTask({ input: params.input, workspaceId, sprintId: params.sprintId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '업무 생성에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/task/api/use-delete-task.ts b/src/entities/side-project/task/api/use-delete-task.ts new file mode 100644 index 0000000..40fb392 --- /dev/null +++ b/src/entities/side-project/task/api/use-delete-task.ts @@ -0,0 +1,20 @@ +'use client'; + +// 업무 삭제 뮤테이션 — 성공 시 tasks/sprints 무효화(포인트 변경 반영) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteTask } from './delete-task'; + +export function useDeleteTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteTask(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '업무 삭제에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/task/api/use-update-task-status.ts b/src/entities/side-project/task/api/use-update-task-status.ts new file mode 100644 index 0000000..2d6ccd1 --- /dev/null +++ b/src/entities/side-project/task/api/use-update-task-status.ts @@ -0,0 +1,22 @@ +'use client'; + +// 칸반 DnD 상태 이동 뮤테이션 — 클라 직접 update를 감싸고, 성공 시 tasks/sprints 무효화(완료 포인트 반영) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateTaskStatus } from './update-task-status'; +import type { TaskStatus } from '../model/task.types'; + +export function useUpdateTaskStatus() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { id: string; status: TaskStatus }) => + updateTaskStatus(params.id, params.status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '상태 변경에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/task/api/use-update-task.ts b/src/entities/side-project/task/api/use-update-task.ts new file mode 100644 index 0000000..66334eb --- /dev/null +++ b/src/entities/side-project/task/api/use-update-task.ts @@ -0,0 +1,21 @@ +'use client'; + +// 업무 수정 뮤테이션 — 성공 시 tasks/sprints 무효화(포인트 변경 반영) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateTask } from './update-task'; +import type { TaskInput } from '../model/task.schema'; + +export function useUpdateTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { id: string; input: TaskInput }) => updateTask(params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '업무 수정에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index a414857..297881a 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -18,3 +18,8 @@ export { getMockBacklogTasks, getMockSprintTasks } from './model/task.mock'; export { countByStatus } from './model/task.selectors'; export { toTask } from './model/task.mapper'; export type { TaskRow, TaskWithAssigneeRow } from './model/task.db.types'; +export { taskInputSchema, type TaskInput } from './model/task.schema'; +export { useCreateTask } from './api/use-create-task'; +export { useUpdateTask } from './api/use-update-task'; +export { useDeleteTask } from './api/use-delete-task'; +export { useUpdateTaskStatus } from './api/use-update-task-status'; diff --git a/src/entities/side-project/task/model/task.mapper.ts b/src/entities/side-project/task/model/task.mapper.ts index cb2bf26..dfb2089 100644 --- a/src/entities/side-project/task/model/task.mapper.ts +++ b/src/entities/side-project/task/model/task.mapper.ts @@ -1,16 +1,21 @@ -// DB row → Task 엔티티 매퍼 — snake_case→camelCase, null 흡수, 담당자 조인 변환을 한곳에 모은다. +// DB row ↔ Task 엔티티 매퍼 — snake_case↔camelCase, null 흡수, 담당자 조인/역변환을 한곳에 모은다. // DB enum(task_status/priority/category)은 엔티티 유니온과 값이 동일해 캐스팅 없이 대입된다. +import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types'; import type { Task, TaskAssignee } from './task.types'; import type { TaskWithAssigneeRow } from './task.db.types'; +import type { TaskInput } from './task.schema'; /** - * 조인된 profiles row → 화면용 담당자(TaskAssignee). - * 미배정(백로그 등)이면 null. avatarLabel은 표시명 첫 글자(성)로, 빈 이름은 '?'로 방어한다. + * assignee_id + 조인된 profiles row → 화면용 담당자(TaskAssignee). + * 미배정(assignee_id null 또는 조인 empty)이면 null. avatarLabel은 표시명 첫 글자(성)로, 빈 이름은 '?'로 방어한다. */ -function toAssignee(profile: { real_name: string } | null): TaskAssignee | null { - if (!profile) return null; +function toAssignee( + assigneeId: string | null, + profile: { real_name: string } | null, +): TaskAssignee | null { + if (!assigneeId || !profile) return null; const name = profile.real_name.trim(); - return { name, avatarLabel: name.charAt(0) || '?' }; + return { userId: assigneeId, name, avatarLabel: name.charAt(0) || '?' }; } /** @@ -28,6 +33,38 @@ export function toTask(row: TaskWithAssigneeRow): Task { status: row.status, priority: row.priority, category: row.category, - assignee: toAssignee(row.assignee), + assignee: toAssignee(row.assignee_id, row.assignee), + }; +} + +/** + * 검증된 입력 → tasks insert 페이로드. id/status는 DB/기본값에 맡기지 않고 명시한다. + * assignee.userId는 이미 폼→입력 단계에서 assigneeId로 추출돼 그대로 assignee_id에 매핑된다. + */ +export function toTaskInsert( + input: TaskInput, + ctx: { workspaceId: string; sprintId: string | null; createdBy: string }, +): GenericTablesInsert<'tasks'> { + return { + workspace_id: ctx.workspaceId, + sprint_id: ctx.sprintId, + created_by: ctx.createdBy, + assignee_id: input.assigneeId, + title: input.title, + point: input.point, + category: input.category, + priority: input.priority, + status: 'todo', + }; +} + +/** 검증된 입력 → tasks update 페이로드(편집 가능 필드만). 배치(workspace/sprint/status)는 여기서 바꾸지 않는다. */ +export function toTaskUpdate(input: TaskInput): GenericTablesUpdate<'tasks'> { + return { + title: input.title, + point: input.point, + category: input.category, + priority: input.priority, + assignee_id: input.assigneeId, }; } diff --git a/src/entities/side-project/task/model/task.mock.ts b/src/entities/side-project/task/model/task.mock.ts index 1ba7fc8..a3abc12 100644 --- a/src/entities/side-project/task/model/task.mock.ts +++ b/src/entities/side-project/task/model/task.mock.ts @@ -20,7 +20,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'design', - assignee: { name: '최민준', avatarLabel: '최' }, + assignee: { userId: 'mock-user-choi', name: '최민준', avatarLabel: '최' }, }, { id: 'task-2', @@ -31,7 +31,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'frontend', - assignee: { name: '박서준', avatarLabel: '박' }, + assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, }, { id: 'task-3', @@ -42,7 +42,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'planning', - assignee: { name: '김지은', avatarLabel: '김' }, + assignee: { userId: 'mock-user-kim', name: '김지은', avatarLabel: '김' }, }, // 스프린트 편입 · 진행 중 (in_progress) — 11pt { @@ -54,7 +54,7 @@ export const mockTasks: Task[] = [ status: 'in_progress', priority: 'medium', category: 'frontend', - assignee: { name: '박서준', avatarLabel: '박' }, + assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, }, { id: 'task-5', @@ -65,7 +65,7 @@ export const mockTasks: Task[] = [ status: 'in_progress', priority: 'medium', category: 'planning', - assignee: { name: '김지은', avatarLabel: '김' }, + assignee: { userId: 'mock-user-kim', name: '김지은', avatarLabel: '김' }, }, // 스프린트 편입 · 완료 (done) — 18pt { @@ -77,7 +77,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { name: '이하은', avatarLabel: '이' }, + assignee: { userId: 'mock-user-lee', name: '이하은', avatarLabel: '이' }, }, { id: 'task-7', @@ -88,7 +88,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { name: '이하은', avatarLabel: '이' }, + assignee: { userId: 'mock-user-lee', name: '이하은', avatarLabel: '이' }, }, { id: 'task-8', @@ -99,7 +99,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'frontend', - assignee: { name: '박서준', avatarLabel: '박' }, + assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, }, // 백로그 (sprintId: null) — 카테고리·담당자 미지정, status는 대기(todo) { diff --git a/src/entities/side-project/task/model/task.schema.ts b/src/entities/side-project/task/model/task.schema.ts new file mode 100644 index 0000000..a4f93f2 --- /dev/null +++ b/src/entities/side-project/task/model/task.schema.ts @@ -0,0 +1,19 @@ +// 업무 생성/수정 입력 검증 — 폼과 서버액션이 같은 스키마를 공유한다(클라이언트 입력을 신뢰하지 않음). +// enum 값은 자동 생성 Constants에서 파생한다(값 추가 시 컴파일로 누락 감지) — supabase-convention §3. +import { z } from 'zod'; +import { Constants } from '@/shared/model/database.types'; // 예외: Constants만 직접 import 허용 + +export const taskInputSchema = z.object({ + title: z + .string() + .trim() + .min(1, '제목을 입력해주세요') + .max(100, '제목은 100자 이내로 입력해주세요'), + point: z.number().int('포인트는 정수여야 합니다').min(0, '포인트는 0 이상이어야 합니다'), + category: z.enum(Constants.public.Enums.task_category).nullable(), + priority: z.enum(Constants.public.Enums.task_priority), + // 담당자 profiles.id. 미배정이면 null. (실 member api 전까지 피커가 비어 사실상 null) + assigneeId: z.string().uuid().nullable(), +}); + +export type TaskInput = z.infer; diff --git a/src/entities/side-project/task/model/task.types.ts b/src/entities/side-project/task/model/task.types.ts index 9519b7a..4f609bd 100644 --- a/src/entities/side-project/task/model/task.types.ts +++ b/src/entities/side-project/task/model/task.types.ts @@ -38,6 +38,8 @@ export const TASK_CATEGORY: Record(initialTasks); - const [backlogTasks, setBacklogTasks] = useState(initialBacklog); +export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSprintBoardParams) { + const createMutation = useCreateTask(workspaceId); + const updateMutation = useUpdateTask(); + const deleteMutation = useDeleteTask(); + const statusMutation = useUpdateTaskStatus(); // 스프린트에 새 업무 추가(기본 상태: 대기) const addSprintTask = useCallback( - (values: TaskFormValues) => { - setSprintTasks((prev) => [ - ...prev, - createTaskFromValues(values, { workspaceId, sprintId, status: 'todo' }), - ]); - }, - [workspaceId, sprintId], + (values: TaskFormValues) => createMutation.mutate({ input: toTaskInput(values), sprintId }), + [createMutation, sprintId], ); // 백로그에 새 항목 추가(스프린트 미편입) const addBacklogTask = useCallback( - (values: TaskFormValues) => { - setBacklogTasks((prev) => [ - ...prev, - createTaskFromValues(values, { workspaceId, sprintId: null, status: 'todo' }), - ]); - }, - [workspaceId], + (values: TaskFormValues) => + createMutation.mutate({ input: toTaskInput(values), sprintId: null }), + [createMutation], ); - // 수정/삭제는 업무가 어느 목록에 있든 처리(스프린트·백로그 공통) - 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); - }, []); + // 수정/삭제는 업무가 어느 목록에 있든 id로 처리(스프린트·백로그 공통) + const updateTask = useCallback( + (id: string, values: TaskFormValues) => + updateMutation.mutate({ id, input: toTaskInput(values) }), + [updateMutation], + ); - const deleteTask = useCallback((id: string) => { - const remove = (list: Task[]) => list.filter((t) => t.id !== id); - setSprintTasks(remove); - setBacklogTasks(remove); - }, []); + const deleteTask = useCallback((id: string) => deleteMutation.mutate(id), [deleteMutation]); // 드래그로 컬럼(상태) 이동 — 스프린트 업무에만 적용 - const moveTask = useCallback((id: string, status: TaskStatus) => { - setSprintTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t))); - }, []); + const moveTask = useCallback( + (id: string, status: TaskStatus) => statusMutation.mutate({ id, status }), + [statusMutation], + ); const dnd = useTaskDnd(moveTask); - const columns = groupTasksByStatus(sprintTasks); + const columns = groupTasksByStatus(tasks); return { columns, - backlogTasks, + backlogTasks: backlog, addSprintTask, addBacklogTask, updateTask, diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/sprint-board/ui/SprintBoard.tsx index 1621d96..8e931c3 100644 --- a/src/features/sprint-board/ui/SprintBoard.tsx +++ b/src/features/sprint-board/ui/SprintBoard.tsx @@ -22,18 +22,12 @@ type DialogState = interface SprintBoardProps { sprintId: string; workspaceId: string; - initialTasks: Task[]; - initialBacklog: Task[]; + tasks: Task[]; + backlog: Task[]; members: WorkspaceMember[]; } -export function SprintBoard({ - sprintId, - workspaceId, - initialTasks, - initialBacklog, - members, -}: SprintBoardProps) { +export function SprintBoard({ sprintId, workspaceId, tasks, backlog, members }: SprintBoardProps) { const { columns, backlogTasks, @@ -44,7 +38,7 @@ export function SprintBoard({ dragProps, dropProps, dragOverStatus, - } = useSprintBoard({ sprintId, workspaceId, initialTasks, initialBacklog }); + } = useSprintBoard({ sprintId, workspaceId, tasks, backlog }); const [dialog, setDialog] = useState(null); diff --git a/src/features/sprint-board/ui/TaskFormDialog.tsx b/src/features/sprint-board/ui/TaskFormDialog.tsx index b3addea..c6ee60b 100644 --- a/src/features/sprint-board/ui/TaskFormDialog.tsx +++ b/src/features/sprint-board/ui/TaskFormDialog.tsx @@ -171,7 +171,7 @@ export function TaskFormDialog({ 미배정 {members.map((member) => { - const selected = values.assignee?.name === member.workspaceNickname; + const selected = values.assignee?.userId === member.userId; return (
diff --git a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx index 2929fb0..82b74db 100644 --- a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx +++ b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx @@ -4,7 +4,12 @@ // · lg: 상태별 카운트 요약 + task 리스트 // 현재 스프린트에 편입된 업무를 셀렉터로 가져온다(백로그는 애초에 포함되지 않음). import { currentSprint } from '@/entities/side-project/sprint'; -import { getMockSprintTasks, Task, TASK_STATUS, type TaskStatus } from '@/entities/side-project/task'; +import { + getMockSprintTasks, + Task, + TASK_STATUS, + type TaskStatus, +} from '@/entities/side-project/task'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; From 022a696d41544c83a866ce3a2ed0c7cebea4ac28 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 10:05:57 +0900 Subject: [PATCH 12/23] =?UTF-8?q?feat:=20=EC=B9=B8=EB=B0=98=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/sprint-board/page.tsx | 7 +++- .../task/api/use-update-task-status.ts | 35 ++++++++++++++----- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 6266d98..0b9dc0d 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -12,5 +12,10 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo const { sprint: sprintParam } = await searchParams; const selectedSprintId = typeof sprintParam === 'string' ? sprintParam : undefined; - return ; + return ( + + ); } diff --git a/src/entities/side-project/task/api/use-update-task-status.ts b/src/entities/side-project/task/api/use-update-task-status.ts index 2d6ccd1..e78ab7d 100644 --- a/src/entities/side-project/task/api/use-update-task-status.ts +++ b/src/entities/side-project/task/api/use-update-task-status.ts @@ -1,22 +1,41 @@ 'use client'; -// 칸반 DnD 상태 이동 뮤테이션 — 클라 직접 update를 감싸고, 성공 시 tasks/sprints 무효화(완료 포인트 반영) +// 칸반 DnD 상태 이동 뮤테이션 — 클라 직접 update를 감싼다. +// DnD는 직접 조작이라 낙관적 업데이트로 카드를 즉시 이동시키고, 실패 시 스냅샷으로 롤백한다. +// 스프린트 포인트(완료 집계)는 RPC 값이라 낙관 반영 대상이 아니고, onSettled 재조회에서 반영된다. import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { updateTaskStatus } from './update-task-status'; -import type { TaskStatus } from '../model/task.types'; +import type { Task, TaskStatus } from '../model/task.types'; + +interface StatusVariables { + id: string; + status: TaskStatus; +} export function useUpdateTaskStatus() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: { id: string; status: TaskStatus }) => - updateTaskStatus(params.id, params.status), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['sprints'] }); + mutationFn: ({ id, status }: StatusVariables) => updateTaskStatus(id, status), + onMutate: async ({ id, status }: StatusVariables) => { + // 진행 중인 tasks 재조회를 멈춰 낙관 값이 덮이지 않게 한다 + await queryClient.cancelQueries({ queryKey: ['tasks'] }); + // 롤백용 스냅샷 확보 후, 캐시된 모든 tasks 목록에서 해당 카드 status만 즉시 교체 + const previous = queryClient.getQueriesData({ queryKey: ['tasks'] }); + queryClient.setQueriesData({ queryKey: ['tasks'] }, (old) => + old?.map((task) => (task.id === id ? { ...task, status } : task)), + ); + return { previous }; }, - onError: (error) => { + onError: (error, _variables, context) => { + // 실패 시 스냅샷으로 복원 + context?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data)); toast.error(error instanceof Error ? error.message : '상태 변경에 실패했습니다'); }, + onSettled: () => { + // 성공/실패 무관하게 서버 상태와 재동기화(스프린트 포인트 집계 포함) + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, }); } From 01d252cbcb6fa638e61a6c64b7c89c9f59949f50 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 10:27:46 +0900 Subject: [PATCH 13/23] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=EB=B3=B4=EB=93=9C=20feature=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EB=A6=AC=EB=84=A4=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/manage-sprint-tasks/index.ts | 2 + .../lib/avatar-color.ts | 0 .../model/sprint-board-columns.ts | 0 .../model/task-form.ts | 0 .../model/use-sprint-board.ts | 0 .../model/use-task-dnd.ts | 0 .../ui/BacklogRow.tsx | 0 .../ui/BacklogSection.tsx | 0 .../ui/SprintBoard.tsx | 0 .../ui/SprintColumn.tsx | 0 .../ui/TaskCard.tsx | 0 .../ui/TaskFormDialog.tsx | 0 src/features/manage-sprints/index.ts | 2 + .../manage-sprints/ui/SprintDeleteDialog.tsx | 63 ++++++++ .../manage-sprints/ui/SprintFormDialog.tsx | 138 ++++++++++++++++++ .../manage-sprints/ui/SprintToolbar.tsx | 67 +++++++++ src/features/sprint-board/index.ts | 2 - .../sprint-board/ui/SprintBoardView.tsx | 9 +- .../sprint-board/ui/SprintSelector.tsx | 2 +- 19 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 src/features/manage-sprint-tasks/index.ts rename src/features/{sprint-board => manage-sprint-tasks}/lib/avatar-color.ts (100%) rename src/features/{sprint-board => manage-sprint-tasks}/model/sprint-board-columns.ts (100%) rename src/features/{sprint-board => manage-sprint-tasks}/model/task-form.ts (100%) rename src/features/{sprint-board => manage-sprint-tasks}/model/use-sprint-board.ts (100%) rename src/features/{sprint-board => manage-sprint-tasks}/model/use-task-dnd.ts (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/BacklogRow.tsx (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/BacklogSection.tsx (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/SprintBoard.tsx (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/SprintColumn.tsx (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/TaskCard.tsx (100%) rename src/features/{sprint-board => manage-sprint-tasks}/ui/TaskFormDialog.tsx (100%) create mode 100644 src/features/manage-sprints/index.ts create mode 100644 src/features/manage-sprints/ui/SprintDeleteDialog.tsx create mode 100644 src/features/manage-sprints/ui/SprintFormDialog.tsx create mode 100644 src/features/manage-sprints/ui/SprintToolbar.tsx delete mode 100644 src/features/sprint-board/index.ts diff --git a/src/features/manage-sprint-tasks/index.ts b/src/features/manage-sprint-tasks/index.ts new file mode 100644 index 0000000..8688f83 --- /dev/null +++ b/src/features/manage-sprint-tasks/index.ts @@ -0,0 +1,2 @@ +// manage-sprint-tasks 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD·DnD) +export { SprintBoard } from './ui/SprintBoard'; diff --git a/src/features/sprint-board/lib/avatar-color.ts b/src/features/manage-sprint-tasks/lib/avatar-color.ts similarity index 100% rename from src/features/sprint-board/lib/avatar-color.ts rename to src/features/manage-sprint-tasks/lib/avatar-color.ts diff --git a/src/features/sprint-board/model/sprint-board-columns.ts b/src/features/manage-sprint-tasks/model/sprint-board-columns.ts similarity index 100% rename from src/features/sprint-board/model/sprint-board-columns.ts rename to src/features/manage-sprint-tasks/model/sprint-board-columns.ts diff --git a/src/features/sprint-board/model/task-form.ts b/src/features/manage-sprint-tasks/model/task-form.ts similarity index 100% rename from src/features/sprint-board/model/task-form.ts rename to src/features/manage-sprint-tasks/model/task-form.ts diff --git a/src/features/sprint-board/model/use-sprint-board.ts b/src/features/manage-sprint-tasks/model/use-sprint-board.ts similarity index 100% rename from src/features/sprint-board/model/use-sprint-board.ts rename to src/features/manage-sprint-tasks/model/use-sprint-board.ts diff --git a/src/features/sprint-board/model/use-task-dnd.ts b/src/features/manage-sprint-tasks/model/use-task-dnd.ts similarity index 100% rename from src/features/sprint-board/model/use-task-dnd.ts rename to src/features/manage-sprint-tasks/model/use-task-dnd.ts diff --git a/src/features/sprint-board/ui/BacklogRow.tsx b/src/features/manage-sprint-tasks/ui/BacklogRow.tsx similarity index 100% rename from src/features/sprint-board/ui/BacklogRow.tsx rename to src/features/manage-sprint-tasks/ui/BacklogRow.tsx diff --git a/src/features/sprint-board/ui/BacklogSection.tsx b/src/features/manage-sprint-tasks/ui/BacklogSection.tsx similarity index 100% rename from src/features/sprint-board/ui/BacklogSection.tsx rename to src/features/manage-sprint-tasks/ui/BacklogSection.tsx diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx similarity index 100% rename from src/features/sprint-board/ui/SprintBoard.tsx rename to src/features/manage-sprint-tasks/ui/SprintBoard.tsx diff --git a/src/features/sprint-board/ui/SprintColumn.tsx b/src/features/manage-sprint-tasks/ui/SprintColumn.tsx similarity index 100% rename from src/features/sprint-board/ui/SprintColumn.tsx rename to src/features/manage-sprint-tasks/ui/SprintColumn.tsx diff --git a/src/features/sprint-board/ui/TaskCard.tsx b/src/features/manage-sprint-tasks/ui/TaskCard.tsx similarity index 100% rename from src/features/sprint-board/ui/TaskCard.tsx rename to src/features/manage-sprint-tasks/ui/TaskCard.tsx diff --git a/src/features/sprint-board/ui/TaskFormDialog.tsx b/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx similarity index 100% rename from src/features/sprint-board/ui/TaskFormDialog.tsx rename to src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx diff --git a/src/features/manage-sprints/index.ts b/src/features/manage-sprints/index.ts new file mode 100644 index 0000000..588317c --- /dev/null +++ b/src/features/manage-sprints/index.ts @@ -0,0 +1,2 @@ +// manage-sprints 피처의 Public API — 스프린트 생성/수정/삭제 인터랙션(툴바 + 다이얼로그) +export { SprintToolbar } from './ui/SprintToolbar'; diff --git a/src/features/manage-sprints/ui/SprintDeleteDialog.tsx b/src/features/manage-sprints/ui/SprintDeleteDialog.tsx new file mode 100644 index 0000000..e8e908b --- /dev/null +++ b/src/features/manage-sprints/ui/SprintDeleteDialog.tsx @@ -0,0 +1,63 @@ +'use client'; + +// 스프린트 삭제 확인 모달 — UI 전용 셸(삭제 로직 미배선). +// TODO(후속): onConfirm을 스프린트 삭제 서버액션(useMutation)에 연결한다. +import { useEffect, useRef } from 'react'; + +interface SprintDeleteDialogProps { + sprintName: string; + onClose: () => void; + onConfirm: () => void; +} + +export function SprintDeleteDialog({ sprintName, onClose, onConfirm }: SprintDeleteDialogProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return undefined; + dialog.showModal(); + const handleCancel = (event: Event) => { + event.preventDefault(); + onClose(); + }; + dialog.addEventListener('cancel', handleCancel); + return () => dialog.removeEventListener('cancel', handleCancel); + }, [onClose]); + + return ( + +

+ 스프린트 삭제 +

+

+ {sprintName} 스프린트를 삭제할까요? 이 + 작업은 되돌릴 수 없습니다. +

+ +
+ + +
+
+ ); +} diff --git a/src/features/manage-sprints/ui/SprintFormDialog.tsx b/src/features/manage-sprints/ui/SprintFormDialog.tsx new file mode 100644 index 0000000..8263f72 --- /dev/null +++ b/src/features/manage-sprints/ui/SprintFormDialog.tsx @@ -0,0 +1,138 @@ +'use client'; + +// 스프린트 생성/수정 모달 — UI 전용 셸(저장 로직 미배선). +// 톤은 TaskFormDialog와 동일(네이티브 .showModal, rounded-2xl 패널, 슬레이트 입력). +// TODO(후속): onSubmit을 스프린트 생성/수정 서버액션(useMutation)에 연결한다. +import { useEffect, useRef, useState, type FormEvent } from 'react'; + +import { X } from 'lucide-react'; + +import type { Sprint } from '@/entities/side-project/sprint'; + +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 fieldLabelClass = 'mb-1.5 text-sm font-bold text-slate-700'; + +export interface SprintFormValues { + name: string; + startDate: string; + endDate: string; +} + +const EMPTY_SPRINT_FORM: SprintFormValues = { name: '', startDate: '', endDate: '' }; + +interface SprintFormDialogProps { + mode: 'create' | 'edit'; + /** 수정 모드 초기값 */ + initial?: Sprint; + onClose: () => void; + onSubmit: (values: SprintFormValues) => void; +} + +export function SprintFormDialog({ mode, initial, onClose, onSubmit }: SprintFormDialogProps) { + const dialogRef = useRef(null); + const [values, setValues] = useState( + initial + ? { name: initial.name, startDate: initial.startDate, endDate: initial.endDate } + : EMPTY_SPRINT_FORM, + ); + + const canSubmit = + values.name.trim().length > 0 && values.startDate !== '' && values.endDate !== ''; + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return undefined; + dialog.showModal(); + const handleCancel = (event: Event) => { + event.preventDefault(); + onClose(); + }; + dialog.addEventListener('cancel', handleCancel); + return () => dialog.removeEventListener('cancel', handleCancel); + }, [onClose]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + onSubmit(values); + onClose(); + }; + + return ( + +
+

+ {mode === 'edit' ? '스프린트 수정' : '새 스프린트'} +

+ +
+ + +
+

스프린트 이름

+ setValues((v) => ({ ...v, name: event.target.value }))} + placeholder="예: Sprint 3" + className={inputClass} + /> +
+ +
+
+

시작일

+ setValues((v) => ({ ...v, startDate: event.target.value }))} + className={inputClass} + /> +
+
+

종료일

+ setValues((v) => ({ ...v, endDate: event.target.value }))} + className={inputClass} + /> +
+
+ +
+ + +
+ +
+ ); +} diff --git a/src/features/manage-sprints/ui/SprintToolbar.tsx b/src/features/manage-sprints/ui/SprintToolbar.tsx new file mode 100644 index 0000000..551c8ae --- /dev/null +++ b/src/features/manage-sprints/ui/SprintToolbar.tsx @@ -0,0 +1,67 @@ +'use client'; + +// 스프린트 액션 버튼(생성/수정/삭제) + 다이얼로그 열림 상태 — UI 전용. +// 저장/삭제 로직은 아직 미배선(TODO). 현재 스프린트를 대상으로 수정/삭제한다. +import { useState } from 'react'; + +import { Pencil, Plus, Trash2 } from 'lucide-react'; + +import type { Sprint } from '@/entities/side-project/sprint'; + +import { SprintDeleteDialog } from './SprintDeleteDialog'; +import { SprintFormDialog } from './SprintFormDialog'; + +type DialogState = { mode: 'create' } | { mode: 'edit' } | { mode: 'delete' } | null; + +const iconButtonClass = + 'border-brand/10 text-brand-muted hover:text-brand-ink flex size-9 items-center justify-center rounded-full border bg-white transition-colors'; + +export function SprintToolbar({ sprint }: { sprint: Sprint }) { + const [dialog, setDialog] = useState(null); + + return ( +
+ + + + + {(dialog?.mode === 'create' || dialog?.mode === 'edit') && ( + setDialog(null)} + // TODO(후속): 스프린트 생성/수정 서버액션(useMutation) 연결 + onSubmit={() => setDialog(null)} + /> + )} + {dialog?.mode === 'delete' && ( + setDialog(null)} + // TODO(후속): 스프린트 삭제 서버액션(useMutation) 연결 + onConfirm={() => setDialog(null)} + /> + )} +
+ ); +} diff --git a/src/features/sprint-board/index.ts b/src/features/sprint-board/index.ts deleted file mode 100644 index 5426856..0000000 --- a/src/features/sprint-board/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// sprint-board 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD 포함) -export { SprintBoard } from './ui/SprintBoard'; diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 4245f72..2065877 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -9,7 +9,9 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; import { useBacklogTasks, useSprintTasks } from '@/entities/side-project/task'; import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member'; -import { SprintBoard } from '@/features/sprint-board'; +import { SprintBoard } from '@/features/manage-sprint-tasks'; + +import { SprintToolbar } from '@/features/manage-sprints'; import SprintSelector from './SprintSelector'; import SprintSummaryHeader from './SprintSummaryHeader'; @@ -57,7 +59,10 @@ export function SprintBoardView({ workspaceId, selectedSprintId }: SprintBoardVi return (
- +
+ + +
{/* key={sprint.id}: 스프린트 전환 시 보드를 리마운트해 초기 데이터로 다시 seed한다 */} +
{sprints.map((sprint) => { const isActive = sprint.id === currentSprintId; return ( From 157590988592b17ef262e0a3f88249efbad6a364 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 10:37:56 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A6=B0?= =?UTF-8?q?=ED=8A=B8=20crud=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../side-project/sprint/api/create-sprint.ts | 26 ++++++++++++ .../side-project/sprint/api/delete-sprint.ts | 15 +++++++ .../side-project/sprint/api/update-sprint.ts | 26 ++++++++++++ .../sprint/api/use-create-sprint.ts | 21 ++++++++++ .../sprint/api/use-delete-sprint.ts | 21 ++++++++++ .../sprint/api/use-update-sprint.ts | 20 ++++++++++ src/entities/side-project/sprint/index.ts | 4 ++ .../sprint/model/sprint.mapper.ts | 29 ++++++++++++-- .../sprint/model/sprint.schema.ts | 21 ++++++++++ .../manage-sprints/ui/SprintFormDialog.tsx | 6 ++- .../manage-sprints/ui/SprintToolbar.tsx | 40 ++++++++++++++----- .../sprint-board/ui/SprintBoardView.tsx | 2 +- 12 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 src/entities/side-project/sprint/api/create-sprint.ts create mode 100644 src/entities/side-project/sprint/api/delete-sprint.ts create mode 100644 src/entities/side-project/sprint/api/update-sprint.ts create mode 100644 src/entities/side-project/sprint/api/use-create-sprint.ts create mode 100644 src/entities/side-project/sprint/api/use-delete-sprint.ts create mode 100644 src/entities/side-project/sprint/api/use-update-sprint.ts create mode 100644 src/entities/side-project/sprint/model/sprint.schema.ts diff --git a/src/entities/side-project/sprint/api/create-sprint.ts b/src/entities/side-project/sprint/api/create-sprint.ts new file mode 100644 index 0000000..ab0f23d --- /dev/null +++ b/src/entities/side-project/sprint/api/create-sprint.ts @@ -0,0 +1,26 @@ +'use server'; + +// 스프린트 생성 서버액션 — 검증 후 sprints insert (단일 테이블 → RPC 불필요) +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { toSprintInsert } from '../model/sprint.mapper'; +import { sprintInputSchema, type SprintInput } from '../model/sprint.schema'; + +interface CreateSprintParams { + input: SprintInput; + workspaceId: string; +} + +export async function createSprint({ input, workspaceId }: CreateSprintParams): Promise { + const parsed = sprintInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from('sprints').insert(toSprintInsert(parsed.data, workspaceId)); + + if (error) { + console.error('[createSprint] insert 실패:', error); + throw new Error('스프린트 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/sprint/api/delete-sprint.ts b/src/entities/side-project/sprint/api/delete-sprint.ts new file mode 100644 index 0000000..714903b --- /dev/null +++ b/src/entities/side-project/sprint/api/delete-sprint.ts @@ -0,0 +1,15 @@ +'use server'; + +// 스프린트 삭제 서버액션 — sprints delete. +// 편입돼 있던 태스크는 FK(on delete set null)로 sprint_id=null이 되어 백로그로 이동한다(수동 cascade 불필요). +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +export async function deleteSprint(id: string): Promise { + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from('sprints').delete().eq('id', id); + + if (error) { + console.error('[deleteSprint] delete 실패:', error); + throw new Error('스프린트 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/sprint/api/update-sprint.ts b/src/entities/side-project/sprint/api/update-sprint.ts new file mode 100644 index 0000000..df0bdb0 --- /dev/null +++ b/src/entities/side-project/sprint/api/update-sprint.ts @@ -0,0 +1,26 @@ +'use server'; + +// 스프린트 수정 서버액션 — 검증 후 sprints update +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { toSprintUpdate } from '../model/sprint.mapper'; +import { sprintInputSchema, type SprintInput } from '../model/sprint.schema'; + +interface UpdateSprintParams { + id: string; + input: SprintInput; +} + +export async function updateSprint({ id, input }: UpdateSprintParams): Promise { + const parsed = sprintInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from('sprints').update(toSprintUpdate(parsed.data)).eq('id', id); + + if (error) { + console.error('[updateSprint] update 실패:', error); + throw new Error('스프린트 수정에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/side-project/sprint/api/use-create-sprint.ts b/src/entities/side-project/sprint/api/use-create-sprint.ts new file mode 100644 index 0000000..e1f075b --- /dev/null +++ b/src/entities/side-project/sprint/api/use-create-sprint.ts @@ -0,0 +1,21 @@ +'use client'; + +// 스프린트 생성 뮤테이션 — 성공 시 sprints 무효화(목록/셀렉터 갱신) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createSprint } from './create-sprint'; +import type { SprintInput } from '../model/sprint.schema'; + +export function useCreateSprint(workspaceId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { input: SprintInput }) => + createSprint({ input: params.input, workspaceId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '스프린트 생성에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/sprint/api/use-delete-sprint.ts b/src/entities/side-project/sprint/api/use-delete-sprint.ts new file mode 100644 index 0000000..e032553 --- /dev/null +++ b/src/entities/side-project/sprint/api/use-delete-sprint.ts @@ -0,0 +1,21 @@ +'use client'; + +// 스프린트 삭제 뮤테이션 — 성공 시 sprints + tasks 무효화. +// (삭제된 스프린트의 태스크가 FK로 백로그(sprint_id=null)로 이동하므로 tasks도 갱신) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteSprint } from './delete-sprint'; + +export function useDeleteSprint() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteSprint(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '스프린트 삭제에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/sprint/api/use-update-sprint.ts b/src/entities/side-project/sprint/api/use-update-sprint.ts new file mode 100644 index 0000000..7515ef9 --- /dev/null +++ b/src/entities/side-project/sprint/api/use-update-sprint.ts @@ -0,0 +1,20 @@ +'use client'; + +// 스프린트 수정 뮤테이션 — 성공 시 sprints 무효화 +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateSprint } from './update-sprint'; +import type { SprintInput } from '../model/sprint.schema'; + +export function useUpdateSprint() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { id: string; input: SprintInput }) => updateSprint(params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '스프린트 수정에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index e9e3ea7..2a13f47 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -4,6 +4,10 @@ export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.ty export { currentSprint, mockSprints, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; export { getSprints } from './api/get-sprints'; export { sprintsQueryKey, useSprints } from './api/use-sprints'; +export { useCreateSprint } from './api/use-create-sprint'; +export { useUpdateSprint } from './api/use-update-sprint'; +export { useDeleteSprint } from './api/use-delete-sprint'; export { resolveCurrentSprint, selectVelocity } from './model/sprint.selectors'; export { toSprint } from './model/sprint.mapper'; export type { SprintRow, SprintRpcRow } from './model/sprint.db.types'; +export { sprintInputSchema, type SprintInput } from './model/sprint.schema'; diff --git a/src/entities/side-project/sprint/model/sprint.mapper.ts b/src/entities/side-project/sprint/model/sprint.mapper.ts index 7867b94..6a14621 100644 --- a/src/entities/side-project/sprint/model/sprint.mapper.ts +++ b/src/entities/side-project/sprint/model/sprint.mapper.ts @@ -1,8 +1,9 @@ -// get_sprints RPC row → Sprint 엔티티 매퍼. -// 파생값(daysLeft, total/completedPoints)은 RPC가 SQL에서 이미 계산해 반환하므로, -// 매퍼는 snake_case→camelCase 필드 변환만 담당한다(순수 함수). +// get_sprints RPC row → Sprint 엔티티 매퍼 + 쓰기 방향(입력 → insert/update 페이로드). +// 파생값(daysLeft, total/completedPoints)은 RPC가 SQL에서 계산해 반환하므로 읽기 매퍼는 필드 변환만 한다(순수 함수). +import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types'; import type { Sprint } from './sprint.types'; import type { SprintRpcRow } from './sprint.db.types'; +import type { SprintInput } from './sprint.schema'; /** get_sprints RPC row → Sprint 엔티티. */ export function toSprint(row: SprintRpcRow): Sprint { @@ -17,3 +18,25 @@ export function toSprint(row: SprintRpcRow): Sprint { completedPoints: row.completed_points, }; } + +/** 검증된 입력 → sprints insert 페이로드. */ +export function toSprintInsert( + input: SprintInput, + workspaceId: string, +): GenericTablesInsert<'sprints'> { + return { + workspace_id: workspaceId, + name: input.name, + start_date: input.startDate, + end_date: input.endDate, + }; +} + +/** 검증된 입력 → sprints update 페이로드. */ +export function toSprintUpdate(input: SprintInput): GenericTablesUpdate<'sprints'> { + return { + name: input.name, + start_date: input.startDate, + end_date: input.endDate, + }; +} diff --git a/src/entities/side-project/sprint/model/sprint.schema.ts b/src/entities/side-project/sprint/model/sprint.schema.ts new file mode 100644 index 0000000..d32edc9 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.schema.ts @@ -0,0 +1,21 @@ +// 스프린트 생성/수정 입력 검증 — 폼과 서버액션이 공유한다. +// 날짜만 검증한다(종료일 ≥ 시작일). 이름 중복은 허용(검사하지 않음). +import { z } from 'zod'; + +export const sprintInputSchema = z + .object({ + name: z + .string() + .trim() + .min(1, '스프린트 이름을 입력해주세요') + .max(50, '이름은 50자 이내로 입력해주세요'), + // 'YYYY-MM-DD' — 문자열 사전순 비교가 날짜 순서와 일치한다 + startDate: z.string().min(1, '시작일을 선택해주세요'), + endDate: z.string().min(1, '종료일을 선택해주세요'), + }) + .refine((v) => v.startDate <= v.endDate, { + message: '종료일은 시작일과 같거나 이후여야 합니다', + path: ['endDate'], + }); + +export type SprintInput = z.infer; diff --git a/src/features/manage-sprints/ui/SprintFormDialog.tsx b/src/features/manage-sprints/ui/SprintFormDialog.tsx index 8263f72..818f753 100644 --- a/src/features/manage-sprints/ui/SprintFormDialog.tsx +++ b/src/features/manage-sprints/ui/SprintFormDialog.tsx @@ -37,8 +37,12 @@ export function SprintFormDialog({ mode, initial, onClose, onSubmit }: SprintFor : EMPTY_SPRINT_FORM, ); + // 이름 + 양쪽 날짜 + 종료일 ≥ 시작일 (서버에서도 재검증) const canSubmit = - values.name.trim().length > 0 && values.startDate !== '' && values.endDate !== ''; + values.name.trim().length > 0 && + values.startDate !== '' && + values.endDate !== '' && + values.startDate <= values.endDate; useEffect(() => { const dialog = dialogRef.current; diff --git a/src/features/manage-sprints/ui/SprintToolbar.tsx b/src/features/manage-sprints/ui/SprintToolbar.tsx index 551c8ae..a65635b 100644 --- a/src/features/manage-sprints/ui/SprintToolbar.tsx +++ b/src/features/manage-sprints/ui/SprintToolbar.tsx @@ -1,24 +1,48 @@ 'use client'; -// 스프린트 액션 버튼(생성/수정/삭제) + 다이얼로그 열림 상태 — UI 전용. -// 저장/삭제 로직은 아직 미배선(TODO). 현재 스프린트를 대상으로 수정/삭제한다. +// 스프린트 액션 버튼(생성/수정/삭제) + 다이얼로그 열림 상태. +// 생성/수정/삭제는 서버액션 뮤테이션에 연결하고, 실패는 각 훅 onError(toast)에서 노출한다. +// 수정/삭제는 현재 스프린트를 대상으로 한다. import { useState } from 'react'; import { Pencil, Plus, Trash2 } from 'lucide-react'; -import type { Sprint } from '@/entities/side-project/sprint'; +import { + type Sprint, + useCreateSprint, + useDeleteSprint, + useUpdateSprint, +} from '@/entities/side-project/sprint'; import { SprintDeleteDialog } from './SprintDeleteDialog'; -import { SprintFormDialog } from './SprintFormDialog'; +import { SprintFormDialog, type SprintFormValues } from './SprintFormDialog'; type DialogState = { mode: 'create' } | { mode: 'edit' } | { mode: 'delete' } | null; const iconButtonClass = 'border-brand/10 text-brand-muted hover:text-brand-ink flex size-9 items-center justify-center rounded-full border bg-white transition-colors'; -export function SprintToolbar({ sprint }: { sprint: Sprint }) { +interface SprintToolbarProps { + workspaceId: string; + sprint: Sprint; +} + +export function SprintToolbar({ workspaceId, sprint }: SprintToolbarProps) { const [dialog, setDialog] = useState(null); + const createSprint = useCreateSprint(workspaceId); + const updateSprint = useUpdateSprint(); + const deleteSprint = useDeleteSprint(); + + // 폼 값(name/startDate/endDate)은 SprintInput과 형태가 같아 그대로 넘긴다(서버에서 재검증) + const handleSubmit = (values: SprintFormValues) => { + if (dialog?.mode === 'edit') { + updateSprint.mutate({ id: sprint.id, input: values }); + } else { + createSprint.mutate({ input: values }); + } + }; + return (
diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 2065877..cdbc624 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -61,7 +61,7 @@ export function SprintBoardView({ workspaceId, selectedSprintId }: SprintBoardVi
- +
{/* key={sprint.id}: 스프린트 전환 시 보드를 리마운트해 초기 데이터로 다시 seed한다 */} From 7bf34cc8dbcfac0c0fd95885004f829572c50800 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 10:53:44 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20=EB=B0=B1=EB=A1=9C=EA=B7=B8=20->?= =?UTF-8?q?=20=20=ED=98=84=EC=9E=AC=20=EC=8A=A4=ED=94=84=EB=A6=B0=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=8E=B8=EC=9E=85=EC=9A=A9=20=ED=9B=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/api/update-task-sprint.ts | 12 ++++++++++ .../task/api/use-update-task-sprint.ts | 22 +++++++++++++++++++ src/entities/side-project/task/index.ts | 1 + .../model/use-sprint-board.ts | 9 ++++++++ .../manage-sprint-tasks/ui/BacklogRow.tsx | 15 +++++++++++-- .../manage-sprint-tasks/ui/BacklogSection.tsx | 11 +++++++++- .../manage-sprint-tasks/ui/SprintBoard.tsx | 2 ++ 7 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/entities/side-project/task/api/update-task-sprint.ts create mode 100644 src/entities/side-project/task/api/use-update-task-sprint.ts diff --git a/src/entities/side-project/task/api/update-task-sprint.ts b/src/entities/side-project/task/api/update-task-sprint.ts new file mode 100644 index 0000000..cf06d78 --- /dev/null +++ b/src/entities/side-project/task/api/update-task-sprint.ts @@ -0,0 +1,12 @@ +// 태스크의 스프린트 편입/해제 — sprint_id 단일 컬럼 부분 수정이라 클라이언트 직접 update(convention §4) +// sprintId에 값을 주면 해당 스프린트로 편입, null이면 백로그로 이동. +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; + +export async function updateTaskSprint(id: string, sprintId: string | null): Promise { + const supabase = getSupabaseBrowserClient(); + const { error } = await supabase.from('tasks').update({ sprint_id: sprintId }).eq('id', id); + + if (error) { + throw new Error(`스프린트 편입에 실패했습니다: ${error.message}`); + } +} diff --git a/src/entities/side-project/task/api/use-update-task-sprint.ts b/src/entities/side-project/task/api/use-update-task-sprint.ts new file mode 100644 index 0000000..6733dc2 --- /dev/null +++ b/src/entities/side-project/task/api/use-update-task-sprint.ts @@ -0,0 +1,22 @@ +'use client'; + +// 태스크 스프린트 편입/해제 뮤테이션 — 성공 시 tasks + sprints 무효화. +// (편입/해제는 백로그·스프린트 목록과 스프린트 포인트 집계를 모두 바꾸므로 둘 다 무효화) +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateTaskSprint } from './update-task-sprint'; + +export function useUpdateTaskSprint() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params: { id: string; sprintId: string | null }) => + updateTaskSprint(params.id, params.sprintId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['sprints'] }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '스프린트 편입에 실패했습니다'); + }, + }); +} diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index 297881a..cd1dc81 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -23,3 +23,4 @@ export { useCreateTask } from './api/use-create-task'; export { useUpdateTask } from './api/use-update-task'; export { useDeleteTask } from './api/use-delete-task'; export { useUpdateTaskStatus } from './api/use-update-task-status'; +export { useUpdateTaskSprint } from './api/use-update-task-sprint'; diff --git a/src/features/manage-sprint-tasks/model/use-sprint-board.ts b/src/features/manage-sprint-tasks/model/use-sprint-board.ts index d69f4c6..df9fd9a 100644 --- a/src/features/manage-sprint-tasks/model/use-sprint-board.ts +++ b/src/features/manage-sprint-tasks/model/use-sprint-board.ts @@ -9,6 +9,7 @@ import { useCreateTask, useDeleteTask, useUpdateTask, + useUpdateTaskSprint, useUpdateTaskStatus, } from '@/entities/side-project/task'; @@ -32,6 +33,7 @@ export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSpr const updateMutation = useUpdateTask(); const deleteMutation = useDeleteTask(); const statusMutation = useUpdateTaskStatus(); + const sprintMutation = useUpdateTaskSprint(); // 스프린트에 새 업무 추가(기본 상태: 대기) const addSprintTask = useCallback( @@ -55,6 +57,12 @@ export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSpr const deleteTask = useCallback((id: string) => deleteMutation.mutate(id), [deleteMutation]); + // 백로그 항목을 현재 스프린트로 편입(sprint_id = 현재 스프린트). status는 유지된다. + const moveToSprint = useCallback( + (id: string) => sprintMutation.mutate({ id, sprintId }), + [sprintMutation, sprintId], + ); + // 드래그로 컬럼(상태) 이동 — 스프린트 업무에만 적용 const moveTask = useCallback( (id: string, status: TaskStatus) => statusMutation.mutate({ id, status }), @@ -71,6 +79,7 @@ export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSpr addBacklogTask, updateTask, deleteTask, + moveToSprint, dragProps: dnd.dragProps, dropProps: dnd.dropProps, dragOverStatus: dnd.dragOverStatus, diff --git a/src/features/manage-sprint-tasks/ui/BacklogRow.tsx b/src/features/manage-sprint-tasks/ui/BacklogRow.tsx index ee46784..e3da2fd 100644 --- a/src/features/manage-sprint-tasks/ui/BacklogRow.tsx +++ b/src/features/manage-sprint-tasks/ui/BacklogRow.tsx @@ -1,7 +1,7 @@ // 백로그 행 — 우선순위 점 · 제목 · 포인트 · 우선순위 배지 + (호버 시) 수정/삭제 액션. // 우선순위 색(TASK_PRIORITY)은 entities를 단일 출처로 사용한다. // '낮음'은 지정색(#d1d5dc)이 옅어 배지 텍스트로 쓰면 대비가 부족하므로 뮤트 그레이로 대체한다. -import { Pencil, Trash2 } from 'lucide-react'; +import { ArrowRightToLine, Pencil, Trash2 } from 'lucide-react'; import { type Task, TASK_PRIORITY } from '@/entities/side-project/task'; @@ -9,9 +9,11 @@ interface BacklogRowProps { task: Task; onEdit: () => void; onDelete: () => void; + /** 현재 스프린트로 편입 */ + onMoveToSprint: () => void; } -export function BacklogRow({ task, onEdit, onDelete }: BacklogRowProps) { +export function BacklogRow({ task, onEdit, onDelete, onMoveToSprint }: BacklogRowProps) { const priority = TASK_PRIORITY[task.priority]; const isLow = task.priority === 'low'; @@ -33,6 +35,15 @@ export function BacklogRow({ task, onEdit, onDelete }: BacklogRowProps) {
+
diff --git a/src/features/manage-sprint-tasks/ui/SprintBoard.tsx b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx index 8e931c3..3dc9391 100644 --- a/src/features/manage-sprint-tasks/ui/SprintBoard.tsx +++ b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx @@ -35,6 +35,7 @@ export function SprintBoard({ sprintId, workspaceId, tasks, backlog, members }: addBacklogTask, updateTask, deleteTask, + moveToSprint, dragProps, dropProps, dragOverStatus, @@ -82,6 +83,7 @@ export function SprintBoard({ sprintId, workspaceId, tasks, backlog, members }: onAdd={() => setDialog({ mode: 'add-backlog' })} onEdit={(task) => setDialog({ mode: 'edit', task })} onDelete={deleteTask} + onMoveToSprint={moveToSprint} /> {dialog && ( From 7b8ec8346df88ff20ad4a7972098eb1eb4b863dc Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 11:42:16 +0900 Subject: [PATCH 16/23] =?UTF-8?q?=20=20refactor:=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20id=20=EC=B0=B8=EC=A1=B0=20+=20members(nick?= =?UTF-8?q?name)=20=ED=95=B4=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/workspaces/[workspaceId]/page.tsx | 3 ++ .../[workspaceId]/sprint-board/page.tsx | 8 ++++-- .../task/api/get-backlog-tasks.ts | 8 ++---- .../side-project/task/api/get-sprint-tasks.ts | 8 ++---- src/entities/side-project/task/index.ts | 3 +- .../side-project/task/model/task.db.types.ts | 11 +------- .../side-project/task/model/task.mapper.ts | 25 ++++------------- .../side-project/task/model/task.mock.ts | 24 ++++++++-------- .../side-project/task/model/task.types.ts | 17 ++++------- .../manage-sprint-tasks/model/board-task.ts | 28 +++++++++++++++++++ .../model/sprint-board-columns.ts | 8 ++++-- .../manage-sprint-tasks/model/task-form.ts | 21 ++++++-------- .../model/use-sprint-board.ts | 28 ++++++++++++++++--- .../manage-sprint-tasks/ui/SprintBoard.tsx | 2 +- .../manage-sprint-tasks/ui/TaskCard.tsx | 5 ++-- .../manage-sprint-tasks/ui/TaskFormDialog.tsx | 17 +++-------- .../sprint-board/ui/SprintBoardView.tsx | 9 +++--- 17 files changed, 119 insertions(+), 106 deletions(-) create mode 100644 src/features/manage-sprint-tasks/model/board-task.ts diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index a148163..2de1af0 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -18,6 +18,9 @@ export default async function WorkspaceHomePage({ params }: WorkspaceHomePagePro if (workspace.purpose === 'store-operation') { redirect(`/workspaces/${workspaceId}/work-schedule`); } + if(workspace.purpose==='side-project'){ + redirect(`/workspaces/${workspaceId}/sprint-board`); + } redirect(`/workspaces/${workspaceId}/project-management`); } diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 0b9dc0d..0b4157f 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,5 +1,6 @@ // 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다. -// 데이터 조회(useQuery)와 스프린트 판정은 컨테이너가 담당하고, 태스크 변경은 보드의 클라이언트 낙관적 업데이트로 처리한다. +// 스프린트/태스크는 컨테이너가 useQuery로 조회하고, 담당자 표시명 해석용 members만 서버에서 조회해 주입한다. +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { @@ -12,10 +13,13 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo const { sprint: sprintParam } = await searchParams; const selectedSprintId = typeof sprintParam === 'string' ? sprintParam : undefined; + const members = await getWorkspaceMembersByWorkspaceId(workspaceId); + return ( ); } diff --git a/src/entities/side-project/task/api/get-backlog-tasks.ts b/src/entities/side-project/task/api/get-backlog-tasks.ts index 4472e3b..078c6e5 100644 --- a/src/entities/side-project/task/api/get-backlog-tasks.ts +++ b/src/entities/side-project/task/api/get-backlog-tasks.ts @@ -1,16 +1,14 @@ -// 백로그(스프린트 미편입) 업무 조회 — workspace 범위 + sprint_id is null 필터 + 담당자 조인 -// profiles FK가 2개(assignee_id/created_by)라 조인 모호성 회피 위해 FK명(tasks_assignee_id_fkey)을 명시한다. +// 백로그(스프린트 미편입) 업무 조회 — workspace 범위 + sprint_id is null 필터 +// 담당자 표시명은 members에서 해석하므로 profiles 조인은 하지 않고 assignee_id만 가져온다. import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; import { toTask } from '../model/task.mapper'; import type { Task } from '../model/task.types'; -const TASK_SELECT = '*, assignee:profiles!tasks_assignee_id_fkey(real_name)'; - export async function getBacklogTasks(workspaceId: string): Promise { const supabase = getSupabaseBrowserClient(); const { data, error } = await supabase .from('tasks') - .select(TASK_SELECT) + .select('*') .eq('workspace_id', workspaceId) .is('sprint_id', null) .order('sort_order'); diff --git a/src/entities/side-project/task/api/get-sprint-tasks.ts b/src/entities/side-project/task/api/get-sprint-tasks.ts index 9fb239a..9b7b90e 100644 --- a/src/entities/side-project/task/api/get-sprint-tasks.ts +++ b/src/entities/side-project/task/api/get-sprint-tasks.ts @@ -1,16 +1,14 @@ -// 특정 스프린트에 편입된 업무 조회 — tasks 단순 필터 + 담당자 조인(집계 아님 → 직접 쿼리) -// profiles FK가 2개(assignee_id/created_by)라 조인 모호성 회피 위해 FK명(tasks_assignee_id_fkey)을 명시한다. +// 특정 스프린트에 편입된 업무 조회 — tasks 단순 필터(집계 아님 → 직접 쿼리) +// 담당자 표시명은 members에서 해석하므로 profiles 조인은 하지 않고 assignee_id만 가져온다. import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; import { toTask } from '../model/task.mapper'; import type { Task } from '../model/task.types'; -const TASK_SELECT = '*, assignee:profiles!tasks_assignee_id_fkey(real_name)'; - export async function getSprintTasks(sprintId: string): Promise { const supabase = getSupabaseBrowserClient(); const { data, error } = await supabase .from('tasks') - .select(TASK_SELECT) + .select('*') .eq('sprint_id', sprintId) .order('sort_order'); diff --git a/src/entities/side-project/task/index.ts b/src/entities/side-project/task/index.ts index cd1dc81..6798079 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -8,7 +8,6 @@ export { type TaskStatus, type TaskPriority, type TaskCategory, - type TaskAssignee, } from './model/task.types'; export { getBacklogTasks } from './api/get-backlog-tasks'; export { getSprintTasks } from './api/get-sprint-tasks'; @@ -17,7 +16,7 @@ export { sprintTasksQueryKey, useSprintTasks } from './api/use-sprint-tasks'; export { getMockBacklogTasks, getMockSprintTasks } from './model/task.mock'; export { countByStatus } from './model/task.selectors'; export { toTask } from './model/task.mapper'; -export type { TaskRow, TaskWithAssigneeRow } from './model/task.db.types'; +export type { TaskRow } from './model/task.db.types'; export { taskInputSchema, type TaskInput } from './model/task.schema'; export { useCreateTask } from './api/use-create-task'; export { useUpdateTask } from './api/use-update-task'; diff --git a/src/entities/side-project/task/model/task.db.types.ts b/src/entities/side-project/task/model/task.db.types.ts index cb1645c..a7b1758 100644 --- a/src/entities/side-project/task/model/task.db.types.ts +++ b/src/entities/side-project/task/model/task.db.types.ts @@ -2,14 +2,5 @@ // 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. import type { GenericTables } from '@/shared/model/supabase.types'; -/** tasks 테이블 Row — select 결과 */ +/** tasks 테이블 Row — select 결과. 담당자 표시명은 members에서 해석하므로 profiles 조인은 하지 않는다. */ export type TaskRow = GenericTables<'tasks'>; - -/** - * 담당자 조인을 포함한 tasks Row. - * Supabase 쿼리 `.select('*, assignee:profiles(real_name)')`의 반환 형태에 대응한다. - * assignee_id가 null이거나 조인이 비면 assignee = null. - */ -export type TaskWithAssigneeRow = TaskRow & { - assignee: { real_name: string } | null; -}; diff --git a/src/entities/side-project/task/model/task.mapper.ts b/src/entities/side-project/task/model/task.mapper.ts index dfb2089..53a5232 100644 --- a/src/entities/side-project/task/model/task.mapper.ts +++ b/src/entities/side-project/task/model/task.mapper.ts @@ -1,29 +1,16 @@ // DB row ↔ Task 엔티티 매퍼 — snake_case↔camelCase, null 흡수, 담당자 조인/역변환을 한곳에 모은다. // DB enum(task_status/priority/category)은 엔티티 유니온과 값이 동일해 캐스팅 없이 대입된다. import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types'; -import type { Task, TaskAssignee } from './task.types'; -import type { TaskWithAssigneeRow } from './task.db.types'; +import type { Task } from './task.types'; +import type { TaskRow } from './task.db.types'; import type { TaskInput } from './task.schema'; /** - * assignee_id + 조인된 profiles row → 화면용 담당자(TaskAssignee). - * 미배정(assignee_id null 또는 조인 empty)이면 null. avatarLabel은 표시명 첫 글자(성)로, 빈 이름은 '?'로 방어한다. - */ -function toAssignee( - assigneeId: string | null, - profile: { real_name: string } | null, -): TaskAssignee | null { - if (!assigneeId || !profile) return null; - const name = profile.real_name.trim(); - return { userId: assigneeId, name, avatarLabel: name.charAt(0) || '?' }; -} - -/** - * tasks row(담당자 조인 포함) → Task 엔티티. + * tasks row → Task 엔티티. * point는 DB에서 null 허용(미산정)이나 엔티티는 숫자를 보장하므로 0으로 흡수한다. - * sprintId는 null을 유지한다(null → 백로그). + * sprintId는 null을 유지한다(null → 백로그). 담당자는 id만 담고 표시명은 members에서 해석한다. */ -export function toTask(row: TaskWithAssigneeRow): Task { +export function toTask(row: TaskRow): Task { return { id: row.id, workspaceId: row.workspace_id, @@ -33,7 +20,7 @@ export function toTask(row: TaskWithAssigneeRow): Task { status: row.status, priority: row.priority, category: row.category, - assignee: toAssignee(row.assignee_id, row.assignee), + assigneeId: row.assignee_id, }; } diff --git a/src/entities/side-project/task/model/task.mock.ts b/src/entities/side-project/task/model/task.mock.ts index a3abc12..2f6e9f9 100644 --- a/src/entities/side-project/task/model/task.mock.ts +++ b/src/entities/side-project/task/model/task.mock.ts @@ -20,7 +20,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'design', - assignee: { userId: 'mock-user-choi', name: '최민준', avatarLabel: '최' }, + assigneeId: 'mock-user-choi', }, { id: 'task-2', @@ -31,7 +31,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'frontend', - assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, + assigneeId: 'mock-user-park', }, { id: 'task-3', @@ -42,7 +42,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'planning', - assignee: { userId: 'mock-user-kim', name: '김지은', avatarLabel: '김' }, + assigneeId: 'mock-user-kim', }, // 스프린트 편입 · 진행 중 (in_progress) — 11pt { @@ -54,7 +54,7 @@ export const mockTasks: Task[] = [ status: 'in_progress', priority: 'medium', category: 'frontend', - assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, + assigneeId: 'mock-user-park', }, { id: 'task-5', @@ -65,7 +65,7 @@ export const mockTasks: Task[] = [ status: 'in_progress', priority: 'medium', category: 'planning', - assignee: { userId: 'mock-user-kim', name: '김지은', avatarLabel: '김' }, + assigneeId: 'mock-user-kim', }, // 스프린트 편입 · 완료 (done) — 18pt { @@ -77,7 +77,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { userId: 'mock-user-lee', name: '이하은', avatarLabel: '이' }, + assigneeId: 'mock-user-lee', }, { id: 'task-7', @@ -88,7 +88,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { userId: 'mock-user-lee', name: '이하은', avatarLabel: '이' }, + assigneeId: 'mock-user-lee', }, { id: 'task-8', @@ -99,7 +99,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'frontend', - assignee: { userId: 'mock-user-park', name: '박서준', avatarLabel: '박' }, + assigneeId: 'mock-user-park', }, // 백로그 (sprintId: null) — 카테고리·담당자 미지정, status는 대기(todo) { @@ -111,7 +111,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'high', category: null, - assignee: null, + assigneeId: null, }, { id: 'task-10', @@ -122,7 +122,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: null, - assignee: null, + assigneeId: null, }, { id: 'task-11', @@ -133,7 +133,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'low', category: null, - assignee: null, + assigneeId: null, }, { id: 'task-12', @@ -144,7 +144,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'low', category: null, - assignee: null, + assigneeId: null, }, ]; diff --git a/src/entities/side-project/task/model/task.types.ts b/src/entities/side-project/task/model/task.types.ts index 4f609bd..8d31edb 100644 --- a/src/entities/side-project/task/model/task.types.ts +++ b/src/entities/side-project/task/model/task.types.ts @@ -37,15 +37,6 @@ export const TASK_CATEGORY: Record { + return new Map(members.map((member) => [member.userId, member])); +} + +/** Task + members 맵 → BoardTask (담당자 닉네임/아바타 해석) */ +export function toBoardTask(task: Task, membersById: Map): BoardTask { + const member = task.assigneeId ? membersById.get(task.assigneeId) : undefined; + return { + ...task, + assignee: member ? { name: member.workspaceNickname, avatarLabel: member.avatarLabel } : null, + }; +} diff --git a/src/features/manage-sprint-tasks/model/sprint-board-columns.ts b/src/features/manage-sprint-tasks/model/sprint-board-columns.ts index 495006c..d9a3c05 100644 --- a/src/features/manage-sprint-tasks/model/sprint-board-columns.ts +++ b/src/features/manage-sprint-tasks/model/sprint-board-columns.ts @@ -1,12 +1,14 @@ // 스프린트 보드 칸반 컬럼 파생 로직 — 업무 목록을 상태(대기/진행 중/완료)별로 그룹핑한다. // 컬럼별 포인트 합계까지 함께 계산해, 컬럼 헤더의 "13pt" 같은 표시에 그대로 쓴다. // 상태 순서/라벨은 entities의 TASK_STATUS를 단일 출처로 삼는다. -import { type Task, type TaskStatus, TASK_STATUS } from '@/entities/side-project/task'; +import { type TaskStatus, TASK_STATUS } from '@/entities/side-project/task'; + +import type { BoardTask } from './board-task'; export interface SprintColumn { id: TaskStatus; title: string; - tasks: Task[]; + tasks: BoardTask[]; /** 컬럼에 속한 업무 포인트 합계 */ totalPoints: number; } @@ -14,7 +16,7 @@ export interface SprintColumn { // 칸반 컬럼 노출 순서 (대기 → 진행 중 → 완료) const COLUMN_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done']; -export function groupTasksByStatus(tasks: Task[]): SprintColumn[] { +export function groupTasksByStatus(tasks: BoardTask[]): SprintColumn[] { return COLUMN_ORDER.map((status) => { const columnTasks = tasks.filter((task) => task.status === status); return { diff --git a/src/features/manage-sprint-tasks/model/task-form.ts b/src/features/manage-sprint-tasks/model/task-form.ts index 011c903..46e2382 100644 --- a/src/features/manage-sprint-tasks/model/task-form.ts +++ b/src/features/manage-sprint-tasks/model/task-form.ts @@ -1,19 +1,14 @@ // 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ Task/입력 변환 헬퍼. -// 담당자는 워크스페이스 멤버에서 선택하며, 선택 결과를 Task.assignee 형태({userId, name, avatarLabel})로 담는다. -import type { - Task, - TaskCategory, - TaskInput, - TaskPriority, - TaskAssignee, -} from '@/entities/side-project/task'; +// 담당자는 워크스페이스 멤버에서 선택하며, 폼은 선택한 멤버의 id(assigneeId)만 담는다(표시명은 members에서 해석). +import type { Task, TaskCategory, TaskInput, TaskPriority } from '@/entities/side-project/task'; export interface TaskFormValues { title: string; point: number; category: TaskCategory | null; priority: TaskPriority; - assignee: TaskAssignee | null; + /** 담당자 profiles.id. 미배정이면 null */ + assigneeId: string | null; } export const EMPTY_TASK_FORM: TaskFormValues = { @@ -21,7 +16,7 @@ export const EMPTY_TASK_FORM: TaskFormValues = { point: 1, category: null, priority: 'medium', - assignee: null, + assigneeId: null, }; // 기존 Task를 편집 폼 초기값으로 변환 @@ -31,17 +26,17 @@ export function valuesFromTask(task: Task): TaskFormValues { point: task.point, category: task.category, priority: task.priority, - assignee: task.assignee, + assigneeId: task.assigneeId, }; } -// 폼 값 → 서버액션 입력(TaskInput). 담당자는 userId만 추출해 assigneeId로 넘긴다(표시용 name/label 제거). +// 폼 값 → 서버액션 입력(TaskInput). export function toTaskInput(values: TaskFormValues): TaskInput { return { title: values.title.trim(), point: values.point, category: values.category, priority: values.priority, - assigneeId: values.assignee?.userId ?? null, + assigneeId: values.assigneeId, }; } diff --git a/src/features/manage-sprint-tasks/model/use-sprint-board.ts b/src/features/manage-sprint-tasks/model/use-sprint-board.ts index df9fd9a..7489b47 100644 --- a/src/features/manage-sprint-tasks/model/use-sprint-board.ts +++ b/src/features/manage-sprint-tasks/model/use-sprint-board.ts @@ -1,7 +1,7 @@ // 스프린트 보드 상태 훅 — 데이터는 react-query(부모 View)가 소유하고, 이 훅은 쓰기(뮤테이션)와 DnD만 배선한다. // CRUD/상태이동은 서버액션·클라 update를 호출하고, 성공 시 쿼리 무효화로 재조회되어 목록이 갱신된다(재조회 방식, B-1). // 낙관적 업데이트는 후속 과제(B-2). 실패는 각 뮤테이션 훅의 onError(toast)에서 노출된다. -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { type Task, @@ -12,7 +12,9 @@ import { useUpdateTaskSprint, useUpdateTaskStatus, } from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { indexMembersById, toBoardTask } from './board-task'; import { groupTasksByStatus } from './sprint-board-columns'; import { toTaskInput, type TaskFormValues } from './task-form'; import { useTaskDnd } from './use-task-dnd'; @@ -26,9 +28,17 @@ interface UseSprintBoardParams { tasks: Task[]; /** 백로그 업무(react-query 데이터) */ backlog: Task[]; + /** 담당자 표시명(닉네임) 해석용 워크스페이스 멤버 */ + members: WorkspaceMember[]; } -export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSprintBoardParams) { +export function useSprintBoard({ + sprintId, + workspaceId, + tasks, + backlog, + members, +}: UseSprintBoardParams) { const createMutation = useCreateTask(workspaceId); const updateMutation = useUpdateTask(); const deleteMutation = useDeleteTask(); @@ -70,11 +80,21 @@ export function useSprintBoard({ sprintId, workspaceId, tasks, backlog }: UseSpr ); const dnd = useTaskDnd(moveTask); - const columns = groupTasksByStatus(tasks); + + // 담당자 표시값(닉네임/아바타)을 members에서 해석해 BoardTask로 만든 뒤 그룹핑한다 + const membersById = useMemo(() => indexMembersById(members), [members]); + const columns = useMemo( + () => groupTasksByStatus(tasks.map((task) => toBoardTask(task, membersById))), + [tasks, membersById], + ); + const backlogTasks = useMemo( + () => backlog.map((task) => toBoardTask(task, membersById)), + [backlog, membersById], + ); return { columns, - backlogTasks: backlog, + backlogTasks, addSprintTask, addBacklogTask, updateTask, diff --git a/src/features/manage-sprint-tasks/ui/SprintBoard.tsx b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx index 3dc9391..3a2127d 100644 --- a/src/features/manage-sprint-tasks/ui/SprintBoard.tsx +++ b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx @@ -39,7 +39,7 @@ export function SprintBoard({ sprintId, workspaceId, tasks, backlog, members }: dragProps, dropProps, dragOverStatus, - } = useSprintBoard({ sprintId, workspaceId, tasks, backlog }); + } = useSprintBoard({ sprintId, workspaceId, tasks, backlog, members }); const [dialog, setDialog] = useState(null); diff --git a/src/features/manage-sprint-tasks/ui/TaskCard.tsx b/src/features/manage-sprint-tasks/ui/TaskCard.tsx index a9e66b3..9b204ac 100644 --- a/src/features/manage-sprint-tasks/ui/TaskCard.tsx +++ b/src/features/manage-sprint-tasks/ui/TaskCard.tsx @@ -5,12 +5,13 @@ import type { DragEventHandler } from 'react'; import { Pencil, Trash2 } from 'lucide-react'; -import { type Task, TASK_CATEGORY } from '@/entities/side-project/task'; +import { TASK_CATEGORY } from '@/entities/side-project/task'; +import type { BoardTask } from '../model/board-task'; import { getAvatarColor } from '../lib/avatar-color'; interface TaskCardProps { - task: Task; + task: BoardTask; onEdit?: () => void; onDelete?: () => void; draggable?: boolean; diff --git a/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx b/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx index c6ee60b..3481828 100644 --- a/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx +++ b/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx @@ -165,27 +165,18 @@ export function TaskFormDialog({
{members.map((member) => { - const selected = values.assignee?.userId === member.userId; + const selected = values.assigneeId === member.userId; return ( - - + + {/* 수정/삭제는 대상 스프린트가 있을 때만 */} + {sprint && ( + <> + + + + )} {(dialog?.mode === 'create' || dialog?.mode === 'edit') && ( )} - {dialog?.mode === 'delete' && ( + {dialog?.mode === 'delete' && sprint && ( setDialog(null)} diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 3c13ef6..3445408 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -50,8 +50,19 @@ export function SprintBoardView({ workspaceId, selectedSprintId, members }: Spri if (sprintsQuery.isError) return 스프린트를 불러오지 못했습니다.; - // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 - if (!sprint) return 아직 생성된 스프린트가 없습니다.; + // 스프린트가 하나도 없는 워크스페이스 — 빈 상태(생성 버튼은 노출) + if (!sprint) { + return ( +
+
+ +
+ + 아직 생성된 스프린트가 없습니다. 새 스프린트를 만들어 시작하세요. + +
+ ); + } if (tasksQuery.isPending || backlogQuery.isPending) return 불러오는 중…; From 5c20d71b2047e9ef9efdbbbe8c9aaba0e41f5cea Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 12:28:36 +0900 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=EB=A5=A0=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20db=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/workspaces/[workspaceId]/page.tsx | 2 +- src/app/workspaces/[workspaceId]/progress-chart/page.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index 2de1af0..2be2246 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -18,7 +18,7 @@ export default async function WorkspaceHomePage({ params }: WorkspaceHomePagePro if (workspace.purpose === 'store-operation') { redirect(`/workspaces/${workspaceId}/work-schedule`); } - if(workspace.purpose==='side-project'){ + if (workspace.purpose === 'side-project') { redirect(`/workspaces/${workspaceId}/sprint-board`); } diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index ffe17d8..64b8829 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,7 +1,7 @@ // 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. import { notFound } from 'next/navigation'; -import { getMockWorkspaceById } from '@/entities/workspace'; import { ProgressChartView } from '@/views/side-project/progress-chart'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; interface ProgressChartRouteProps { params: Promise<{ workspaceId: string }>; @@ -10,7 +10,7 @@ interface ProgressChartRouteProps { export default async function ProgressChartPage({ params }: ProgressChartRouteProps) { const { workspaceId } = await params; - const workspace = getMockWorkspaceById(workspaceId); + const workspace = await getWorkspaceById(workspaceId); if (!workspace) return notFound(); if (workspace.purpose === 'side-project') { From f2077d3e020ac12fedc4a4bd245ad1efdaae78f1 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 14:11:40 +0900 Subject: [PATCH 19/23] =?UTF-8?q?fix:=20=EC=97=85=EB=AC=B4=20created=5Fby?= =?UTF-8?q?=20=EC=83=81=EC=88=98->=20getCurrentUserId=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/side-project/task/api/create-task.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/entities/side-project/task/api/create-task.ts b/src/entities/side-project/task/api/create-task.ts index f711595..ba44aeb 100644 --- a/src/entities/side-project/task/api/create-task.ts +++ b/src/entities/side-project/task/api/create-task.ts @@ -1,8 +1,8 @@ 'use server'; // 업무 생성 서버액션 — 검증 후 tasks insert (created_by는 서버에서 현재 유저로 채운다) +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { toTaskInsert } from '../model/task.mapper'; import { taskInputSchema, type TaskInput } from '../model/task.schema'; @@ -25,7 +25,8 @@ export async function createTask({ } const supabase = await createSupabaseServerClient(); - const payload = toTaskInsert(parsed.data, { workspaceId, sprintId, createdBy: DEV_USER_ID }); + const createdBy = await getCurrentUserId(); + const payload = toTaskInsert(parsed.data, { workspaceId, sprintId, createdBy }); const { error } = await supabase.from('tasks').insert(payload); if (error) { From 899220925678031f1c8f91d867aa165dbd78f4b4 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 14:14:39 +0900 Subject: [PATCH 20/23] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D/todo=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/manage-sprints/ui/SprintDeleteDialog.tsx | 2 -- src/features/manage-sprints/ui/SprintFormDialog.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/features/manage-sprints/ui/SprintDeleteDialog.tsx b/src/features/manage-sprints/ui/SprintDeleteDialog.tsx index e8e908b..53f7e66 100644 --- a/src/features/manage-sprints/ui/SprintDeleteDialog.tsx +++ b/src/features/manage-sprints/ui/SprintDeleteDialog.tsx @@ -1,7 +1,5 @@ 'use client'; -// 스프린트 삭제 확인 모달 — UI 전용 셸(삭제 로직 미배선). -// TODO(후속): onConfirm을 스프린트 삭제 서버액션(useMutation)에 연결한다. import { useEffect, useRef } from 'react'; interface SprintDeleteDialogProps { diff --git a/src/features/manage-sprints/ui/SprintFormDialog.tsx b/src/features/manage-sprints/ui/SprintFormDialog.tsx index 818f753..5d50628 100644 --- a/src/features/manage-sprints/ui/SprintFormDialog.tsx +++ b/src/features/manage-sprints/ui/SprintFormDialog.tsx @@ -1,8 +1,6 @@ 'use client'; -// 스프린트 생성/수정 모달 — UI 전용 셸(저장 로직 미배선). // 톤은 TaskFormDialog와 동일(네이티브 .showModal, rounded-2xl 패널, 슬레이트 입력). -// TODO(후속): onSubmit을 스프린트 생성/수정 서버액션(useMutation)에 연결한다. import { useEffect, useRef, useState, type FormEvent } from 'react'; import { X } from 'lucide-react'; From da8880cecf169f96e331eed2b342ebd0af6cf75e Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 14:27:42 +0900 Subject: [PATCH 21/23] =?UTF-8?q?style:=20MyTasks=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20import=20=EC=9D=BC=EA=B4=80=ED=99=94=20(Ta?= =?UTF-8?q?sk=20=E2=86=92=20type=20Task)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx index 82b74db..b736b3f 100644 --- a/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx +++ b/src/widgets/side-project/dashboard-my-tasks/ui/MyTasks.tsx @@ -6,7 +6,7 @@ import { currentSprint } from '@/entities/side-project/sprint'; import { getMockSprintTasks, - Task, + type Task, TASK_STATUS, type TaskStatus, } from '@/entities/side-project/task'; From b9e6745bb41dda1a17bbfc0cd45fb7252874f82f Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 14:40:14 +0900 Subject: [PATCH 22/23] style: prettier --- src/views/progress-chart/ui/ProgressChartPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/views/progress-chart/ui/ProgressChartPage.tsx b/src/views/progress-chart/ui/ProgressChartPage.tsx index ac16584..8161376 100644 --- a/src/views/progress-chart/ui/ProgressChartPage.tsx +++ b/src/views/progress-chart/ui/ProgressChartPage.tsx @@ -9,7 +9,6 @@ interface ProgressChartPageProps { } export default async function ProgressChartPage({ workspaceId }: ProgressChartPageProps) { - const workspace = await getWorkspaceById(workspaceId); if (!workspace) return notFound(); From 31dc5378ee1344e23bb956b9e8fdd19ad9c31292 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 14:56:02 +0900 Subject: [PATCH 23/23] =?UTF-8?q?docs:=20=EC=A7=84=ED=96=89=EB=A5=A0?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20purpose?= =?UTF-8?q?=EB=B3=84=20=EB=B7=B0=20=EB=B6=84=EA=B8=B0=20todo,=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/progress-chart/ui/ProgressChartPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/views/progress-chart/ui/ProgressChartPage.tsx b/src/views/progress-chart/ui/ProgressChartPage.tsx index 8161376..5e958ce 100644 --- a/src/views/progress-chart/ui/ProgressChartPage.tsx +++ b/src/views/progress-chart/ui/ProgressChartPage.tsx @@ -1,3 +1,7 @@ +// side-project 진행률만 실 DB 연동 완료. +// 그 외 purpose는 manage-progress-chart 뷰(다른 담당자, mock 기반) — 실 API 연동 전이라 +// workspaceId 'test' 하드코딩 상태. 실 연동은 해당 담당자 작업으로 남김. +// TODO(담당자): ProgressChartView 실 API 연동 + workspaceId={workspaceId} 전달 import { ProgressChartView } from '@/features/manage-progress-chart'; import { ProgressChartView as SideProjectProgressChartView } from '@/views/side-project/progress-chart'; import { plusJakartaSans } from '@/shared/lib/fonts'; @@ -17,7 +21,7 @@ export default async function ProgressChartPage({ workspaceId }: ProgressChartPa } return (
- +
); }