From 3fd1a1c6c10a27175f7984a1e1e9fdbdd0887533 Mon Sep 17 00:00:00 2001 From: hyojin Date: Thu, 30 Apr 2026 14:58:41 +0900 Subject: [PATCH 01/47] =?UTF-8?q?fix(#248):=20=EC=83=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20>=20=ED=95=A0=20=EC=9D=BC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?/=20=EC=95=8C=EB=A6=BC=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/notification/utils.ts | 2 +- .../NavigationBar/NotificationPopover/NotificationItem.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/notification/utils.ts b/src/features/notification/utils.ts index b9e3e08e..e0871342 100644 --- a/src/features/notification/utils.ts +++ b/src/features/notification/utils.ts @@ -5,7 +5,7 @@ export const formatNotificationType = (type: string) => { return "마감일 알림"; case "TEAM_TODO_CREATED": - return "새 알림"; + return "할 일 생성 알림"; } }; diff --git a/src/widgets/NavigationBar/NotificationPopover/NotificationItem.tsx b/src/widgets/NavigationBar/NotificationPopover/NotificationItem.tsx index 39190c8a..bfd2fb67 100644 --- a/src/widgets/NavigationBar/NotificationPopover/NotificationItem.tsx +++ b/src/widgets/NavigationBar/NotificationPopover/NotificationItem.tsx @@ -31,7 +31,7 @@ const NotificationItem = ({
From b35fd7b352f2fe2cbab2240a1be5a8b69a41c750 Mon Sep 17 00:00:00 2001 From: hyojin Date: Thu, 30 Apr 2026 15:17:54 +0900 Subject: [PATCH 02/47] =?UTF-8?q?feat(#248):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=EC=8B=9C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/notification/useNotificationSSE.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/features/notification/useNotificationSSE.ts b/src/features/notification/useNotificationSSE.ts index 39cf6dce..c290207f 100644 --- a/src/features/notification/useNotificationSSE.ts +++ b/src/features/notification/useNotificationSSE.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; +import { useToast } from "@/shared/hooks/useToast"; import { NotificationApi } from "./api"; @@ -18,6 +19,7 @@ const SSE_STREAM_URL = SSE_BASE_URL export function useNotificationSSE() { const queryClient = useQueryClient(); + const { toast } = useToast(); const esRef = useRef(null); const timerRef = useRef(null); @@ -59,6 +61,7 @@ export function useNotificationSSE() { es.addEventListener("NOTIFICATION", () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }); + toast({ title: "새로운 알림이 도착하였습니다." }); }); es.onerror = async () => { From f6a828441a24669b8956590a751632deb4d62c0b Mon Sep 17 00:00:00 2001 From: hyojin Date: Thu, 30 Apr 2026 23:08:14 +0900 Subject: [PATCH 03/47] =?UTF-8?q?refactor(#248):=20FSD=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/taskmate/layout.tsx | 2 +- .../dashboard/api/dashboard.api.ts} | 2 +- src/entities/dashboard/index.ts | 8 ++++ .../dashboard/types/dashboard.types.ts} | 0 .../notification/api/notification.api.ts} | 2 +- src/entities/notification/index.ts | 14 +++++++ .../notification/types/notification.types.ts} | 0 src/features/notification/index.ts | 7 ++++ .../{utils.ts => notification.utils.ts} | 41 ------------------- .../query/notificationInfiniteQueries.ts | 4 +- .../notification/useNotificationSSE.ts | 3 +- src/shared/utils/createPaginationOptions.ts | 37 +++++++++++++++++ .../NotificationPopover/NotificatioPanel.tsx | 6 +-- .../NotificationPopover.tsx | 2 +- .../FavoriteGoalsItem.tsx | 0 .../FavoriteGoalsSection.tsx | 2 +- .../home/FavoriteGoalsSection/index.ts | 1 + .../home/ProgressSection/ProgressSection.tsx | 5 +-- .../MainTodoItem.tsx | 0 .../TodoOverviewSection.tsx | 2 +- src/widgets/home/query/mainInfiniteQueries.ts | 2 +- 21 files changed, 82 insertions(+), 58 deletions(-) rename src/{widgets/home/api.ts => entities/dashboard/api/dashboard.api.ts} (70%) create mode 100644 src/entities/dashboard/index.ts rename src/{widgets/home/types.ts => entities/dashboard/types/dashboard.types.ts} (100%) rename src/{features/notification/api.ts => entities/notification/api/notification.api.ts} (97%) create mode 100644 src/entities/notification/index.ts rename src/{features/notification/types.ts => entities/notification/types/notification.types.ts} (100%) create mode 100644 src/features/notification/index.ts rename src/features/notification/{utils.ts => notification.utils.ts} (56%) create mode 100644 src/shared/utils/createPaginationOptions.ts rename src/widgets/home/{ => FavoriteGoalsSection}/FavoriteGoalsItem.tsx (100%) rename src/widgets/home/{ => FavoriteGoalsSection}/FavoriteGoalsSection.tsx (98%) create mode 100644 src/widgets/home/FavoriteGoalsSection/index.ts rename src/widgets/home/{MainTodoItem => TodoOverviewSection}/MainTodoItem.tsx (100%) diff --git a/src/app/taskmate/layout.tsx b/src/app/taskmate/layout.tsx index 96c2db5b..d2d70dbe 100644 --- a/src/app/taskmate/layout.tsx +++ b/src/app/taskmate/layout.tsx @@ -1,4 +1,4 @@ -import NotificationSubscriber from "@/features/notification/NotificationSubscriber"; +import { NotificationSubscriber } from "@/features/notification"; import { NavigationBar } from "@/widgets/NavigationBar"; import NavigationBarProvider from "@/widgets/NavigationBar/provider"; diff --git a/src/widgets/home/api.ts b/src/entities/dashboard/api/dashboard.api.ts similarity index 70% rename from src/widgets/home/api.ts rename to src/entities/dashboard/api/dashboard.api.ts index e628cf37..311aadd5 100644 --- a/src/widgets/home/api.ts +++ b/src/entities/dashboard/api/dashboard.api.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/shared/lib/api/client"; -import { ProgressSuccessResponse } from "./types"; +import { ProgressSuccessResponse } from "../types/dashboard.types"; export const progressApi = { read: () => apiClient.get("/api/main/progress"), diff --git a/src/entities/dashboard/index.ts b/src/entities/dashboard/index.ts new file mode 100644 index 00000000..f6ae9871 --- /dev/null +++ b/src/entities/dashboard/index.ts @@ -0,0 +1,8 @@ +export { progressApi } from "./api/dashboard.api"; +export type { + ProgressData, + ProgressErrorResponse, + ProgressItem, + ProgressResponse, + ProgressSuccessResponse, +} from "./types/dashboard.types"; diff --git a/src/widgets/home/types.ts b/src/entities/dashboard/types/dashboard.types.ts similarity index 100% rename from src/widgets/home/types.ts rename to src/entities/dashboard/types/dashboard.types.ts diff --git a/src/features/notification/api.ts b/src/entities/notification/api/notification.api.ts similarity index 97% rename from src/features/notification/api.ts rename to src/entities/notification/api/notification.api.ts index d5cd7c53..cb4aa8b4 100644 --- a/src/features/notification/api.ts +++ b/src/entities/notification/api/notification.api.ts @@ -5,7 +5,7 @@ import { NotificationReadAllSuccessResponse, NotificationReadSuccessResponse, NotificationSSETokenSuccessResponse, -} from "./types"; +} from "../types/notification.types"; type GetParams = { cursorId?: number; diff --git a/src/entities/notification/index.ts b/src/entities/notification/index.ts new file mode 100644 index 00000000..d613f7d1 --- /dev/null +++ b/src/entities/notification/index.ts @@ -0,0 +1,14 @@ +export { NotificationApi } from "./api/notification.api"; +export type { + NotificationItem, + NotificationListErrorResponse, + NotificationListSuccessResponse, + NotificationReadAllErrorResponse, + NotificationReadAllSuccessResponse, + NotificationReadErrorResponse, + NotificationReadSuccessResponse, + NotificationSSEData, + NotificationSSEErrorResponse, + NotificationSSEEvent, + NotificationSSETokenSuccessResponse, +} from "./types/notification.types"; diff --git a/src/features/notification/types.ts b/src/entities/notification/types/notification.types.ts similarity index 100% rename from src/features/notification/types.ts rename to src/entities/notification/types/notification.types.ts diff --git a/src/features/notification/index.ts b/src/features/notification/index.ts new file mode 100644 index 00000000..e879c7e8 --- /dev/null +++ b/src/features/notification/index.ts @@ -0,0 +1,7 @@ +export { + buildNotificationUrl, + formatNotificationType, + formatRelativeTime, +} from "./notification.utils"; +export { default as NotificationSubscriber } from "./NotificationSubscriber"; +export { notificationInfiniteQueries } from "./query/notificationInfiniteQueries"; diff --git a/src/features/notification/utils.ts b/src/features/notification/notification.utils.ts similarity index 56% rename from src/features/notification/utils.ts rename to src/features/notification/notification.utils.ts index e0871342..f03240fb 100644 --- a/src/features/notification/utils.ts +++ b/src/features/notification/notification.utils.ts @@ -51,44 +51,3 @@ export const buildNotificationUrl = ({ goalId, teamId }: BuildUrlParams) => { return `/taskmate/personal/goal/${goalId}`; }; - -// 무한스크롤 옵션 객체 생성 유틸 함수 -// todo: main utils에 중복 정의 하나의 공통유틸로 분리 - -type CursorParams = { - size?: number; - cursorId?: number; - cursorCreatedAt?: string; -}; - -type CursorPage = { - hasNext: boolean; - nextCursorId?: number; - nextCursorCreatedAt?: string; -}; - -export function createPaginationOptions< - Params extends CursorParams = CursorParams, - Page extends CursorPage = CursorPage, ->(apiKey: string, apiFunction: (param: Params) => Promise<{ data: Page }>) { - const size = 20; - - return { - queryKey: [apiKey], - initialPageParam: { size } as Params, - - queryFn: async ({ pageParam }: { pageParam: Params }) => { - const res = await apiFunction(pageParam); - return res.data; - }, - - getNextPageParam: (lastPage: Page): Params | undefined => - lastPage.hasNext - ? ({ - size, - cursorId: lastPage.nextCursorId, - cursorCreatedAt: lastPage.nextCursorCreatedAt, - } as Params) - : undefined, - }; -} diff --git a/src/features/notification/query/notificationInfiniteQueries.ts b/src/features/notification/query/notificationInfiniteQueries.ts index 10e0c82e..4c8c60ed 100644 --- a/src/features/notification/query/notificationInfiniteQueries.ts +++ b/src/features/notification/query/notificationInfiniteQueries.ts @@ -1,5 +1,5 @@ -import { NotificationApi } from "../api"; -import { createPaginationOptions } from "../utils"; +import { NotificationApi } from "@/entities/notification"; +import { createPaginationOptions } from "@/shared/utils/createPaginationOptions"; export const notificationInfiniteQueries = { notificationsInfinite: () => diff --git a/src/features/notification/useNotificationSSE.ts b/src/features/notification/useNotificationSSE.ts index c290207f..7d25322c 100644 --- a/src/features/notification/useNotificationSSE.ts +++ b/src/features/notification/useNotificationSSE.ts @@ -4,10 +4,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; +import { NotificationApi } from "@/entities/notification"; import { useToast } from "@/shared/hooks/useToast"; -import { NotificationApi } from "./api"; - const SSE_BASE_URL = (process.env.NEXT_PUBLIC_SSE_BASE_URL ?? "").replace( /\/$/, "", diff --git a/src/shared/utils/createPaginationOptions.ts b/src/shared/utils/createPaginationOptions.ts new file mode 100644 index 00000000..67ba7296 --- /dev/null +++ b/src/shared/utils/createPaginationOptions.ts @@ -0,0 +1,37 @@ +type CursorParams = { + size?: number; + cursorId?: number; + cursorCreatedAt?: string; +}; + +type CursorPage = { + hasNext: boolean; + nextCursorId?: number; + nextCursorCreatedAt?: string; +}; + +export function createPaginationOptions< + Params extends CursorParams = CursorParams, + Page extends CursorPage = CursorPage, +>(apiKey: string, apiFunction: (param: Params) => Promise<{ data: Page }>) { + const size = 20; + + return { + queryKey: [apiKey], + initialPageParam: { size } as Params, + + queryFn: async ({ pageParam }: { pageParam: Params }) => { + const res = await apiFunction(pageParam); + return res.data; + }, + + getNextPageParam: (lastPage: Page): Params | undefined => + lastPage.hasNext + ? ({ + size, + cursorId: lastPage.nextCursorId, + cursorCreatedAt: lastPage.nextCursorCreatedAt, + } as Params) + : undefined, + }; +} diff --git a/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx b/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx index bf68f0c1..fef0d19b 100644 --- a/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx +++ b/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx @@ -1,13 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; import React from "react"; -import { NotificationApi } from "@/features/notification/api"; -import { notificationInfiniteQueries } from "@/features/notification/query/notificationInfiniteQueries"; +import { NotificationApi } from "@/entities/notification"; import { buildNotificationUrl, formatNotificationType, formatRelativeTime, -} from "@/features/notification/utils"; + notificationInfiniteQueries, +} from "@/features/notification"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteScroll"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; diff --git a/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx b/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx index 8ed1a150..8f50ee63 100644 --- a/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx +++ b/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx @@ -4,7 +4,7 @@ import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { cva } from "class-variance-authority"; import { useEffect, useRef, useState } from "react"; -import { notificationInfiniteQueries } from "@/features/notification/query/notificationInfiniteQueries"; +import { notificationInfiniteQueries } from "@/features/notification"; import { Icon } from "@/shared/ui/Icon"; import { cn } from "@/shared/utils/styles/cn"; diff --git a/src/widgets/home/FavoriteGoalsItem.tsx b/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsItem.tsx similarity index 100% rename from src/widgets/home/FavoriteGoalsItem.tsx rename to src/widgets/home/FavoriteGoalsSection/FavoriteGoalsItem.tsx diff --git a/src/widgets/home/FavoriteGoalsSection.tsx b/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx similarity index 98% rename from src/widgets/home/FavoriteGoalsSection.tsx rename to src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx index 55e6d6cc..1bbcb9ad 100644 --- a/src/widgets/home/FavoriteGoalsSection.tsx +++ b/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx @@ -8,7 +8,7 @@ import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteS import Button from "@/shared/ui/Button/Button/Button"; import { Icon } from "@/shared/ui/Icon"; import { Spacing } from "@/shared/ui/Spacing"; -import { FavoriteGoalsItem } from "@/widgets/home/FavoriteGoalsItem"; +import { FavoriteGoalsItem } from "@/widgets/home/FavoriteGoalsSection/FavoriteGoalsItem"; import { mainInfiniteQueries } from "@/widgets/home/query/mainInfiniteQueries"; export function FavoriteGoalsSection() { diff --git a/src/widgets/home/FavoriteGoalsSection/index.ts b/src/widgets/home/FavoriteGoalsSection/index.ts new file mode 100644 index 00000000..70680bcb --- /dev/null +++ b/src/widgets/home/FavoriteGoalsSection/index.ts @@ -0,0 +1 @@ +export { FavoriteGoalsSection } from "./FavoriteGoalsSection"; diff --git a/src/widgets/home/ProgressSection/ProgressSection.tsx b/src/widgets/home/ProgressSection/ProgressSection.tsx index 527801ec..35c63fb5 100644 --- a/src/widgets/home/ProgressSection/ProgressSection.tsx +++ b/src/widgets/home/ProgressSection/ProgressSection.tsx @@ -4,12 +4,11 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; +import { ProgressItem } from "@/entities/dashboard"; +import { progressApi } from "@/entities/dashboard"; import { CircularProgress } from "@/shared/ui/CircularProgress"; import { Icon } from "@/shared/ui/Icon"; import Slider from "@/widgets/home/Slider/Slider"; -import { ProgressItem } from "@/widgets/home/types"; - -import { progressApi } from "../api"; // @TODO: Loading/Error 상태 처리 필요 // @TODO: 데이터가 없을 때 처리 확인 필요 diff --git a/src/widgets/home/MainTodoItem/MainTodoItem.tsx b/src/widgets/home/TodoOverviewSection/MainTodoItem.tsx similarity index 100% rename from src/widgets/home/MainTodoItem/MainTodoItem.tsx rename to src/widgets/home/TodoOverviewSection/MainTodoItem.tsx diff --git a/src/widgets/home/TodoOverviewSection/TodoOverviewSection.tsx b/src/widgets/home/TodoOverviewSection/TodoOverviewSection.tsx index 1f302f4b..2eef12cc 100644 --- a/src/widgets/home/TodoOverviewSection/TodoOverviewSection.tsx +++ b/src/widgets/home/TodoOverviewSection/TodoOverviewSection.tsx @@ -7,7 +7,7 @@ import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteS import { Icon } from "@/shared/ui/Icon"; import { mainInfiniteQueries } from "@/widgets/home/query/mainInfiniteQueries"; -import MainTodoItem from "../MainTodoItem/MainTodoItem"; +import MainTodoItem from "./MainTodoItem"; export default function TodoOverviewSection() { // 최근 등록한 할 일 - 무한 스크롤 diff --git a/src/widgets/home/query/mainInfiniteQueries.ts b/src/widgets/home/query/mainInfiniteQueries.ts index a54d969b..725d857b 100644 --- a/src/widgets/home/query/mainInfiniteQueries.ts +++ b/src/widgets/home/query/mainInfiniteQueries.ts @@ -1,6 +1,6 @@ import { goalQueryOptions } from "@/entities/goal"; import { todoApi } from "@/entities/todo"; -import { createPaginationOptions } from "@/features/notification/utils"; +import { createPaginationOptions } from "@/shared/utils/createPaginationOptions"; export const mainInfiniteQueries = { favoriteGoalsInfinite: () => goalQueryOptions.getFavoriteGoalListInfinite(), From 8fdd47c9c4b68b468ef689ed50dcc5f0eb66110e Mon Sep 17 00:00:00 2001 From: hyojin Date: Fri, 1 May 2026 09:03:43 +0900 Subject: [PATCH 04/47] refactor(#248): REACT QUERY + MSW --- .../team/[teamId]/management/page.tsx | 26 +-- src/entities/dashboard/index.ts | 1 + .../dashboard/query/dashboard.queryOptions.ts | 17 ++ src/entities/goal/api/api.test.ts | 211 ------------------ .../notification/api/notification.api.test.ts | 0 .../notification/api/notification.api.ts | 2 +- src/entities/notification/index.ts | 2 + .../query/notification.queryOptions.ts | 26 +++ src/entities/team/index.ts | 1 + .../team/query/management.queryOptions.ts | 27 +++ src/features/management/index.ts | 7 + .../{team => management}/mock/management.ts | 9 +- .../management/model/management.model.ts | 11 + .../mutation/useDeleteMemberMutation.ts | 25 +++ .../mutation/useDeleteTeamMutation.ts | 18 ++ .../mutation/useInviteMemberMutation.ts | 19 ++ .../mutation/useUpdateMemberRoleMutation.ts | 27 +++ .../mutation/useUpdateTeamNameMutation.ts | 24 ++ src/features/notification/index.ts | 3 +- .../notification/mock/notification.ts | 159 +++++++++++++ .../useReadAllNotificationMutation.ts | 21 ++ .../mutation/useReadNotificationMutation.ts | 21 ++ .../query/notificationInfiniteQueries.ts | 7 - src/features/team/management.utils.ts | 26 --- src/shared/mock/handlers.ts | 6 +- src/shared/utils/createPaginationOptions.ts | 37 --- .../NotificationPopover/NotificatioPanel.tsx | 45 ++-- .../NotificationPopover.tsx | 4 +- .../FavoriteGoalsSection.tsx | 4 +- .../home/ProgressSection/ProgressSection.tsx | 129 +++++------ .../TodoOverviewSection.tsx | 6 +- src/widgets/home/mock/home.ts | 104 +++++++++ src/widgets/home/query/home.queryOptions.ts | 62 +++++ src/widgets/home/query/mainInfiniteQueries.ts | 11 - src/widgets/management/DeleteModal.tsx | 53 +++-- src/widgets/management/InviteModal.tsx | 73 +++--- src/widgets/management/MemberList.tsx | 161 ++++++------- src/widgets/management/TeamNameEditor.tsx | 54 ++--- src/widgets/management/index.ts | 7 + 39 files changed, 826 insertions(+), 620 deletions(-) create mode 100644 src/entities/dashboard/query/dashboard.queryOptions.ts delete mode 100644 src/entities/goal/api/api.test.ts create mode 100644 src/entities/notification/api/notification.api.test.ts create mode 100644 src/entities/notification/query/notification.queryOptions.ts create mode 100644 src/entities/team/query/management.queryOptions.ts create mode 100644 src/features/management/index.ts rename src/features/{team => management}/mock/management.ts (98%) create mode 100644 src/features/management/model/management.model.ts create mode 100644 src/features/management/mutation/useDeleteMemberMutation.ts create mode 100644 src/features/management/mutation/useDeleteTeamMutation.ts create mode 100644 src/features/management/mutation/useInviteMemberMutation.ts create mode 100644 src/features/management/mutation/useUpdateMemberRoleMutation.ts create mode 100644 src/features/management/mutation/useUpdateTeamNameMutation.ts create mode 100644 src/features/notification/mock/notification.ts create mode 100644 src/features/notification/mutation/useReadAllNotificationMutation.ts create mode 100644 src/features/notification/mutation/useReadNotificationMutation.ts delete mode 100644 src/features/notification/query/notificationInfiniteQueries.ts delete mode 100644 src/features/team/management.utils.ts delete mode 100644 src/shared/utils/createPaginationOptions.ts create mode 100644 src/widgets/home/mock/home.ts create mode 100644 src/widgets/home/query/home.queryOptions.ts delete mode 100644 src/widgets/home/query/mainInfiniteQueries.ts create mode 100644 src/widgets/management/index.ts diff --git a/src/app/taskmate/team/[teamId]/management/page.tsx b/src/app/taskmate/team/[teamId]/management/page.tsx index 5d4aa064..add1295a 100644 --- a/src/app/taskmate/team/[teamId]/management/page.tsx +++ b/src/app/taskmate/team/[teamId]/management/page.tsx @@ -3,8 +3,9 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { inviteApi, teamDetailApi } from "@/entities/team/api/management.api"; +import { teamDetailApi } from "@/entities/team/api/management.api"; import { useOverlay } from "@/shared/hooks/useOverlay"; +import AsyncBoundary from "@/shared/ui/AsyncBoundary"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; import DeleteModal from "@/widgets/management/DeleteModal"; import ErrorModal from "@/widgets/management/ErrorModal"; @@ -22,15 +23,7 @@ const TeamManagement = () => { const [errorModalOpen, setErrorModalOpen] = useState(false); const handleOpenInvite = () => { - open( - "invite-modal", - close()} - onSubmitInvite={async (email: string) => { - await inviteApi.create(teamId, email); // string 전달 - }} - />, - ); + open("invite-modal", close()} />); }; const handleOpenDelete = () => { @@ -38,11 +31,6 @@ const TeamManagement = () => { "delete-modal", close()} - onSubmitDelete={async () => { - await teamDetailApi.delete(Number(teamId)); - close(); - router.replace("/taskmate"); - }} onError={(message) => { setErrorMessage(message); setErrorModalOpen(true); @@ -80,8 +68,12 @@ const TeamManagement = () => { 팀 정보 수정
- - + error
}> + + + error
}> + + + queryOptions({ + queryKey: ["dashboard", "progress"], + queryFn: async () => { + const response = await progressApi.read(); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), +}; diff --git a/src/entities/goal/api/api.test.ts b/src/entities/goal/api/api.test.ts deleted file mode 100644 index 971cdc2c..00000000 --- a/src/entities/goal/api/api.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { apiClient } from "@/shared/lib/api/client"; - -import { goalApi } from "./api"; - -jest.mock("@/shared/lib/api/client", () => ({ - apiClient: { - get: jest.fn(), - post: jest.fn(), - patch: jest.fn(), - delete: jest.fn(), - }, -})); - -const mockApiClient = apiClient as jest.Mocked; - -const makeResponse = (data: T) => ({ - success: true, - code: "OK", - message: "success", - data, - timestamp: "2026-01-01T00:00:00Z", -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe("goalApi", () => { - describe("createGoal", () => { - test("POST /api/goals를 호출한다", async () => { - mockApiClient.post.mockResolvedValue(makeResponse({ success: true })); - - await goalApi.createGoal({ - name: "목표", - dueDate: "2026-12-31", - type: "PERSONAL", - }); - - expect(mockApiClient.post).toHaveBeenCalledWith( - "/api/goals", - expect.objectContaining({ name: "목표", dueDate: "2026-12-31" }), - ); - }); - }); - - describe("getPersonalGoalList", () => { - test("GET /api/goals/personal을 호출하고 data를 반환한다", async () => { - const mockData = [{ goalId: 1, goalName: "알고리즘 풀기" }]; - mockApiClient.get.mockResolvedValue(makeResponse(mockData)); - - const result = await goalApi.getPersonalGoalList(); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/goals/personal"); - expect(result.data).toEqual(mockData); - }); - }); - - describe("getTeamGoalList", () => { - test("cursor 없이 sort만 파라미터로 전달한다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], nextCursor: null, size: 6 }), - ); - - await goalApi.getTeamGoalList("42", "LATEST"); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { - params: { sort: "LATEST" }, - }); - }); - - test("cursorCreatedAt와 cursorId가 모두 있으면 파라미터에 포함한다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], nextCursor: null, size: 6 }), - ); - - await goalApi.getTeamGoalList("42", "OLDEST", { - cursorCreatedAt: "2026-03-31T09:00:00Z", - cursorId: 120, - }); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { - params: { - sort: "OLDEST", - cursorCreatedAt: "2026-03-31T09:00:00Z", - cursorId: 120, - }, - }); - }); - - test("cursorCreatedAt만 있고 cursorId가 없으면 cursor 파라미터를 포함하지 않는다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], nextCursor: null, size: 6 }), - ); - - await goalApi.getTeamGoalList("42", "LATEST", { - cursorCreatedAt: "2026-03-31T09:00:00Z", - }); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { - params: { sort: "LATEST" }, - }); - }); - - test("cursorId만 있고 cursorCreatedAt가 없으면 cursor 파라미터를 포함하지 않는다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], nextCursor: null, size: 6 }), - ); - - await goalApi.getTeamGoalList("42", "LATEST", { cursorId: 120 }); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/teams/42/goals", { - params: { sort: "LATEST" }, - }); - }); - }); - - describe("toggleFavorite", () => { - test("POST /api/goals/:goalId/favorite를 호출한다", async () => { - mockApiClient.post.mockResolvedValue(makeResponse({ success: true })); - - const result = await goalApi.toggleFavorite("99"); - - expect(mockApiClient.post).toHaveBeenCalledWith("/api/goals/99/favorite"); - expect(result.data).toEqual({ success: true }); - }); - }); - - describe("getSummary", () => { - test("GET /api/goals/:goalId/summary를 호출하고 data를 반환한다", async () => { - const summaryData = { - goalId: 1, - goalName: "디자인 시스템", - dueDate: "2026-12-31", - dDay: 42, - progressPercent: 68, - }; - mockApiClient.get.mockResolvedValue(makeResponse(summaryData)); - - const result = await goalApi.getSummary("1"); - - expect(mockApiClient.get).toHaveBeenCalledWith("/api/goals/1/summary"); - expect(result.data).toEqual(summaryData); - }); - }); - - describe("deleteGoal", () => { - test("DELETE /api/goals/:goalId를 호출한다", async () => { - mockApiClient.delete.mockResolvedValue(makeResponse(null)); - - await goalApi.deleteGoal("10"); - - expect(mockApiClient.delete).toHaveBeenCalledWith("/api/goals/10"); - }); - }); - - describe("updateGoal", () => { - test("PATCH /api/goals/:goalId를 body와 함께 호출한다", async () => { - mockApiClient.patch.mockResolvedValue( - makeResponse({ id: 10, name: "수정된 목표", dueDate: "2026-06-30" }), - ); - - await goalApi.updateGoal("10", { - name: "수정된 목표", - dueDate: "2026-06-30", - }); - - expect(mockApiClient.patch).toHaveBeenCalledWith("/api/goals/10", { - name: "수정된 목표", - dueDate: "2026-06-30", - }); - }); - }); - - describe("getFavoriteGoalList", () => { - test("params 없이 호출하면 빈 params로 요청한다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], hasNext: false }), - ); - - await goalApi.getFavoriteGoalList(); - - expect(mockApiClient.get).toHaveBeenCalledWith( - "/api/main/favorite-goals", - { params: {} }, - ); - }); - - test("cursor params를 전달하면 함께 요청한다", async () => { - mockApiClient.get.mockResolvedValue( - makeResponse({ items: [], hasNext: false }), - ); - - await goalApi.getFavoriteGoalList({ - cursorId: 5, - cursorCreatedAt: "2026-03-31T09:00:00Z", - size: 20, - }); - - expect(mockApiClient.get).toHaveBeenCalledWith( - "/api/main/favorite-goals", - { - params: { - cursorId: 5, - cursorCreatedAt: "2026-03-31T09:00:00Z", - size: 20, - }, - }, - ); - }); - }); -}); diff --git a/src/entities/notification/api/notification.api.test.ts b/src/entities/notification/api/notification.api.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/entities/notification/api/notification.api.ts b/src/entities/notification/api/notification.api.ts index cb4aa8b4..c6f7e575 100644 --- a/src/entities/notification/api/notification.api.ts +++ b/src/entities/notification/api/notification.api.ts @@ -7,7 +7,7 @@ import { NotificationSSETokenSuccessResponse, } from "../types/notification.types"; -type GetParams = { +export type GetParams = { cursorId?: number; cursorCreatedAt?: string; size?: number; diff --git a/src/entities/notification/index.ts b/src/entities/notification/index.ts index d613f7d1..c71e4921 100644 --- a/src/entities/notification/index.ts +++ b/src/entities/notification/index.ts @@ -1,4 +1,6 @@ +export type { GetParams } from "./api/notification.api"; export { NotificationApi } from "./api/notification.api"; +export { notificationQueryOptions } from "./query/notification.queryOptions"; export type { NotificationItem, NotificationListErrorResponse, diff --git a/src/entities/notification/query/notification.queryOptions.ts b/src/entities/notification/query/notification.queryOptions.ts new file mode 100644 index 00000000..2230c4c4 --- /dev/null +++ b/src/entities/notification/query/notification.queryOptions.ts @@ -0,0 +1,26 @@ +import { infiniteQueryOptions } from "@tanstack/react-query"; + +import { STALE_TIME } from "@/shared/constants/query/staleTime"; + +import { GetParams, NotificationApi } from "../api/notification.api"; + +export const notificationQueryOptions = { + notificationsInfinite: () => + infiniteQueryOptions({ + queryKey: ["notifications"], + queryFn: async ({ pageParam }: { pageParam: GetParams }) => { + const response = await NotificationApi.get(pageParam); + return response.data; + }, + initialPageParam: { size: 20 } as GetParams, + getNextPageParam: (lastPage): GetParams | undefined => + lastPage.hasNext + ? { + size: 20, + cursorId: lastPage.nextCursorId, + cursorCreatedAt: lastPage.nextCursorCreatedAt, + } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), +}; diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index ec946b1d..be2ea66c 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -1,6 +1,7 @@ export { teamApi } from "./api/team.api"; export type { CreateTeamInput } from "./model/team.model"; export { createTeamSchema } from "./model/team.model"; +export { managementQueryOptions } from "./query/management.queryOptions"; export { teamQueryOptions } from "./query/team.queryOptions"; export type { TeamInvitationDetail } from "./types/invitation.types"; export type { Member, MemberRole } from "./types/team.types"; diff --git a/src/entities/team/query/management.queryOptions.ts b/src/entities/team/query/management.queryOptions.ts new file mode 100644 index 00000000..3dd8bce8 --- /dev/null +++ b/src/entities/team/query/management.queryOptions.ts @@ -0,0 +1,27 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { STALE_TIME } from "@/shared/constants/query/staleTime"; + +import { memberListApi, teamDetailApi } from "../api/management.api"; + +export const managementQueryOptions = { + teamDetail: (teamId: number) => + queryOptions({ + queryKey: ["management", teamId, "detail"], + queryFn: async () => { + const response = await teamDetailApi.read(teamId); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), + + memberList: (teamId: number) => + queryOptions({ + queryKey: ["management", teamId, "memberList"], + queryFn: async () => { + const response = await memberListApi.read(teamId); + return response.data; + }, + staleTime: STALE_TIME.DEFAULT, + }), +}; diff --git a/src/features/management/index.ts b/src/features/management/index.ts new file mode 100644 index 00000000..b95a2e08 --- /dev/null +++ b/src/features/management/index.ts @@ -0,0 +1,7 @@ +export type { InviteEmailInput } from "./model/management.model"; +export { inviteEmailSchema } from "./model/management.model"; +export { useDeleteMemberMutation } from "./mutation/useDeleteMemberMutation"; +export { useDeleteTeamMutation } from "./mutation/useDeleteTeamMutation"; +export { useInviteMemberMutation } from "./mutation/useInviteMemberMutation"; +export { useUpdateMemberRoleMutation } from "./mutation/useUpdateMemberRoleMutation"; +export { useUpdateTeamNameMutation } from "./mutation/useUpdateTeamNameMutation"; diff --git a/src/features/team/mock/management.ts b/src/features/management/mock/management.ts similarity index 98% rename from src/features/team/mock/management.ts rename to src/features/management/mock/management.ts index d2154f87..b20cc1ff 100644 --- a/src/features/team/mock/management.ts +++ b/src/features/management/mock/management.ts @@ -2,7 +2,6 @@ import { HttpResponse } from "msw"; import { apiMock } from "@/shared/mock/apiMock"; -// @TODO: ? const now = "2026-04-02T00:00:00Z"; const teamDetail = { @@ -13,7 +12,7 @@ const teamDetail = { updatedAt: now, }; -const members = [ +const INITIAL_MEMBERS = [ { id: 1, userId: 101, @@ -151,6 +150,12 @@ const members = [ }, ]; +const members = [...INITIAL_MEMBERS]; + +export const resetMembers = () => { + members.splice(0, members.length, ...INITIAL_MEMBERS); +}; + export const managementHandler = [ // 팀 상세 조회 apiMock.get("/api/teams/:teamId", ({ params }) => { diff --git a/src/features/management/model/management.model.ts b/src/features/management/model/management.model.ts new file mode 100644 index 00000000..f8e87e28 --- /dev/null +++ b/src/features/management/model/management.model.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +export const inviteEmailSchema = z.object({ + email: z + .string() + .trim() + .min(1, "이메일을 입력해주세요.") + .email("올바른 이메일 형식이 아닙니다."), +}); + +export type InviteEmailInput = z.infer; diff --git a/src/features/management/mutation/useDeleteMemberMutation.ts b/src/features/management/mutation/useDeleteMemberMutation.ts new file mode 100644 index 00000000..2b25c358 --- /dev/null +++ b/src/features/management/mutation/useDeleteMemberMutation.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { teamApi } from "@/entities/team"; + +type UseDeleteMemberMutationOptions = { + teamId: number; + onSuccess?: () => void; +}; + +export function useDeleteMemberMutation({ + teamId, + onSuccess, +}: UseDeleteMemberMutationOptions) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (memberId: number) => teamApi.deleteMember(teamId, memberId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["management", teamId, "memberList"], + }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/management/mutation/useDeleteTeamMutation.ts b/src/features/management/mutation/useDeleteTeamMutation.ts new file mode 100644 index 00000000..60565507 --- /dev/null +++ b/src/features/management/mutation/useDeleteTeamMutation.ts @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; + +import { teamApi } from "@/entities/team"; + +type UseDeleteTeamMutationOptions = { + onSuccess?: () => void; +}; + +export function useDeleteTeamMutation({ + onSuccess, +}: UseDeleteTeamMutationOptions = {}) { + return useMutation({ + mutationFn: (teamId: number) => teamApi.deleteTeam(teamId), + onSuccess: () => { + onSuccess?.(); + }, + }); +} diff --git a/src/features/management/mutation/useInviteMemberMutation.ts b/src/features/management/mutation/useInviteMemberMutation.ts new file mode 100644 index 00000000..ef010302 --- /dev/null +++ b/src/features/management/mutation/useInviteMemberMutation.ts @@ -0,0 +1,19 @@ +import { useMutation } from "@tanstack/react-query"; + +import { teamApi } from "@/entities/team"; + +type UseInviteMemberMutationOptions = { + onSuccess?: () => void; +}; + +export function useInviteMemberMutation({ + onSuccess, +}: UseInviteMemberMutationOptions = {}) { + return useMutation({ + mutationFn: ({ teamId, email }: { teamId: string; email: string }) => + teamApi.createInvitation(teamId, email), + onSuccess: () => { + onSuccess?.(); + }, + }); +} diff --git a/src/features/management/mutation/useUpdateMemberRoleMutation.ts b/src/features/management/mutation/useUpdateMemberRoleMutation.ts new file mode 100644 index 00000000..4ab17b4b --- /dev/null +++ b/src/features/management/mutation/useUpdateMemberRoleMutation.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { MemberRole } from "@/entities/team"; +import { teamApi } from "@/entities/team"; + +type UseUpdateMemberRoleMutationOptions = { + teamId: number; + onSuccess?: () => void; +}; + +export function useUpdateMemberRoleMutation({ + teamId, + onSuccess, +}: UseUpdateMemberRoleMutationOptions) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ memberId, role }: { memberId: number; role: MemberRole }) => + teamApi.updateMemberRole(teamId, memberId, role), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["management", teamId, "memberList"], + }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/management/mutation/useUpdateTeamNameMutation.ts b/src/features/management/mutation/useUpdateTeamNameMutation.ts new file mode 100644 index 00000000..5896b0a2 --- /dev/null +++ b/src/features/management/mutation/useUpdateTeamNameMutation.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { teamApi } from "@/entities/team"; + +type UseUpdateTeamNameMutationOptions = { + onSuccess?: () => void; +}; + +export function useUpdateTeamNameMutation({ + onSuccess, +}: UseUpdateTeamNameMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ teamId, name }: { teamId: number; name: string }) => + teamApi.update(teamId, name), + onSuccess: (_, { teamId }) => { + queryClient.invalidateQueries({ + queryKey: ["management", teamId, "detail"], + }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/notification/index.ts b/src/features/notification/index.ts index e879c7e8..2c9fda5a 100644 --- a/src/features/notification/index.ts +++ b/src/features/notification/index.ts @@ -1,7 +1,8 @@ +export { useReadAllNotificationMutation } from "./mutation/useReadAllNotificationMutation"; +export { useReadNotificationMutation } from "./mutation/useReadNotificationMutation"; export { buildNotificationUrl, formatNotificationType, formatRelativeTime, } from "./notification.utils"; export { default as NotificationSubscriber } from "./NotificationSubscriber"; -export { notificationInfiniteQueries } from "./query/notificationInfiniteQueries"; diff --git a/src/features/notification/mock/notification.ts b/src/features/notification/mock/notification.ts new file mode 100644 index 00000000..6a731fab --- /dev/null +++ b/src/features/notification/mock/notification.ts @@ -0,0 +1,159 @@ +import { HttpResponse } from "msw"; + +import { apiMock } from "@/shared/mock/apiMock"; + +const now = "2026-04-02T00:00:00Z"; + +const INITIAL_NOTIFICATIONS = [ + { + id: 1, + type: "TODO_DUE_SOON", + message: "[코드 리팩토링]의 마감일이 하루 남았어요!", + isRead: false, + createdAt: now, + + goalId: 1, + teamId: 1, + goalName: "코드 리팩토링", + spaceName: "TaskMate Team", + }, + { + id: 2, + type: "TEAM_TODO_CREATED", + message: "[API 에러 핸들링] 할 일이 생성되었어요!", + isRead: true, + createdAt: now, + + goalId: 2, + teamId: 1, + goalName: "API 에러 핸들링", + spaceName: "TaskMate Team", + }, + { + id: 3, + type: "TODO_DUE_SOON", + message: "[React Query 최적화]의 마감일이 하루 남았어요!", + isRead: false, + createdAt: now, + + goalId: 3, + teamId: 1, + goalName: "React Query 최적화", + spaceName: "TaskMate Team", + }, + { + id: 4, + type: "TEAM_TODO_CREATED", + message: "[로그인 UI 개선] 할 일이 생성되었어요!", + isRead: true, + createdAt: now, + + goalId: 4, + teamId: 1, + goalName: "로그인 UI 개선", + spaceName: "TaskMate Team", + }, + { + id: 5, + type: "TODO_DUE_SOON", + message: "[토큰 만료 처리]의 마감일이 하루 남았어요!", + isRead: false, + createdAt: now, + + goalId: 5, + teamId: 1, + goalName: "토큰 만료 처리", + spaceName: "TaskMate Team", + }, +]; + +const notifications = [...INITIAL_NOTIFICATIONS]; + +export const resetNotifications = () => { + notifications.splice(0, notifications.length, ...INITIAL_NOTIFICATIONS); +}; + +export const notificationHandler = [ + // 알림 목록 조회 (infinite) + apiMock.get("/api/notifications", ({ request }) => { + const url = new URL(request.url); + const cursor = Number(url.searchParams.get("cursor") ?? 0); + + const PAGE_SIZE = 10; + + const start = cursor; + const end = start + PAGE_SIZE; + + const items = notifications.slice(start, end); + const nextCursor = end < notifications.length ? end : null; + + return HttpResponse.json({ + success: true, + code: "OK", + message: "알림 목록 조회 성공", + data: { + items, + nextCursor, + }, + timestamp: now, + }); + }), + + // 알림 읽음 처리 (단건) + apiMock.patch("/api/notifications/:notificationId/read", ({ params }) => { + const notificationId = Number(params.notificationId); + + if (Number.isNaN(notificationId)) { + return HttpResponse.json( + { + success: false, + code: "NOTIFICATION_NOT_FOUND", + message: "알림을 찾을 수 없습니다.", + data: null, + timestamp: now, + }, + { status: 404 }, + ); + } + + const target = notifications.find((n) => n.id === notificationId); + + if (!target) { + return HttpResponse.json( + { + success: false, + code: "NOTIFICATION_NOT_FOUND", + message: "알림을 찾을 수 없습니다.", + data: null, + timestamp: now, + }, + { status: 404 }, + ); + } + + target.isRead = true; + + return HttpResponse.json({ + success: true, + code: "OK", + message: "알림 읽음 처리 성공", + data: null, + timestamp: now, + }); + }), + + // 전체 읽음 처리 + apiMock.patch("/api/notifications/read-all", () => { + notifications.forEach((n) => { + n.isRead = true; + }); + + return HttpResponse.json({ + success: true, + code: "OK", + message: "전체 읽음 처리 성공", + data: null, + timestamp: now, + }); + }), +]; diff --git a/src/features/notification/mutation/useReadAllNotificationMutation.ts b/src/features/notification/mutation/useReadAllNotificationMutation.ts new file mode 100644 index 00000000..7a11c46a --- /dev/null +++ b/src/features/notification/mutation/useReadAllNotificationMutation.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { NotificationApi } from "@/entities/notification"; + +type UseReadAllNotificationMutationOptions = { + onSuccess?: () => void; +}; + +export function useReadAllNotificationMutation({ + onSuccess, +}: UseReadAllNotificationMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => NotificationApi.readAll(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/notification/mutation/useReadNotificationMutation.ts b/src/features/notification/mutation/useReadNotificationMutation.ts new file mode 100644 index 00000000..9c68062b --- /dev/null +++ b/src/features/notification/mutation/useReadNotificationMutation.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { NotificationApi } from "@/entities/notification"; + +type UseReadNotificationMutationOptions = { + onSuccess?: () => void; +}; + +export function useReadNotificationMutation({ + onSuccess, +}: UseReadNotificationMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => NotificationApi.read(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + onSuccess?.(); + }, + }); +} diff --git a/src/features/notification/query/notificationInfiniteQueries.ts b/src/features/notification/query/notificationInfiniteQueries.ts deleted file mode 100644 index 4c8c60ed..00000000 --- a/src/features/notification/query/notificationInfiniteQueries.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NotificationApi } from "@/entities/notification"; -import { createPaginationOptions } from "@/shared/utils/createPaginationOptions"; - -export const notificationInfiniteQueries = { - notificationsInfinite: () => - createPaginationOptions("notifications", NotificationApi.get), -}; diff --git a/src/features/team/management.utils.ts b/src/features/team/management.utils.ts deleted file mode 100644 index 9de48d8d..00000000 --- a/src/features/team/management.utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -// validateTeamName -// @TODO: zod로 맞추기 (리팩토링) -export const validateTeamName = (raw: string): string | null => { - const value = raw.trim(); // 앞뒤 공백만 제거 - - //if (!value)는 value가 빈 문자열("")이면 true - if (!value) return "팀명을 입력해주세요."; - - return null; -}; - -// validateEmail -// @TODO: zod로 맞추기 (리팩토링) -export const validateEmail = (raw: string): string | null => { - const value = raw.trim(); - - if (!value) return "이메일을 입력해주세요."; - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - - if (!emailRegex.test(value)) { - return "올바른 이메일 형식이 아닙니다."; - } - - return null; -}; diff --git a/src/shared/mock/handlers.ts b/src/shared/mock/handlers.ts index f9d8fb3f..795723f9 100644 --- a/src/shared/mock/handlers.ts +++ b/src/shared/mock/handlers.ts @@ -1,15 +1,19 @@ import { authHandlers } from "@/features/auth/mock/auth"; import { goalsHandlers } from "@/features/goal/mock/goals"; +import { managementHandler } from "@/features/management/mock/management"; +import { notificationHandler } from "@/features/notification/mock/notification"; import { invitationsHandlers } from "@/features/team/mock/invitations"; -import { managementHandler } from "@/features/team/mock/management"; import { teamsHandlers } from "@/features/team/mock/teams"; import { todosHandlers } from "@/features/todo/mock/todos"; +import { homeHandler } from "@/widgets/home/mock/home"; export const handlers = [ + ...homeHandler, ...teamsHandlers, ...invitationsHandlers, ...goalsHandlers, ...authHandlers, ...todosHandlers, ...managementHandler, + ...notificationHandler, ]; diff --git a/src/shared/utils/createPaginationOptions.ts b/src/shared/utils/createPaginationOptions.ts deleted file mode 100644 index 67ba7296..00000000 --- a/src/shared/utils/createPaginationOptions.ts +++ /dev/null @@ -1,37 +0,0 @@ -type CursorParams = { - size?: number; - cursorId?: number; - cursorCreatedAt?: string; -}; - -type CursorPage = { - hasNext: boolean; - nextCursorId?: number; - nextCursorCreatedAt?: string; -}; - -export function createPaginationOptions< - Params extends CursorParams = CursorParams, - Page extends CursorPage = CursorPage, ->(apiKey: string, apiFunction: (param: Params) => Promise<{ data: Page }>) { - const size = 20; - - return { - queryKey: [apiKey], - initialPageParam: { size } as Params, - - queryFn: async ({ pageParam }: { pageParam: Params }) => { - const res = await apiFunction(pageParam); - return res.data; - }, - - getNextPageParam: (lastPage: Page): Params | undefined => - lastPage.hasNext - ? ({ - size, - cursorId: lastPage.nextCursorId, - cursorCreatedAt: lastPage.nextCursorCreatedAt, - } as Params) - : undefined, - }; -} diff --git a/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx b/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx index fef0d19b..b3038d10 100644 --- a/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx +++ b/src/widgets/NavigationBar/NotificationPopover/NotificatioPanel.tsx @@ -1,12 +1,10 @@ -import { useQueryClient } from "@tanstack/react-query"; -import React from "react"; - -import { NotificationApi } from "@/entities/notification"; +import { notificationQueryOptions } from "@/entities/notification"; import { buildNotificationUrl, formatNotificationType, formatRelativeTime, - notificationInfiniteQueries, + useReadAllNotificationMutation, + useReadNotificationMutation, } from "@/features/notification"; import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll/useInfiniteScroll"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; @@ -18,11 +16,8 @@ interface Props { } const NotificationPanel = ({ onClose }: Props) => { - const queryClient = useQueryClient(); - - // 내 알림 목록 무한 스크롤 const { data, isFetchingNextPage, ref } = useInfiniteScroll( - notificationInfiniteQueries.notificationsInfinite(), + notificationQueryOptions.notificationsInfinite(), ); const notifications = @@ -39,30 +34,26 @@ const NotificationPanel = ({ onClose }: Props) => { })), ) ?? []; - // 모두 읽기 핸들러 - const handleReadAll = async () => { - try { - await NotificationApi.readAll(); - await queryClient.invalidateQueries({ queryKey: ["notifications"] }); - } catch (e) { - console.error("알림 전체 읽음 처리 실패:", e); - } - }; + const { mutate: readAll } = useReadAllNotificationMutation(); + const { mutate: readOne } = useReadNotificationMutation(); + + const handleReadAll = () => readAll(); - // 알림 아이템 클릭 핸들러 - const handleItemClick = async (item: { + const handleItemClick = (item: { id: number; isRead: boolean; url: string; }) => { - try { - if (!item.isRead) { - await NotificationApi.read(item.id); - await queryClient.invalidateQueries({ queryKey: ["notifications"] }); - } - } finally { + if (!item.isRead) { + readOne(item.id, { + onSettled: () => { + onClose(); + window.location.assign(item.url); + }, + }); + } else { onClose(); - window.location.assign(item.url); // 또는 router.push(item.url) + window.location.assign(item.url); } }; diff --git a/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx b/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx index 8f50ee63..89acfd78 100644 --- a/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx +++ b/src/widgets/NavigationBar/NotificationPopover/NotificationPopover.tsx @@ -4,7 +4,7 @@ import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { cva } from "class-variance-authority"; import { useEffect, useRef, useState } from "react"; -import { notificationInfiniteQueries } from "@/features/notification"; +import { notificationQueryOptions } from "@/entities/notification"; import { Icon } from "@/shared/ui/Icon"; import { cn } from "@/shared/utils/styles/cn"; @@ -52,7 +52,7 @@ const NotificationPopover = ({ placement }: NotificationPopoverProps) => { // 알림 표시 const { data: notificationData } = useSuspenseInfiniteQuery( - notificationInfiniteQueries.notificationsInfinite(), + notificationQueryOptions.notificationsInfinite(), ); const hasUnread = diff --git a/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx b/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx index 1bbcb9ad..70b99c7d 100644 --- a/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx +++ b/src/widgets/home/FavoriteGoalsSection/FavoriteGoalsSection.tsx @@ -9,13 +9,13 @@ import Button from "@/shared/ui/Button/Button/Button"; import { Icon } from "@/shared/ui/Icon"; import { Spacing } from "@/shared/ui/Spacing"; import { FavoriteGoalsItem } from "@/widgets/home/FavoriteGoalsSection/FavoriteGoalsItem"; -import { mainInfiniteQueries } from "@/widgets/home/query/mainInfiniteQueries"; +import { homeQueryOptions } from "@/widgets/home/query/home.queryOptions"; export function FavoriteGoalsSection() { const router = useRouter(); const { ref, data, isFetchingNextPage } = useInfiniteScroll( - mainInfiniteQueries.favoriteGoalsInfinite(), + homeQueryOptions.favoriteGoalsInfinite(), ); const items = data.pages.flatMap((page) => diff --git a/src/widgets/home/ProgressSection/ProgressSection.tsx b/src/widgets/home/ProgressSection/ProgressSection.tsx index 35c63fb5..bdba4c76 100644 --- a/src/widgets/home/ProgressSection/ProgressSection.tsx +++ b/src/widgets/home/ProgressSection/ProgressSection.tsx @@ -1,45 +1,27 @@ "use client"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; -import { ProgressItem } from "@/entities/dashboard"; -import { progressApi } from "@/entities/dashboard"; +import { dashboardQueryOptions } from "@/entities/dashboard"; import { CircularProgress } from "@/shared/ui/CircularProgress"; import { Icon } from "@/shared/ui/Icon"; import Slider from "@/widgets/home/Slider/Slider"; -// @TODO: Loading/Error 상태 처리 필요 -// @TODO: 데이터가 없을 때 처리 확인 필요 export default function ProgressSection() { - const { data } = useSuspenseQuery(userQueries.myInfo()); - // (팀/개인) progress usestate로 저장 - const [teamProgress, setTeamProgress] = useState([]); - const [myProgressPercent, setMyProgressPercent] = useState(0); - - //@TODO: useEffect 에서 데이터 요청하는 패턴에서 tanstack-query 활용하는 패턴으로 맞추기 - // (팀/개인) progress 데이터 요청 - useEffect(() => { - (async () => { - const res = await progressApi.read(); - setTeamProgress(res.data.teamProgress); - setMyProgressPercent(res.data.myProgressPercent); - })(); - }, []); + const { data: me } = useSuspenseQuery(userQueries.myInfo()); + const { data: progress } = useSuspenseQuery(dashboardQueryOptions.progress()); return (
{/* 팀 진행상황 */}
- {/* 타이틀 */}
- {/* @TODO (refactor): 전반적으로 사용되는 mobile | tablet | desktop 일때만 보이게끔 하는 로직 > 하나로 묶는게 좋아보임 ( Wrapper 나 tailwind utitility 등 ) */}
- {/* 카드 슬라이드 */}
- {teamProgress.map(({ teamId, teamName, todayProgressPercent }) => ( -
- - -
-
- - {teamName} - - 의 진행도는 -
-
- - {todayProgressPercent} - - % + {progress.teamProgress.map( + ({ teamId, teamName, todayProgressPercent }) => ( +
+ + +
+
+ + {teamName} + + 의 진행도는 +
+
+ + {todayProgressPercent} + + % +
+ +
- - - -
- ))} + ), + )}
{/* 내 진행 상황 */}
- {/* 타이틀 */}
- {/* 카드 */}
- - {/* 텍스트 */}
- {data.nickname} + {me.nickname} 님의 진행도는
-
- {myProgressPercent} + {progress.myProgressPercent} %
- - {/* 캐릭터 */} page.items.map((item) => ({ @@ -32,7 +32,7 @@ export default function TodoOverviewSection() { data: dueSoonData, isFetchingNextPage: dueSoonIsFetchingNextPage, ref: dueSoonRef, - } = useInfiniteScroll(mainInfiniteQueries.dueSoonInfiniteOptions()); + } = useInfiniteScroll(homeQueryOptions.dueSoonInfiniteOptions()); const dueSoonItems = dueSoonData?.pages.flatMap((page) => diff --git a/src/widgets/home/mock/home.ts b/src/widgets/home/mock/home.ts new file mode 100644 index 00000000..65c3185f --- /dev/null +++ b/src/widgets/home/mock/home.ts @@ -0,0 +1,104 @@ +import { HttpResponse } from "msw"; + +import { apiMock } from "@/shared/mock/apiMock"; + +const now = "2026-04-02T00:00:00Z"; +const PAGE_SIZE = 10; + +const progressData = { + teamProgress: [ + { teamId: 1, teamName: "프론트 팀", todayProgressPercent: 75 }, + { teamId: 2, teamName: "백엔드 팀", todayProgressPercent: 60 }, + { teamId: 3, teamName: "디자인 팀", todayProgressPercent: 45 }, + ], + myProgressPercent: 68, +}; + +const favoriteGoals = Array.from({ length: 10 }).map((_, i) => ({ + teamId: 1, + teamName: "프론트", + goalId: i + 1, + goalName: `목표 ${i + 1}`, + progressPercent: Math.floor(Math.random() * 100), + isFavorite: true, + createdAt: now, +})); + +const recentTodos = Array.from({ length: 15 }).map((_, i) => ({ + todoId: i + 1, + title: `최근 할 일 ${i + 1}`, + teamDisplayName: "프론트", + goalTitle: `목표 ${i + 1}`, + dueDate: "2026-04-10", +})); + +const dueSoonTodos = Array.from({ length: 10 }).map((_, i) => ({ + todoId: i + 100, + title: `마감 임박 ${i + 1}`, + teamDisplayName: "백엔드", + goalTitle: `목표 ${i + 1}`, + dueDate: "2026-04-03", +})); + +export const homeHandler = [ + apiMock.get("/api/main/progress", () => { + return HttpResponse.json({ + success: true, + code: "OK", + message: "진행 상황 조회 성공", + data: progressData, + timestamp: now, + }); + }), + + apiMock.get("/api/main/favorite-goals", ({ request }) => { + const url = new URL(request.url); + const cursor = Number(url.searchParams.get("cursor") ?? 0); + + const items = favoriteGoals.slice(cursor, cursor + PAGE_SIZE); + const nextCursor = + cursor + PAGE_SIZE < favoriteGoals.length ? cursor + PAGE_SIZE : null; + + return HttpResponse.json({ + success: true, + code: "OK", + message: "즐겨찾기 목표 조회 성공", + data: { items, nextCursor }, + timestamp: now, + }); + }), + + apiMock.get("/api/todos/recent", ({ request }) => { + const url = new URL(request.url); + const cursor = Number(url.searchParams.get("cursor") ?? 0); + + const items = recentTodos.slice(cursor, cursor + PAGE_SIZE); + const nextCursor = + cursor + PAGE_SIZE < recentTodos.length ? cursor + PAGE_SIZE : null; + + return HttpResponse.json({ + success: true, + code: "OK", + message: "최근 할 일 조회 성공", + data: { items, nextCursor }, + timestamp: now, + }); + }), + + apiMock.get("/api/todos/due-soon", ({ request }) => { + const url = new URL(request.url); + const cursor = Number(url.searchParams.get("cursor") ?? 0); + + const items = dueSoonTodos.slice(cursor, cursor + PAGE_SIZE); + const nextCursor = + cursor + PAGE_SIZE < dueSoonTodos.length ? cursor + PAGE_SIZE : null; + + return HttpResponse.json({ + success: true, + code: "OK", + message: "마감 임박 할 일 조회 성공", + data: { items, nextCursor }, + timestamp: now, + }); + }), +]; diff --git a/src/widgets/home/query/home.queryOptions.ts b/src/widgets/home/query/home.queryOptions.ts new file mode 100644 index 00000000..b613f8bf --- /dev/null +++ b/src/widgets/home/query/home.queryOptions.ts @@ -0,0 +1,62 @@ +import { infiniteQueryOptions } from "@tanstack/react-query"; + +import { goalQueryOptions } from "@/entities/goal"; +import { todoApi } from "@/entities/todo"; +import { STALE_TIME } from "@/shared/constants/query/staleTime"; + +type InfiniteScrollCursorParams = { + size?: number; + cursorId?: number; + cursorCreatedAt?: string; + cursorDueDate?: string; +}; + +export const homeQueryOptions = { + favoriteGoalsInfinite: () => goalQueryOptions.getFavoriteGoalListInfinite(), + + recentInfiniteOptions: () => + infiniteQueryOptions({ + queryKey: ["recent"], + queryFn: async ({ + pageParam, + }: { + pageParam: InfiniteScrollCursorParams; + }) => { + const response = await todoApi.getRecent(pageParam); + return response.data; + }, + initialPageParam: { size: 20 } as InfiniteScrollCursorParams, + getNextPageParam: (lastPage): InfiniteScrollCursorParams | undefined => + lastPage.hasNext + ? { + size: 20, + cursorId: lastPage.nextCursorId ?? undefined, + cursorCreatedAt: lastPage.nextCursorCreatedAt ?? undefined, + } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), + + dueSoonInfiniteOptions: () => + infiniteQueryOptions({ + queryKey: ["dueSoon"], + queryFn: async ({ + pageParam, + }: { + pageParam: InfiniteScrollCursorParams; + }) => { + const response = await todoApi.getDueSoon(pageParam); + return response.data; + }, + initialPageParam: { size: 20 } as InfiniteScrollCursorParams, + getNextPageParam: (lastPage): InfiniteScrollCursorParams | undefined => + lastPage.hasNext + ? { + size: 20, + cursorId: lastPage.nextCursorId ?? undefined, + cursorDueDate: lastPage.nextCursorDueDate ?? undefined, + } + : undefined, + staleTime: STALE_TIME.DEFAULT, + }), +}; diff --git a/src/widgets/home/query/mainInfiniteQueries.ts b/src/widgets/home/query/mainInfiniteQueries.ts deleted file mode 100644 index 725d857b..00000000 --- a/src/widgets/home/query/mainInfiniteQueries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { goalQueryOptions } from "@/entities/goal"; -import { todoApi } from "@/entities/todo"; -import { createPaginationOptions } from "@/shared/utils/createPaginationOptions"; - -export const mainInfiniteQueries = { - favoriteGoalsInfinite: () => goalQueryOptions.getFavoriteGoalListInfinite(), - recentInfiniteOptions: () => - createPaginationOptions("recent", todoApi.getRecent), - dueSoonInfiniteOptions: () => - createPaginationOptions("dueSoon", todoApi.getDueSoon), -}; diff --git a/src/widgets/management/DeleteModal.tsx b/src/widgets/management/DeleteModal.tsx index 38ced050..08a5e434 100644 --- a/src/widgets/management/DeleteModal.tsx +++ b/src/widgets/management/DeleteModal.tsx @@ -1,39 +1,44 @@ "use client"; +import { useRouter } from "next/navigation"; + +import { useDeleteTeamMutation } from "@/features/management"; +import { useTeamId } from "@/features/team/hooks/useTeamId"; import Button from "@/shared/ui/Button/Button/Button"; import TextButton from "@/shared/ui/Button/TextButton/TextButton"; interface DeleteModalProps { onClose: () => void; - onSubmitDelete: () => Promise; onError: (message: string) => void; } -// @TODO: onSubmitDelete 함수를 Page에서 받아오는 방식 제거 ( Page가 갖는 책임 아님 ) -const DeleteModal = ({ - onClose, - onSubmitDelete, - onError, -}: DeleteModalProps) => { - const handleSubmit = async (e: React.SyntheticEvent) => { - e.preventDefault(); +const DeleteModal = ({ onClose, onError }: DeleteModalProps) => { + const router = useRouter(); + const teamId = Number(useTeamId()); - // @TODO: useMutation 으로 리팩토링 - try { - await onSubmitDelete(); - onClose(); - } catch (error: unknown) { + const { mutate: deleteTeam } = useDeleteTeamMutation({ + onSuccess: () => { onClose(); - const message = - typeof error === "object" && - error !== null && - "data" in error && - typeof (error as { data?: { message?: unknown } }).data?.message === - "string" - ? ((error as { data: { message: string } }).data.message ?? "") - : "팀 삭제에 실패했습니다."; - onError(message); - } + router.replace("/taskmate"); + }, + }); + + const handleSubmit = (e: React.SyntheticEvent) => { + e.preventDefault(); + deleteTeam(teamId, { + onError: (error: unknown) => { + onClose(); + const message = + typeof error === "object" && + error !== null && + "data" in error && + typeof (error as { data?: { message?: unknown } }).data?.message === + "string" + ? ((error as { data: { message: string } }).data.message ?? "") + : "팀 삭제에 실패했습니다."; + onError(message); + }, + }); }; // @TODO: Modal 공통 컴포넌트로 리팩토링 diff --git a/src/widgets/management/InviteModal.tsx b/src/widgets/management/InviteModal.tsx index bbd805b5..8ce47e2e 100644 --- a/src/widgets/management/InviteModal.tsx +++ b/src/widgets/management/InviteModal.tsx @@ -1,46 +1,54 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { teamApi } from "@/entities/team"; +import { managementQueryOptions } from "@/entities/team"; +import { + inviteEmailSchema, + useInviteMemberMutation, +} from "@/features/management"; import { useTeamId } from "@/features/team/hooks/useTeamId"; -import { validateEmail } from "@/features/team/management.utils"; import Button from "@/shared/ui/Button/Button/Button"; import Input from "@/shared/ui/Input/Input"; interface InviteModalProps { onClose: () => void; - onSubmitInvite: (email: string) => Promise; } -const InviteModal = ({ onClose, onSubmitInvite }: InviteModalProps) => { +const InviteModal = ({ onClose }: InviteModalProps) => { + const teamId = useTeamId(); + const { data: teamDetail } = useQuery( + managementQueryOptions.teamDetail(Number(teamId)), + ); + const [email, setEmail] = useState(""); - const emailError = validateEmail(email); - const isDisabled = Boolean(emailError); - const teamId = Number(useTeamId()); - const [value, setValue] = useState(""); - const [initialName, setInitialName] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + const isDisabled = !inviteEmailSchema.safeParse({ email }).success; + + const { mutate: inviteMember } = useInviteMemberMutation({ + onSuccess: onClose, + }); - const handleSubmit = async (e: React.SyntheticEvent) => { + const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault(); - const error = validateEmail(email); - if (error) return; + const result = inviteEmailSchema.safeParse({ email }); + if (!result.success) return; - // @TODO: useMutation 으로 리팩토링 - try { - await onSubmitInvite(email); - onClose(); - } catch (err) { - setErrorMessage( - (err as { message?: string }).message ?? - "알 수 없는 오류가 발생했습니다.", - ); - } + inviteMember( + { teamId: String(teamId), email: result.data.email }, + { + onError: (err) => { + setErrorMessage( + (err as { message?: string }).message ?? + "알 수 없는 오류가 발생했습니다.", + ); + }, + }, + ); }; // @TODO: useOverlay 또는 Modal.BackDrop 처리 - // @TODO: 중복코드 useEffect(() => { const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; @@ -49,22 +57,6 @@ const InviteModal = ({ onClose, onSubmitInvite }: InviteModalProps) => { }; }, []); - useEffect(() => { - // @TODO: useTeamId 에서 처리 - if (Number.isNaN(teamId)) return; - - // @TODO: useSuspenseQuery 및 AsyncBoundary 사용 - teamApi - .getDetail(teamId) - .then((res) => { - if (res?.data?.name) setValue(res.data.name); - setInitialName(res.data.name); - }) - .catch(() => { - setValue("팀명"); - }); - }, [teamId]); - // @TODO: Modal 공통 컴포넌트로 리팩토링 return (
@@ -78,7 +70,7 @@ const InviteModal = ({ onClose, onSubmitInvite }: InviteModalProps) => { {/* @TODO: label 태그 사용 */}

소속 팀

@@ -92,7 +84,6 @@ const InviteModal = ({ onClose, onSubmitInvite }: InviteModalProps) => { value={email} onChange={(e) => setEmail(e.target.value)} /> - {/* @TODO: "errorMessage && 로 처리하기" 랑 지금 코드랑 비교후 적용 */}

{errorMessage || " "}

diff --git a/src/widgets/management/MemberList.tsx b/src/widgets/management/MemberList.tsx index 4dcd38c1..77e22659 100644 --- a/src/widgets/management/MemberList.tsx +++ b/src/widgets/management/MemberList.tsx @@ -1,16 +1,17 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { userQueries } from "@/entities/auth/query/user.queryKey"; -import { memberListApi } from "@/entities/team/api/management.api"; -import { memberApi } from "@/entities/team/api/management.api"; -import { memberRoleApi } from "@/entities/team/api/management.api"; -import { MemberData } from "@/entities/team/types/management.types"; +import { managementQueryOptions } from "@/entities/team"; import { MemberRole } from "@/entities/team/types/management.types"; +import { + useDeleteMemberMutation, + useUpdateMemberRoleMutation, +} from "@/features/management"; import { formatMemberList } from "@/features/team/utils/formatMemberList"; import Dropdown from "@/shared/hooks/useDropdown/Dropdown"; import Button from "@/shared/ui/Button/Button/Button"; @@ -26,13 +27,16 @@ interface MemberListProps { } const MemberList = ({ onInviteClick }: MemberListProps) => { - const [members, setMembers] = useState([]); const router = useRouter(); // @TODO: useTeamId 에서 처리 const params = useParams<{ teamId: string }>(); const teamId = Number(params.teamId); + const { data: memberListData } = useSuspenseQuery( + managementQueryOptions.memberList(teamId), + ); + const [confirmMessage, setConfirmMessage] = useState(""); const [roleChangeModalOpen, setRoleChangeModalOpen] = useState(false); const [memberDeleteModalOpen, setMemberDeleteModalOpen] = useState(false); @@ -76,47 +80,56 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { const myUserId = me?.id; + const members = + typeof myUserId === "number" + ? formatMemberList(memberListData, myUserId) + : memberListData; + + const { mutate: updateMemberRole } = useUpdateMemberRoleMutation({ + teamId, + onSuccess: () => { + if (!pending) return; + const memberUserId = + members.find((m) => m.id === pending.memberId)?.userId ?? + pending.memberId; + + if (myUserId === memberUserId && pending.role !== "ADMIN") { + router.push("/taskmate"); + } + + setRoleChangeModalOpen(false); + }, + }); + + const { mutate: deleteMember } = useDeleteMemberMutation({ + teamId, + onSuccess: () => { + setMemberDeleteModalOpen(false); + }, + }); + // 드롭다운 선택시 const openRoleChangeModal = (memberId: number, value: "어드민" | "팀원") => { const role: MemberRole = value === "어드민" ? "ADMIN" : "MEMBER"; - setPending({ memberId, role }); // + setPending({ memberId, role }); setConfirmMessage("팀원의 권한을 변경 하시겠습니까?"); setRoleChangeModalOpen(true); }; - // 모달 확인 버튼에서 Api 호출 - const handleUpdateRole = async () => { + const handleUpdateRole = () => { if (!pending || !pending.role) return; - - // @TODO: useMutation 으로 리팩토링 - try { - await memberRoleApi.update(teamId, pending.memberId, pending.role); - - // 권한 변경시 배지 업데이트 - const { memberId, role } = pending; - setMembers((prev) => - prev.map((member) => - member.id === memberId ? { ...member, role } : member, - ), - ); - - // 나의 권한을 admin > member로 변경시 메인페이지로 이동 - const response = await memberListApi.read(teamId); - const memberUserId = - response?.data?.find((member) => member.id == pending.memberId) - ?.userId ?? pending.memberId; - - if (myUserId === memberUserId && pending.role !== "ADMIN") { - router.push("/taskmate"); - } - } catch (error) { - setErrorMessage( - getApiErrorMessage(error, "유효하지 않은 권한 설정 입니다."), - ); - setErrorModalOpen(true); - } finally { - setRoleChangeModalOpen(false); - } + updateMemberRole( + { memberId: pending.memberId, role: pending.role }, + { + onError: (error) => { + setErrorMessage( + getApiErrorMessage(error, "유효하지 않은 권한 설정 입니다."), + ); + setErrorModalOpen(true); + setRoleChangeModalOpen(false); + }, + }, + ); }; /* @TODO: useOverlay 공통 hooks 로 적용 */ @@ -126,58 +139,21 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { setMemberDeleteModalOpen(true); }; - const handleDeleteMember = async () => { + const handleDeleteMember = () => { if (!pending) return; - - // @TODO: useMutation 으로 리팩토링 - try { - await memberApi.delete(teamId, pending.memberId); - setMembers((prev) => - prev.filter((member) => member.id !== pending.memberId), - ); - } catch (error) { - setErrorMessage( - getApiErrorMessage(error, "관리자는 본인을 팀에서 삭제할 수 없습니다."), - ); - setErrorModalOpen(true); - } finally { - setMemberDeleteModalOpen(false); - } - }; - - useEffect(() => { - // @TODO: useSuspenseQuery 및 AsyncBoundary 사용 - const loadMemberList = async () => { - const res = await memberListApi.read(teamId); - setMembers(Array.isArray(res.data) ? res.data : []); - }; - - if (Number.isFinite(teamId)) loadMemberList(); - }, [teamId]); - - useEffect(() => { - // @TODO: useTeamId 에서 처리 - if (!Number.isFinite(teamId)) return; - - // @TODO: useSuspenseQuery 및 AsyncBoundary 사용 - const loadMemberList = async (): Promise => { - try { - const res = await memberListApi.read(teamId); - const list = Array.isArray(res.data) ? res.data : []; - - setMembers( - typeof myUserId === "number" - ? formatMemberList(list, myUserId) - : list, + deleteMember(pending.memberId, { + onError: (error) => { + setErrorMessage( + getApiErrorMessage( + error, + "관리자는 본인을 팀에서 삭제할 수 없습니다.", + ), ); - } catch (error) { - // @TODO: console 제거 - console.error("member list error", error); - } - }; - - loadMemberList(); - }, [teamId, myUserId]); + setErrorModalOpen(true); + setMemberDeleteModalOpen(false); + }, + }); + }; return (
@@ -193,7 +169,7 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { avatar={member.profileImageUrl ?? ""} hasCrownIcon={member.role === "ADMIN"} name={member.userNickname} - tag={member.id === myUserId ? "나" : undefined} // isMe 판정 여기서 + tag={member.id === myUserId ? "나" : undefined} email={member.userEmail} />
@@ -201,9 +177,8 @@ const MemberList = ({ onInviteClick }: MemberListProps) => {
openRoleChangeModal(member.id, value)} // member.id 를 준다. + onSelect={(value) => openRoleChangeModal(member.id, value)} />
+ ); +} + +// ✅ After: 분기를 하나로 모으고 각 컴포넌트는 한 가지 맥락만 담당 +function SubmitButton() { + const isViewer = useRole() === "viewer"; + return isViewer ? : ; +} + +function ViewerSubmitButton() { + return Submit; +} + +function AdminSubmitButton() { + useEffect(() => { + showButtonAnimation(); + }, []); + return ; +} +``` + +#### B. 구현 세부사항을 추상화한다 + +불필요한 세부사항이 노출되면 한 번에 파악해야 할 맥락이 늘어난다. + +```tsx +// ❌ Before: 로그인 체크 로직이 LoginStartPage 안에 그대로 노출 +function LoginStartPage() { + useCheckLogin({ + onChecked: (status) => { + if (status === "LOGGED_IN") location.href = "/home"; + }, + }); + return <>{/* 로그인 관련 컴포넌트 */}; +} + +// ✅ After A — Wrapper 컴포넌트로 분리 +function App() { + return ( + + + + ); +} + +function AuthGuard({ children }) { + const status = useCheckLoginStatus(); + useEffect(() => { + if (status === "LOGGED_IN") location.href = "/home"; + }, [status]); + return status !== "LOGGED_IN" ? children : null; +} + +// ✅ After B — HOC로 분리 +export default withAuthGuard(LoginStartPage); +``` + +관련 로직과 실행 버튼을 함께 추상화하면 각 컴포넌트의 역할이 명확해진다. + +```tsx +// ❌ Before: FriendInvitation 안에 overlay 열기 로직이 노출 +function FriendInvitation() { + const handleClick = async () => { + const canInvite = await overlay.openAsync(/* 복잡한 JSX */); + if (canInvite) await sendPush(); + }; + return ; +} + +// ✅ After: 초대 관련 로직을 InviteButton으로 추상화 +function FriendInvitation() { + return ; +} + +function InviteButton({ name }) { + return ( + + ); +} +``` + +#### C. 로직 유형이 아닌 역할 단위로 함수를 분리한다 + +"이 페이지의 모든 query param을 관리한다"처럼 로직 유형으로 묶으면 책임이 무한정 커진다. + +```tsx +// ❌ Before: 페이지 전체의 query param을 한 훅에서 관리 +function usePageState() { + const [query, setQuery] = useQueryParams({ + cardId: NumberParam, + statementId: NumberParam, + dateFrom: DateParam, + // ... + }); + // ... +} + +// ✅ After: query param별로 훅을 분리 +function useCardIdQueryParam() { + const [cardId, _setCardId] = useQueryParam("cardId", NumberParam); + const setCardId = useCallback((cardId: number) => { + _setCardId({ cardId }, "replaceIn"); + }, []); + return [cardId ?? undefined, setCardId] as const; +} +``` + +--- + +### 1-2. 네이밍 + +#### A. 복잡한 조건에 이름을 붙인다 + +```tsx +// ❌ Before +const result = products.filter((product) => + product.categories.some( + (category) => + category.id === targetCategory.id && + product.prices.some((price) => price >= minPrice && price <= maxPrice), + ), +); + +// ✅ After +const matchedProducts = products.filter((product) => { + return product.categories.some((category) => { + const isSameCategory = category.id === targetCategory.id; + const isPriceInRange = product.prices.some( + (price) => price >= minPrice && price <= maxPrice, + ); + return isSameCategory && isPriceInRange; + }); +}); +``` + +네이밍이 필요한 상황: 로직이 복잡할 때, 재사용이 필요할 때, 단위 테스트가 필요할 때. +네이밍이 불필요한 상황: 로직이 단순할 때(`arr.map(x => x * 2)`), 한 번만 쓰이고 단순할 때. + +#### B. 매직 넘버에 이름을 붙인다 + +```tsx +// ❌ Before: 300이 왜 필요한지 알 수 없음 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} + +// ✅ After +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` + +--- + +### 1-3. 위에서 아래로 읽기 + +#### A. 눈의 이동을 줄인다 + +여러 파일이나 함수를 오가며 읽어야 할수록 맥락을 유지하기 어렵다. + +```tsx +// ❌ Before: canInvite를 파악하려면 getPolicyByRole → POLICY_SET 순서로 이동해야 함 +function Page() { + const user = useUser(); + const policy = getPolicyByRole(user.role); + return ( +
+ + +
+ ); +} +function getPolicyByRole(role) { + /* ... */ +} +const POLICY_SET = { admin: ["invite", "view"], viewer: ["view"] }; + +// ✅ After A: 조건을 직접 노출 +function Page() { + const user = useUser(); + switch (user.role) { + case "admin": + return ( +
+ + +
+ ); + case "viewer": + return ( +
+ + +
+ ); + default: + return null; + } +} + +// ✅ After B: 컴포넌트 안에서 한눈에 파악할 수 있는 객체로 관리 +function Page() { + const user = useUser(); + const policy = { + admin: { canInvite: true, canView: true }, + viewer: { canInvite: false, canView: true }, + }[user.role]; + return ( +
+ + +
+ ); +} +``` + +#### B. 삼항 연산자를 단순화한다 + +```tsx +// ❌ Before: 중첩 삼항 연산자 +const status = + ACondition && BCondition + ? "BOTH" + : ACondition || BCondition + ? ACondition + ? "A" + : "B" + : "NONE"; + +// ✅ After: if 문으로 풀어내기 +const status = (() => { + if (ACondition && BCondition) return "BOTH"; + if (ACondition) return "A"; + if (BCondition) return "B"; + return "NONE"; +})(); +``` + +#### C. 비교 연산은 왼쪽에서 오른쪽으로 읽히도록 작성한다 + +```tsx +// ❌ Before: a를 두 번 확인해야 함 +if (a >= b && a <= c) { +} +if (score >= 80 && score <= 100) { +} + +// ✅ After: 수학의 부등식처럼 b ≤ a ≤ c 순서로 +if (b <= a && a <= c) { +} +if (80 <= score && score <= 100) { +} +if (minPrice <= price && price <= maxPrice) { +} +``` + +--- + +## 2. 예측 가능성 (Predictability) + +이름·파라미터·반환값만 보고도 동작을 예측할 수 있어야 한다. + +### A. 이름이 겹치지 않도록 관리한다 + +```tsx +// ❌ Before: http라는 이름이 라이브러리와 서비스 모듈에 동시 사용 +import { http as httpLibrary } from "@some-library/http"; +export const http = { + async get(url: string) { + const token = await fetchToken(); // 이름만 봐선 토큰 추가를 알 수 없음 + return httpLibrary.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, +}; + +// ✅ After: 역할을 드러내는 이름으로 구분 +export const httpService = { + async getWithAuth(url: string) { + const token = await fetchToken(); + return httpLibrary.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + }, +}; +``` + +### B. 유사한 함수는 반환 타입을 통일한다 + +```tsx +// ❌ Before: 같은 API 호출 훅인데 반환 타입이 다름 +function useUser() { + return useQuery({ queryKey: ["user"], queryFn: fetchUser }); // Query 객체 반환 +} +function useServerTime() { + return useQuery({ queryKey: ["serverTime"], queryFn: fetchServerTime }).data; // data만 반환 +} + +// ✅ After: 모두 Query 객체를 반환 +function useUser() { + return useQuery({ queryKey: ["user"], queryFn: fetchUser }); +} +function useServerTime() { + return useQuery({ queryKey: ["serverTime"], queryFn: fetchServerTime }); +} +``` + +validation 함수도 마찬가지로 반환 타입을 통일한다. + +```tsx +// ✅ Discriminated Union으로 일관된 반환 타입 정의 +type ValidationResult = { ok: true } | { ok: false; reason: string }; + +function checkIsNameValid(name: string): ValidationResult { + /* ... */ +} +function checkIsAgeValid(age: number): ValidationResult { + /* ... */ +} +``` + +### C. 숨겨진 로직을 드러낸다 + +```tsx +// ❌ Before: fetchBalance를 호출하면 로깅이 발생하지만 이름에서 알 수 없음 +async function fetchBalance(): Promise { + const balance = await http.get("..."); + logging.log("balance_fetched"); // 숨겨진 사이드 이펙트 + return balance; +} + +// ✅ After: 함수는 이름이 나타내는 일만 한다 +async function fetchBalance(): Promise { + return http.get("..."); +} + +// 로깅은 호출부에서 명시적으로 +; +``` + +--- + +## 3. 응집도 (Cohesion) + +함께 수정될 코드는 항상 함께 수정되도록 구조화한다. + +> **가독성과 응집도는 충돌할 수 있다.** 함께 수정하지 않으면 버그가 생길 위험이 크다면 응집도를 우선하고, 위험이 낮다면 가독성(중복 허용)을 우선한다. + +### A. 함께 수정되는 파일은 같은 디렉터리에 둔다 + +```text +// ❌ Before: 모듈 유형별로 분리 → 관련 파일이 여기저기 흩어짐 +src/ + components/ + hooks/ + utils/ + constants/ + +// ✅ After: 함께 변경되는 파일을 도메인 단위로 묶음 +src/ + components/ ← 프로젝트 전체에서 사용 + hooks/ + domains/ + Domain1/ ← Domain1에서만 사용하는 코드 모음 + components/ + hooks/ + utils/ + Domain2/ + components/ + hooks/ +``` + +같은 도메인 내 코드만 서로 참조해야 한다. 다른 도메인의 파일을 import하는 구조라면 경고 신호다. + +### B. 매직 넘버를 제거한다 (응집도 관점) + +```tsx +// ❌ Before: 애니메이션 시간이 300으로 하드코딩 — 애니메이션 변경 시 이 코드를 찾아 함께 바꿔야 함을 알기 어려움 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} + +// ✅ After: 상수로 선언해 변경 지점을 하나로 모음 +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` + +### C. 폼 응집도를 고려한다 + +폼 관리는 **필드 레벨 응집도**와 **폼 레벨 응집도** 중 상황에 맞는 방식을 선택한다. + +**필드 레벨 응집도** — 각 필드가 독립적으로 검증 로직을 가짐 + +```tsx +// react-hook-form의 register 안에 validate를 필드별로 정의 + v ? "" : "이름을 입력해주세요" })} /> + isValidEmail(v) ? "" : "올바른 이메일 형식이 아닙니다" })} /> +``` + +언제 선택: 비동기 검증이 필요하거나 필드를 다른 폼에서 재사용할 때. + +**폼 레벨 응집도** — Zod 등 스키마로 전체 폼 검증을 한 곳에서 관리 + +```tsx +const schema = z.object({ + name: z.string().min(1, "이름을 입력해주세요"), + email: z.string().min(1).email("올바른 이메일 형식이 아닙니다"), +}); +useForm({ resolver: zodResolver(schema) }); +``` + +언제 선택: 필드가 서로 의존하거나, 단일 비즈니스 로직을 구성하는 폼일 때. + +--- + +## 4. 결합도 (Coupling) + +코드를 수정했을 때 영향 범위가 넓을수록 수정하기 어렵다. + +### A. 책임을 개별로 관리한다 + +```tsx +// ❌ Before: "페이지의 모든 query param"을 한 훅이 담당 → 이 훅에 의존하는 컴포넌트가 많아질수록 변경 위험 증가 +function usePageState() { + /* cardId, statementId, dateFrom, dateTo, statusList 전부 */ +} + +// ✅ After: query param별로 책임을 분리 +function useCardIdQueryParam() { + /* cardId만 */ +} +function useDateRangeQueryParam() { + /* dateFrom, dateTo만 */ +} +``` + +### B. 중복 코드를 허용한다 + +공통 훅/컴포넌트로 묶으면 코드는 줄지만, 한쪽 변경이 다른 쪽에 영향을 미칠 위험이 커진다. + +```tsx +// 공통 훅으로 추출 전에 스스로에게 물어본다: +// - 로깅 값이 페이지마다 달라질 가능성이 있는가? +// - 화면을 닫는 동작이 일부 페이지에서만 필요한가? +// - 바텀시트에 보여줄 텍스트/이미지가 달라질 수 있는가? +// → 하나라도 "예"라면 중복을 허용하는 것이 더 안전하다 +``` + +중복 코드 허용이 나은 경우: 페이지마다 동작이 달라질 가능성이 있을 때. +공통 추출이 나은 경우: 동작이 완전히 동일하고 미래에도 동일할 것이 확실할 때. + +### C. Props Drilling을 제거한다 + +```tsx +// ❌ Before: recommendedItems, onConfirm이 ItemEditModal → ItemEditBody → ItemEditList로 전달 +function ItemEditModal({ items, recommendedItems, onConfirm, onClose }) { + return ( + + + + ); +} + +// ✅ After: Composition 패턴으로 중간 컴포넌트 제거 +function ItemEditModal({ items, recommendedItems, onConfirm, onClose }) { + const [keyword, setKeyword] = useState(""); + return ( + + setKeyword(e.target.value)} + /> + + + + ); +} +``` + +--- + +## 판단 흐름 요약 + +``` +코드를 작성하거나 리뷰할 때 아래 순서로 확인한다. + +1. 가독성 — 한 번에 파악해야 할 맥락이 너무 많지 않은가? +2. 예측 가능성 — 이름·파라미터·반환값만 봐도 동작을 예측할 수 있는가? +3. 응집도 — 함께 변경될 코드가 흩어져 있지 않은가? +4. 결합도 — 이 코드를 바꿀 때 영향 범위가 지나치게 넓지 않은가? + +네 가지를 동시에 만족하기 어려울 때는 현재 상황에서 어떤 값을 +우선해야 장기적으로 변경이 쉬운지 팀과 함께 판단한다. +``` + +--- + +> 출처: [Frontend Fundamentals](https://frontend-fundamentals.com) — Toss Frontend Chapter From 4d745783fbab4f81c70472d910530ae2091b76b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:15:38 +0900 Subject: [PATCH 07/47] =?UTF-8?q?docs(#245):=20create,=20refactor=20?= =?UTF-8?q?=EB=91=90=EA=B0=80=EC=A7=80=20SKILL=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/create/SKILL.md | 116 +++++++++++++++++++++++++++++++ .claude/skills/refactor/SKILL.md | 99 ++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 .claude/skills/create/SKILL.md create mode 100644 .claude/skills/refactor/SKILL.md diff --git a/.claude/skills/create/SKILL.md b/.claude/skills/create/SKILL.md new file mode 100644 index 00000000..297af730 --- /dev/null +++ b/.claude/skills/create/SKILL.md @@ -0,0 +1,116 @@ +--- +name: create +description: 스펙 문서를 읽고 지정한 경로에 코드를 생성한다. FSD 레이어 책임 범위, conventions, code-quality 4원칙을 준수하며 테스트까지 작성한다. +arguments: [path, spec] +--- + +# create + +스펙 문서와 대상 경로를 바탕으로 코드를 생성하고, 테스트까지 작성한다. + +**참조 문서:** + +- @docs/architecture.md — FSD 레이어별 책임 범위 및 의존성 방향 +- @docs/conventions.md — 파일 네이밍 및 코드 컨벤션 +- @docs/code-quality.md — 코드 품질 기준 (가독성 / 예측 가능성 / 응집도 / 결합도) +- @docs/testing-guide.md — 테스트 도구 선택 기준 + +--- + +## 실행 절차 + +### 1단계 — 스펙 파악 + +`$spec` 경로의 문서를 읽고 다음을 파악한다. + +- 구현해야 할 기능과 동작 조건 +- 필요한 props / 인터페이스 / 상태 +- 예외 처리 및 에러 케이스 +- 스펙에 명시되지 않은 부분은 architecture.md와 conventions.md를 기준으로 합리적으로 판단하고, 가정한 내용을 결과 보고에 명시한다. + +### 2단계 — FSD 레이어 판단 + +`$path`에서 레이어를 확인하고 `docs/architecture.md` 기준으로 책임 범위를 파악한다. + +- 생성할 코드가 해당 레이어의 책임에 맞는가? +- 의존성 방향이 올바른가? (상위 레이어 → 하위 레이어만 허용) +- 이미 존재하는 인접 파일이 있으면 함께 읽어 패턴을 맞춘다. + +### 3단계 — 코드 생성 + +`docs/conventions.md`의 네이밍 규칙과 `docs/code-quality.md`의 4원칙을 준수하며 코드를 작성한다. + +**가독성:** + +- 동시에 실행되지 않는 분기는 컴포넌트/함수로 분리한다. +- 구현 세부사항은 적절히 추상화한다. +- 복잡한 조건과 매직 넘버에는 이름을 붙인다. +- 중첩 삼항 연산자 대신 if 문을 사용한다. +- 비교 연산은 왼쪽에서 오른쪽으로 읽히도록 작성한다. (`minPrice <= price && price <= maxPrice`) + +**예측 가능성:** + +- 같은 유형의 함수는 반환 타입을 통일한다. (API 훅은 Query 객체, validation 함수는 `{ ok, reason }`) +- 함수 이름·파라미터·반환값으로 예측할 수 없는 로직은 함수 밖으로 분리한다. +- 기존 코드베이스의 이름과 충돌하지 않도록 명확한 이름을 사용한다. + +**응집도:** + +- 함께 수정될 파일은 같은 디렉터리에 둔다. +- 매직 넘버는 상수로 선언해 변경 지점을 하나로 모은다. +- 폼 검증은 필드 레벨/폼 레벨 중 스펙에 맞는 방식을 선택한다. + +**결합도:** + +- 하나의 함수/훅이 하나의 책임만 가지도록 설계한다. +- Props Drilling이 발생하면 Composition 패턴으로 해소한다. +- 공통 추출보다 중복 허용이 나은 경우를 판단한다. (페이지마다 동작이 달라질 가능성이 있으면 중복 허용) + +### 4단계 — 테스트 작성 + +`docs/testing-guide.md`의 레이어별 기준에 따라 테스트를 작성한다. + +**테스트 작성 판단 기준:** + +- 버그가 생기면 치명적인가? +- 이 코드가 바뀔 가능성이 높은가? +- 로직이 복잡한가? + +→ 하나라도 해당되면 Jest + RTL 테스트를 작성한다. + +**레이어별 테스트 도구:** + +| 경로 패턴 | Jest + RTL | Storybook Chromatic | Storybook play() | +| ------------------------------------- | --------------------- | ------------------- | ---------------- | +| `shared/lib`, `shared/utils` | 반드시 | — | — | +| `shared/hooks`, `shared/store` | 반드시 (`renderHook`) | — | — | +| `shared/ui` | 내부 로직이 있을 때만 | 항상 | 검토 | +| `entities/model`, `entities/api` | 반드시 | — | — | +| `entities/ui` | 검토 | 항상 | 항상 | +| `entities/query` | **작성 금지** | — | — | +| `features/ui` | 반드시 | 검토 | 검토 | +| `features/mutation`, `features/hooks` | 반드시 (MSW 모킹) | — | — | +| `widgets/` | 핵심 인터랙션만 | 검토 | 검토 | + +### 5단계 — 검증 + +```bash +# 타입 체크 +pnpm tsc --noEmit + +# lint +pnpm lint {생성한 파일 경로} + +# 테스트 실행 +pnpm test -- {생성한 테스트 파일 경로} +``` + +실패하면 에러를 읽고 수정한다. 모든 항목이 통과할 때까지 반복한다. + +### 6단계 — 결과 보고 + +- 생성한 파일 목록 +- 파일별 역할 요약 +- 스펙에 명시되지 않아 가정한 내용 +- 원칙 간 트레이드오프가 있었던 경우 판단 이유 +- Storybook / Chromatic이 추가로 필요하다 판단되면 언급 diff --git a/.claude/skills/refactor/SKILL.md b/.claude/skills/refactor/SKILL.md new file mode 100644 index 00000000..8531b5e4 --- /dev/null +++ b/.claude/skills/refactor/SKILL.md @@ -0,0 +1,99 @@ +--- +name: refactor +description: 지정한 경로의 코드를 docs/code-quality.md의 4원칙(가독성·예측 가능성·응집도·결합도) 기준으로 분석하고 개선한다. FSD 레이어와 conventions도 함께 준수한다. +arguments: [path] +--- + +# refactor + +대상 코드를 읽고, 품질 기준에 맞게 개선한 뒤 lint와 테스트로 검증한다. + +**참조 문서:** + +- @docs/code-quality.md — 리팩터링 판단 기준 (가독성 / 예측 가능성 / 응집도 / 결합도) +- @docs/architecture.md — FSD 레이어별 책임 범위 +- @docs/conventions.md — 파일 네이밍 및 코드 컨벤션 + +--- + +## 실행 절차 + +### 1단계 — 대상 파일 파악 + +`$path` 경로 아래의 모든 소스 파일을 읽는다. +연관된 파일(import 대상, 같은 레이어의 인접 파일)도 함께 읽어 전체 맥락을 파악한다. + +### 2단계 — 문제 분석 + +`docs/code-quality.md`의 4원칙 기준으로 개선이 필요한 부분을 파악한다. +각 문제에 대해 **어떤 원칙을 위반하는지**, **왜 문제인지**를 명시한다. + +**가독성 체크리스트:** + +- 동시에 실행되지 않는 코드가 한 함수/컴포넌트에 혼재하는가? +- 구현 세부사항이 불필요하게 노출되어 있는가? +- 로직 유형(query param, state 등)으로 함수를 묶고 있는가? +- 복잡한 조건이나 매직 넘버에 이름이 없는가? +- 중첩 삼항 연산자가 있는가? +- 코드를 위에서 아래로 읽을 때 눈의 이동이 많은가? + +**예측 가능성 체크리스트:** + +- 같은 이름을 가진 함수/변수가 다른 동작을 하는가? +- 유사한 함수의 반환 타입이 일치하지 않는가? +- 함수 이름·파라미터·반환값으로 예측할 수 없는 숨겨진 로직이 있는가? + +**응집도 체크리스트:** + +- 함께 수정될 파일이 서로 다른 디렉터리에 흩어져 있는가? +- 매직 넘버가 하드코딩되어 변경 지점이 여러 곳에 분산되어 있는가? +- 폼의 검증 로직이 적절한 레벨(필드/폼)에서 관리되고 있는가? + +**결합도 체크리스트:** + +- 하나의 함수/훅이 지나치게 넓은 책임을 가지고 있는가? +- 중복을 제거하기 위해 공통 훅/컴포넌트로 묶었지만 페이지마다 동작이 달라질 가능성이 있는가? +- Props Drilling이 발생하고 있는가? + +**FSD/conventions 체크리스트:** + +- 레이어 간 의존성 방향이 올바른가? (상위 레이어 → 하위 레이어) +- 파일명·함수명이 conventions.md를 준수하는가? + +### 3단계 — 리팩터링 계획 수립 + +분석 결과를 바탕으로 수정할 항목을 우선순위와 함께 나열한다. + +> 원칙 간 트레이드오프가 있을 때는 현재 코드의 맥락을 고려해 판단하고, 이유를 명시한다. +> 예: "응집도를 높이면 가독성이 낮아지지만, 이 값은 반드시 함께 수정되어야 하므로 응집도를 우선한다." + +### 4단계 — 리팩터링 실행 + +계획한 순서대로 코드를 수정한다. + +- 한 번에 하나의 원칙씩 수정하고, 각 수정이 다른 원칙을 침해하지 않는지 확인한다. +- import 경로는 **반드시 현재 실제 경로**를 사용한다. (구 경로 사용 금지) +- 수정 범위가 넓다면 파일별로 나누어 단계적으로 진행한다. + +### 5단계 — 검증 + +```bash +# 타입 체크 +pnpm tsc --noEmit + +# lint +pnpm lint {수정한 파일 경로} + +# 관련 테스트 실행 +pnpm test -- {관련 테스트 파일 경로} +``` + +실패하면 에러를 읽고 수정한다. 모든 항목이 통과할 때까지 반복한다. + +### 6단계 — 결과 보고 + +- 수정한 파일 목록 +- 파일별 적용한 원칙과 변경 내용 요약 +- 트레이드오프가 있었던 경우 판단 이유 명시 +- 이번 리팩터링 범위에서 의도적으로 제외한 항목이 있다면 이유 명시 +- 테스트가 추가로 필요하다 판단되면 언급 (`/write-tests` 실행 제안) From 17f19fbba2f2340189c1ff1850e52ac5a041effe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:32:15 +0900 Subject: [PATCH 08/47] =?UTF-8?q?refactor(#245):=20TodoSection=20=EB=82=B4?= =?UTF-8?q?=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD,?= =?UTF-8?q?=20ul=20=EB=82=B4=20li=20=ED=83=9C=EA=B7=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/TodoSection/TodoColumnList.tsx | 20 +++++-- src/widgets/todo/TodoSection/index.tsx | 54 +++++++++---------- .../todo/TodoSection/state/Loading.tsx | 2 - .../todo/TodoSection/state/Wrapper.tsx | 2 - 4 files changed, 42 insertions(+), 36 deletions(-) diff --git a/src/widgets/todo/TodoSection/TodoColumnList.tsx b/src/widgets/todo/TodoSection/TodoColumnList.tsx index a4729c02..bf7f32a6 100644 --- a/src/widgets/todo/TodoSection/TodoColumnList.tsx +++ b/src/widgets/todo/TodoSection/TodoColumnList.tsx @@ -102,11 +102,21 @@ export function TodoColumnList({ todo={todo} /> ))} -
- {!config.showCreateButton && } +
  • +
    +
  • + {!config.showCreateButton && ( +
  • + )} ); } diff --git a/src/widgets/todo/TodoSection/index.tsx b/src/widgets/todo/TodoSection/index.tsx index 6c719b7a..5f3ec2f5 100644 --- a/src/widgets/todo/TodoSection/index.tsx +++ b/src/widgets/todo/TodoSection/index.tsx @@ -15,31 +15,6 @@ import { Error as ListError } from "./state/Error"; import { Loading } from "./state/Loading"; import { TodoColumnList } from "./TodoColumnList"; -type TodoColumnSectionProps = { - className?: string; - children: React.ReactNode; -}; - -function TodoColumnSection({ className, children }: TodoColumnSectionProps) { - return ( -
    -
    - } - errorFallback={(error, onReset) => ( - - )} - > - {children} - -
    -
    - ); -} - export const TodoSection = () => { const goalId = useGoalId(); const breakpoint = useBreakpoint(); @@ -68,8 +43,8 @@ export const TodoSection = () => { />
  • -
    - +
    + 내 할일만 보기 @@ -107,3 +82,28 @@ export const TodoSection = () => {
    ); }; + +type TodoColumnSectionProps = { + className?: string; + children: React.ReactNode; +}; + +function TodoColumnSection({ className, children }: TodoColumnSectionProps) { + return ( +
    +
    + } + errorFallback={(error, onReset) => ( + + )} + > + {children} + +
    +
    + ); +} diff --git a/src/widgets/todo/TodoSection/state/Loading.tsx b/src/widgets/todo/TodoSection/state/Loading.tsx index 21a106be..dd6d5c9e 100644 --- a/src/widgets/todo/TodoSection/state/Loading.tsx +++ b/src/widgets/todo/TodoSection/state/Loading.tsx @@ -1,5 +1,3 @@ -"use client"; - import Spinner from "@/shared/ui/Spinner"; import { Wrapper } from "./Wrapper"; diff --git a/src/widgets/todo/TodoSection/state/Wrapper.tsx b/src/widgets/todo/TodoSection/state/Wrapper.tsx index 1ff8f542..32c59e6e 100644 --- a/src/widgets/todo/TodoSection/state/Wrapper.tsx +++ b/src/widgets/todo/TodoSection/state/Wrapper.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactNode } from "react"; interface WrapperProps { From a00ad3478cad3a424151f09635a116bfdaeb914e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:44:45 +0900 Subject: [PATCH 09/47] =?UTF-8?q?test(#245):=20TodoColumnList=20Test=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/TodoSection/TodoColumnList.test.tsx | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/widgets/todo/TodoSection/TodoColumnList.test.tsx diff --git a/src/widgets/todo/TodoSection/TodoColumnList.test.tsx b/src/widgets/todo/TodoSection/TodoColumnList.test.tsx new file mode 100644 index 00000000..22a46766 --- /dev/null +++ b/src/widgets/todo/TodoSection/TodoColumnList.test.tsx @@ -0,0 +1,285 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import type { Todo } from "@/entities/todo"; +import { useInfiniteScroll } from "@/shared/hooks/useInfiniteScroll"; + +import { TodoColumnList } from "./TodoColumnList"; + +jest.mock("@/shared/hooks/useInfiniteScroll", () => ({ + useInfiniteScroll: jest.fn(), +})); + +jest.mock("@/features/todo/hooks/useTodoCreateModal", () => ({ + useTodoCreateModal: () => ({ openTodoCreateModal: jest.fn() }), +})); + +jest.mock("@/features/todo/hooks/useTodoDeleteModal", () => ({ + useTodoDeleteModal: () => ({ openTodoDeleteModal: jest.fn() }), +})); + +jest.mock("@/features/todo/hooks/useTodoDetailModal", () => ({ + useTodoDetailModal: () => ({ openTodoDetailModal: jest.fn() }), +})); + +jest.mock("@/features/todo/mutation/usePatchTodoStatusMutation", () => ({ + usePatchTodoStatusMutation: () => ({ mutate: jest.fn(), isPending: false }), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ src, alt }: { src: string; alt: string }) => ( + {alt} + ), +})); + +jest.mock("@/shared/assets/images/avatar.png", () => ({ src: "/avatar.png" })); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +const mockUseInfiniteScroll = useInfiniteScroll as jest.MockedFunction< + typeof useInfiniteScroll +>; + +const makeTodo = (overrides: Partial = {}): Todo => ({ + id: 1, + goalId: 100, + title: "테스트 할 일", + startDate: "2026-04-01", + dueDate: "2026-12-31", + status: "TODO", + memo: "", + assigneeSummary: "", + assignees: [], + ...overrides, +}); + +const defaultProps = { + goalId: "1", + keyword: "", + isMyTodo: false, +}; + +beforeEach(() => { + mockUseInfiniteScroll.mockReturnValue({ + ref: { current: null }, + data: { pages: [{ items: [] }] }, + isFetchingNextPage: false, + } as ReturnType); +}); + +describe("TodoColumnList", () => { + describe("컬럼 이름 렌더링", () => { + test("status가 TODO이면 'TODO' 헤딩이 표시된다", () => { + render( + , + ); + expect(screen.getByRole("heading", { name: "TODO" })).toBeInTheDocument(); + }); + + test("status가 DOING이면 'DOING' 헤딩이 표시된다", () => { + render( + , + ); + expect( + screen.getByRole("heading", { name: "DOING" }), + ).toBeInTheDocument(); + }); + + test("status가 DONE이면 'DONE' 헤딩이 표시된다", () => { + render( + , + ); + expect(screen.getByRole("heading", { name: "DONE" })).toBeInTheDocument(); + }); + }); + + describe("기본 정렬 레이블", () => { + test("TODO 컬럼의 기본 정렬은 '마감일 순'이다", () => { + render( + , + ); + expect(screen.getByText("마감일 순")).toBeInTheDocument(); + }); + + test("DOING 컬럼의 기본 정렬은 '최신순'이다", () => { + render( + , + ); + expect(screen.getByText("최신순")).toBeInTheDocument(); + }); + + test("DONE 컬럼의 기본 정렬은 '오래된순'이다", () => { + render( + , + ); + expect(screen.getByText("오래된순")).toBeInTheDocument(); + }); + }); + + describe("할 일 추가 버튼", () => { + test("TODO 컬럼에는 '할 일 추가' 버튼이 표시된다", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "할 일 추가" }), + ).toBeInTheDocument(); + }); + + test("DOING 컬럼에는 '할 일 추가' 버튼이 표시되지 않는다", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: "할 일 추가" }), + ).not.toBeInTheDocument(); + }); + + test("DONE 컬럼에는 '할 일 추가' 버튼이 표시되지 않는다", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: "할 일 추가" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("할일 목록 렌더링", () => { + test("페이지에 할일이 있으면 해당 제목이 표시된다", () => { + const todo1 = makeTodo({ id: 1, title: "첫 번째 할 일" }); + const todo2 = makeTodo({ id: 2, title: "두 번째 할 일" }); + + mockUseInfiniteScroll.mockReturnValue({ + ref: { current: null }, + data: { pages: [{ items: [todo1, todo2] }] }, + isFetchingNextPage: false, + } as ReturnType); + + render( + , + ); + + expect(screen.getByText("첫 번째 할 일")).toBeInTheDocument(); + expect(screen.getByText("두 번째 할 일")).toBeInTheDocument(); + }); + + test("여러 페이지에 걸친 할일이 모두 표시된다", () => { + const todo1 = makeTodo({ id: 1, title: "페이지1 할 일" }); + const todo2 = makeTodo({ id: 2, title: "페이지2 할 일" }); + + mockUseInfiniteScroll.mockReturnValue({ + ref: { current: null }, + data: { pages: [{ items: [todo1] }, { items: [todo2] }] }, + isFetchingNextPage: false, + } as ReturnType); + + render( + , + ); + + expect(screen.getByText("페이지1 할 일")).toBeInTheDocument(); + expect(screen.getByText("페이지2 할 일")).toBeInTheDocument(); + }); + + test("할일이 없으면 할일 제목이 표시되지 않는다", () => { + render( + , + ); + + expect(screen.queryByText("테스트 할 일")).not.toBeInTheDocument(); + }); + }); + + describe("정렬 변경", () => { + test("정렬 버튼 클릭 시 정렬 옵션 드롭다운이 열린다", async () => { + render( + , + ); + + await userEvent.click(screen.getByText("마감일 순")); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + test("TODO 컬럼에서 다른 정렬 옵션 선택 시 선택된 정렬이 변경된다", async () => { + render( + , + ); + + await userEvent.click(screen.getByText("마감일 순")); + const listbox = screen.getByRole("listbox"); + await userEvent.click(within(listbox).getByText("최신순")); + + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "최신순" }), + ).toBeInTheDocument(); + }); + + test("DOING 컬럼에서 '마감일 순'으로 정렬 변경 시 반영된다", async () => { + render( + , + ); + + await userEvent.click(screen.getByText("최신순")); + const listbox = screen.getByRole("listbox"); + await userEvent.click(within(listbox).getByText("마감일 순")); + + expect( + screen.getByRole("button", { name: "마감일 순" }), + ).toBeInTheDocument(); + }); + }); +}); From ad836a92a62d0ac2343c98f913d54445df64607b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:47:48 +0900 Subject: [PATCH 10/47] =?UTF-8?q?test(#245):=20TodoSection=20Test=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/todo/TodoSection/index.test.tsx | 92 +++++++++++++++++++ .../todo/TodoSection/state/Empty.test.tsx | 42 +++++++++ .../todo/TodoSection/state/Error.test.tsx | 60 ++++++++++++ .../todo/TodoSection/state/Loading.test.tsx | 17 ++++ 4 files changed, 211 insertions(+) create mode 100644 src/widgets/todo/TodoSection/index.test.tsx create mode 100644 src/widgets/todo/TodoSection/state/Empty.test.tsx create mode 100644 src/widgets/todo/TodoSection/state/Error.test.tsx create mode 100644 src/widgets/todo/TodoSection/state/Loading.test.tsx diff --git a/src/widgets/todo/TodoSection/index.test.tsx b/src/widgets/todo/TodoSection/index.test.tsx new file mode 100644 index 00000000..9eda4c9f --- /dev/null +++ b/src/widgets/todo/TodoSection/index.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { TodoSection } from "."; + +jest.mock("@/features/goal/hooks/useGoalId", () => ({ + useGoalId: jest.fn().mockReturnValue("1"), +})); + +jest.mock("@/shared/hooks/useBreakpoint", () => ({ + useBreakpoint: jest.fn().mockReturnValue("desktop"), +})); + +const mockOnKeywordChange = jest.fn(); + +jest.mock("@/shared/hooks/useDebouncedKeyword", () => ({ + useDebouncedKeyword: () => ({ + keywordInput: "", + keyword: "", + onKeywordChange: mockOnKeywordChange, + }), +})); + +jest.mock("./TodoColumnList", () => ({ + TodoColumnList: ({ status }: { status: string }) => ( +
    + ), +})); + +jest.mock("@/shared/ui/AsyncBoundary", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("TodoSection", () => { + describe("초기 렌더링", () => { + test("검색 인풋이 렌더링된다", () => { + render(); + expect( + screen.getByPlaceholderText("할 일을 이름으로 검색해보세요."), + ).toBeInTheDocument(); + }); + + test("'내 할일만 보기' 레이블이 표시된다", () => { + render(); + expect(screen.getByText("내 할일만 보기")).toBeInTheDocument(); + }); + + test("TODO, DOING, DONE 세 컬럼이 렌더링된다", () => { + render(); + expect(screen.getByTestId("todo-column-TODO")).toBeInTheDocument(); + expect(screen.getByTestId("todo-column-DOING")).toBeInTheDocument(); + expect(screen.getByTestId("todo-column-DONE")).toBeInTheDocument(); + }); + }); + + describe("'내 할일만 보기' 토글", () => { + test("초기에 토글이 비활성화 상태이다", () => { + render(); + expect(screen.getByRole("button")).toHaveClass("bg-gray-300"); + }); + + test("토글 클릭 시 활성화 상태로 전환된다", async () => { + render(); + const toggle = screen.getByRole("button"); + await userEvent.click(toggle); + expect(toggle).toHaveClass("bg-blue-800"); + }); + + test("토글을 두 번 클릭하면 비활성화 상태로 돌아온다", async () => { + render(); + const toggle = screen.getByRole("button"); + await userEvent.click(toggle); + await userEvent.click(toggle); + expect(toggle).toHaveClass("bg-gray-300"); + }); + }); + + describe("검색 인풋", () => { + test("검색어 입력 시 onKeywordChange가 호출된다", async () => { + render(); + const input = + screen.getByPlaceholderText("할 일을 이름으로 검색해보세요."); + await userEvent.type(input, "테스트"); + expect(mockOnKeywordChange).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/widgets/todo/TodoSection/state/Empty.test.tsx b/src/widgets/todo/TodoSection/state/Empty.test.tsx new file mode 100644 index 00000000..bf08234c --- /dev/null +++ b/src/widgets/todo/TodoSection/state/Empty.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { Empty } from "./Empty"; + +const mockOpenTodoCreateModal = jest.fn(); + +jest.mock("@/features/todo/hooks/useTodoCreateModal", () => ({ + useTodoCreateModal: () => ({ + openTodoCreateModal: mockOpenTodoCreateModal, + closeTodoCreateModal: jest.fn(), + }), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("TodoSection Empty 상태", () => { + beforeEach(() => { + mockOpenTodoCreateModal.mockClear(); + }); + + test("빈 상태 안내 문구가 표시된다", () => { + render(); + expect(screen.getByText("생성된 할 일이 없어요")).toBeInTheDocument(); + expect( + screen.getByText("새로운 할 일을 만들고 관리해보세요"), + ).toBeInTheDocument(); + }); + + test("'할 일 추가' 버튼이 표시된다", () => { + render(); + expect(screen.getByText("할 일 추가")).toBeInTheDocument(); + }); + + test("'할 일 추가' 버튼 클릭 시 openTodoCreateModal이 호출된다", async () => { + render(); + await userEvent.click(screen.getByText("할 일 추가")); + expect(mockOpenTodoCreateModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/todo/TodoSection/state/Error.test.tsx b/src/widgets/todo/TodoSection/state/Error.test.tsx new file mode 100644 index 00000000..ce4bb7a9 --- /dev/null +++ b/src/widgets/todo/TodoSection/state/Error.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { Error as ListError } from "./Error"; + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("TodoSection Error 상태", () => { + const error = new Error("서버 오류가 발생했습니다"); + const onReset = jest.fn(); + + beforeEach(() => { + onReset.mockClear(); + }); + + test("에러 안내 문구가 표시된다", () => { + render( + , + ); + expect( + screen.getByText("할 일 목록을 불러오지 못했어요"), + ).toBeInTheDocument(); + }); + + test("전달된 에러 메시지가 표시된다", () => { + render( + , + ); + expect(screen.getByText("서버 오류가 발생했습니다")).toBeInTheDocument(); + }); + + test("'다시 요청하기' 버튼이 표시된다", () => { + render( + , + ); + expect(screen.getByText("다시 요청하기")).toBeInTheDocument(); + }); + + test("'다시 요청하기' 버튼 클릭 시 onReset이 호출된다", async () => { + render( + , + ); + await userEvent.click(screen.getByText("다시 요청하기")); + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/todo/TodoSection/state/Loading.test.tsx b/src/widgets/todo/TodoSection/state/Loading.test.tsx new file mode 100644 index 00000000..6ef4adf4 --- /dev/null +++ b/src/widgets/todo/TodoSection/state/Loading.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; + +import { Loading } from "./Loading"; + +describe("TodoSection Loading 상태", () => { + test("로딩 중 안내 문구가 표시된다", () => { + render(); + expect( + screen.getByText("할 일 목록을 불러오고 있어요"), + ).toBeInTheDocument(); + }); + + test("스피너가 렌더링된다", () => { + render(); + expect(screen.getByLabelText("할 일 목록 로딩 중")).toBeInTheDocument(); + }); +}); From 22c90f5628e915c8499e1fdba50f78ab9ae600ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:51:13 +0900 Subject: [PATCH 11/47] =?UTF-8?q?refactor(#245):=20features/todo/ui/List?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/todo/ui/List/Order.tsx | 2 +- .../todo/ui/List/TodoAssigneeAvatars.tsx | 36 ++++++++++--------- src/features/todo/ui/List/index.tsx | 31 ++++++++-------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/features/todo/ui/List/Order.tsx b/src/features/todo/ui/List/Order.tsx index 32ca919f..15292a1f 100644 --- a/src/features/todo/ui/List/Order.tsx +++ b/src/features/todo/ui/List/Order.tsx @@ -29,7 +29,7 @@ export const Order = ({ options, selected, onSelect }: OrderProps) => { onClick={toggle} > - {selectedSort || "최신순"} + {selectedSort} { return (
    - {/* @TODO: 담당자 이미지 받아서 처리 필요 ( 중간 이후 )*/} - {/* @TODO: mouse hover 시 담당자 정보 표시? */} - {(assignees ?? []).slice(0, 4).map((assignee, index) => ( - - Avatar Image - - ))} + {(assignees ?? []) + .slice(0, MAX_VISIBLE_ASSIGNEES) + .map((assignee, index) => ( + + Avatar Image + + ))}
    ); }; diff --git a/src/features/todo/ui/List/index.tsx b/src/features/todo/ui/List/index.tsx index a805ce0d..eb0e26d8 100644 --- a/src/features/todo/ui/List/index.tsx +++ b/src/features/todo/ui/List/index.tsx @@ -38,24 +38,21 @@ const ListComponent = ({ - {footer != null ? ( -
    -
      - {children} -
    -
    {footer}
    -
    - ) : ( -
    +
    + {footer != null ? ( + <> +
      + {children} +
    +
    {footer}
    + + ) : (
      {children}
    -
    - )} + )} +
    ); }; From 8e79eca3c9f3e5cf6237f275612c014af547bbd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 00:54:06 +0900 Subject: [PATCH 12/47] =?UTF-8?q?refactor(#245):=20Toggle=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Toggle/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/Toggle/index.tsx b/src/shared/ui/Toggle/index.tsx index 69c3f7ca..0dd0682f 100644 --- a/src/shared/ui/Toggle/index.tsx +++ b/src/shared/ui/Toggle/index.tsx @@ -55,18 +55,17 @@ export const Toggle = ({ return (
    From 2e6e2114eed90409937f63fccbec13e9cae788b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 15:40:32 +0900 Subject: [PATCH 14/47] =?UTF-8?q?test(#245):=20features/todo/ui/List=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20story=20=EB=B0=8F=20test=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/write-tests/SKILL.md | 99 +++++++++++- .storybook/preview.ts | 23 --- .storybook/preview.tsx | 40 +++++ .../todo/ui/List/CreateButton.stories.tsx | 22 +++ .../todo/ui/List/CreateButton.test.tsx | 33 ++++ src/features/todo/ui/List/Item.stories.tsx | 91 +++++++++++ src/features/todo/ui/List/Item.test.tsx | 118 +++++++++++++++ src/features/todo/ui/List/Order.stories.tsx | 22 +++ src/features/todo/ui/List/Order.test.tsx | 113 ++++++++++++++ .../ui/List/TodoAssigneeAvatars.stories.tsx | 34 +++++ .../todo/ui/List/TodoAssigneeAvatars.test.tsx | 38 +++++ .../todo/ui/List/TodoStatusSelect.stories.tsx | 37 +++++ .../todo/ui/List/TodoStatusSelect.test.tsx | 142 ++++++++++++++++++ src/features/todo/ui/List/index.stories.tsx | 47 ++++++ src/features/todo/ui/List/index.test.tsx | 121 +++++++++++++++ src/widgets/todo/TodoSection/index.test.tsx | 55 ++++++- 16 files changed, 1003 insertions(+), 32 deletions(-) delete mode 100644 .storybook/preview.ts create mode 100644 .storybook/preview.tsx create mode 100644 src/features/todo/ui/List/CreateButton.stories.tsx create mode 100644 src/features/todo/ui/List/CreateButton.test.tsx create mode 100644 src/features/todo/ui/List/Item.stories.tsx create mode 100644 src/features/todo/ui/List/Item.test.tsx create mode 100644 src/features/todo/ui/List/Order.stories.tsx create mode 100644 src/features/todo/ui/List/Order.test.tsx create mode 100644 src/features/todo/ui/List/TodoAssigneeAvatars.stories.tsx create mode 100644 src/features/todo/ui/List/TodoAssigneeAvatars.test.tsx create mode 100644 src/features/todo/ui/List/TodoStatusSelect.stories.tsx create mode 100644 src/features/todo/ui/List/TodoStatusSelect.test.tsx create mode 100644 src/features/todo/ui/List/index.stories.tsx create mode 100644 src/features/todo/ui/List/index.test.tsx diff --git a/.claude/skills/write-tests/SKILL.md b/.claude/skills/write-tests/SKILL.md index 754476de..a0edaf9f 100644 --- a/.claude/skills/write-tests/SKILL.md +++ b/.claude/skills/write-tests/SKILL.md @@ -62,7 +62,7 @@ arguments: [path] > `entities/query` — React Query 훅처럼 외부 라이브러리에 의존하는 레이어는 테스트 작성 금지. > Chromatic은 CI 설정이므로 코드로 작성하지 않는다. 해당 레이어라면 주석으로 언급만 한다. -### 3단계 — 테스트 작성 +### 3단계 — Jest + RTL 테스트 작성 **파일 위치:** 소스 파일과 같은 디렉터리에 `{SourceFile}.test.tsx` (또는 `.test.ts`) @@ -102,6 +102,25 @@ const mockUseParams = useParams as jest.MockedFunction; mockUseParams.mockReturnValue({ teamId: "1" } as ReturnType); ``` +**훅 반환값 mock 시 타입 단언 규칙:** + +`UseMutationResult`, `UseQueryResult` 같이 필드 수가 많은 타입은 일부 필드만 제공한 객체를 `as ReturnType<...>` 단일 단언으로 캐스팅하면 TS 에러가 발생한다. +테스트에 필요한 필드만 제공할 때는 반드시 `as unknown as ReturnType<...>` 이중 단언을 사용한다. + +```ts +// ❌ 단일 단언 — UseMutationResult와 구조가 충분히 겹치지 않아 TS 에러 +mockUseXxxMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, +} as ReturnType); + +// ✅ 이중 단언 — unknown을 경유해 타입 검사를 우회 +mockUseXxxMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, +} as unknown as ReturnType); +``` + **커스텀 훅 테스트 (renderHook) 기본 패턴:** ```ts @@ -133,7 +152,78 @@ describe("{컴포넌트 또는 훅 이름}", () => { }); ``` -### 4단계 — 실행 및 검증 +### 4단계 — Story 작성 + +2단계에서 Story 기준에 해당한다고 판단한 컴포넌트에 대해 `{SourceFile}.stories.tsx`를 작성한다. + +**파일 위치:** 소스 파일과 같은 디렉터리 + +**기본 구조:** + +```tsx +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { ComponentName } from "./ComponentName"; + +const meta = { + title: "{layer}/{domain}/{ComponentName}", + component: ComponentName, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { args: { ... } }; +export const AnotherVariant: Story = { args: { ... } }; +``` + +**Story 케이스 선정 기준:** + +- props 값에 따른 시각적 상태 변화 (예: status별 색상, 개수별 레이아웃) +- 빈 상태 / 경계값 (예: 담당자 0명, 최대 초과) +- 이미 커버된 케이스는 작성하지 않는다 + +**복잡한 hook 의존성이 있는 경우:** + +React Query가 필요한 컴포넌트는 전역 `preview.tsx`에 `QueryClientProvider` + `Suspense` 데코레이터가 있는지 확인한다. +없으면 story에 로컬 decorator로 추가한다. + +```tsx +decorators: [ + (Story) => ( + + + + ), +], +``` + +MSW로 API를 인터셉트해야 하는 컴포넌트(`useSuspenseQuery` 내부 호출 포함)는 +`.storybook/preview.tsx`에 `beforeAll`로 worker가 시작되는지 먼저 확인한다. +없으면 추가한 뒤 story를 작성한다. + +```tsx +// .storybook/preview.tsx +import { worker } from "@/shared/mock/browser"; +beforeAll: async () => { + await worker.start({ onUnhandledRequest: "bypass" }); +}, +``` + +`useParams`가 필요한 경우 `parameters.nextjs.navigation.segments`로 공급한다. + +```tsx +parameters: { + nextjs: { + appDirectory: true, + navigation: { + segments: { goalId: "1" }, + }, + }, +}, +``` + +### 5단계 — 실행 및 검증 작성 후 아래 명령으로 실행한다. @@ -144,9 +234,8 @@ pnpm test -- {작성한 테스트 파일 경로} 실패하면 에러를 읽고 수정한다. 모든 케이스가 통과할 때까지 반복한다. 테스트 통과 후, 수정 파일에 대한 lint 에러도 확인한다. -### 5단계 — 결과 보고 +### 6단계 — 결과 보고 -- 작성한 파일 목록 +- 작성한 파일 목록 (Jest 테스트 + Story 파일 모두) - 파일별 커버한 시나리오 요약 - 의도적으로 제외한 케이스가 있으면 이유 명시 -- Storybook play / Chromatic이 추가로 필요하다 판단되면 언급 diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 8866056a..00000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,23 +0,0 @@ -import "@/app/globals.css"; - -import type { Preview } from "@storybook/nextjs-vite"; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: "todo", - }, - }, -}; - -export default preview; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 00000000..4e3634ef --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,40 @@ +import "../src/app/globals.css"; + +import type { Preview } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Suspense } from "react"; + +import { worker } from "@/shared/mock/browser"; + +const preview: Preview = { + beforeAll: async () => { + await worker.start({ onUnhandledRequest: "bypass" }); + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + + + ); + }, + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: "todo", + }, + }, +}; + +export default preview; diff --git a/src/features/todo/ui/List/CreateButton.stories.tsx b/src/features/todo/ui/List/CreateButton.stories.tsx new file mode 100644 index 00000000..86224452 --- /dev/null +++ b/src/features/todo/ui/List/CreateButton.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { CreateButton } from "./CreateButton"; + +const meta = { + title: "features/todo/List/CreateButton", + component: CreateButton, + tags: ["autodocs"], + parameters: { + nextjs: { + appDirectory: true, + navigation: { + segments: [["goalId", "1"]], + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/features/todo/ui/List/CreateButton.test.tsx b/src/features/todo/ui/List/CreateButton.test.tsx new file mode 100644 index 00000000..c08e80f2 --- /dev/null +++ b/src/features/todo/ui/List/CreateButton.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { CreateButton } from "./CreateButton"; + +const mockOpenTodoCreateModal = jest.fn(); + +jest.mock("@/features/todo/hooks/useTodoCreateModal", () => ({ + useTodoCreateModal: () => ({ + openTodoCreateModal: mockOpenTodoCreateModal, + }), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("CreateButton", () => { + beforeEach(() => { + mockOpenTodoCreateModal.mockClear(); + }); + + test("'할 일 추가' 버튼이 렌더링된다", () => { + render(); + expect(screen.getByText("할 일 추가")).toBeInTheDocument(); + }); + + test("버튼 클릭 시 openTodoCreateModal이 호출된다", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /할 일 추가/i })); + expect(mockOpenTodoCreateModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/todo/ui/List/Item.stories.tsx b/src/features/todo/ui/List/Item.stories.tsx new file mode 100644 index 00000000..78ed472e --- /dev/null +++ b/src/features/todo/ui/List/Item.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import type { Todo } from "@/entities/todo"; + +import { Item } from "./Item"; + +const baseTodo: Todo = { + id: 1, + goalId: 1, + title: "컴포넌트 리팩토링 작업", + startDate: "2026-04-01", + dueDate: "2026-05-01", + status: "TODO", + memo: "", + assigneeSummary: "홍길동", + assignees: [{ userId: 101, nickname: "홍길동" }], +}; + +const meta = { + title: "features/todo/List/Item", + component: Item, + tags: ["autodocs"], + parameters: { + nextjs: { + appDirectory: true, + navigation: { + segments: [["goalId", "1"]], + }, + }, + }, + decorators: [ + (Story: React.ComponentType) => ( +
      + +
    + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TodoStatus: Story = { + args: { todo: { ...baseTodo, status: "TODO" } }, +}; + +export const DoingStatus: Story = { + args: { todo: { ...baseTodo, status: "DOING" } }, +}; + +export const DoneStatus: Story = { + args: { todo: { ...baseTodo, status: "DONE" } }, +}; + +export const NoAssignees: Story = { + args: { + todo: { + ...baseTodo, + assignees: [], + assigneeSummary: "", + }, + }, +}; + +export const MultipleAssignees: Story = { + name: "담당자 5명 (아바타 4개까지 표시)", + args: { + todo: { + ...baseTodo, + assignees: [ + { userId: 101, nickname: "홍길동" }, + { userId: 102, nickname: "김철수" }, + { userId: 103, nickname: "이영희" }, + { userId: 104, nickname: "박민수" }, + { userId: 105, nickname: "최지수" }, + ], + assigneeSummary: "홍길동 외 4명", + }, + }, +}; + +export const LongTitle: Story = { + name: "긴 제목 (말줄임 처리)", + args: { + todo: { + ...baseTodo, + title: + "매우 긴 할 일 제목이 들어왔을 때 텍스트가 올바르게 잘리는지 확인하는 스토리입니다", + }, + }, +}; diff --git a/src/features/todo/ui/List/Item.test.tsx b/src/features/todo/ui/List/Item.test.tsx new file mode 100644 index 00000000..15307438 --- /dev/null +++ b/src/features/todo/ui/List/Item.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import type { Todo } from "@/entities/todo"; +import { useTodoDeleteModal } from "@/features/todo/hooks/useTodoDeleteModal"; +import { useTodoDetailModal } from "@/features/todo/hooks/useTodoDetailModal"; + +import { Item } from "./Item"; + +jest.mock("@/features/todo/hooks/useTodoDeleteModal", () => ({ + useTodoDeleteModal: jest.fn(), +})); + +jest.mock("@/features/todo/hooks/useTodoDetailModal", () => ({ + useTodoDetailModal: jest.fn(), +})); + +jest.mock("@/features/todo/utils/formatDDay", () => ({ + formatDDay: (dueDate: string) => `D-test(${dueDate})`, +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +jest.mock("./TodoAssigneeAvatars", () => ({ + TodoAssigneeAvatars: () =>
    , +})); + +jest.mock("./TodoStatusSelect", () => ({ + TodoStatusSelect: () =>
    , +})); + +const mockOpenTodoDeleteModal = jest.fn(); +const mockOpenTodoDetailModal = jest.fn(); + +const mockUseTodoDeleteModal = useTodoDeleteModal as jest.MockedFunction< + typeof useTodoDeleteModal +>; +const mockUseTodoDetailModal = useTodoDetailModal as jest.MockedFunction< + typeof useTodoDetailModal +>; + +const makeTodo = (overrides: Partial = {}): Todo => ({ + id: 1, + goalId: 100, + title: "테스트 할 일", + startDate: "2026-04-01", + dueDate: "2026-12-31", + status: "TODO", + memo: "", + assigneeSummary: "", + assignees: [], + ...overrides, +}); + +describe("Item", () => { + beforeEach(() => { + mockOpenTodoDeleteModal.mockClear(); + mockOpenTodoDetailModal.mockClear(); + + mockUseTodoDeleteModal.mockReturnValue({ + openTodoDeleteModal: mockOpenTodoDeleteModal, + closeTodoDeleteModal: jest.fn(), + }); + mockUseTodoDetailModal.mockReturnValue({ + openTodoDetailModal: mockOpenTodoDetailModal, + closeTodoDetailModal: jest.fn(), + }); + }); + + describe("초기 렌더링", () => { + test("할 일 제목이 표시된다", () => { + render(); + expect(screen.getByText("오늘 할 일")).toBeInTheDocument(); + }); + + test("formatDDay로 포맷된 마감일이 표시된다", () => { + render(); + expect(screen.getByText("D-test(2026-12-31)")).toBeInTheDocument(); + }); + + test("상태 셀렉트가 렌더링된다", () => { + render(); + expect(screen.getByTestId("todo-status-select")).toBeInTheDocument(); + }); + + test("담당자 아바타 영역이 렌더링된다", () => { + render(); + expect(screen.getByTestId("assignee-avatars")).toBeInTheDocument(); + }); + + test("삭제 아이콘 버튼이 렌더링된다", () => { + render(); + expect(screen.getByTestId("icon-Trash")).toBeInTheDocument(); + }); + }); + + describe("인터랙션", () => { + test("아이템 영역 클릭 시 openTodoDetailModal이 호출된다", async () => { + render(); + await userEvent.click(screen.getByText("클릭할 할 일")); + expect(mockOpenTodoDetailModal).toHaveBeenCalledTimes(1); + }); + + test("삭제 버튼 클릭 시 openTodoDeleteModal이 호출된다", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + expect(mockOpenTodoDeleteModal).toHaveBeenCalledTimes(1); + }); + + test("삭제 버튼 클릭 시 이벤트가 부모로 전파되지 않아 openTodoDetailModal이 호출되지 않는다", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + expect(mockOpenTodoDetailModal).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/todo/ui/List/Order.stories.tsx b/src/features/todo/ui/List/Order.stories.tsx new file mode 100644 index 00000000..1acb87bd --- /dev/null +++ b/src/features/todo/ui/List/Order.stories.tsx @@ -0,0 +1,22 @@ +import type { StoryObj } from "@storybook/nextjs-vite"; + +import { Order } from "./Order"; + +const meta = { + title: "features/todo/List/Order", + component: Order, + tags: ["autodocs"], + args: { + options: ["마감일 순", "최신순", "오래된순"], + selected: "마감일 순", + onSelect: () => {}, + }, +}; + +export default meta; + +export const Closed: StoryObj = {}; + +export const AnotherSelected: StoryObj = { + args: { selected: "최신순" }, +}; diff --git a/src/features/todo/ui/List/Order.test.tsx b/src/features/todo/ui/List/Order.test.tsx new file mode 100644 index 00000000..eacc0d79 --- /dev/null +++ b/src/features/todo/ui/List/Order.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { Order } from "./Order"; + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +describe("Order", () => { + const options = ["마감일 순", "최신순", "오래된순"]; + const onSelect = jest.fn(); + + beforeEach(() => { + onSelect.mockClear(); + }); + + describe("초기 렌더링", () => { + test("선택된 정렬 옵션이 표시된다", () => { + render( + , + ); + expect(screen.getByText("마감일 순")).toBeInTheDocument(); + }); + + test("초기에 드롭다운이 닫혀 있다", () => { + render( + , + ); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + describe("드롭다운 열기/닫기", () => { + test("버튼 클릭 시 드롭다운이 열린다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + test("드롭다운이 열리면 전달된 모든 옵션이 표시된다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + const listbox = screen.getByRole("listbox"); + options.forEach((option) => { + expect(within(listbox).getByText(option)).toBeInTheDocument(); + }); + }); + + test("버튼을 두 번 클릭하면 드롭다운이 닫힌다", async () => { + render( + , + ); + const button = screen.getByRole("button"); + await userEvent.click(button); + await userEvent.click(button); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + describe("옵션 선택", () => { + test("옵션 클릭 시 onSelect가 해당 값으로 호출된다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + await userEvent.click(screen.getByText("최신순")); + expect(onSelect).toHaveBeenCalledWith("최신순"); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test("옵션 선택 후 드롭다운이 닫힌다", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button")); + await userEvent.click(screen.getByText("최신순")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/todo/ui/List/TodoAssigneeAvatars.stories.tsx b/src/features/todo/ui/List/TodoAssigneeAvatars.stories.tsx new file mode 100644 index 00000000..31a9c57f --- /dev/null +++ b/src/features/todo/ui/List/TodoAssigneeAvatars.stories.tsx @@ -0,0 +1,34 @@ +import type { StoryObj } from "@storybook/nextjs-vite"; + +import { TodoAssigneeAvatars } from "./TodoAssigneeAvatars"; + +const meta = { + title: "features/todo/List/TodoAssigneeAvatars", + component: TodoAssigneeAvatars, + tags: ["autodocs"], +}; + +export default meta; + +const makeAssignees = (count: number) => + Array.from({ length: count }, (_, i) => ({ + userId: i + 1, + nickname: `담당자${i + 1}`, + })); + +export const NoAssignees: StoryObj = { + args: { assignees: [] }, +}; + +export const TwoAssignees: StoryObj = { + args: { assignees: makeAssignees(2) }, +}; + +export const FourAssignees: StoryObj = { + args: { assignees: makeAssignees(4) }, +}; + +export const OverMax: StoryObj = { + name: "6명 (최대 4개만 표시)", + args: { assignees: makeAssignees(6) }, +}; diff --git a/src/features/todo/ui/List/TodoAssigneeAvatars.test.tsx b/src/features/todo/ui/List/TodoAssigneeAvatars.test.tsx new file mode 100644 index 00000000..034fae2a --- /dev/null +++ b/src/features/todo/ui/List/TodoAssigneeAvatars.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; + +import { TodoAssigneeAvatars } from "./TodoAssigneeAvatars"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt }: { src: string; alt: string }) => {alt}, +})); + +jest.mock("@/shared/assets/images/avatar.png", () => ({ src: "/avatar.png" })); + +const makeAssignees = (count: number) => + Array.from({ length: count }, (_, i) => ({ + userId: i + 1, + nickname: `담당자${i + 1}`, + })); + +describe("TodoAssigneeAvatars", () => { + test("담당자가 없으면 아바타 이미지가 렌더링되지 않는다", () => { + render(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + test("담당자가 2명이면 아바타 이미지가 2개 렌더링된다", () => { + render(); + expect(screen.getAllByRole("img")).toHaveLength(2); + }); + + test("담당자가 4명이면 아바타 이미지가 4개 렌더링된다", () => { + render(); + expect(screen.getAllByRole("img")).toHaveLength(4); + }); + + test("담당자가 5명이어도 최대 4개의 아바타만 렌더링된다", () => { + render(); + expect(screen.getAllByRole("img")).toHaveLength(4); + }); +}); diff --git a/src/features/todo/ui/List/TodoStatusSelect.stories.tsx b/src/features/todo/ui/List/TodoStatusSelect.stories.tsx new file mode 100644 index 00000000..3c34fb63 --- /dev/null +++ b/src/features/todo/ui/List/TodoStatusSelect.stories.tsx @@ -0,0 +1,37 @@ +import type { StoryObj } from "@storybook/nextjs-vite"; + +import type { Todo } from "@/entities/todo"; + +import { TodoStatusSelect } from "./TodoStatusSelect"; + +const meta = { + title: "features/todo/List/TodoStatusSelect", + component: TodoStatusSelect, + tags: ["autodocs"], +}; + +export default meta; + +const baseTodo: Todo = { + id: 1, + goalId: 100, + title: "테스트 할 일", + startDate: "2026-04-01", + dueDate: "2026-12-31", + status: "TODO", + memo: "", + assigneeSummary: "", + assignees: [], +}; + +export const TodoStatus: StoryObj = { + args: { todo: { ...baseTodo, status: "TODO" } }, +}; + +export const DoingStatus: StoryObj = { + args: { todo: { ...baseTodo, status: "DOING" } }, +}; + +export const DoneStatus: StoryObj = { + args: { todo: { ...baseTodo, status: "DONE" } }, +}; diff --git a/src/features/todo/ui/List/TodoStatusSelect.test.tsx b/src/features/todo/ui/List/TodoStatusSelect.test.tsx new file mode 100644 index 00000000..e2b01efa --- /dev/null +++ b/src/features/todo/ui/List/TodoStatusSelect.test.tsx @@ -0,0 +1,142 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import type { Todo } from "@/entities/todo"; +import { usePatchTodoStatusMutation } from "@/features/todo/mutation/usePatchTodoStatusMutation"; + +import { TodoStatusSelect } from "./TodoStatusSelect"; + +jest.mock("@/features/todo/mutation/usePatchTodoStatusMutation", () => ({ + usePatchTodoStatusMutation: jest.fn(), +})); + +jest.mock("@/shared/ui/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +const mockMutate = jest.fn(); +const mockUsePatchTodoStatusMutation = + usePatchTodoStatusMutation as jest.MockedFunction< + typeof usePatchTodoStatusMutation + >; + +const makeTodo = (overrides: Partial = {}): Todo => ({ + id: 1, + goalId: 100, + title: "테스트 할 일", + startDate: "2026-04-01", + dueDate: "2026-12-31", + status: "TODO", + memo: "", + assigneeSummary: "", + assignees: [], + ...overrides, +}); + +describe("TodoStatusSelect", () => { + beforeEach(() => { + mockMutate.mockClear(); + mockUsePatchTodoStatusMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, + } as unknown as ReturnType); + }); + + describe("초기 렌더링", () => { + test("현재 상태 배지가 표시된다", () => { + render(); + expect(screen.getByText("TODO")).toBeInTheDocument(); + }); + + test("DOING 상태의 배지가 표시된다", () => { + render(); + expect(screen.getByText("DOING")).toBeInTheDocument(); + }); + + test("초기에 드롭다운이 닫혀 있다", () => { + render(); + expect(screen.queryByText("DONE")).not.toBeInTheDocument(); + }); + }); + + describe("드롭다운 열기", () => { + test("배지 버튼 클릭 시 모든 상태 옵션이 표시된다", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByText("DOING")).toBeInTheDocument(); + expect(screen.getByText("DONE")).toBeInTheDocument(); + }); + }); + + describe("상태 변경", () => { + test("DOING 선택 시 patchTodoStatus가 DOING 상태로 호출된다", async () => { + const todo = makeTodo({ + id: 1, + goalId: 100, + status: "TODO", + assignees: [{ userId: 5, nickname: "홍길동" }], + }); + render(); + await userEvent.click(screen.getByRole("button")); + + const doingText = screen.getByText("DOING"); + await userEvent.click(doingText.closest("button")!); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + goalId: "100", + todoId: "1", + todoData: expect.objectContaining({ + status: "DOING", + assigneeIds: [5], + }), + }), + ); + }); + + test("DONE 선택 시 patchTodoStatus가 DONE 상태로 호출된다", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + + const doneText = screen.getByText("DONE"); + await userEvent.click(doneText.closest("button")!); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + todoData: expect.objectContaining({ status: "DONE" }), + }), + ); + }); + }); + + describe("isPending 상태", () => { + test("isPending이 true이면 옵션 버튼이 disabled 처리된다", async () => { + mockUsePatchTodoStatusMutation.mockReturnValue({ + mutate: mockMutate, + isPending: true, + } as unknown as ReturnType); + + render(); + await userEvent.click(screen.getByRole("button")); + + const allButtons = screen.getAllByRole("button"); + const optionButtons = allButtons.slice(1); + optionButtons.forEach((btn) => { + expect(btn).toBeDisabled(); + }); + }); + }); + + describe("이벤트 전파 차단", () => { + test("상태 셀렉트 컨테이너 클릭이 부모로 전파되지 않는다", async () => { + const parentClick = jest.fn(); + render( +
    + +
    , + ); + await userEvent.click(screen.getByRole("button")); + expect(parentClick).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/todo/ui/List/index.stories.tsx b/src/features/todo/ui/List/index.stories.tsx new file mode 100644 index 00000000..46127971 --- /dev/null +++ b/src/features/todo/ui/List/index.stories.tsx @@ -0,0 +1,47 @@ +import type { StoryObj } from "@storybook/nextjs-vite"; + +import { TodoList } from "./index"; + +const meta = { + title: "features/todo/List/TodoList", + component: TodoList.List, + tags: ["autodocs"], + args: { + name: "TODO", + height: "400px", + sortOptions: ["마감일 순", "최신순", "오래된순"], + selectedSort: "마감일 순", + onSortChange: () => {}, + }, +}; + +export default meta; + +export const Empty: StoryObj = { + render: (args) => {null}, +}; + +export const WithItems: StoryObj = { + render: (args) => ( + +
  • 할 일 항목 1
  • +
  • 할 일 항목 2
  • +
  • 할 일 항목 3
  • +
    + ), +}; + +export const WithFooter: StoryObj = { + render: (args) => ( + + 할 일 추가 + + } + > +
  • 할 일 항목 1
  • +
    + ), +}; diff --git a/src/features/todo/ui/List/index.test.tsx b/src/features/todo/ui/List/index.test.tsx new file mode 100644 index 00000000..5fe9fc3e --- /dev/null +++ b/src/features/todo/ui/List/index.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { TodoList } from "./index"; + +jest.mock("@/shared/ui/Spacing", () => ({ + Spacing: () => null, +})); + +jest.mock("./Order", () => ({ + Order: ({ + selected, + onSelect, + options, + }: { + selected: string; + onSelect: (v: string) => void; + options: string[]; + }) => ( +
    + {selected} + {options.map((opt) => ( + + ))} +
    + ), +})); + +describe("TodoList.List", () => { + const onSortChange = jest.fn(); + + const defaultProps = { + name: "TODO", + height: "728px", + sortOptions: ["마감일 순", "최신순", "오래된순"], + selectedSort: "마감일 순", + onSortChange, + }; + + beforeEach(() => { + onSortChange.mockClear(); + }); + + describe("초기 렌더링", () => { + test("name이 헤딩으로 표시된다", () => { + render( + +
  • 아이템
  • +
    , + ); + expect(screen.getByRole("heading", { name: "TODO" })).toBeInTheDocument(); + }); + + test("선택된 정렬 옵션이 Order에 전달된다", () => { + render( + +
  • 아이템
  • +
    , + ); + expect(screen.getByTestId("order-selected")).toHaveTextContent( + "마감일 순", + ); + }); + + test("children이 목록에 렌더링된다", () => { + render( + +
  • 아이템 1
  • +
  • 아이템 2
  • +
    , + ); + expect(screen.getByText("아이템 1")).toBeInTheDocument(); + expect(screen.getByText("아이템 2")).toBeInTheDocument(); + }); + }); + + describe("footer 조건부 렌더링", () => { + test("footer가 전달되면 렌더링된다", () => { + render( + 할 일 추가} + > +
  • 아이템
  • +
    , + ); + expect( + screen.getByRole("button", { name: "할 일 추가" }), + ).toBeInTheDocument(); + }); + + test("footer가 없으면 footer 영역이 렌더링되지 않는다", () => { + render( + +
  • 아이템
  • +
    , + ); + expect( + screen.queryByRole("button", { name: "할 일 추가" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("정렬 변경", () => { + test("정렬 옵션 선택 시 onSortChange가 선택된 값으로 호출된다", async () => { + render( + +
  • 아이템
  • +
    , + ); + await userEvent.click(screen.getByRole("button", { name: "최신순" })); + expect(onSortChange).toHaveBeenCalledWith("최신순"); + expect(onSortChange).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/widgets/todo/TodoSection/index.test.tsx b/src/widgets/todo/TodoSection/index.test.tsx index 9eda4c9f..057ad95f 100644 --- a/src/widgets/todo/TodoSection/index.test.tsx +++ b/src/widgets/todo/TodoSection/index.test.tsx @@ -16,15 +16,24 @@ const mockOnKeywordChange = jest.fn(); jest.mock("@/shared/hooks/useDebouncedKeyword", () => ({ useDebouncedKeyword: () => ({ keywordInput: "", - keyword: "", + keyword: "검색어", onKeywordChange: mockOnKeywordChange, }), })); +type CapturedProps = { + status: string; + goalId: string; + keyword: string; + isMyTodo: boolean; +}; +const capturedProps: CapturedProps[] = []; + jest.mock("./TodoColumnList", () => ({ - TodoColumnList: ({ status }: { status: string }) => ( -
    - ), + TodoColumnList: (props: CapturedProps) => { + capturedProps.push(props); + return
    ; + }, })); jest.mock("@/shared/ui/AsyncBoundary", () => ({ @@ -36,6 +45,10 @@ jest.mock("@/shared/ui/Icon", () => ({ Icon: ({ name }: { name: string }) => , })); +beforeEach(() => { + capturedProps.length = 0; +}); + describe("TodoSection", () => { describe("초기 렌더링", () => { test("검색 인풋이 렌더링된다", () => { @@ -89,4 +102,38 @@ describe("TodoSection", () => { expect(mockOnKeywordChange).toHaveBeenCalled(); }); }); + + describe("TodoColumnList 로 props 전파", () => { + test("세 컬럼 모두 useGoalId에서 반환된 goalId를 전달받는다", () => { + render(); + const columns = capturedProps.filter((p) => + ["TODO", "DOING", "DONE"].includes(p.status), + ); + expect(columns).toHaveLength(3); + columns.forEach((p) => expect(p.goalId).toBe("1")); + }); + + test("세 컬럼 모두 useDebouncedKeyword에서 반환된 keyword를 전달받는다", () => { + render(); + capturedProps + .filter((p) => ["TODO", "DOING", "DONE"].includes(p.status)) + .forEach((p) => expect(p.keyword).toBe("검색어")); + }); + + test("초기에 세 컬럼 모두 isMyTodo=false를 전달받는다", () => { + render(); + capturedProps + .filter((p) => ["TODO", "DOING", "DONE"].includes(p.status)) + .forEach((p) => expect(p.isMyTodo).toBe(false)); + }); + + test("토글 클릭 후 세 컬럼 모두 isMyTodo=true를 전달받는다", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + const latest = ["TODO", "DOING", "DONE"].map( + (status) => capturedProps.filter((p) => p.status === status).at(-1)!, + ); + latest.forEach((p) => expect(p.isMyTodo).toBe(true)); + }); + }); }); From 6a62e420f3e46c1ebfc0e9df5c09e4420561e778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A2=85=EC=A7=84=28SugarSyrup=29?= Date: Tue, 28 Apr 2026 22:08:14 +0900 Subject: [PATCH 15/47] =?UTF-8?q?refactor(#245):=20features/todo/ui=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/main.ts | 8 + .storybook/preview.tsx | 2 +- .../todo/hooks/useTodoCreateModal.tsx | 187 +---------------- .../AssigneeSelect/index.test.tsx | 0 .../AssigneeSelect/index.tsx | 11 +- .../ui/CreateTodoModal/TodoCreateModal.tsx | 188 ++++++++++++++++++ src/features/todo/ui/List/index.test.tsx | 2 +- src/features/todo/ui/List/index.tsx | 22 +- .../Team/{Tean.test.tsx => Team.test.tsx} | 0 src/features/todo/utils/formatDDay.ts | 14 +- .../ui/Order}/Order.stories.tsx | 2 +- .../List => shared/ui/Order}/Order.test.tsx | 0 .../ui/List => shared/ui/Order}/Order.tsx | 0 src/shared/ui/Order/index.ts | 1 + src/widgets/team/GoalList/GoalList.tsx | 2 +- 15 files changed, 226 insertions(+), 213 deletions(-) rename src/features/todo/ui/{ => CreateTodoModal}/AssigneeSelect/index.test.tsx (100%) rename src/features/todo/ui/{ => CreateTodoModal}/AssigneeSelect/index.tsx (92%) create mode 100644 src/features/todo/ui/CreateTodoModal/TodoCreateModal.tsx rename src/features/todo/ui/TodoItem/Team/{Tean.test.tsx => Team.test.tsx} (100%) rename src/{features/todo/ui/List => shared/ui/Order}/Order.stories.tsx (92%) rename src/{features/todo/ui/List => shared/ui/Order}/Order.test.tsx (100%) rename src/{features/todo/ui/List => shared/ui/Order}/Order.tsx (100%) create mode 100644 src/shared/ui/Order/index.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index fe1aecaf..4d6650e6 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from "@storybook/nextjs-vite"; +import path from "path"; import svgr from "vite-plugin-svgr"; const config: StorybookConfig = { @@ -29,6 +30,13 @@ const config: StorybookConfig = { include: "**/*.svg", }), ]; + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + "@": path.resolve(__dirname, "../src"), + }, + }; return config; }, staticDirs: ["../public"], diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4e3634ef..a2e4a0f4 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,4 +1,4 @@ -import "../src/app/globals.css"; +import "@/app/globals.css"; import type { Preview } from "@storybook/nextjs-vite"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/src/features/todo/hooks/useTodoCreateModal.tsx b/src/features/todo/hooks/useTodoCreateModal.tsx index 10538587..c63df11e 100644 --- a/src/features/todo/hooks/useTodoCreateModal.tsx +++ b/src/features/todo/hooks/useTodoCreateModal.tsx @@ -8,196 +8,11 @@ import { goalQueryOptions } from "@/entities/goal"; import type { Member } from "@/entities/team"; import { teamQueryOptions } from "@/entities/team"; import { useGoalId } from "@/features/goal/hooks/useGoalId"; -import { AssigneeSelect } from "@/features/todo/ui/AssigneeSelect"; +import { TodoCreateModal } from "@/features/todo/ui/CreateTodoModal/TodoCreateModal"; import { useOverlay } from "@/shared/hooks/useOverlay"; -import Button from "@/shared/ui/Button/Button/Button"; -import Input from "@/shared/ui/Input"; -import { Modal } from "@/shared/ui/Modal"; -import { Spacing } from "@/shared/ui/Spacing"; - -import { useCreateTodoForm } from "./useCreateTodoForm"; const TODO_CREATE_MODAL_ID = "todo-create-modal"; -// @TODO: 할일 생성 실패 시, 처리 빠짐 -// @TODO: 멤버 리스트 가져와서 처리하기 ( 효진님 작업 이후 ) -const TodoCreateModal = ({ - onClose, - goalName, - teamName, - memberList, - isAssigneeFixed, - fixedAssigneeNickname, - initialAssigneeIds, -}: { - onClose: () => void; - goalName: string; - teamName: string; - memberList: Member[]; - isAssigneeFixed: boolean; - fixedAssigneeNickname?: string; - initialAssigneeIds: number[]; -}) => { - const goalId = useGoalId(); - - const { - assigneeIds, - setAssigneeIds, - startDate, - handleStartDateChange, - handleSubmit, - isPending, - } = useCreateTodoForm({ - goalId, - onSuccess: onClose, - initialAssigneeIds, - }); - - return ( - - -
    -
    -

    {goalName}

    - - {teamName} - -
    - - - -
    -
    - -
    - -
    -
    - -
    - -
    - -
    -
    - -
    - -
    - -
    -
    - -
    - -
    - -
    -
    - -
    - -
    - {isAssigneeFixed ? ( -
    - {fixedAssigneeNickname ?? "나"} -
    - ) : ( - - )} -
    -
    - -
    - -
    -