Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/app/workspaces/[workspaceId]/sprint-board/page.tsx
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}
Comment thread
Kwon812 marked this conversation as resolved.
/>
);
}
9 changes: 9 additions & 0 deletions src/entities/side-project/sprint/api/get-sprints.ts
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);
}
9 changes: 8 additions & 1 deletion src/entities/side-project/sprint/index.ts
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';
24 changes: 20 additions & 4 deletions src/entities/side-project/sprint/model/sprint.mock.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
// 스프린트 목데이터 — 현재 스프린트 메타 + 벨로시티
// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts / task.selectors.ts 참고
// 스프린트 목데이터 — 워크스페이스의 스프린트 목록 + 벨로시티
// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts 참고. 백로그는 스프린트와 무관하게 워크스페이스 공통.
import type { Sprint, VelocityPoint } from './sprint.types';

// 사이드 프로젝트 데모 워크스페이스 id — 스프린트·업무 목데이터가 공유하는 소유 워크스페이스
export const SIDE_PROJECT_WORKSPACE_ID = 'ws-side-project';
// 사이드 프로젝트 데모 워크스페이스 id — 실제 워크스페이스(mock-workspace)의 'side-workspace'와 일치시킨다.
export const SIDE_PROJECT_WORKSPACE_ID = 'side-workspace';

// 과거(완료된) 스프린트
const sprint1: Sprint = {
id: 'sprint-1',
workspaceId: SIDE_PROJECT_WORKSPACE_ID,
name: 'Sprint 1',
startDate: '2025-06-17',
endDate: '2025-06-30',
daysLeft: 0,
totalPoints: 38,
completedPoints: 34,
};

// 현재 진행 중 스프린트
export const currentSprint: Sprint = {
id: 'sprint-2',
workspaceId: SIDE_PROJECT_WORKSPACE_ID,
Expand All @@ -16,6 +29,9 @@ export const currentSprint: Sprint = {
completedPoints: 28,
};

// 워크스페이스의 스프린트 목록(선택기용) — 시간순
export const mockSprints: Sprint[] = [sprint1, currentSprint];

/** 스프린트별 계획/완료 포인트 추이 */
export const sprintVelocity: VelocityPoint[] = [
{ sprint: 'S1', planned: 38, completed: 34 },
Expand Down
16 changes: 16 additions & 0 deletions src/entities/side-project/sprint/model/sprint.selectors.ts
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];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions src/features/sprint-board/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// sprint-board 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD 포함)
export { SprintBoard } from './ui/SprintBoard';
19 changes: 19 additions & 0 deletions src/features/sprint-board/lib/avatar-color.ts
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];
}
27 changes: 27 additions & 0 deletions src/features/sprint-board/model/sprint-board-columns.ts
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),
};
});
}
60 changes: 60 additions & 0 deletions src/features/sprint-board/model/task-form.ts
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,
};
}
89 changes: 89 additions & 0 deletions src/features/sprint-board/model/use-sprint-board.ts
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,
};
}
44 changes: 44 additions & 0 deletions src/features/sprint-board/model/use-task-dnd.ts
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();
},
});
Comment thread
Kwon812 marked this conversation as resolved.

return { draggingId, dragOverStatus, dragProps, dropProps };
}
Comment thread
Kwon812 marked this conversation as resolved.
Loading