Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
81f4f09
refactor: 벨로시티 목데이터를 스프린트에서 파생하도록 통합
Kwon812 Jul 10, 2026
1f1b605
feat: 태스크 상태 분포 집계 셀렉터 추가
Kwon812 Jul 10, 2026
58e3c88
feat: 진행률 차트 뷰 및 차트 컴포넌트 구현
Kwon812 Jul 10, 2026
9e2d2ed
feat: 진행률 차트 라우트 연결, 차트 뷰 조립
Kwon812 Jul 10, 2026
9e23b56
style: prettier
Kwon812 Jul 10, 2026
7f6e8c0
refactor: 진행률 차트 분기처리
Kwon812 Jul 10, 2026
5a43052
feat: get_sprints RPC 추가
Kwon812 Jul 12, 2026
2d024b8
feat: tasks/sprints DB 매퍼 및 enum 파생 추가
Kwon812 Jul 12, 2026
fe13245
feat: 스프린트/태스크 읽기 fetcher Supabase 전환
Kwon812 Jul 12, 2026
1c9cd0f
feat: 진행률 차트·스프린트 보드 실 데이터 연동 (useQuery)
Kwon812 Jul 12, 2026
bb935f3
feat: 업무 생성/수정/삭제 + DnD 상태변경 쓰기 연동
Kwon812 Jul 13, 2026
022a696
feat: 칸반보드 낙관적 업데이트 적용
Kwon812 Jul 13, 2026
01d252c
refactor: 스프린트보드 feature폴더 리네임
Kwon812 Jul 13, 2026
1575909
feat: 스프린트 crud 연동
Kwon812 Jul 13, 2026
7bf34cc
feat: 백로그 -> 현재 스프린트로 편입용 훅 추가
Kwon812 Jul 13, 2026
c7bd75e
Merge remote-tracking branch 'origin/develop' into feat/#41/side-proj…
Kwon812 Jul 13, 2026
7b8ec83
refactor: 담당자를 id 참조 + members(nickname) 해석으로 전환
Kwon812 Jul 13, 2026
640fef3
feat: 스프린트 없을 때 생성 버튼 노출
Kwon812 Jul 13, 2026
5c20d71
feat: 진행률 차트 db연동
Kwon812 Jul 13, 2026
f2077d3
fix: 업무 created_by 상수-> getCurrentUserId로 변경
Kwon812 Jul 13, 2026
8992209
refactor: 불필요한 주석/todo삭제
Kwon812 Jul 13, 2026
da8880c
style: MyTasks 타입 전용 import 일관화 (Task → type Task)
Kwon812 Jul 13, 2026
733a226
Merge branch 'develop' into feat/#41/side-project-sprint-backend
Kwon812 Jul 13, 2026
b9e6745
style: prettier
Kwon812 Jul 13, 2026
31dc537
docs: 진행률차트페이지 purpose별 뷰 분기 todo,주석 추가
Kwon812 Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/app/workspaces/[workspaceId]/calendar/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CalendarPage workspaceId={workspaceId} />;
Expand Down
3 changes: 3 additions & 0 deletions src/app/workspaces/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
1 change: 1 addition & 0 deletions src/app/workspaces/[workspaceId]/progress-chart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다.
import { ProgressChartPage } from '@/views/progress-chart';

interface WorkspaceProgressChartPageProps {
Expand Down
35 changes: 7 additions & 28 deletions src/app/workspaces/[workspaceId]/sprint-board/page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<div className="text-brand-muted flex min-h-full items-center justify-center p-6 text-sm">
아직 생성된 스프린트가 없습니다.
</div>
);
}

const initialTasks = getSprintTasks(sprint.id);
const initialBacklog = getBacklogTasks(workspaceId);
const members = await getWorkspaceMembersByWorkspaceId(workspaceId);

return (
<SprintBoardView
workspaceId={workspaceId}
sprint={sprint}
sprints={sprints}
initialTasks={initialTasks}
initialBacklog={initialBacklog}
members={mockWorkspaceMembers}
selectedSprintId={selectedSprintId}
members={members}
/>
);
}
3 changes: 2 additions & 1 deletion src/entities/calendar-event/model/calendar-event.types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/entities/side-project/sprint/api/create-sprint.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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('스프린트 생성에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
15 changes: 15 additions & 0 deletions src/entities/side-project/sprint/api/delete-sprint.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const supabase = await createSupabaseServerClient();
const { error } = await supabase.from('sprints').delete().eq('id', id);

if (error) {
console.error('[deleteSprint] delete 실패:', error);
throw new Error('스프린트 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
18 changes: 12 additions & 6 deletions src/entities/side-project/sprint/api/get-sprints.ts
Original file line number Diff line number Diff line change
@@ -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<Sprint[]> {
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);
}
26 changes: 26 additions & 0 deletions src/entities/side-project/sprint/api/update-sprint.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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('스프린트 수정에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
21 changes: 21 additions & 0 deletions src/entities/side-project/sprint/api/use-create-sprint.ts
Original file line number Diff line number Diff line change
@@ -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 : '스프린트 생성에 실패했습니다');
},
});
}
21 changes: 21 additions & 0 deletions src/entities/side-project/sprint/api/use-delete-sprint.ts
Original file line number Diff line number Diff line change
@@ -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 : '스프린트 삭제에 실패했습니다');
},
});
}
15 changes: 15 additions & 0 deletions src/entities/side-project/sprint/api/use-sprints.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
20 changes: 20 additions & 0 deletions src/entities/side-project/sprint/api/use-update-sprint.ts
Original file line number Diff line number Diff line change
@@ -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 : '스프린트 수정에 실패했습니다');
},
});
}
16 changes: 9 additions & 7 deletions src/entities/side-project/sprint/index.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 12 additions & 0 deletions src/entities/side-project/sprint/model/sprint.db.types.ts
Original file line number Diff line number Diff line change
@@ -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];
42 changes: 42 additions & 0 deletions src/entities/side-project/sprint/model/sprint.mapper.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
11 changes: 3 additions & 8 deletions src/entities/side-project/sprint/model/sprint.mock.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
];
21 changes: 21 additions & 0 deletions src/entities/side-project/sprint/model/sprint.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof sprintInputSchema>;
14 changes: 13 additions & 1 deletion src/entities/side-project/sprint/model/sprint.selectors.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Loading