From 16ff6cd6376aaddc22cb69f722b8594d645d9067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 16:22:30 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(#220):=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20=EB=B0=8F=20useInfiniteScro?= =?UTF-8?q?ll=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/team/[teamId]/page.tsx | 2 +- src/components/team/GoalList/index.tsx | 32 ++++--- src/features/goal/api.ts | 30 ++++++- src/features/goal/query/goal.queryKey.ts | 20 ++++- src/features/goal/types.ts | 22 +++-- src/mocks/handlers/goals.ts | 101 +++++++++++++++++++++-- 6 files changed, 176 insertions(+), 31 deletions(-) diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index b3406505..25162a0d 100644 --- a/src/app/taskmate/team/[teamId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/page.tsx @@ -5,7 +5,7 @@ import { Summary } from "@/components/team/Summary"; export default function Page() { return ( -
+
{/* @TODO: Loading & Error 처리 */} diff --git a/src/components/team/GoalList/index.tsx b/src/components/team/GoalList/index.tsx index 2fd9be09..79b19ba6 100644 --- a/src/components/team/GoalList/index.tsx +++ b/src/components/team/GoalList/index.tsx @@ -1,7 +1,6 @@ "use client"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Icon } from "@/components/common/Icon"; import { MainSecondaryProgressCard } from "@/components/team/MainSecondaryProgressCard"; @@ -9,25 +8,25 @@ import { Order } from "@/components/todo/List/Order"; import { goalQueries } from "@/features/goal/query/goal.queryKey"; import { SortType } from "@/features/goal/types"; import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll/useInfiniteScroll"; const sortTypeByLabel: Record = { 최신순: "LATEST", - "마감일 순": "OLDEST", + 오래된순: "OLDEST", }; -// @TODO: 목표 목록 조회 시 무한 스크롤 처리 (useSuspenseInfiniteQuery ) export const GoalList = () => { const teamId = useTeamId(); - const sortOptions = ["최신순", "마감일 순"]; + const sortOptions = ["최신순", "오래된순"]; const [selectedSort, setSelectedSort] = useState("최신순"); + const sort = sortTypeByLabel[selectedSort] ?? "LATEST"; - const { - data: { items: goalList }, - } = useSuspenseQuery( - goalQueries.getTeamGoalList( - teamId, - sortTypeByLabel[selectedSort] ?? "LATEST", - ), + const { ref, data, isFetchingNextPage } = useInfiniteScroll( + goalQueries.getTeamGoalListInfinite(teamId, sort), + ); + const goalList = useMemo( + () => data.pages.flatMap((page) => page.items), + [data.pages], ); return ( @@ -66,6 +65,15 @@ export const GoalList = () => { /> ))}
+
+ {isFetchingNextPage && ( +

+ 목표를 불러오는 중... +

+ )}
); }; 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, }, }); }), From ae3db2281c2b71713e2837be6a794cac0e357a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 16:34:39 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(#220):=20Error=20/=20Loading=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9D=BC=EB=95=8C=20UI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/team/[teamId]/page.tsx | 13 +++- src/components/team/GoalList/Error.tsx | 42 +++++++++++ src/components/team/GoalList/GoalList.tsx | 77 ++++++++++++++++++++ src/components/team/GoalList/Loading.tsx | 26 +++++++ src/components/team/GoalList/index.tsx | 87 +++-------------------- 5 files changed, 164 insertions(+), 81 deletions(-) create mode 100644 src/components/team/GoalList/Error.tsx create mode 100644 src/components/team/GoalList/GoalList.tsx create mode 100644 src/components/team/GoalList/Loading.tsx diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index 25162a0d..4e699ff6 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"; @@ -11,8 +13,15 @@ export default function Page() { - {/* @TODO: Loading & Error 처리 */} - + } + errorFallback={(error, onReset) => ( + + )} + > diff --git a/src/components/team/GoalList/Error.tsx b/src/components/team/GoalList/Error.tsx new file mode 100644 index 00000000..f51ef080 --- /dev/null +++ b/src/components/team/GoalList/Error.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Button from "@/components/common/Button/Button"; +import { Icon } from "@/components/common/Icon"; + +interface Props { + error: Error; + onReset: () => void; +} + +export default function GoalListError({ error, onReset }: Props) { + return ( +
+
+ +

+ 목표 +

+
+ +
+

+ 목표를 불러오지 못했어요 +

+

+ {error.message || "잠시 후 다시 시도해주세요."} +

+ +
+
+ ); +} diff --git a/src/components/team/GoalList/GoalList.tsx b/src/components/team/GoalList/GoalList.tsx new file mode 100644 index 00000000..41a9fc25 --- /dev/null +++ b/src/components/team/GoalList/GoalList.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { Icon } from "@/components/common/Icon"; +import { MainSecondaryProgressCard } from "@/components/team/MainSecondaryProgressCard"; +import { Order } from "@/components/todo/List/Order"; +import { goalQueries } from "@/features/goal/query/goal.queryKey"; +import { SortType } from "@/features/goal/types"; +import { useTeamId } from "@/features/team/hooks/useTeamId"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll/useInfiniteScroll"; + +const sortTypeByLabel: Record = { + 최신순: "LATEST", + 오래된순: "OLDEST", +}; + +export default function GoalList() { + const teamId = useTeamId(); + const sortOptions = ["최신순", "오래된순"]; + const [selectedSort, setSelectedSort] = useState("최신순"); + const sort = sortTypeByLabel[selectedSort] ?? "LATEST"; + + const { ref, data } = useInfiniteScroll( + goalQueries.getTeamGoalListInfinite(teamId, sort), + ); + const goalList = useMemo( + () => data.pages.flatMap((page) => page.items), + [data.pages], + ); + + return ( +
+
+
+ +

+ 목표 +

+ + {goalList.length}개 + +
+ + +
+ +
+
+ {goalList.map((goal) => ( + + ))} +
+ +
+
+
+ ); +} diff --git a/src/components/team/GoalList/Loading.tsx b/src/components/team/GoalList/Loading.tsx new file mode 100644 index 00000000..f23bb369 --- /dev/null +++ b/src/components/team/GoalList/Loading.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Icon } from "@/components/common/Icon"; +import Spinner from "@/components/common/Spinner"; + +export default function GoalListLoading() { + return ( +
+
+
+ +

+ 목표 +

+
+
+ +
+ +
+
+ ); +} diff --git a/src/components/team/GoalList/index.tsx b/src/components/team/GoalList/index.tsx index 79b19ba6..4deb27a0 100644 --- a/src/components/team/GoalList/index.tsx +++ b/src/components/team/GoalList/index.tsx @@ -1,79 +1,8 @@ -"use client"; - -import { useMemo, useState } from "react"; - -import { Icon } from "@/components/common/Icon"; -import { MainSecondaryProgressCard } from "@/components/team/MainSecondaryProgressCard"; -import { Order } from "@/components/todo/List/Order"; -import { goalQueries } from "@/features/goal/query/goal.queryKey"; -import { SortType } from "@/features/goal/types"; -import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { useInfiniteScroll } from "@/hooks/useInfiniteScroll/useInfiniteScroll"; - -const sortTypeByLabel: Record = { - 최신순: "LATEST", - 오래된순: "OLDEST", -}; - -export const GoalList = () => { - const teamId = useTeamId(); - const sortOptions = ["최신순", "오래된순"]; - const [selectedSort, setSelectedSort] = useState("최신순"); - const sort = sortTypeByLabel[selectedSort] ?? "LATEST"; - - const { ref, data, isFetchingNextPage } = useInfiniteScroll( - goalQueries.getTeamGoalListInfinite(teamId, sort), - ); - const goalList = useMemo( - () => data.pages.flatMap((page) => page.items), - [data.pages], - ); - - return ( -
-
-
- -

- 목표 -

- - {goalList.length}개 - -
- - -
- -
- {goalList.map((goal) => ( - - ))} -
-
- {isFetchingNextPage && ( -

- 목표를 불러오는 중... -

- )} -
- ); -}; +import GoalListError from "./Error"; +import GoalListComponent from "./GoalList"; +import GoalListLoading from "./Loading"; + +export const GoalList = Object.assign(GoalListComponent, { + Error: GoalListError, + Loading: GoalListLoading, +}); From 7d38365569865cda01d9edd094260b7ffe8331a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 16:48:31 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(#220):=20=EB=B0=98=EC=9D=91=ED=98=95?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/team/GoalList/GoalList.tsx | 2 +- src/components/team/MainSecondaryProgressCard.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/team/GoalList/GoalList.tsx b/src/components/team/GoalList/GoalList.tsx index 41a9fc25..c9fb5afc 100644 --- a/src/components/team/GoalList/GoalList.tsx +++ b/src/components/team/GoalList/GoalList.tsx @@ -53,7 +53,7 @@ export default function GoalList() {
-
+
{goalList.map((goal) => (
-

{title}

+

+ {title} +

{iconSrc ? ( Date: Tue, 14 Apr 2026 17:13:14 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(#220):=20=ED=8C=80=20=EB=82=B4=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=97=90=20Loading/Error/=EB=B0=98=EC=9D=91=ED=98=95?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/team/[teamId]/page.tsx | 11 ++- src/components/team/MemberList/Error.tsx | 42 +++++++++++ src/components/team/MemberList/Loading.tsx | 11 +++ src/components/team/MemberList/Member.tsx | 2 +- src/components/team/MemberList/MemberList.tsx | 65 ++++++++++++++++ src/components/team/MemberList/index.tsx | 74 ++----------------- 6 files changed, 136 insertions(+), 69 deletions(-) create mode 100644 src/components/team/MemberList/Error.tsx create mode 100644 src/components/team/MemberList/Loading.tsx create mode 100644 src/components/team/MemberList/MemberList.tsx diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index 4e699ff6..44ad9b1b 100644 --- a/src/app/taskmate/team/[teamId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/page.tsx @@ -25,8 +25,15 @@ export default function Page() { - {/* @TODO: Loading & Error 처리 */} - + } + errorFallback={(error, onReset) => ( + + )} + >
diff --git a/src/components/team/MemberList/Error.tsx b/src/components/team/MemberList/Error.tsx new file mode 100644 index 00000000..71026a0d --- /dev/null +++ b/src/components/team/MemberList/Error.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Button from "@/components/common/Button/Button"; +import { Icon } from "@/components/common/Icon"; + +interface Props { + error: Error; + onReset: () => 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({
+
+
+ +

+ 멤버 +

+ + {members.length}명 + +
+ + + + 팀 나가기 + + +
+ +
+ {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, +}); From 940de87f022d6872b1cf250d2c67deb15f59a7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 17:16:26 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(#220):=20=ED=8C=80=20=EB=82=B4=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=97=90=20=ED=8C=80=EB=82=98=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EB=A0=8C=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/team/MemberList/MemberList.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/team/MemberList/MemberList.tsx b/src/components/team/MemberList/MemberList.tsx index f3e410ed..d0366477 100644 --- a/src/components/team/MemberList/MemberList.tsx +++ b/src/components/team/MemberList/MemberList.tsx @@ -20,6 +20,9 @@ export default function MemberListComponent() { const { data: me } = useSuspenseQuery(userQueries.myInfo()); const formattedMembers = formatMemberList(members, me.id); + const isMeAdmin = members.some( + (member) => member.userId === me.id && member.role === "ADMIN", + ); return (
@@ -36,16 +39,19 @@ export default function MemberListComponent() { {members.length}명
- - - - 팀 나가기 - - + + {isMeAdmin && ( + + + + 팀 나가기 + + + )}
From 7125829ef6445c248cd7bbe749524d8ffd3fe1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 17:21:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(#220):=20Summary=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20/=20Loading=20/=20Error=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/team/[teamId]/page.tsx | 11 +++++- src/components/team/MainHeroProgressCard.tsx | 26 ++++++++----- src/components/team/Summary/Error.tsx | 29 +++++++++++++++ src/components/team/Summary/Loading.tsx | 11 ++++++ src/components/team/Summary/Summary.tsx | 31 ++++++++++++++++ src/components/team/Summary/index.tsx | 39 ++++---------------- 6 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 src/components/team/Summary/Error.tsx create mode 100644 src/components/team/Summary/Loading.tsx create mode 100644 src/components/team/Summary/Summary.tsx diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index 44ad9b1b..0f5072af 100644 --- a/src/app/taskmate/team/[teamId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/page.tsx @@ -8,8 +8,15 @@ import { Summary } from "@/components/team/Summary"; export default function Page() { return (
- {/* @TODO: Loading & Error 처리 */} - + } + errorFallback={(error, onReset) => ( + + )} + > diff --git a/src/components/team/MainHeroProgressCard.tsx b/src/components/team/MainHeroProgressCard.tsx index 985c6f7b..cb2b668a 100644 --- a/src/components/team/MainHeroProgressCard.tsx +++ b/src/components/team/MainHeroProgressCard.tsx @@ -38,11 +38,15 @@ function StatItem({ suffix: string; }) { return ( -
-

{label}

-

- {value} - +

+

+ {label} +

+

+ + {value} + + {suffix}

@@ -53,7 +57,7 @@ function StatItem({ const Character = () => { return (
-

{title}

+

+ {title} +

{isAdmin && (
-
+
= 80 && progress < 100 && (
@@ -232,7 +238,7 @@ export const MainHeroProgressCard = ({ height={24} className="shrink-0 object-contain" /> - + {statusLabel} 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, +}); From cac8bc186735711de93390f7f73e5010b2454283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 14 Apr 2026 17:27:34 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(#220):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20UI=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/team/[teamId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/taskmate/team/[teamId]/page.tsx b/src/app/taskmate/team/[teamId]/page.tsx index 0f5072af..79b97bdb 100644 --- a/src/app/taskmate/team/[teamId]/page.tsx +++ b/src/app/taskmate/team/[teamId]/page.tsx @@ -7,7 +7,7 @@ import { Summary } from "@/components/team/Summary"; export default function Page() { return ( -
+
} errorFallback={(error, onReset) => ( From 7a7115c677fa0b0cb14b8ab8336caaca31aeed4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Thu, 16 Apr 2026 16:43:00 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(#220):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EA=B0=AF=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=98=EB=AA=BB=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/team/GoalList/GoalList.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/team/GoalList/GoalList.tsx b/src/components/team/GoalList/GoalList.tsx index c9fb5afc..300c37af 100644 --- a/src/components/team/GoalList/GoalList.tsx +++ b/src/components/team/GoalList/GoalList.tsx @@ -24,6 +24,8 @@ export default function GoalList() { const { ref, data } = useInfiniteScroll( goalQueries.getTeamGoalListInfinite(teamId, sort), ); + + const size = data.pages[0].size; const goalList = useMemo( () => data.pages.flatMap((page) => page.items), [data.pages], @@ -41,7 +43,7 @@ export default function GoalList() { 목표 - {goalList.length}개 + {size}개