-
Notifications
You must be signed in to change notification settings - Fork 3
feat: 사이드 프로젝트 스프린트 보드 페이지 구현 #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
dd441b1
feat: 라우트·뷰 골격 및 보드 상태 기반 구성
Kwon812 528e1b9
feat: 스프린트 요약 헤더 구현
Kwon812 b5382a1
feat: 칸반 카드,컬럼,백로그 섹션 구현
Kwon812 c84010b
feat: 사용자 인터렉션 구현
Kwon812 ebf45d6
feat: URL 기반 스프린트 전환 + Sprint 1 목데이터 추가
Kwon812 3735d1c
refactor: 스프린트 뷰 공개 배럴로 변경
Kwon812 75fb39a
fix: 스프린트 셀렉터 날짜 기준 수정
Kwon812 792fa45
fix: 업무 모달 네이티브 dialog 전환
Kwon812 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어, RSC에서 해당 스프린트 데이터를 조회해 주입한다. | ||
| // 스프린트 전환 = URL 변경(네비게이션) → 이 RSC가 다시 실행되어 새 스프린트로 seed된다(클라 페칭 없음). | ||
| // 이후 태스크 변경은 클라이언트 낙관적 업데이트로 처리하며 재조회하지 않는다. | ||
| // 실 API 전환 시 아래 조회부만 async(Supabase)로 교체한다. | ||
| import { getSprints, resolveCurrentSprint } from '@/entities/side-project/sprint'; | ||
| import { getBacklogTasks, getSprintTasks } from '@/entities/side-project/task'; | ||
| import { mockWorkspaceMembers } from '@/entities/workspace-member'; | ||
| import { SprintBoardView } from '@/views/side-project/sprint-board'; | ||
|
|
||
| interface SprintBoardRouteProps { | ||
| params: Promise<{ workspaceId: string }>; | ||
| searchParams: Promise<{ sprint?: string | string[] }>; | ||
| } | ||
|
|
||
| export default async function SprintBoardPage({ params, searchParams }: SprintBoardRouteProps) { | ||
| const { workspaceId } = await params; | ||
| const { sprint: sprintParam } = await searchParams; | ||
|
|
||
| const sprints = getSprints(workspaceId); | ||
| const selectedId = typeof sprintParam === 'string' ? sprintParam : undefined; | ||
| // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) | ||
| const sprint = sprints.find((item) => item.id === selectedId) ?? resolveCurrentSprint(sprints); | ||
|
|
||
| // 스프린트가 하나도 없는 워크스페이스 — 빈 상태 | ||
| if (!sprint) { | ||
| return ( | ||
| <div className="text-brand-muted flex min-h-full items-center justify-center p-6 text-sm"> | ||
| 아직 생성된 스프린트가 없습니다. | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const initialTasks = getSprintTasks(sprint.id); | ||
| const initialBacklog = getBacklogTasks(workspaceId); | ||
|
|
||
| return ( | ||
| <SprintBoardView | ||
| workspaceId={workspaceId} | ||
| sprint={sprint} | ||
| sprints={sprints} | ||
| initialTasks={initialTasks} | ||
| initialBacklog={initialBacklog} | ||
| members={mockWorkspaceMembers} | ||
| /> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| // 워크스페이스의 스프린트 목록 조회 — Mock 구현. | ||
| // 백엔드 준비 시 supabase.from('sprints').select().eq('workspace_id', workspaceId).order('start_date') 로 교체한다. | ||
| // TODO(async): Supabase 전환 시 Promise 반환으로 바꾼다. | ||
| import type { Sprint } from '../model/sprint.types'; | ||
| import { mockSprints } from '../model/sprint.mock'; | ||
|
|
||
| export function getSprints(workspaceId: string): Sprint[] { | ||
| return mockSprints.filter((sprint) => sprint.workspaceId === workspaceId); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,11 @@ | ||
| // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 | ||
| // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 | ||
| export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; | ||
| export { currentSprint, sprintVelocity, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; | ||
| export { | ||
| currentSprint, | ||
| mockSprints, | ||
| sprintVelocity, | ||
| SIDE_PROJECT_WORKSPACE_ID, | ||
| } from './model/sprint.mock'; | ||
| export { getSprints } from './api/get-sprints'; | ||
| export { resolveCurrentSprint } from './model/sprint.selectors'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 16 additions & 0 deletions
16
src/entities/side-project/sprint/model/sprint.selectors.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| // 스프린트 선택 로직 — 데이터에서 "현재 스프린트"를 판정한다(하드코딩 상수에 의존하지 않음). | ||
| // 진행 중(오늘이 기간 안) 스프린트를 우선하고, 없으면 가장 최근 시작한 스프린트를 고른다. | ||
| // 실 DB에서는 이 규칙이 `where start_date<=now()<=end_date` → 없으면 `order by start_date desc limit 1`에 대응한다. | ||
| import type { Sprint } from './sprint.types'; | ||
|
|
||
| export function resolveCurrentSprint(sprints: Sprint[]): Sprint | undefined { | ||
| const now = new Date(); | ||
| const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String( | ||
| now.getDate(), | ||
| ).padStart(2, '0')}`; // 로컬 타임존 기준 YYYY-MM-DD | ||
| const ongoing = sprints.find((sprint) => sprint.startDate <= today && today <= sprint.endDate); | ||
| if (ongoing) return ongoing; | ||
|
|
||
| // 진행 중이 없으면 가장 최근 시작한 스프린트 (원본 불변) | ||
| return [...sprints].sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| // sprint-board 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD 포함) | ||
| export { SprintBoard } from './ui/SprintBoard'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| // 담당자 아바타 배경색 — Task 모델에는 색상 필드가 없어, 표시명 기반으로 결정론적 색을 고른다. | ||
| // 같은 담당자는 항상 같은 색이 나오도록 간단한 해시로 팔레트를 선택한다. | ||
| const AVATAR_PALETTE = [ | ||
| '#00C950', | ||
| '#FE9A00', | ||
| '#615FFF', | ||
| '#00B8DB', | ||
| '#2B7FFF', | ||
| '#F6339A', | ||
| '#7E22CE', | ||
| ]; | ||
|
|
||
| export function getAvatarColor(seed: string): string { | ||
| let hash = 0; | ||
| for (let i = 0; i < seed.length; i += 1) { | ||
| hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; | ||
| } | ||
| return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // 스프린트 보드 칸반 컬럼 파생 로직 — 업무 목록을 상태(대기/진행 중/완료)별로 그룹핑한다. | ||
| // 컬럼별 포인트 합계까지 함께 계산해, 컬럼 헤더의 "13pt" 같은 표시에 그대로 쓴다. | ||
| // 상태 순서/라벨은 entities의 TASK_STATUS를 단일 출처로 삼는다. | ||
| import { type Task, type TaskStatus, TASK_STATUS } from '@/entities/side-project/task'; | ||
|
|
||
| export interface SprintColumn { | ||
| id: TaskStatus; | ||
| title: string; | ||
| tasks: Task[]; | ||
| /** 컬럼에 속한 업무 포인트 합계 */ | ||
| totalPoints: number; | ||
| } | ||
|
|
||
| // 칸반 컬럼 노출 순서 (대기 → 진행 중 → 완료) | ||
| const COLUMN_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done']; | ||
|
|
||
| export function groupTasksByStatus(tasks: Task[]): SprintColumn[] { | ||
| return COLUMN_ORDER.map((status) => { | ||
| const columnTasks = tasks.filter((task) => task.status === status); | ||
| return { | ||
| id: status, | ||
| title: TASK_STATUS[status].label, | ||
| tasks: columnTasks, | ||
| totalPoints: columnTasks.reduce((sum, task) => sum + task.point, 0), | ||
| }; | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| // 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ Task 변환 헬퍼. | ||
| // 담당자는 워크스페이스 멤버에서 선택하며, 선택 결과를 Task.assignee 형태({name, avatarLabel})로 그대로 담는다. | ||
| import type { Task, TaskAssignee, TaskCategory, TaskPriority } from '@/entities/side-project/task'; | ||
|
|
||
| export interface TaskFormValues { | ||
| title: string; | ||
| point: number; | ||
| category: TaskCategory | null; | ||
| priority: TaskPriority; | ||
| assignee: TaskAssignee | null; | ||
| } | ||
|
|
||
| export const EMPTY_TASK_FORM: TaskFormValues = { | ||
| title: '', | ||
| point: 1, | ||
| category: null, | ||
| priority: 'medium', | ||
| assignee: null, | ||
| }; | ||
|
|
||
| // 기존 Task를 편집 폼 초기값으로 변환 | ||
| export function valuesFromTask(task: Task): TaskFormValues { | ||
| return { | ||
| title: task.title, | ||
| point: task.point, | ||
| category: task.category, | ||
| priority: task.priority, | ||
| assignee: task.assignee, | ||
| }; | ||
| } | ||
|
|
||
| // 폼 값을 기존 Task에 반영(id/workspaceId/sprintId/status는 유지) | ||
| export function applyValuesToTask(task: Task, values: TaskFormValues): Task { | ||
| return { | ||
| ...task, | ||
| title: values.title.trim(), | ||
| point: values.point, | ||
| category: values.category, | ||
| priority: values.priority, | ||
| assignee: values.assignee, | ||
| }; | ||
| } | ||
|
|
||
| // 폼 값으로 새 Task 생성. 배치(스프린트 편입/백로그, 상태)는 호출부가 결정한다. | ||
| export function createTaskFromValues( | ||
| values: TaskFormValues, | ||
| placement: { workspaceId: string; sprintId: string | null; status: Task['status'] }, | ||
| ): Task { | ||
| return { | ||
| id: crypto.randomUUID(), | ||
| workspaceId: placement.workspaceId, | ||
| sprintId: placement.sprintId, | ||
| title: values.title.trim(), | ||
| point: values.point, | ||
| status: placement.status, | ||
| priority: values.priority, | ||
| category: values.category, | ||
| assignee: values.assignee, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // 스프린트 보드 상태 훅 — 서버에서 받은 초기 데이터를 로컬 상태로 seed하고, | ||
| // 이후 CRUD/드래그 이동을 낙관적으로 로컬에서 처리한다(재조회 없음). | ||
| // 실 API 전환 시: 각 액션 안에서 서버 저장을 호출하고, 실패 시 이전 상태로 롤백한다. | ||
| // - 추가: 임시 카드를 먼저 그린 뒤, 서버가 준 실제 id로 교체 | ||
| // - 그 외: setState 뒤 저장 호출, 실패 시 prev로 복원 | ||
| // 컴포넌트/DnD는 이 훅이 주는 값만 소비하므로, 위 교체 시에도 UI는 그대로 유지된다. | ||
| import { useCallback, useState } from 'react'; | ||
|
|
||
| import { type Task, type TaskStatus } from '@/entities/side-project/task'; | ||
|
|
||
| import { groupTasksByStatus } from './sprint-board-columns'; | ||
| import { applyValuesToTask, createTaskFromValues, type TaskFormValues } from './task-form'; | ||
| import { useTaskDnd } from './use-task-dnd'; | ||
|
|
||
| interface UseSprintBoardParams { | ||
| /** 새 업무를 편입할 현재 스프린트 id */ | ||
| sprintId: string; | ||
| /** 새 업무가 속할 워크스페이스 id */ | ||
| workspaceId: string; | ||
| initialTasks: Task[]; | ||
| initialBacklog: Task[]; | ||
| } | ||
|
|
||
| export function useSprintBoard({ | ||
| sprintId, | ||
| workspaceId, | ||
| initialTasks, | ||
| initialBacklog, | ||
| }: UseSprintBoardParams) { | ||
| // 서버에서 받은 초기 데이터로 seed | ||
| const [sprintTasks, setSprintTasks] = useState<Task[]>(initialTasks); | ||
| const [backlogTasks, setBacklogTasks] = useState<Task[]>(initialBacklog); | ||
|
|
||
| // 스프린트에 새 업무 추가(기본 상태: 대기) | ||
| const addSprintTask = useCallback( | ||
| (values: TaskFormValues) => { | ||
| setSprintTasks((prev) => [ | ||
| ...prev, | ||
| createTaskFromValues(values, { workspaceId, sprintId, status: 'todo' }), | ||
| ]); | ||
| }, | ||
| [workspaceId, sprintId], | ||
| ); | ||
|
|
||
| // 백로그에 새 항목 추가(스프린트 미편입) | ||
| const addBacklogTask = useCallback( | ||
| (values: TaskFormValues) => { | ||
| setBacklogTasks((prev) => [ | ||
| ...prev, | ||
| createTaskFromValues(values, { workspaceId, sprintId: null, status: 'todo' }), | ||
| ]); | ||
| }, | ||
| [workspaceId], | ||
| ); | ||
|
|
||
| // 수정/삭제는 업무가 어느 목록에 있든 처리(스프린트·백로그 공통) | ||
| const updateTask = useCallback((id: string, values: TaskFormValues) => { | ||
| const patch = (list: Task[]) => | ||
| list.map((t) => (t.id === id ? applyValuesToTask(t, values) : t)); | ||
| setSprintTasks(patch); | ||
| setBacklogTasks(patch); | ||
| }, []); | ||
|
|
||
| const deleteTask = useCallback((id: string) => { | ||
| const remove = (list: Task[]) => list.filter((t) => t.id !== id); | ||
| setSprintTasks(remove); | ||
| setBacklogTasks(remove); | ||
| }, []); | ||
|
|
||
| // 드래그로 컬럼(상태) 이동 — 스프린트 업무에만 적용 | ||
| const moveTask = useCallback((id: string, status: TaskStatus) => { | ||
| setSprintTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t))); | ||
| }, []); | ||
|
|
||
| const dnd = useTaskDnd(moveTask); | ||
| const columns = groupTasksByStatus(sprintTasks); | ||
|
|
||
| return { | ||
| columns, | ||
| backlogTasks, | ||
| addSprintTask, | ||
| addBacklogTask, | ||
| updateTask, | ||
| deleteTask, | ||
| dragProps: dnd.dragProps, | ||
| dropProps: dnd.dropProps, | ||
| dragOverStatus: dnd.dragOverStatus, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| // 칸반 드래그앤드롭 훅 — 네이티브 HTML5 DnD의 보일러플레이트를 캡슐화한다. | ||
| // 컴포넌트는 카드에 dragProps(taskId), 컬럼에 dropProps(status)만 스프레드하면 된다. | ||
| // dragOverStatus로 "지금 드롭하면 들어갈 컬럼"을 알려, 컬럼에서 하이라이트를 그릴 수 있게 한다. | ||
| // 컬럼 내 재정렬은 하지 않고(디자인 요구 없음), 드롭 대상 컬럼의 상태로 이동만 시킨다. | ||
| import { useState, type DragEvent } from 'react'; | ||
|
|
||
| import type { TaskStatus } from '@/entities/side-project/task'; | ||
|
|
||
| export function useTaskDnd(onMove: (taskId: string, status: TaskStatus) => void) { | ||
| const [draggingId, setDraggingId] = useState<string | null>(null); | ||
| // 현재 드래그가 올라와 있는 컬럼(=드롭 시 들어갈 곳). 하이라이트 표시에 사용. | ||
| const [dragOverStatus, setDragOverStatus] = useState<TaskStatus | null>(null); | ||
|
|
||
| const reset = () => { | ||
| setDraggingId(null); | ||
| setDragOverStatus(null); | ||
| }; | ||
|
|
||
| const dragProps = (taskId: string) => ({ | ||
| draggable: true, | ||
| onDragStart: (event: DragEvent<HTMLElement>) => { | ||
| event.dataTransfer.effectAllowed = 'move'; | ||
| event.dataTransfer.setData('text/plain', taskId); | ||
| setDraggingId(taskId); | ||
| }, | ||
| onDragEnd: reset, | ||
| }); | ||
|
|
||
| const dropProps = (status: TaskStatus) => ({ | ||
| onDragOver: (event: DragEvent<HTMLElement>) => { | ||
| event.preventDefault(); | ||
| event.dataTransfer.dropEffect = 'move'; | ||
| if (dragOverStatus !== status) setDragOverStatus(status); | ||
| }, | ||
| onDrop: (event: DragEvent<HTMLElement>) => { | ||
| event.preventDefault(); | ||
| const taskId = event.dataTransfer.getData('text/plain') || draggingId; | ||
| if (taskId) onMove(taskId, status); | ||
| reset(); | ||
| }, | ||
| }); | ||
|
Kwon812 marked this conversation as resolved.
|
||
|
|
||
| return { draggingId, dragOverStatus, dragProps, dropProps }; | ||
| } | ||
|
Kwon812 marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.