diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index b3406505..79b97bdb 100644 --- a/src/app/taskmate/team/[teamId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import AsyncBoundary from "@/components/common/AsyncBoundary"; import { GoalList } from "@/components/team/GoalList"; import { MemberList } from "@/components/team/MemberList"; @@ -5,19 +7,40 @@ import { Summary } from "@/components/team/Summary"; export default function Page() { return ( -
+ 목표를 불러오지 못했어요 +
+ {error.message || "잠시 후 다시 시도해주세요."} +
{label}
- {value} - + + + {label} + + + + {value} + + {suffix} @@ -53,7 +57,7 @@ function StatItem({ const Character = () => { return ( - {title} + + {title} + {isAdmin && ( @@ -194,7 +200,7 @@ export const MainHeroProgressCard = ({ )} - + = 80 && progress < 100 && ( @@ -232,7 +238,7 @@ export const MainHeroProgressCard = ({ height={24} className="shrink-0 object-contain" /> - + {statusLabel} diff --git a/src/components/team/MainSecondaryProgressCard.tsx b/src/components/team/MainSecondaryProgressCard.tsx index be28baf1..d28b9274 100644 --- a/src/components/team/MainSecondaryProgressCard.tsx +++ b/src/components/team/MainSecondaryProgressCard.tsx @@ -56,7 +56,9 @@ export const MainSecondaryProgressCard = ({ }} > - {title} + + {title} + {iconSrc ? ( void; +} + +export default function MemberListError({ error, onReset }: Props) { + return ( + + + + + 멤버 + + + + + + 멤버 정보를 불러오지 못했어요 + + + {error.message || "잠시 후 다시 시도해주세요."} + + + 다시 시도 + + + + ); +} diff --git a/src/components/team/MemberList/Loading.tsx b/src/components/team/MemberList/Loading.tsx new file mode 100644 index 00000000..f6b88e7c --- /dev/null +++ b/src/components/team/MemberList/Loading.tsx @@ -0,0 +1,11 @@ +"use client"; + +import Spinner from "@/components/common/Spinner"; + +export default function MemberListLoading() { + return ( + + + + ); +} diff --git a/src/components/team/MemberList/Member.tsx b/src/components/team/MemberList/Member.tsx index 8de227c3..5fb9841a 100644 --- a/src/components/team/MemberList/Member.tsx +++ b/src/components/team/MemberList/Member.tsx @@ -33,7 +33,7 @@ export default function Member({ member.userId === me.id && member.role === "ADMIN", + ); + + return ( + + + + + + 멤버 + + + {members.length}명 + + + + {isMeAdmin && ( + + + + 팀 나가기 + + + )} + + + + {formattedMembers.map((member) => ( + + ))} + + + ); +} diff --git a/src/components/team/MemberList/index.tsx b/src/components/team/MemberList/index.tsx index 09cc367a..8af0d421 100644 --- a/src/components/team/MemberList/index.tsx +++ b/src/components/team/MemberList/index.tsx @@ -1,66 +1,8 @@ -"use client"; - -import { useSuspenseQuery } from "@tanstack/react-query"; - -import { Icon } from "@/components/common/Icon"; -import TextButton from "@/components/common/TextButton/TextButton"; -import { userQueries } from "@/constants/queryKeys/user.queryKey"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { useTeamLeaveModal } from "@/features/team/hooks/useTeamLeaveModal"; -import { teamQueries } from "@/features/team/query/team.queryKey"; -import { formatMemberList } from "@/utils/formatMemberList"; - -import Member from "./Member"; - -// @TODO: 목표 목록 조회 시 무한 스크롤 처리 (useSuspenseInfiniteQuery) -export const MemberList = () => { - const teamId = useTeamId(); - const { openLeaveTeamModal } = useTeamLeaveModal(teamId); - - const { data: members } = useSuspenseQuery(teamQueries.memberList(teamId)); - const { data: me } = useSuspenseQuery(userQueries.myInfo()); - - const formattedMembers = formatMemberList(members, me.id); - - return ( - - - - - - 멤버 - - - {members.length}명 - - - - - - 팀 나가기 - - - - - - {formattedMembers.map((member) => ( - - ))} - - - ); -}; +import MemberListError from "./Error"; +import MemberListLoading from "./Loading"; +import MemberListComponent from "./MemberList"; + +export const MemberList = Object.assign(MemberListComponent, { + Error: MemberListError, + Loading: MemberListLoading, +}); diff --git a/src/components/team/Summary/Error.tsx b/src/components/team/Summary/Error.tsx new file mode 100644 index 00000000..35107138 --- /dev/null +++ b/src/components/team/Summary/Error.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Button from "@/components/common/Button/Button"; + +interface Props { + error: Error; + onReset: () => void; +} + +export default function SummaryError({ error, onReset }: Props) { + return ( + + + 팀 요약 정보를 불러오지 못했어요 + + + {error.message || "잠시 후 다시 시도해주세요."} + + + 다시 시도 + + + ); +} diff --git a/src/components/team/Summary/Loading.tsx b/src/components/team/Summary/Loading.tsx new file mode 100644 index 00000000..521b4f38 --- /dev/null +++ b/src/components/team/Summary/Loading.tsx @@ -0,0 +1,11 @@ +"use client"; + +import Spinner from "@/components/common/Spinner"; + +export default function SummaryLoading() { + return ( + + + + ); +} diff --git a/src/components/team/Summary/Summary.tsx b/src/components/team/Summary/Summary.tsx new file mode 100644 index 00000000..524731d9 --- /dev/null +++ b/src/components/team/Summary/Summary.tsx @@ -0,0 +1,31 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { MainHeroProgressCard } from "@/components/team/MainHeroProgressCard"; +import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { teamQueries } from "@/features/team/query/team.queryKey"; + +export default function Summary() { + const teamId = useTeamId(); + const { + data: { + teamName, + todayProgressPercent, + todayTodoCount, + overdueTodoCount, + isAdmin, + doneTodoCount, + }, + } = useSuspenseQuery(teamQueries.summary(teamId)); + + return ( + + ); +} diff --git a/src/components/team/Summary/index.tsx b/src/components/team/Summary/index.tsx index 1ebada00..9f702611 100644 --- a/src/components/team/Summary/index.tsx +++ b/src/components/team/Summary/index.tsx @@ -1,33 +1,10 @@ "use client"; -import { useSuspenseQuery } from "@tanstack/react-query"; - -import { MainHeroProgressCard } from "@/components/team/MainHeroProgressCard"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { teamQueries } from "@/features/team/query/team.queryKey"; - -export const Summary = () => { - const teamId = useTeamId(); - const { - data: { - teamName, - todayProgressPercent, - todayTodoCount, - overdueTodoCount, - isAdmin, - doneTodoCount, - }, - } = useSuspenseQuery(teamQueries.summary(teamId)); - - return ( - - ); -}; +import SummaryError from "./Error"; +import SummaryLoading from "./Loading"; +import SummaryComponent from "./Summary"; + +export const Summary = Object.assign(SummaryComponent, { + Error: SummaryError, + Loading: SummaryLoading, +}); diff --git a/src/features/goal/api.ts b/src/features/goal/api.ts index 61460360..ae5b0bd3 100644 --- a/src/features/goal/api.ts +++ b/src/features/goal/api.ts @@ -4,6 +4,7 @@ import type { CreateGoalResponse, CreatePersonalGoalInput, CreateTeamGoalInput, + GoalListCursor, GoalSummaryResponse, PersonalGoalListResponse, SortType, @@ -20,10 +21,31 @@ export const goalApi = { getPersonalGoalList: () => apiClient.get("/api/goals/personal"), - getTeamGoalList: (teamId: string, sort: SortType) => - apiClient.get(`/api/teams/${teamId}/goals`, { - params: { sort }, - }), + getTeamGoalList: ( + teamId: string, + sort: SortType, + cursor?: Partial, + ) => { + if ( + cursor && + ((cursor.cursorCreatedAt && cursor.cursorId == null) || + (!cursor.cursorCreatedAt && cursor.cursorId != null)) + ) { + throw new Error( + "cursorCreatedAt와 cursorId는 다음 페이지 요청 시 함께 전달해야 합니다.", + ); + } + + const params: Record = { sort }; + if (cursor?.cursorCreatedAt && cursor.cursorId != null) { + params.cursorCreatedAt = cursor.cursorCreatedAt; + params.cursorId = cursor.cursorId; + } + + return apiClient.get(`/api/teams/${teamId}/goals`, { + params, + }); + }, toggleFavorite: (goalId: number) => apiClient.post<{ success: boolean }>(`/api/goals/${goalId}/favorite`), diff --git a/src/features/goal/query/goal.queryKey.ts b/src/features/goal/query/goal.queryKey.ts index 86086aeb..293258e7 100644 --- a/src/features/goal/query/goal.queryKey.ts +++ b/src/features/goal/query/goal.queryKey.ts @@ -1,9 +1,9 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { STALE_TIME } from "@/constants/staleTime"; import { goalApi } from "../api"; -import { SortType } from "../types"; +import { GoalListCursor, SortType } from "../types"; export const goalQueries = { getPersonalGoalList: () => @@ -26,6 +26,22 @@ export const goalQueries = { staleTime: STALE_TIME.DEFAULT, }), + getTeamGoalListInfinite: (teamId: string, sort: SortType = "LATEST") => + infiniteQueryOptions({ + queryKey: ["team", teamId, "goals", "infinite", sort], + queryFn: async ({ pageParam }) => { + const response = await goalApi.getTeamGoalList( + teamId, + sort, + pageParam ?? undefined, + ); + return response.data; + }, + initialPageParam: null as GoalListCursor | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: STALE_TIME.DEFAULT, + }), + getSummary: (goalId: string) => queryOptions({ queryKey: ["goal", goalId, "summary"], diff --git a/src/features/goal/types.ts b/src/features/goal/types.ts index f7dc53cc..acbc5461 100644 --- a/src/features/goal/types.ts +++ b/src/features/goal/types.ts @@ -39,17 +39,27 @@ export interface PersonalGoalListResponse { export type SortType = "LATEST" | "OLDEST"; +export interface GoalListCursor { + cursorCreatedAt: string; + cursorId: number; +} + +export interface TeamGoalListItem { + goalId: number; + name: string; + progressPercent: number; + isFavorite: boolean; + createdAt: string; +} + export interface TeamGoalListResponse { success: boolean; code: string; message: string; data: { - items: { - goalId: number; - name: string; - progressPercent: number; - isFavorite: boolean; - }[]; + items: TeamGoalListItem[]; + nextCursor: GoalListCursor | null; + size: number; }; } diff --git a/src/mocks/handlers/goals.ts b/src/mocks/handlers/goals.ts index 19d5d7b0..d284f061 100644 --- a/src/mocks/handlers/goals.ts +++ b/src/mocks/handlers/goals.ts @@ -23,37 +23,126 @@ export const goalsHandlers = [ apiMock.get("*/api/teams/:teamId/goals", ({ params, request }) => { const teamId = Number(params.teamId); - const sort = new URL(request.url).searchParams.get("sort"); + const searchParams = new URL(request.url).searchParams; + const sort = searchParams.get("sort") ?? "LATEST"; + const cursorCreatedAt = searchParams.get("cursorCreatedAt"); + const cursorId = searchParams.get("cursorId"); + const PAGE_SIZE = 6; + + if ((cursorCreatedAt && !cursorId) || (!cursorCreatedAt && cursorId)) { + return HttpResponse.json( + { + success: false, + code: "BAD_REQUEST", + message: "cursorCreatedAt와 cursorId는 함께 전달해야 합니다.", + }, + { status: 400 }, + ); + } const items = [ { - goalId: 101, + goalId: 120, name: "디자인 시스템 완성", progressPercent: 82, isFavorite: true, + createdAt: "2026-03-31T09:12:45Z", }, { - goalId: 102, + goalId: 119, name: "컴포넌트 QA", progressPercent: 56, isFavorite: false, + createdAt: "2026-03-31T08:10:00Z", }, { - goalId: 103, + goalId: 118, name: "테스트 코드 보강", progressPercent: 31, isFavorite: false, + createdAt: "2026-03-30T21:15:00Z", + }, + { + goalId: 117, + name: "배포 파이프라인 고도화", + progressPercent: 49, + isFavorite: false, + createdAt: "2026-03-30T10:00:00Z", + }, + { + goalId: 116, + name: "로그 대시보드 개선", + progressPercent: 64, + isFavorite: true, + createdAt: "2026-03-29T16:42:00Z", + }, + { + goalId: 115, + name: "문서 자동화 구축", + progressPercent: 23, + isFavorite: false, + createdAt: "2026-03-29T08:30:00Z", + }, + { + goalId: 114, + name: "릴리즈 체크리스트 정비", + progressPercent: 70, + isFavorite: false, + createdAt: "2026-03-28T20:20:00Z", + }, + { + goalId: 113, + name: "회귀 테스트 시나리오 추가", + progressPercent: 18, + isFavorite: false, + createdAt: "2026-03-28T09:00:00Z", + }, + { + goalId: 112, + name: "성능 최적화 2차", + progressPercent: 40, + isFavorite: true, + createdAt: "2026-03-27T14:10:00Z", }, ]; - const sortedItems = sort === "OLDEST" ? [...items].reverse() : items; + const sortedItems = + sort === "OLDEST" + ? [...items].sort((a, b) => { + if (a.createdAt === b.createdAt) return a.goalId - b.goalId; + return a.createdAt.localeCompare(b.createdAt); + }) + : [...items].sort((a, b) => { + if (a.createdAt === b.createdAt) return b.goalId - a.goalId; + return b.createdAt.localeCompare(a.createdAt); + }); + + const startIndex = + cursorCreatedAt && cursorId + ? sortedItems.findIndex( + (item) => + item.createdAt === cursorCreatedAt && + item.goalId === Number(cursorId), + ) + 1 + : 0; + + const pagedItems = sortedItems.slice(startIndex, startIndex + PAGE_SIZE); + const lastItem = pagedItems[pagedItems.length - 1]; + const hasNext = startIndex + PAGE_SIZE < sortedItems.length; return HttpResponse.json({ success: true, code: "OK", message: `${Number.isNaN(teamId) ? "1" : String(teamId)}번 팀 목표 목록 조회 성공`, data: { - items: sortedItems, + items: pagedItems, + nextCursor: hasNext + ? { + cursorCreatedAt: lastItem.createdAt, + cursorId: lastItem.goalId, + } + : null, + size: PAGE_SIZE, }, }); }),
+ {label} +
+ + {value} + + {suffix}
+ 멤버 정보를 불러오지 못했어요 +
+ 팀 요약 정보를 불러오지 못했어요 +