diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx new file mode 100644 index 0000000..12e7c03 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -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 ( +
+ 아직 생성된 스프린트가 없습니다. +
+ ); + } + + const initialTasks = getSprintTasks(sprint.id); + const initialBacklog = getBacklogTasks(workspaceId); + + return ( + + ); +} diff --git a/src/entities/side-project/sprint/api/get-sprints.ts b/src/entities/side-project/sprint/api/get-sprints.ts new file mode 100644 index 0000000..60a7050 --- /dev/null +++ b/src/entities/side-project/sprint/api/get-sprints.ts @@ -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); +} diff --git a/src/entities/side-project/sprint/index.ts b/src/entities/side-project/sprint/index.ts index 07f1ede..0a88b89 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,4 +1,11 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { currentSprint, sprintVelocity, SIDE_PROJECT_WORKSPACE_ID } from './model/sprint.mock'; +export { + currentSprint, + mockSprints, + sprintVelocity, + SIDE_PROJECT_WORKSPACE_ID, +} from './model/sprint.mock'; +export { getSprints } from './api/get-sprints'; +export { resolveCurrentSprint } from './model/sprint.selectors'; diff --git a/src/entities/side-project/sprint/model/sprint.mock.ts b/src/entities/side-project/sprint/model/sprint.mock.ts index 6663c98..95bf205 100644 --- a/src/entities/side-project/sprint/model/sprint.mock.ts +++ b/src/entities/side-project/sprint/model/sprint.mock.ts @@ -1,10 +1,23 @@ -// 스프린트 목데이터 — 현재 스프린트 메타 + 벨로시티 -// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts / task.selectors.ts 참고 +// 스프린트 목데이터 — 워크스페이스의 스프린트 목록 + 벨로시티 +// 업무(Task)는 여기서 소유하지 않는다 → task.mock.ts 참고. 백로그는 스프린트와 무관하게 워크스페이스 공통. import type { Sprint, VelocityPoint } from './sprint.types'; -// 사이드 프로젝트 데모 워크스페이스 id — 스프린트·업무 목데이터가 공유하는 소유 워크스페이스 -export const SIDE_PROJECT_WORKSPACE_ID = 'ws-side-project'; +// 사이드 프로젝트 데모 워크스페이스 id — 실제 워크스페이스(mock-workspace)의 'side-workspace'와 일치시킨다. +export const SIDE_PROJECT_WORKSPACE_ID = 'side-workspace'; +// 과거(완료된) 스프린트 +const sprint1: Sprint = { + id: 'sprint-1', + workspaceId: SIDE_PROJECT_WORKSPACE_ID, + name: 'Sprint 1', + startDate: '2025-06-17', + endDate: '2025-06-30', + daysLeft: 0, + totalPoints: 38, + completedPoints: 34, +}; + +// 현재 진행 중 스프린트 export const currentSprint: Sprint = { id: 'sprint-2', workspaceId: SIDE_PROJECT_WORKSPACE_ID, @@ -16,6 +29,9 @@ export const currentSprint: Sprint = { completedPoints: 28, }; +// 워크스페이스의 스프린트 목록(선택기용) — 시간순 +export const mockSprints: Sprint[] = [sprint1, currentSprint]; + /** 스프린트별 계획/완료 포인트 추이 */ export const sprintVelocity: VelocityPoint[] = [ { sprint: 'S1', planned: 38, completed: 34 }, diff --git a/src/entities/side-project/sprint/model/sprint.selectors.ts b/src/entities/side-project/sprint/model/sprint.selectors.ts new file mode 100644 index 0000000..751aca7 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.selectors.ts @@ -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]; +} diff --git a/src/features/sprint-board/index.ts b/src/features/sprint-board/index.ts new file mode 100644 index 0000000..5426856 --- /dev/null +++ b/src/features/sprint-board/index.ts @@ -0,0 +1,2 @@ +// sprint-board 피처의 Public API — 스프린트 칸반 보드 + 백로그(업무 CRUD 포함) +export { SprintBoard } from './ui/SprintBoard'; diff --git a/src/features/sprint-board/lib/avatar-color.ts b/src/features/sprint-board/lib/avatar-color.ts new file mode 100644 index 0000000..aa661ee --- /dev/null +++ b/src/features/sprint-board/lib/avatar-color.ts @@ -0,0 +1,19 @@ +// 담당자 아바타 배경색 — Task 모델에는 색상 필드가 없어, 표시명 기반으로 결정론적 색을 고른다. +// 같은 담당자는 항상 같은 색이 나오도록 간단한 해시로 팔레트를 선택한다. +const AVATAR_PALETTE = [ + '#00C950', + '#FE9A00', + '#615FFF', + '#00B8DB', + '#2B7FFF', + '#F6339A', + '#7E22CE', +]; + +export function getAvatarColor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; +} diff --git a/src/features/sprint-board/model/sprint-board-columns.ts b/src/features/sprint-board/model/sprint-board-columns.ts new file mode 100644 index 0000000..495006c --- /dev/null +++ b/src/features/sprint-board/model/sprint-board-columns.ts @@ -0,0 +1,27 @@ +// 스프린트 보드 칸반 컬럼 파생 로직 — 업무 목록을 상태(대기/진행 중/완료)별로 그룹핑한다. +// 컬럼별 포인트 합계까지 함께 계산해, 컬럼 헤더의 "13pt" 같은 표시에 그대로 쓴다. +// 상태 순서/라벨은 entities의 TASK_STATUS를 단일 출처로 삼는다. +import { type Task, type TaskStatus, TASK_STATUS } from '@/entities/side-project/task'; + +export interface SprintColumn { + id: TaskStatus; + title: string; + tasks: Task[]; + /** 컬럼에 속한 업무 포인트 합계 */ + totalPoints: number; +} + +// 칸반 컬럼 노출 순서 (대기 → 진행 중 → 완료) +const COLUMN_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done']; + +export function groupTasksByStatus(tasks: Task[]): SprintColumn[] { + return COLUMN_ORDER.map((status) => { + const columnTasks = tasks.filter((task) => task.status === status); + return { + id: status, + title: TASK_STATUS[status].label, + tasks: columnTasks, + totalPoints: columnTasks.reduce((sum, task) => sum + task.point, 0), + }; + }); +} diff --git a/src/features/sprint-board/model/task-form.ts b/src/features/sprint-board/model/task-form.ts new file mode 100644 index 0000000..9cd18f1 --- /dev/null +++ b/src/features/sprint-board/model/task-form.ts @@ -0,0 +1,60 @@ +// 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ Task 변환 헬퍼. +// 담당자는 워크스페이스 멤버에서 선택하며, 선택 결과를 Task.assignee 형태({name, avatarLabel})로 그대로 담는다. +import type { Task, TaskAssignee, TaskCategory, TaskPriority } from '@/entities/side-project/task'; + +export interface TaskFormValues { + title: string; + point: number; + category: TaskCategory | null; + priority: TaskPriority; + assignee: TaskAssignee | null; +} + +export const EMPTY_TASK_FORM: TaskFormValues = { + title: '', + point: 1, + category: null, + priority: 'medium', + assignee: null, +}; + +// 기존 Task를 편집 폼 초기값으로 변환 +export function valuesFromTask(task: Task): TaskFormValues { + return { + title: task.title, + point: task.point, + category: task.category, + priority: task.priority, + assignee: task.assignee, + }; +} + +// 폼 값을 기존 Task에 반영(id/workspaceId/sprintId/status는 유지) +export function applyValuesToTask(task: Task, values: TaskFormValues): Task { + return { + ...task, + title: values.title.trim(), + point: values.point, + category: values.category, + priority: values.priority, + assignee: values.assignee, + }; +} + +// 폼 값으로 새 Task 생성. 배치(스프린트 편입/백로그, 상태)는 호출부가 결정한다. +export function createTaskFromValues( + values: TaskFormValues, + placement: { workspaceId: string; sprintId: string | null; status: Task['status'] }, +): Task { + return { + id: crypto.randomUUID(), + workspaceId: placement.workspaceId, + sprintId: placement.sprintId, + title: values.title.trim(), + point: values.point, + status: placement.status, + priority: values.priority, + category: values.category, + assignee: values.assignee, + }; +} diff --git a/src/features/sprint-board/model/use-sprint-board.ts b/src/features/sprint-board/model/use-sprint-board.ts new file mode 100644 index 0000000..8ddb521 --- /dev/null +++ b/src/features/sprint-board/model/use-sprint-board.ts @@ -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(initialTasks); + const [backlogTasks, setBacklogTasks] = useState(initialBacklog); + + // 스프린트에 새 업무 추가(기본 상태: 대기) + const addSprintTask = useCallback( + (values: TaskFormValues) => { + setSprintTasks((prev) => [ + ...prev, + createTaskFromValues(values, { workspaceId, sprintId, status: 'todo' }), + ]); + }, + [workspaceId, sprintId], + ); + + // 백로그에 새 항목 추가(스프린트 미편입) + const addBacklogTask = useCallback( + (values: TaskFormValues) => { + setBacklogTasks((prev) => [ + ...prev, + createTaskFromValues(values, { workspaceId, sprintId: null, status: 'todo' }), + ]); + }, + [workspaceId], + ); + + // 수정/삭제는 업무가 어느 목록에 있든 처리(스프린트·백로그 공통) + const updateTask = useCallback((id: string, values: TaskFormValues) => { + const patch = (list: Task[]) => + list.map((t) => (t.id === id ? applyValuesToTask(t, values) : t)); + setSprintTasks(patch); + setBacklogTasks(patch); + }, []); + + const deleteTask = useCallback((id: string) => { + const remove = (list: Task[]) => list.filter((t) => t.id !== id); + setSprintTasks(remove); + setBacklogTasks(remove); + }, []); + + // 드래그로 컬럼(상태) 이동 — 스프린트 업무에만 적용 + const moveTask = useCallback((id: string, status: TaskStatus) => { + setSprintTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t))); + }, []); + + const dnd = useTaskDnd(moveTask); + const columns = groupTasksByStatus(sprintTasks); + + return { + columns, + backlogTasks, + addSprintTask, + addBacklogTask, + updateTask, + deleteTask, + dragProps: dnd.dragProps, + dropProps: dnd.dropProps, + dragOverStatus: dnd.dragOverStatus, + }; +} diff --git a/src/features/sprint-board/model/use-task-dnd.ts b/src/features/sprint-board/model/use-task-dnd.ts new file mode 100644 index 0000000..37a1778 --- /dev/null +++ b/src/features/sprint-board/model/use-task-dnd.ts @@ -0,0 +1,44 @@ +// 칸반 드래그앤드롭 훅 — 네이티브 HTML5 DnD의 보일러플레이트를 캡슐화한다. +// 컴포넌트는 카드에 dragProps(taskId), 컬럼에 dropProps(status)만 스프레드하면 된다. +// dragOverStatus로 "지금 드롭하면 들어갈 컬럼"을 알려, 컬럼에서 하이라이트를 그릴 수 있게 한다. +// 컬럼 내 재정렬은 하지 않고(디자인 요구 없음), 드롭 대상 컬럼의 상태로 이동만 시킨다. +import { useState, type DragEvent } from 'react'; + +import type { TaskStatus } from '@/entities/side-project/task'; + +export function useTaskDnd(onMove: (taskId: string, status: TaskStatus) => void) { + const [draggingId, setDraggingId] = useState(null); + // 현재 드래그가 올라와 있는 컬럼(=드롭 시 들어갈 곳). 하이라이트 표시에 사용. + const [dragOverStatus, setDragOverStatus] = useState(null); + + const reset = () => { + setDraggingId(null); + setDragOverStatus(null); + }; + + const dragProps = (taskId: string) => ({ + draggable: true, + onDragStart: (event: DragEvent) => { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', taskId); + setDraggingId(taskId); + }, + onDragEnd: reset, + }); + + const dropProps = (status: TaskStatus) => ({ + onDragOver: (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (dragOverStatus !== status) setDragOverStatus(status); + }, + onDrop: (event: DragEvent) => { + event.preventDefault(); + const taskId = event.dataTransfer.getData('text/plain') || draggingId; + if (taskId) onMove(taskId, status); + reset(); + }, + }); + + return { draggingId, dragOverStatus, dragProps, dropProps }; +} diff --git a/src/features/sprint-board/ui/BacklogRow.tsx b/src/features/sprint-board/ui/BacklogRow.tsx new file mode 100644 index 0000000..ee46784 --- /dev/null +++ b/src/features/sprint-board/ui/BacklogRow.tsx @@ -0,0 +1,55 @@ +// 백로그 행 — 우선순위 점 · 제목 · 포인트 · 우선순위 배지 + (호버 시) 수정/삭제 액션. +// 우선순위 색(TASK_PRIORITY)은 entities를 단일 출처로 사용한다. +// '낮음'은 지정색(#d1d5dc)이 옅어 배지 텍스트로 쓰면 대비가 부족하므로 뮤트 그레이로 대체한다. +import { Pencil, Trash2 } from 'lucide-react'; + +import { type Task, TASK_PRIORITY } from '@/entities/side-project/task'; + +interface BacklogRowProps { + task: Task; + onEdit: () => void; + onDelete: () => void; +} + +export function BacklogRow({ task, onEdit, onDelete }: BacklogRowProps) { + const priority = TASK_PRIORITY[task.priority]; + const isLow = task.priority === 'low'; + + return ( +
+ + + {task.title} + + {task.point}pt + + {priority.label} + + +
+ + +
+
+ ); +} diff --git a/src/features/sprint-board/ui/BacklogSection.tsx b/src/features/sprint-board/ui/BacklogSection.tsx new file mode 100644 index 0000000..c08064c --- /dev/null +++ b/src/features/sprint-board/ui/BacklogSection.tsx @@ -0,0 +1,69 @@ +'use client'; + +// 백로그 섹션 — 접기/펼치기 가능한 패널. 헤더(아이콘 + 제목 + 개수 배지)와 항목 목록, 추가 버튼으로 구성. +// 펼침 여부는 로컬 상태로 관리한다(상호작용). 항목 추가 동작은 Epic E에서 연결한다. +import { useState } from 'react'; + +import { ChevronDown, Package, Plus } from 'lucide-react'; + +import type { Task } from '@/entities/side-project/task'; + +import { BacklogRow } from './BacklogRow'; + +interface BacklogSectionProps { + tasks: Task[]; + onAdd: () => void; + onEdit: (task: Task) => void; + onDelete: (taskId: string) => void; +} + +export function BacklogSection({ tasks, onAdd, onEdit, onDelete }: BacklogSectionProps) { + const [open, setOpen] = useState(true); + + return ( +
+ + + {open && ( +
+
+ {tasks.map((task) => ( + onEdit(task)} + onDelete={() => onDelete(task.id)} + /> + ))} +
+ + +
+ )} +
+ ); +} diff --git a/src/features/sprint-board/ui/SprintBoard.tsx b/src/features/sprint-board/ui/SprintBoard.tsx new file mode 100644 index 0000000..1621d96 --- /dev/null +++ b/src/features/sprint-board/ui/SprintBoard.tsx @@ -0,0 +1,104 @@ +'use client'; + +// 스프린트 보드 — 칸반(대기/진행 중/완료) + 백로그를 담는 상호작용 컨테이너. +// 상태·CRUD·드래그는 useSprintBoard 훅에 위임하고, 여기서는 다이얼로그 열림 상태만 관리한다. +import { useState } from 'react'; + +import { Plus } from 'lucide-react'; + +import type { Task } from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; + +import { useSprintBoard } from '../model/use-sprint-board'; +import { valuesFromTask, type TaskFormValues } from '../model/task-form'; +import { BacklogSection } from './BacklogSection'; +import { SprintColumn } from './SprintColumn'; +import { TaskFormDialog } from './TaskFormDialog'; + +// 다이얼로그 상태 — 스프린트 추가 / 백로그 추가 / 수정(대상 Task) / 닫힘 +type DialogState = + { mode: 'add-sprint' } | { mode: 'add-backlog' } | { mode: 'edit'; task: Task } | null; + +interface SprintBoardProps { + sprintId: string; + workspaceId: string; + initialTasks: Task[]; + initialBacklog: Task[]; + members: WorkspaceMember[]; +} + +export function SprintBoard({ + sprintId, + workspaceId, + initialTasks, + initialBacklog, + members, +}: SprintBoardProps) { + const { + columns, + backlogTasks, + addSprintTask, + addBacklogTask, + updateTask, + deleteTask, + dragProps, + dropProps, + dragOverStatus, + } = useSprintBoard({ sprintId, workspaceId, initialTasks, initialBacklog }); + + const [dialog, setDialog] = useState(null); + + const handleSubmit = (values: TaskFormValues) => { + if (!dialog) return; + if (dialog.mode === 'add-sprint') addSprintTask(values); + else if (dialog.mode === 'add-backlog') addBacklogTask(values); + else updateTask(dialog.task.id, values); + }; + + return ( +
+
+ +
+ + {/* 칸반 보드 — 상태별 컬럼 */} +
+ {columns.map((column) => ( + setDialog({ mode: 'edit', task })} + onDeleteTask={deleteTask} + /> + ))} +
+ + {/* 백로그 섹션 */} + setDialog({ mode: 'add-backlog' })} + onEdit={(task) => setDialog({ mode: 'edit', task })} + onDelete={deleteTask} + /> + + {dialog && ( + setDialog(null)} + onSubmit={handleSubmit} + /> + )} +
+ ); +} diff --git a/src/features/sprint-board/ui/SprintColumn.tsx b/src/features/sprint-board/ui/SprintColumn.tsx new file mode 100644 index 0000000..40434a6 --- /dev/null +++ b/src/features/sprint-board/ui/SprintColumn.tsx @@ -0,0 +1,76 @@ +// 스프린트 칸반 컬럼 — 상태 헤더(점 + 라벨 + 포인트 합계) + 업무 카드 목록 + 드롭 영역. +// 상태 점 색은 TASK_STATUS를 단일 출처로 사용한다. 카드 드래그/카드 액션은 상위에서 주입한다. +import type { DragEventHandler } from 'react'; + +import { TASK_STATUS, type Task } from '@/entities/side-project/task'; + +import type { SprintColumn as SprintColumnData } from '../model/sprint-board-columns'; +import { TaskCard } from './TaskCard'; + +interface DropProps { + onDragOver: DragEventHandler; + onDrop: DragEventHandler; +} + +interface DragProps { + draggable: boolean; + onDragStart: DragEventHandler; + onDragEnd: DragEventHandler; +} + +interface SprintColumnProps { + column: SprintColumnData; + /** 드래그가 이 컬럼 위에 올라와 있는지(드롭 대상 하이라이트) */ + isDragOver: boolean; + /** 이 컬럼(상태)의 드롭 핸들러 */ + dropProps: DropProps; + /** 카드 id별 드래그 핸들러 생성기 */ + getDragProps: (taskId: string) => DragProps; + onEditTask: (task: Task) => void; + onDeleteTask: (taskId: string) => void; +} + +export function SprintColumn({ + column, + isDragOver, + dropProps, + getDragProps, + onEditTask, + onDeleteTask, +}: SprintColumnProps) { + const status = TASK_STATUS[column.id]; + + return ( +
+
+
+ + {column.title} +
+ {column.totalPoints}pt +
+ + {/* 드롭 영역 — 빈 컬럼도 드롭받을 수 있도록 최소 높이를 준다. + 드래그가 올라오면 링/배경으로 "여기에 들어감"을 표시한다. */} +
+ {column.tasks.map((task) => ( + onEditTask(task)} + onDelete={() => onDeleteTask(task.id)} + {...getDragProps(task.id)} + /> + ))} + {column.tasks.length === 0 && ( +

업무 없음

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

{task.title}

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

+ {title} +

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

포인트

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

우선순위

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

카테고리

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

담당자

+
+ + {members.map((member) => { + const selected = values.assignee?.name === member.workspaceNickname; + return ( + + ); + })} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/views/side-project/sprint-board/index.ts b/src/views/side-project/sprint-board/index.ts new file mode 100644 index 0000000..72a3003 --- /dev/null +++ b/src/views/side-project/sprint-board/index.ts @@ -0,0 +1,2 @@ +// 스프린트 보드 뷰의 Public API +export { SprintBoardView } from './ui/SprintBoardView'; diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx new file mode 100644 index 0000000..b9cb5db --- /dev/null +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -0,0 +1,50 @@ +// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고, 서버에서 받은 초기 데이터를 하위로 전달한다. +// 순수 표시용 요약 헤더는 여기(뷰)에서 sprint를 받아 렌더하고, 상호작용 보드/백로그는 feature에 위임한다. +import { Plus_Jakarta_Sans } from 'next/font/google'; + +import type { Sprint } from '@/entities/side-project/sprint'; +import type { Task } from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { SprintBoard } from '@/features/sprint-board'; + +import SprintSelector from './SprintSelector'; +import SprintSummaryHeader from './SprintSummaryHeader'; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], +}); + +interface SprintBoardViewProps { + workspaceId: string; + sprint: Sprint; + sprints: Sprint[]; + initialTasks: Task[]; + initialBacklog: Task[]; + members: WorkspaceMember[]; +} + +export function SprintBoardView({ + workspaceId, + sprint, + sprints, + initialTasks, + initialBacklog, + members, +}: SprintBoardViewProps) { + return ( +
+ + + {/* key={sprint.id}: 스프린트 전환 시 보드를 리마운트해 초기 데이터로 다시 seed한다 */} + +
+ ); +} diff --git a/src/views/side-project/sprint-board/ui/SprintSelector.tsx b/src/views/side-project/sprint-board/ui/SprintSelector.tsx new file mode 100644 index 0000000..3df16db --- /dev/null +++ b/src/views/side-project/sprint-board/ui/SprintSelector.tsx @@ -0,0 +1,36 @@ +// 스프린트 선택기 — URL 파라미터(?sprint=id)로 전환한다. +// 클릭 = 네비게이션 → 라우트 RSC가 다시 실행되어 선택 스프린트로 재조회/재seed된다(클라 페칭 없음). +import Link from 'next/link'; + +import type { Sprint } from '@/entities/side-project/sprint'; +import { cn } from '@/shared/lib/utils'; + +interface SprintSelectorProps { + sprints: Sprint[]; + currentSprintId: string; +} + +export default function SprintSelector({ sprints, currentSprintId }: SprintSelectorProps) { + return ( +
+ {sprints.map((sprint) => { + const isActive = sprint.id === currentSprintId; + return ( + + {sprint.name} + + ); + })} +
+ ); +} diff --git a/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx new file mode 100644 index 0000000..2857910 --- /dev/null +++ b/src/views/side-project/sprint-board/ui/SprintSummaryHeader.tsx @@ -0,0 +1,50 @@ +// 스프린트 요약 헤더 — 스프린트 메타(기간·진행률·남은 일수/포인트)를 그라데이션 배너로 표시. +// 순수 표시용이라 sprint를 props로 받는다(상호작용 없음). 진행률은 완료/총 포인트에서 파생. +import type { Sprint } from '@/entities/side-project/sprint'; + +// 'YYYY-MM-DD' → 'M/D' +const monthDay = (iso: string) => { + const [, month, day] = iso.split('-'); + return `${Number(month)}/${Number(day)}`; +}; + +export default function SprintSummaryHeader({ sprint }: { sprint: Sprint }) { + const { name, startDate, endDate, daysLeft, totalPoints, completedPoints } = sprint; + const remainingPoints = totalPoints - completedPoints; + const percent = totalPoints === 0 ? 0 : Math.round((completedPoints / totalPoints) * 100); + const period = `${monthDay(startDate)} – ${monthDay(endDate)}`; + + return ( +
+
+

현재 스프린트

+

+ {name} · {period} +

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

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

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

{daysLeft}일

+

남은 일수

+
+
+

{remainingPoints}pt

+

남은 포인트

+
+
+
+ ); +}