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/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index a148163..2be2246 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]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index 046bc65..a6b5722 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,3 +1,4 @@ +// 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. import { ProgressChartPage } from '@/views/progress-chart'; interface WorkspaceProgressChartPageProps { diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 12e7c03..0b4157f 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,10 +1,6 @@ -// 스프린트 보드 라우트 — 선택 스프린트를 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'; +// 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다. +// 스프린트/태스크는 컨테이너가 useQuery로 조회하고, 담당자 표시명 해석용 members만 서버에서 조회해 주입한다. +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { @@ -15,32 +11,15 @@ 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 = 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); + const members = await getWorkspaceMembersByWorkspaceId(workspaceId); 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/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/get-sprints.ts b/src/entities/side-project/sprint/api/get-sprints.ts index 60a7050..55ccd95 100644 --- a/src/entities/side-project/sprint/api/get-sprints.ts +++ b/src/entities/side-project/sprint/api/get-sprints.ts @@ -1,9 +1,15 @@ -// 워크스페이스의 스프린트 목록 조회 — Mock 구현. -// 백엔드 준비 시 supabase.from('sprints').select().eq('workspace_id', workspaceId).order('start_date') 로 교체한다. -// TODO(async): Supabase 전환 시 Promise 반환으로 바꾼다. +// 워크스페이스의 스프린트 목록 조회 — get_sprints RPC (포인트 집계·days_left 포함 단일 쿼리) +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import { toSprint } from '../model/sprint.mapper'; 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); +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/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-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/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 0a88b89..2a13f47 100644 --- a/src/entities/side-project/sprint/index.ts +++ b/src/entities/side-project/sprint/index.ts @@ -1,11 +1,13 @@ // sprint 엔티티의 Public API — 스프린트 메타 + 벨로시티 // 업무(Task)는 별도 슬라이스(@/entities/side-project/task)로 분리됨 export { VELOCITY_MAX, type Sprint, type VelocityPoint } from './model/sprint.types'; -export { - currentSprint, - mockSprints, - sprintVelocity, - 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 } from './model/sprint.selectors'; +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.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..6a14621 --- /dev/null +++ b/src/entities/side-project/sprint/model/sprint.mapper.ts @@ -0,0 +1,42 @@ +// 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 { + 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, + }; +} + +/** 검증된 입력 → 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.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.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/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/entities/side-project/task/api/create-task.ts b/src/entities/side-project/task/api/create-task.ts new file mode 100644 index 0000000..ba44aeb --- /dev/null +++ b/src/entities/side-project/task/api/create-task.ts @@ -0,0 +1,36 @@ +'use server'; + +// 업무 생성 서버액션 — 검증 후 tasks insert (created_by는 서버에서 현재 유저로 채운다) +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +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 createdBy = await getCurrentUserId(); + const payload = toTaskInsert(parsed.data, { workspaceId, sprintId, createdBy }); + 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/get-backlog-tasks.ts b/src/entities/side-project/task/api/get-backlog-tasks.ts index d540e09..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,9 +1,21 @@ -// 백로그(스프린트 미편입) 업무 조회 — Mock 구현. -// 백엔드 준비 시 supabase.from('tasks').select().eq('workspace_id', workspaceId).is('sprint_id', null) 로 교체한다. -// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +// 백로그(스프린트 미편입) 업무 조회 — 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'; -import { mockTasks } from './task.mock'; -export function getBacklogTasks(workspaceId: string): Task[] { - return mockTasks.filter((task) => task.workspaceId === workspaceId && task.sprintId === null); +export async function getBacklogTasks(workspaceId: string): Promise { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase + .from('tasks') + .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 d478b65..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,9 +1,20 @@ -// 특정 스프린트에 편입된 업무 조회 — Mock 구현. -// 백엔드 준비 시 supabase.from('tasks').select().eq('sprint_id', sprintId) 로 교체한다. -// TODO(async): Supabase 전환 시 Promise 반환으로 바꾸고, 소비 위젯을 페칭 구조로 함께 옮긴다. +// 특정 스프린트에 편입된 업무 조회 — 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'; -import { mockTasks } from './task.mock'; -export function getSprintTasks(sprintId: string): Task[] { - return mockTasks.filter((task) => task.sprintId === sprintId); +export async function getSprintTasks(sprintId: string): Promise { + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase + .from('tasks') + .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/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/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-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-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-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/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/api/use-update-task-status.ts b/src/entities/side-project/task/api/use-update-task-status.ts new file mode 100644 index 0000000..e78ab7d --- /dev/null +++ b/src/entities/side-project/task/api/use-update-task-status.ts @@ -0,0 +1,41 @@ +'use client'; + +// 칸반 DnD 상태 이동 뮤테이션 — 클라 직접 update를 감싼다. +// DnD는 직접 조작이라 낙관적 업데이트로 카드를 즉시 이동시키고, 실패 시 스냅샷으로 롤백한다. +// 스프린트 포인트(완료 집계)는 RPC 값이라 낙관 반영 대상이 아니고, onSettled 재조회에서 반영된다. +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateTaskStatus } from './update-task-status'; +import type { Task, TaskStatus } from '../model/task.types'; + +interface StatusVariables { + id: string; + status: TaskStatus; +} + +export function useUpdateTaskStatus() { + const queryClient = useQueryClient(); + return useMutation({ + 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, _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'] }); + }, + }); +} 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 0e905b4..6798079 100644 --- a/src/entities/side-project/task/index.ts +++ b/src/entities/side-project/task/index.ts @@ -8,7 +8,18 @@ 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'; +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'; +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'; +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/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..a7b1758 --- /dev/null +++ b/src/entities/side-project/task/model/task.db.types.ts @@ -0,0 +1,6 @@ +// task 도메인 DB 타입 — 자동 생성 스키마(database.types)에서 파생한다. +// 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. +import type { GenericTables } from '@/shared/model/supabase.types'; + +/** tasks 테이블 Row — select 결과. 담당자 표시명은 members에서 해석하므로 profiles 조인은 하지 않는다. */ +export type TaskRow = GenericTables<'tasks'>; 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..53a5232 --- /dev/null +++ b/src/entities/side-project/task/model/task.mapper.ts @@ -0,0 +1,57 @@ +// 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 } from './task.types'; +import type { TaskRow } from './task.db.types'; +import type { TaskInput } from './task.schema'; + +/** + * tasks row → Task 엔티티. + * point는 DB에서 null 허용(미산정)이나 엔티티는 숫자를 보장하므로 0으로 흡수한다. + * sprintId는 null을 유지한다(null → 백로그). 담당자는 id만 담고 표시명은 members에서 해석한다. + */ +export function toTask(row: TaskRow): 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, + assigneeId: row.assignee_id, + }; +} + +/** + * 검증된 입력 → 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/api/task.mock.ts b/src/entities/side-project/task/model/task.mock.ts similarity index 77% rename from src/entities/side-project/task/api/task.mock.ts rename to src/entities/side-project/task/model/task.mock.ts index e30421a..2f6e9f9 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; @@ -20,7 +20,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'design', - assignee: { name: '최민준', avatarLabel: '최' }, + assigneeId: 'mock-user-choi', }, { id: 'task-2', @@ -31,7 +31,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'frontend', - assignee: { name: '박서준', avatarLabel: '박' }, + assigneeId: 'mock-user-park', }, { id: 'task-3', @@ -42,7 +42,7 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'medium', category: 'planning', - assignee: { 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: { 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: { name: '김지은', avatarLabel: '김' }, + assigneeId: 'mock-user-kim', }, // 스프린트 편입 · 완료 (done) — 18pt { @@ -77,7 +77,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { name: '이하은', avatarLabel: '이' }, + assigneeId: 'mock-user-lee', }, { id: 'task-7', @@ -88,7 +88,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'backend', - assignee: { name: '이하은', avatarLabel: '이' }, + assigneeId: 'mock-user-lee', }, { id: 'task-8', @@ -99,7 +99,7 @@ export const mockTasks: Task[] = [ status: 'done', priority: 'medium', category: 'frontend', - assignee: { 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,6 +144,16 @@ export const mockTasks: Task[] = [ status: 'todo', priority: 'low', category: null, - assignee: null, + assigneeId: 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/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.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; +} diff --git a/src/entities/side-project/task/model/task.types.ts b/src/entities/side-project/task/model/task.types.ts index 8d0b92b..8d31edb 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; @@ -34,13 +37,6 @@ export const TASK_CATEGORY: Record 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/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/manage-sprint-tasks/model/board-task.ts b/src/features/manage-sprint-tasks/model/board-task.ts new file mode 100644 index 0000000..88b4fa6 --- /dev/null +++ b/src/features/manage-sprint-tasks/model/board-task.ts @@ -0,0 +1,28 @@ +// 보드 표시용 Task — 엔티티 Task(assigneeId만 보유)에 담당자 표시값을 해석해 붙인 뷰 모델. +// 표시명은 워크스페이스 members의 닉네임을 단일 출처로 삼는다(엔티티는 id만, 여기서 해석). +import type { Task } from '@/entities/side-project/task'; +import type { WorkspaceMember } from '@/entities/workspace-member'; + +export interface AssigneeDisplay { + name: string; + avatarLabel: string; +} + +export interface BoardTask extends Task { + /** assigneeId를 members에서 해석한 표시값. 미배정/멤버 없음이면 null */ + assignee: AssigneeDisplay | null; +} + +/** members를 userId → member 맵으로 (반복 해석용) */ +export function indexMembersById(members: WorkspaceMember[]): Map { + 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/sprint-board/model/sprint-board-columns.ts b/src/features/manage-sprint-tasks/model/sprint-board-columns.ts similarity index 80% rename from src/features/sprint-board/model/sprint-board-columns.ts rename to src/features/manage-sprint-tasks/model/sprint-board-columns.ts index 495006c..d9a3c05 100644 --- a/src/features/sprint-board/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 new file mode 100644 index 0000000..46e2382 --- /dev/null +++ b/src/features/manage-sprint-tasks/model/task-form.ts @@ -0,0 +1,42 @@ +// 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ 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; + /** 담당자 profiles.id. 미배정이면 null */ + assigneeId: string | null; +} + +export const EMPTY_TASK_FORM: TaskFormValues = { + title: '', + point: 1, + category: null, + priority: 'medium', + assigneeId: null, +}; + +// 기존 Task를 편집 폼 초기값으로 변환 +export function valuesFromTask(task: Task): TaskFormValues { + return { + title: task.title, + point: task.point, + category: task.category, + priority: task.priority, + assigneeId: task.assigneeId, + }; +} + +// 폼 값 → 서버액션 입력(TaskInput). +export function toTaskInput(values: TaskFormValues): TaskInput { + return { + title: values.title.trim(), + point: values.point, + category: values.category, + priority: values.priority, + 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 new file mode 100644 index 0000000..7489b47 --- /dev/null +++ b/src/features/manage-sprint-tasks/model/use-sprint-board.ts @@ -0,0 +1,107 @@ +// 스프린트 보드 상태 훅 — 데이터는 react-query(부모 View)가 소유하고, 이 훅은 쓰기(뮤테이션)와 DnD만 배선한다. +// CRUD/상태이동은 서버액션·클라 update를 호출하고, 성공 시 쿼리 무효화로 재조회되어 목록이 갱신된다(재조회 방식, B-1). +// 낙관적 업데이트는 후속 과제(B-2). 실패는 각 뮤테이션 훅의 onError(toast)에서 노출된다. +import { useCallback, useMemo } from 'react'; + +import { + type Task, + type TaskStatus, + useCreateTask, + useDeleteTask, + useUpdateTask, + 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'; + +interface UseSprintBoardParams { + /** 새 업무를 편입할 현재 스프린트 id */ + sprintId: string; + /** 새 업무가 속할 워크스페이스 id */ + workspaceId: string; + /** 현재 스프린트 업무(react-query 데이터) */ + tasks: Task[]; + /** 백로그 업무(react-query 데이터) */ + backlog: Task[]; + /** 담당자 표시명(닉네임) 해석용 워크스페이스 멤버 */ + members: WorkspaceMember[]; +} + +export function useSprintBoard({ + sprintId, + workspaceId, + tasks, + backlog, + members, +}: UseSprintBoardParams) { + const createMutation = useCreateTask(workspaceId); + const updateMutation = useUpdateTask(); + const deleteMutation = useDeleteTask(); + const statusMutation = useUpdateTaskStatus(); + const sprintMutation = useUpdateTaskSprint(); + + // 스프린트에 새 업무 추가(기본 상태: 대기) + const addSprintTask = useCallback( + (values: TaskFormValues) => createMutation.mutate({ input: toTaskInput(values), sprintId }), + [createMutation, sprintId], + ); + + // 백로그에 새 항목 추가(스프린트 미편입) + const addBacklogTask = useCallback( + (values: TaskFormValues) => + createMutation.mutate({ input: toTaskInput(values), sprintId: null }), + [createMutation], + ); + + // 수정/삭제는 업무가 어느 목록에 있든 id로 처리(스프린트·백로그 공통) + const updateTask = useCallback( + (id: string, values: TaskFormValues) => + updateMutation.mutate({ id, input: toTaskInput(values) }), + [updateMutation], + ); + + 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 }), + [statusMutation], + ); + + const dnd = useTaskDnd(moveTask); + + // 담당자 표시값(닉네임/아바타)을 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, + addSprintTask, + addBacklogTask, + updateTask, + deleteTask, + moveToSprint, + dragProps: dnd.dragProps, + dropProps: dnd.dropProps, + dragOverStatus: dnd.dragOverStatus, + }; +} 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 76% rename from src/features/sprint-board/ui/BacklogRow.tsx rename to src/features/manage-sprint-tasks/ui/BacklogRow.tsx index ee46784..e3da2fd 100644 --- a/src/features/sprint-board/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/sprint-board/ui/SprintBoard.tsx b/src/features/manage-sprint-tasks/ui/SprintBoard.tsx similarity index 92% rename from src/features/sprint-board/ui/SprintBoard.tsx rename to src/features/manage-sprint-tasks/ui/SprintBoard.tsx index 1621d96..3a2127d 100644 --- a/src/features/sprint-board/ui/SprintBoard.tsx +++ b/src/features/manage-sprint-tasks/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, @@ -41,10 +35,11 @@ export function SprintBoard({ addBacklogTask, updateTask, deleteTask, + moveToSprint, dragProps, dropProps, dragOverStatus, - } = useSprintBoard({ sprintId, workspaceId, initialTasks, initialBacklog }); + } = useSprintBoard({ sprintId, workspaceId, tasks, backlog, members }); const [dialog, setDialog] = useState(null); @@ -88,6 +83,7 @@ export function SprintBoard({ onAdd={() => setDialog({ mode: 'add-backlog' })} onEdit={(task) => setDialog({ mode: 'edit', task })} onDelete={deleteTask} + onMoveToSprint={moveToSprint} /> {dialog && ( 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 95% rename from src/features/sprint-board/ui/TaskCard.tsx rename to src/features/manage-sprint-tasks/ui/TaskCard.tsx index a9e66b3..9b204ac 100644 --- a/src/features/sprint-board/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/sprint-board/ui/TaskFormDialog.tsx b/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx similarity index 92% rename from src/features/sprint-board/ui/TaskFormDialog.tsx rename to src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx index b3addea..3481828 100644 --- a/src/features/sprint-board/ui/TaskFormDialog.tsx +++ b/src/features/manage-sprint-tasks/ui/TaskFormDialog.tsx @@ -165,26 +165,18 @@ export function TaskFormDialog({
{members.map((member) => { - const selected = values.assignee?.name === member.workspaceNickname; + const selected = values.assigneeId === member.userId; return ( + +
+ + ); +} diff --git a/src/features/manage-sprints/ui/SprintFormDialog.tsx b/src/features/manage-sprints/ui/SprintFormDialog.tsx new file mode 100644 index 0000000..5d50628 --- /dev/null +++ b/src/features/manage-sprints/ui/SprintFormDialog.tsx @@ -0,0 +1,140 @@ +'use client'; + +// 톤은 TaskFormDialog와 동일(네이티브 .showModal, rounded-2xl 패널, 슬레이트 입력). +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 !== '' && + 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..4405647 --- /dev/null +++ b/src/features/manage-sprints/ui/SprintToolbar.tsx @@ -0,0 +1,96 @@ +'use client'; + +// 스프린트 액션 버튼(생성/수정/삭제) + 다이얼로그 열림 상태. +// 생성/수정/삭제는 서버액션 뮤테이션에 연결하고, 실패는 각 훅 onError(toast)에서 노출한다. +// 수정/삭제는 현재 스프린트를 대상으로 한다. +import { useState } from 'react'; + +import { Pencil, Plus, Trash2 } from 'lucide-react'; + +import { + type Sprint, + useCreateSprint, + useDeleteSprint, + useUpdateSprint, +} from '@/entities/side-project/sprint'; + +import { SprintDeleteDialog } from './SprintDeleteDialog'; +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'; + +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' && sprint) { + updateSprint.mutate({ id: sprint.id, input: values }); + } else { + createSprint.mutate({ input: values }); + } + }; + + return ( +
+ + + {/* 수정/삭제는 대상 스프린트가 있을 때만 */} + {sprint && ( + <> + + + + )} + + {(dialog?.mode === 'create' || dialog?.mode === 'edit') && ( + setDialog(null)} + onSubmit={handleSubmit} + /> + )} + {dialog?.mode === 'delete' && sprint && ( + setDialog(null)} + onConfirm={() => deleteSprint.mutate(sprint.id)} + /> + )} +
+ ); +} 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/features/sprint-board/model/task-form.ts b/src/features/sprint-board/model/task-form.ts deleted file mode 100644 index 9cd18f1..0000000 --- a/src/features/sprint-board/model/task-form.ts +++ /dev/null @@ -1,60 +0,0 @@ -// 업무 추가/수정 다이얼로그의 폼 값과, 폼 ↔ 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 deleted file mode 100644 index 8ddb521..0000000 --- a/src/features/sprint-board/model/use-sprint-board.ts +++ /dev/null @@ -1,89 +0,0 @@ -// 스프린트 보드 상태 훅 — 서버에서 받은 초기 데이터를 로컬 상태로 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/shared/model/database.types.ts b/src/shared/model/database.types.ts index 56d13fb..8acb007 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -798,6 +798,19 @@ 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 diff --git a/src/views/progress-chart/ui/ProgressChartPage.tsx b/src/views/progress-chart/ui/ProgressChartPage.tsx index aeaab95..5e958ce 100644 --- a/src/views/progress-chart/ui/ProgressChartPage.tsx +++ b/src/views/progress-chart/ui/ProgressChartPage.tsx @@ -1,14 +1,27 @@ +// 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'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; +import { notFound } from 'next/navigation'; interface ProgressChartPageProps { workspaceId: string; } -export default function ProgressChartPage({ workspaceId }: ProgressChartPageProps) { +export default async function ProgressChartPage({ workspaceId }: ProgressChartPageProps) { + const workspace = await getWorkspaceById(workspaceId); + if (!workspace) return notFound(); + + if (workspace.purpose === 'side-project') { + return ; + } return (
- +
); } 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..cf5b1a0 --- /dev/null +++ b/src/views/side-project/progress-chart/ui/ProgressChartView.tsx @@ -0,0 +1,64 @@ +'use client'; + +// 진행률 차트 페이지 뷰 — useQuery로 스프린트/업무를 조회하고 파생값을 계산해 렌더한다(GET 컨벤션 §5). +// 로딩/에러/빈 상태를 여기서 분기하고, 하위 차트 컴포넌트는 순수 표현만 담당한다. +import { Plus_Jakarta_Sans } from 'next/font/google'; + +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'; +import StatusDonutChart from './StatusDonutChart'; +import VelocityChart from './VelocityChart'; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], +}); + +function CenteredMessage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +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/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..65fa94e --- /dev/null +++ b/src/views/side-project/progress-chart/ui/VelocityChart.tsx @@ -0,0 +1,60 @@ +// 스프린트별 벨로시티 막대 차트 — 스프린트마다 완료 포인트를 막대로 비교 +// 막대 수가 적어 차트 라이브러리 없이 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} + + ))} +
+
+
+ + ); +} diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index b9cb5db..3445408 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -1,11 +1,17 @@ -// 스프린트 보드 페이지 셸 — 폰트/배경만 잡고, 서버에서 받은 초기 데이터를 하위로 전달한다. -// 순수 표시용 요약 헤더는 여기(뷰)에서 sprint를 받아 렌더하고, 상호작용 보드/백로그는 feature에 위임한다. +'use client'; + +// 스프린트 보드 페이지 뷰 — useQuery로 스프린트/업무/백로그를 조회해 렌더한다(GET 컨벤션 §5). +// 선택 스프린트는 URL(?sprint=id)에서 온 selectedSprintId로 판정하고, 없으면 현재 스프린트로 폴백한다. +// 로딩/에러/빈 상태를 여기서 분기하고, 상호작용 보드/백로그는 feature에 위임한다. +// members(담당자 표시명 해석용)는 서버(RSC)에서 조회해 prop으로 주입받는다. 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 { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; +import { useBacklogTasks, useSprintTasks } from '@/entities/side-project/task'; import type { WorkspaceMember } 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'; @@ -15,34 +21,68 @@ 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[]; + selectedSprintId?: string; + /** 담당자 표시명 해석용 워크스페이스 멤버(RSC에서 주입) */ members: WorkspaceMember[]; } -export function SprintBoardView({ - workspaceId, - sprint, - sprints, - initialTasks, - initialBacklog, - members, -}: SprintBoardViewProps) { +export function SprintBoardView({ workspaceId, selectedSprintId, members }: 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); + + 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한다 */}
diff --git a/src/views/side-project/sprint-board/ui/SprintSelector.tsx b/src/views/side-project/sprint-board/ui/SprintSelector.tsx index 3df16db..da67c18 100644 --- a/src/views/side-project/sprint-board/ui/SprintSelector.tsx +++ b/src/views/side-project/sprint-board/ui/SprintSelector.tsx @@ -12,7 +12,7 @@ interface SprintSelectorProps { export default function SprintSelector({ sprints, currentSprintId }: SprintSelectorProps) { return ( -
+
{sprints.map((sprint) => { const isActive = sprint.id === currentSprintId; return ( 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..b736b3f 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 { getSprintTasks, Task, TASK_STATUS, type TaskStatus } from '@/entities/side-project/task'; +import { + getMockSprintTasks, + type 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 +18,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 }) { 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 ( 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; +$$;