diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index 2aa5514..01aa47c 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,4 +1,5 @@ // 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. +// 선택 스프린트는 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다(사이드 프로젝트 진행률에서만 사용). import { ProgressChartPage } from '@/views/progress-chart'; import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; @@ -6,13 +7,17 @@ interface WorkspaceProgressChartPageProps { params: Promise<{ workspaceId: string; }>; + searchParams: Promise<{ sprint?: string | string[] }>; } export default async function WorkspaceProgressChartPage({ params, + searchParams, }: WorkspaceProgressChartPageProps) { const { workspaceId } = await params; + const { sprint: sprintParam } = await searchParams; + const selectedSprintId = typeof sprintParam === 'string' ? sprintParam : undefined; await assertWorkspaceRouteAccess(workspaceId, 'progress-chart'); - return ; + return ; } diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index f26429a..3c02c56 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -7,7 +7,13 @@ 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, selectVelocityMax } from './model/sprint.selectors'; +export { + resolveCurrentSprint, + resolveSelectedSprint, + selectVelocity, + selectVelocityMax, +} 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'; +export { SprintSelector } from './ui/SprintSelector'; diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts index 2e1d8aa..8ce83f3 100644 --- a/src/entities/side-project/sprint/model/sprint.selectors.ts +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -37,3 +37,15 @@ export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { // 진행 중이 없으면 가장 최근 시작한 스프린트 (원본 불변) return [...sprints].sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; } + +/** + * 화면에 표시할 스프린트를 판정한다 — 선택 id(URL ?sprint=id 등)가 유효하면 그 스프린트, + * 없거나 유효하지 않으면 현재 스프린트로 폴백한다(진행 중 우선 → 없으면 최신). + * 스프린트 보드/진행률 차트가 공유하는 선택 규칙을 한 곳에 둔다. + */ +export function resolveSelectedSprint( + sprints: Sprint[], + selectedSprintId: string | undefined, +): Sprint | undefined { + return sprints.find((sprint) => sprint.id === selectedSprintId) ?? resolveCurrentSprint(sprints); +} diff --git a/src/views/side-project/sprint-board/ui/SprintSelector.tsx b/src/entities/side-project/sprint/ui/SprintSelector.tsx similarity index 80% rename from src/views/side-project/sprint-board/ui/SprintSelector.tsx rename to src/entities/side-project/sprint/ui/SprintSelector.tsx index da67c18..38498dc 100644 --- a/src/views/side-project/sprint-board/ui/SprintSelector.tsx +++ b/src/entities/side-project/sprint/ui/SprintSelector.tsx @@ -1,16 +1,18 @@ // 스프린트 선택기 — URL 파라미터(?sprint=id)로 전환한다. // 클릭 = 네비게이션 → 라우트 RSC가 다시 실행되어 선택 스프린트로 재조회/재seed된다(클라 페칭 없음). +// 스프린트 보드/진행률 차트가 공유하는 순수 표현 컴포넌트라 entity 계층에 둔다. import Link from 'next/link'; -import type { Sprint } from '@/entities/side-project/sprint'; import { cn } from '@/shared/lib/utils'; +import type { Sprint } from '../model/sprint.types'; + interface SprintSelectorProps { sprints: Sprint[]; currentSprintId: string; } -export default function SprintSelector({ sprints, currentSprintId }: SprintSelectorProps) { +export function SprintSelector({ sprints, currentSprintId }: SprintSelectorProps) { return (
{sprints.map((sprint) => { diff --git a/src/views/progress-chart/ui/ProgressChartPage.tsx b/src/views/progress-chart/ui/ProgressChartPage.tsx index 8161376..fbaa29d 100644 --- a/src/views/progress-chart/ui/ProgressChartPage.tsx +++ b/src/views/progress-chart/ui/ProgressChartPage.tsx @@ -6,14 +6,24 @@ import { notFound } from 'next/navigation'; interface ProgressChartPageProps { workspaceId: string; + /** 선택 스프린트(?sprint=id) — 사이드 프로젝트 진행률에서만 사용, 없으면 현재 스프린트로 폴백 */ + selectedSprintId?: string; } -export default async function ProgressChartPage({ workspaceId }: ProgressChartPageProps) { +export default async function ProgressChartPage({ + workspaceId, + selectedSprintId, +}: ProgressChartPageProps) { const workspace = await getWorkspaceById(workspaceId); if (!workspace) return notFound(); if (workspace.purpose === 'side-project') { - return ; + return ( + + ); } return (
diff --git a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx index cf5b1a0..349bf8c 100644 --- a/src/views/side-project/progress-chart/ui/ProgressChartView.tsx +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -4,7 +4,12 @@ // 로딩/에러/빈 상태를 여기서 분기하고, 하위 차트 컴포넌트는 순수 표현만 담당한다. import { Plus_Jakarta_Sans } from 'next/font/google'; -import { resolveCurrentSprint, selectVelocity, useSprints } from '@/entities/side-project/sprint'; +import { + resolveSelectedSprint, + selectVelocity, + SprintSelector, + useSprints, +} from '@/entities/side-project/sprint'; import { countByStatus, useSprintTasks } from '@/entities/side-project/task'; import ProgressStatRow from './ProgressStatRow'; @@ -25,10 +30,18 @@ function CenteredMessage({ children }: { children: React.ReactNode }) { ); } -export function ProgressChartView({ workspaceId }: { workspaceId: string }) { +interface ProgressChartViewProps { + workspaceId: string; + /** 선택 스프린트(?sprint=id) — 없거나 유효하지 않으면 현재 스프린트로 폴백 */ + selectedSprintId?: string; +} + +export function ProgressChartView({ workspaceId, selectedSprintId }: ProgressChartViewProps) { const sprintsQuery = useSprints(workspaceId); - // 진행 중(오늘이 기간 안) 스프린트 우선 → 없으면 최신. 로딩 중이면 undefined. - const sprint = sprintsQuery.data ? resolveCurrentSprint(sprintsQuery.data) : undefined; + // 선택값이 없거나 유효하지 않으면 현재 스프린트로 폴백. 로딩 중이면 undefined. + const sprint = sprintsQuery.data + ? resolveSelectedSprint(sprintsQuery.data, selectedSprintId) + : undefined; const tasksQuery = useSprintTasks(sprint?.id); if (sprintsQuery.isPending) return 불러오는 중…; @@ -46,6 +59,9 @@ export function ProgressChartView({ workspaceId }: { workspaceId: string }) { return (
+ {/* 스프린트 선택기 — 상단/스탯/도넛은 선택 스프린트에 묶이고, 벨로시티는 전체 추이를 유지한다 */} + +
diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 0a544af..d1d5923 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -6,7 +6,7 @@ // members(담당자 표시명 해석용)는 RSC 값(initialMembers)으로 첫 렌더를 채우고 공유 캐시가 소유한다. import { Plus_Jakarta_Sans } from 'next/font/google'; -import { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; +import { resolveSelectedSprint, SprintSelector, useSprints } from '@/entities/side-project/sprint'; import { useBacklogTasks, useSprintTasks } from '@/entities/side-project/task'; import { useWorkspaceMembersByWorkspaceId, @@ -16,7 +16,6 @@ import { SprintBoard } from '@/features/manage-sprint-tasks'; import { SprintToolbar } from '@/features/manage-sprints'; -import SprintSelector from './SprintSelector'; import SprintSummaryHeader from './SprintSummaryHeader'; const jakarta = Plus_Jakarta_Sans({ @@ -49,10 +48,9 @@ export function SprintBoardView({ initialMembers, ); const sprintsQuery = useSprints(workspaceId); - // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) + // 선택값이 없거나 유효하지 않으면 현재 스프린트로 폴백(진행 중 우선 → 없으면 최신) const sprint = sprintsQuery.data - ? (sprintsQuery.data.find((item) => item.id === selectedSprintId) ?? - resolveCurrentSprint(sprintsQuery.data)) + ? resolveSelectedSprint(sprintsQuery.data, selectedSprintId) : undefined; const tasksQuery = useSprintTasks(sprint?.id); const backlogQuery = useBacklogTasks(workspaceId);