diff --git a/src/app/taskmate/layout.tsx b/src/app/taskmate/layout.tsx index 587057a5..d658ce71 100644 --- a/src/app/taskmate/layout.tsx +++ b/src/app/taskmate/layout.tsx @@ -1,6 +1,5 @@ import { NavigationBar } from "@/components/NavigationBar"; import NavigationBarProvider from "@/components/NavigationBar/provider"; - import NotificationSubscriber from "@/features/notification/NotificationSubscriber"; export default function TaskmateLayout({ diff --git a/src/app/taskmate/page.tsx b/src/app/taskmate/page.tsx index 5f35aaa2..de7a98cf 100644 --- a/src/app/taskmate/page.tsx +++ b/src/app/taskmate/page.tsx @@ -8,7 +8,7 @@ import WelcomeBanner from "@/components/home/WelcomeBanner"; export default function TaskmatePage() { return ( -
+
error
}> diff --git a/src/app/taskmate/team/[teamId]/management/page.tsx b/src/app/taskmate/team/[teamId]/management/page.tsx index e8f94b96..c15d2a2b 100644 --- a/src/app/taskmate/team/[teamId]/management/page.tsx +++ b/src/app/taskmate/team/[teamId]/management/page.tsx @@ -1,10 +1,11 @@ "use client"; import { useParams, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import TextButton from "@/components/common/TextButton/TextButton"; import DeleteModal from "@/components/management/DeleteModal"; +import ErrorModal from "@/components/management/ErrorModal"; import InviteModal from "@/components/management/InviteModal"; import MemberList from "@/components/management/MemberList"; import TeamNameEditor from "@/components/management/TeamNameEditor"; @@ -17,6 +18,8 @@ const TeamManagement = () => { const router = useRouter(); const params = useParams<{ teamId: string }>(); const teamId = params.teamId; + const [errorMessage, setErrorMessage] = useState(""); + const [errorModalOpen, setErrorModalOpen] = useState(false); const handleOpenInvite = () => { open( @@ -40,6 +43,10 @@ const TeamManagement = () => { close(); router.replace("/taskmate"); }} + onError={(message) => { + setErrorMessage(message); + setErrorModalOpen(true); + }} />, ); }; @@ -66,21 +73,31 @@ const TeamManagement = () => { }, [teamId, router]); return ( -
-
-

팀 정보 수정

-
- - - - 팀 삭제하기 - +
+
+
+

+ 팀 정보 수정 +

+
+ + + + 팀 삭제하기 + +
-
-
+ + + setErrorModalOpen(false)} + /> + ); }; diff --git a/src/components/NavigationBar/NotificationPopover/NotificatioPanel.tsx b/src/components/NavigationBar/NotificationPopover/NotificatioPanel.tsx index cf0a95ef..8d138027 100644 --- a/src/components/NavigationBar/NotificationPopover/NotificatioPanel.tsx +++ b/src/components/NavigationBar/NotificationPopover/NotificatioPanel.tsx @@ -67,7 +67,7 @@ const NotificationPanel = ({ onClose }: Props) => { }; return ( -
+
알림 diff --git a/src/components/NavigationBar/NotificationPopover/NotificationPopover.tsx b/src/components/NavigationBar/NotificationPopover/NotificationPopover.tsx index c000a737..13c47212 100644 --- a/src/components/NavigationBar/NotificationPopover/NotificationPopover.tsx +++ b/src/components/NavigationBar/NotificationPopover/NotificationPopover.tsx @@ -1,29 +1,96 @@ "use client"; -import React, { useState } from "react"; -import { Icon } from "@/components/common/Icon/index"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import { cva } from "class-variance-authority"; +import { useEffect, useRef, useState } from "react"; + +import { Icon } from "@/components/common/Icon"; +import { notificationInfiniteQueries } from "@/features/notification/query/notificationInfiniteQueries"; +import { cn } from "@/utils/utils"; import NotificationPanel from "./NotificatioPanel"; -const NotificationPopover = () => { +const buttonVariants = cva( + "cursor-pointer bg-inverse-normal flex shrink-0 items-center justify-center rounded-[99px]", + { + variants: { + placement: { + default: "size-16 ring-1 ring-gray-300 ring-inset", + aside: "size-[30px]", // bell(30)와 동일 + }, + }, + defaultVariants: { + placement: "default", + }, + }, +); + +interface NotificationPopoverProps { + placement?: "aside"; +} + +const NotificationPopover = ({ placement }: NotificationPopoverProps) => { const [open, setOpen] = useState(false); + const popoverRef = useRef(null); const handleClick = () => setOpen((prev) => !prev); + useEffect(() => { + if (!open) return; + + const handlePointerDownOutside = (event: PointerEvent) => { + const target = event.target as Node; + if (!popoverRef.current?.contains(target)) { + setOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDownOutside); + return () => + document.removeEventListener("pointerdown", handlePointerDownOutside); + }, [open]); + + // 알림 표시 + const { data: notificationData } = useSuspenseInfiniteQuery( + notificationInfiniteQueries.notificationsInfinite(), + ); + + const hasUnread = + notificationData?.pages.some((page) => + page.items.some((item) => item.isRead === false), + ) ?? false; + return ( -
+
- {open && setOpen(false)} />} + {open && ( +
e.stopPropagation()} + > + setOpen(false)} /> +
+ )}
); }; diff --git a/src/components/NavigationBar/index.test.tsx b/src/components/NavigationBar/index.test.tsx index 85d82b99..5668d92d 100644 --- a/src/components/NavigationBar/index.test.tsx +++ b/src/components/NavigationBar/index.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { useRouter } from "next/navigation"; import type { ComponentProps, ReactElement } from "react"; @@ -35,6 +36,11 @@ jest.mock("@/components/common/LogoutButton", () => ({ default: () => null, })); +jest.mock("@/components/NavigationBar/NotificationPopover", () => ({ + __esModule: true, + default: () => null, +})); + jest.mock("@/components/common/Icon", () => ({ Icon: ({ name }: { name: string }) => , })); @@ -47,6 +53,7 @@ function renderWithNavContext( ui: ReactElement, value: Partial = {}, ) { + const queryClient = new QueryClient(); const defaults: ContextValue = { isOpen: true, open: jest.fn(), @@ -57,9 +64,11 @@ function renderWithNavContext( }; return render( - - {ui} - , + + + {ui} + + , ); } diff --git a/src/components/NavigationBar/index.tsx b/src/components/NavigationBar/index.tsx index 772117cb..052caca9 100644 --- a/src/components/NavigationBar/index.tsx +++ b/src/components/NavigationBar/index.tsx @@ -34,7 +34,8 @@ const navigationBarAsideVariants = cva( variants: { open: { true: "h-screen overflow-y-scroll mobile:w-[360px] mobile:p-8", - false: "h-[56px] mobile:w-[60px] mobile:px-3 mobile:py-8", + false: + "h-[56px] mobile:w-[60px] mobile:px-3 mobile:py-8 gap-6 items-center", }, }, defaultVariants: { open: false }, @@ -53,7 +54,7 @@ export const NavigationBar = () => { style={{ willChange: "width, height", zIndex: NAVIGATION_BAR_ZINDEX }} >
- {/* */} + {!isOpen && } { - + + +
diff --git a/src/components/home/FavoriteGoalsItem.tsx b/src/components/home/FavoriteGoalsItem.tsx index 8b76044f..c04d28f3 100644 --- a/src/components/home/FavoriteGoalsItem.tsx +++ b/src/components/home/FavoriteGoalsItem.tsx @@ -8,6 +8,8 @@ export interface FavoriteGoalsItemProps { goal: { goalId: number; goalName: string; + progressPercent: number; + isFavorite: boolean; }; } @@ -25,9 +27,9 @@ export function FavoriteGoalsItem({ teamId={String(teamId)} goalId={goal.goalId} title={goal.goalName} - progress={0} + progress={goal.progressPercent} color="green" - isFavorite={false} + isFavorite={goal.isFavorite} className="w-full shrink-0 bg-green-100" />
diff --git a/src/components/home/FavoriteGoalsSection.tsx b/src/components/home/FavoriteGoalsSection.tsx index 8bd01383..0868927c 100644 --- a/src/components/home/FavoriteGoalsSection.tsx +++ b/src/components/home/FavoriteGoalsSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -13,6 +14,7 @@ import { useInfiniteScroll } from "@/hooks/useInfiniteScroll/useInfiniteScroll"; export function FavoriteGoalsSection() { const router = useRouter(); + const queryClient = useQueryClient(); const { ref, data, isFetchingNextPage } = useInfiniteScroll( mainInfiniteQueries.favoriteGoalsInfinite(), @@ -74,6 +76,20 @@ export function FavoriteGoalsSection() { return () => container.removeEventListener("scroll", updateScrollState); }, []); + useEffect(() => { + const handleFavoriteToggled = () => { + queryClient.invalidateQueries({ queryKey: ["favoriteGoals"] }); + }; + + window.addEventListener("goal-favorite-toggled", handleFavoriteToggled); + return () => { + window.removeEventListener( + "goal-favorite-toggled", + handleFavoriteToggled, + ); + }; + }, [queryClient]); + if (items.length === 0) { return (
diff --git a/src/components/management/DeleteModal.tsx b/src/components/management/DeleteModal.tsx index 2725cf80..e5cf050d 100644 --- a/src/components/management/DeleteModal.tsx +++ b/src/components/management/DeleteModal.tsx @@ -1,30 +1,38 @@ "use client"; -import { useRouter } from "next/navigation"; - import Button from "@/components/common/Button/Button"; import TextButton from "@/components/common/TextButton/TextButton"; interface DeleteModalProps { onClose: () => void; onSubmitDelete: () => Promise; + onError: (message: string) => void; } // @TODO: onSubmitDelete 함수를 Page에서 받아오는 방식 제거 ( Page가 갖는 책임 아님 ) -const DeleteModal = ({ onClose, onSubmitDelete }: DeleteModalProps) => { - const router = useRouter(); - +const DeleteModal = ({ + onClose, + onSubmitDelete, + onError, +}: DeleteModalProps) => { const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); // @TODO: useMutation 으로 리팩토링 - // @TODO: console 제거 try { await onSubmitDelete(); onClose(); - router.replace("/taskmate"); - } catch (error) { - console.log("팀 삭제 실패", error); + } catch (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); } }; @@ -37,8 +45,8 @@ const DeleteModal = ({ onClose, onSubmitDelete }: DeleteModalProps) => {

팀 페이지와 팀 정보를 삭제합니다.

-

- 삭제된 팀 페이지는 복구할 수 없어요 +

+ 삭제된 팀 페이지는 복구할 수 없어요.

{ - {message} + {message} diff --git a/src/components/management/MemberList.tsx b/src/components/management/MemberList.tsx index e13681b4..d4d24e36 100644 --- a/src/components/management/MemberList.tsx +++ b/src/components/management/MemberList.tsx @@ -44,6 +44,29 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { const [errorMessage, setErrorMessage] = useState(""); const [errorModalOpen, setErrorModalOpen] = useState(false); + const getApiErrorMessage = (error: unknown, fallback: string) => { + if (error && typeof error === "object") { + const data = (error as { data?: unknown }).data; + if ( + data && + typeof data === "object" && + "message" in data && + typeof (data as { message?: unknown }).message === "string" + ) { + return (data as { message: string }).message; + } + + if ( + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + } + + return fallback; + }; + // @TODO: myUserId를 가져오는 Hooks 로 분리 const { data: me } = useQuery({ ...userQueries.myInfo(), @@ -86,8 +109,10 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { if (myUserId === memberUserId && pending.role !== "ADMIN") { router.push("/taskmate"); } - } catch { - setErrorMessage("유효하지 않은 권한 설정 입니다."); + } catch (error) { + setErrorMessage( + getApiErrorMessage(error, "유효하지 않은 권한 설정 입니다."), + ); setErrorModalOpen(true); } finally { setRoleChangeModalOpen(false); @@ -97,7 +122,7 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { /* @TODO: useOverlay 공통 hooks 로 적용 */ const openMemberDeleteModal = (memberId: number) => { setPending({ memberId }); - setConfirmMessage("팀원의 권한을 삭제 하시겠습니까?"); + setConfirmMessage("팀원을 삭제 하시겠습니까?"); setMemberDeleteModalOpen(true); }; @@ -110,8 +135,10 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { setMembers((prev) => prev.filter((member) => member.id !== pending.memberId), ); - } catch { - setErrorMessage("관리자는 본인을 팀에서 삭제할 수 없습니다."); + } catch (error) { + setErrorMessage( + getApiErrorMessage(error, "관리자는 본인을 팀에서 삭제할 수 없습니다."), + ); setErrorModalOpen(true); } finally { setMemberDeleteModalOpen(false); @@ -218,7 +245,7 @@ const MemberList = ({ onInviteClick }: MemberListProps) => { {/* 팀원 삭제 모달 */} {/* @TODO: useOverlay 공통 hooks 로 적용 */} setMemberDeleteModalOpen(false)} onConfirm={handleDeleteMember} diff --git a/src/features/goal/api.ts b/src/features/goal/api.ts index be37a79e..02d3afe3 100644 --- a/src/features/goal/api.ts +++ b/src/features/goal/api.ts @@ -25,8 +25,19 @@ export const goalApi = { params: { sort }, }), - toggleFavorite: (goalId: number) => - apiClient.post<{ success: boolean }>(`/api/goals/${goalId}/favorite`), + toggleFavorite: async (goalId: number) => { + const result = await apiClient.post<{ success: boolean }>( + `/api/goals/${goalId}/favorite`, + ); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("goal-favorite-toggled", { detail: { goalId } }), + ); + } + + return result; + }, getSummary: (goalId: string) => apiClient.get(`/api/goals/${goalId}/summary`), diff --git a/src/features/notification/useNotificationSSE.ts b/src/features/notification/useNotificationSSE.ts index 208b9401..e7369c76 100644 --- a/src/features/notification/useNotificationSSE.ts +++ b/src/features/notification/useNotificationSSE.ts @@ -32,6 +32,8 @@ export function useNotificationSSE() { if (!isLogin) return; let unmounted = false; + let connectAttempts = 0; + const MAX_CONNECT_ATTEMPTS = 3; const clear = () => { if (timerRef.current !== null) { @@ -44,6 +46,8 @@ export function useNotificationSSE() { const connect = async () => { if (unmounted) return; + if (connectAttempts >= MAX_CONNECT_ATTEMPTS) return; + connectAttempts += 1; try { const tokenRes = await NotificationApi.issueSseToken(); @@ -59,6 +63,7 @@ export function useNotificationSSE() { es.onerror = async () => { clear(); + if (connectAttempts >= MAX_CONNECT_ATTEMPTS) return; try { // 401 등 인증 문제 시 BFF 재발급 트리거 await queryClient.fetchQuery(userQueries.myInfo()); diff --git a/src/hooks/useDropdown/Dropdown.tsx b/src/hooks/useDropdown/Dropdown.tsx index c2dceb72..12728551 100644 --- a/src/hooks/useDropdown/Dropdown.tsx +++ b/src/hooks/useDropdown/Dropdown.tsx @@ -45,6 +45,7 @@ export const Dropdown = ({ isOpen, selected: current, toggle, + close, selectItem, containerRef, } = useDropdown([...options], selected); @@ -52,12 +53,17 @@ export const Dropdown = ({ // TODO: 삭제 예정 // 기존 코드의 동작을 이해해야 함 const handleSelect = async (value: T) => { + if (!onSelect) { + selectItem(value); + return; + } + try { await onSelect?.(value); - // API 성공 시에만 UI 선택값 변경 - selectItem(value); } catch (error) { console.error("dropdown select error", error); + } finally { + close(); } }; diff --git a/src/hooks/useDropdown/index.ts b/src/hooks/useDropdown/index.ts index 957cf882..5e9b9ff1 100644 --- a/src/hooks/useDropdown/index.ts +++ b/src/hooks/useDropdown/index.ts @@ -43,5 +43,5 @@ export function useDropdown(options: string[], initialSelected?: string) { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - return { isOpen, selected, toggle, selectItem, containerRef }; + return { isOpen, selected, toggle, close, selectItem, containerRef }; }