From 3504e24230a0e787dcd37f723c58db4591bce1d1 Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 26 Sep 2026 16:03:07 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(common):=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 앱의 ConfirmModal을 한국어 문구로 옮겨 왔습니다. 확인 버튼 문구를 바꾸는 confirmLabel과, 요청이 진행 중일 때 확인 버튼을 비활성화하는 isPending 옵션을 더했습니다. Co-Authored-By: Claude Opus 5.5 --- src/components/common/Modal/CONST_MODAL.ts | 10 ++ .../common/Modal/ConfirmModal.test.tsx | 121 ++++++++++++++++++ src/components/common/Modal/ConfirmModal.tsx | 106 +++++++++++++++ src/components/common/index.ts | 1 + 4 files changed, 238 insertions(+) create mode 100644 src/components/common/Modal/CONST_MODAL.ts create mode 100644 src/components/common/Modal/ConfirmModal.test.tsx create mode 100644 src/components/common/Modal/ConfirmModal.tsx diff --git a/src/components/common/Modal/CONST_MODAL.ts b/src/components/common/Modal/CONST_MODAL.ts new file mode 100644 index 0000000..8d4757a --- /dev/null +++ b/src/components/common/Modal/CONST_MODAL.ts @@ -0,0 +1,10 @@ +export const style = { + baseBtn: "flex-1 flex-center text-body1-semibold py-[10px] rounded-[10px]", + confirmBtn: "bg-fill-brand-normal-default text-white glass-card", + cancelBtn: "bg-white text-neutral-normal-default border border-neutral-normal-default", +} as const; + +export const sizeMap = { + small: "w-[320px]", + medium: "w-[400px]", +} as const; diff --git a/src/components/common/Modal/ConfirmModal.test.tsx b/src/components/common/Modal/ConfirmModal.test.tsx new file mode 100644 index 0000000..d450e43 --- /dev/null +++ b/src/components/common/Modal/ConfirmModal.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import ConfirmModal from "./ConfirmModal"; + +const ModalLayoutMock = ({ isOpen, onClose, className, children }: any) => { + if (!isOpen) return null; + return ( +
e.currentTarget === e.target && onClose?.()} + > +
e.stopPropagation()}> + {children} +
+
+ ); +}; + +const IconMock = (props: any) =>
; + +jest.mock("./_internal/ModalLayout", () => ({ + __esModule: true, + default: (props: any) => ModalLayoutMock(props), +})); + +jest.mock("../Icon/Icon", () => ({ + __esModule: true, + default: (props: any) => IconMock(props), +})); + +describe("", () => { + const baseProps = { + title: "제목", + content: "내용", + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + onCancel: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("isOpen=false이면 렌더하지 않습니다.", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByText("제목")).not.toBeInTheDocument(); + }); + + it("열렸을 때 제목과 내용을 렌더합니다.", () => { + render(); + expect(screen.getByText("제목")).toBeInTheDocument(); + expect(screen.getByText("내용")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "취소" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "확인" })).toBeInTheDocument(); + }); + + it("아이콘 props가 있으면 아이콘을 렌더합니다.", () => { + render(); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + }); + + it("확인/취소 버튼 클릭 시 핸들러를 호출합니다.", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "확인" })); + expect(baseProps.onConfirm).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "취소" })); + expect(baseProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("백드롭 mousedown 시 onClose를 호출합니다.", () => { + render(); + const backdrop = screen.getByTestId("backdrop"); + fireEvent.mouseDown(backdrop); + expect(baseProps.onClose).toHaveBeenCalledTimes(1); + }); + + it("컨텐츠 영역 mousedown은 전파가 차단되어 onClose가 호출되지 않습니다.", () => { + render(); + const content = screen.getByTestId("content"); + fireEvent.mouseDown(content); + expect(baseProps.onClose).not.toHaveBeenCalled(); + }); + + it('size="small"일 때 클래스 병합이 전달됩니다. (스냅샷 수준 검증)', () => { + const { getByTestId } = render(); + const backdrop = getByTestId("backdrop"); + expect(backdrop.className).toContain("p-6"); + }); +}); + +describe(" 어드민 확장", () => { + const baseProps = { + title: "제목", + content: "내용", + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + onCancel: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("confirmLabel을 주면 확인 버튼 문구를 바꿉니다.", () => { + render(); + expect(screen.getByRole("button", { name: "삭제" })).toBeInTheDocument(); + }); + + it("isPending이면 확인 버튼이 비활성화되어 onConfirm을 호출하지 않습니다.", () => { + render(); + const confirm = screen.getByRole("button", { name: "확인" }); + expect(confirm).toBeDisabled(); + fireEvent.click(confirm); + expect(baseProps.onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/common/Modal/ConfirmModal.tsx b/src/components/common/Modal/ConfirmModal.tsx new file mode 100644 index 0000000..dc2bffe --- /dev/null +++ b/src/components/common/Modal/ConfirmModal.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import { cn } from "@/utils"; +import Icon, { Props as IconProps } from "../Icon/Icon"; +import { sizeMap, style } from "./CONST_MODAL"; +import ModalLayout from "./_internal/ModalLayout"; + +/** + * 확인/취소 액션을 제공하는 모달 컴포넌트입니다. + * + * @remarks + * - ESC 키 및 백드롭 클릭 시 `onClose`가 호출됩니다. + * - `isPending`이면 확인 버튼을 비활성화해 요청이 중복으로 나가지 않게 합니다. + * + * @author jikwon + */ + +interface ConfirmModalProps { + /** 모달 제목 */ + title: ReactNode; + /** 모달 내용 */ + content: ReactNode; + /** 아이콘 (이름/크기/접근성 라벨만 사용) */ + icon?: Pick; + /** 모달 열림 여부 */ + isOpen: boolean; + /** 닫기 핸들러 (ESC/백드롭 포함) */ + onClose: () => void; + /** 확인 버튼 클릭 핸들러 */ + onConfirm: () => void; + /** 취소 버튼 클릭 핸들러 */ + onCancel: () => void; + /** 확인 버튼 문구 (default: '확인') */ + confirmLabel?: string; + /** 확인 요청 진행 중 여부 */ + isPending?: boolean; + /** 모달 크기 (default: 'medium') */ + size?: "small" | "medium"; +} + +/** + * @example + * ```tsx + * + * ``` + */ + +const ConfirmModal = ({ + isOpen, + onClose, + title, + content, + icon, + onConfirm, + onCancel, + confirmLabel = "확인", + isPending = false, + size = "medium", +}: ConfirmModalProps) => { + return ( + +
+ {icon && ( +
+ +
+ )} +
+ + +
+
+ +
+ + +
+
+ ); +}; + +export default ConfirmModal; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index f5b686a..e09ee99 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -17,6 +17,7 @@ export { default as DateWheel } from "./DateWheel/DateWheel"; export { default as InputComment } from "./Input/InputComment/InputComment"; export { default as InputCommentField } from "./Input/InputComment/InputComment"; export { default as ModalLayout } from "./Modal/_internal/ModalLayout"; +export { default as ConfirmModal } from "./Modal/ConfirmModal"; export { default as DeleteButton } from "./Input/_internal/DeleteButton/DeleteButton"; export type { IconName } from "./Icon/Icon"; export type { InputTextProps } from "./Input/InputText/InputText"; From 7ceb51f7b6fe4bbef918f701f7742deb46d7939b Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 26 Sep 2026 16:03:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(notice):=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EC=99=80=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공지 수정 화면 헤더에 삭제 버튼을 두고, 확인 후 삭제하면 공지 목록으로 이동합니다. 공지 글쓰기 화면에 임시저장을 추가했습니다. 진입할 때 임시저장본이 있으면 불러올지 묻고, 불러온 상태에서 작성 완료하면 그 임시저장본을 발행합니다. 작성, 임시저장, 수정에서 같던 이미지 업로드 로직은 useUploadNoticeImages로 합쳤습니다. Co-Authored-By: Claude Opus 5.5 --- src/api/fetch/admin/api/useDeleteNotice.ts | 22 +++++++ src/api/fetch/admin/api/useGetNoticeDraft.ts | 10 +++ src/api/fetch/admin/api/usePostNoticeDraft.ts | 26 ++++++++ src/api/fetch/admin/api/usePutNoticeDraft.ts | 39 ++++++++++++ src/api/fetch/admin/index.ts | 6 ++ src/api/fetch/admin/types/NoticeDraftType.ts | 23 +++++++ .../NoticeDeleteButton/NoticeDeleteButton.tsx | 43 +++++++++++++ .../notice/write/[id]/_components/index.ts | 1 + .../write/[id]/_hooks/useSubmitNoticeEdit.ts | 42 ++----------- .../admin/notice/write/[id]/layout.tsx | 14 ++++- .../NoticeWriteForm/NoticeWriteForm.tsx | 12 +++- .../notice/write/_hooks/useSaveNoticeDraft.ts | 37 +++++++++++ .../notice/write/_hooks/useSubmitNotice.ts | 41 ++++++------ .../write/_hooks/useUploadNoticeImages.ts | 45 +++++++++++++ .../notice/write/_types/NoticeWriteType.ts | 3 +- .../write/_utils/toNoticeWriteFormValues.ts | 13 ++++ src/app/(admin)/admin/notice/write/page.tsx | 63 +++++++++++++++++-- tests/e2e/admin-notice.spec.ts | 39 ++++++++++++ 18 files changed, 412 insertions(+), 67 deletions(-) create mode 100644 src/api/fetch/admin/api/useDeleteNotice.ts create mode 100644 src/api/fetch/admin/api/useGetNoticeDraft.ts create mode 100644 src/api/fetch/admin/api/usePostNoticeDraft.ts create mode 100644 src/api/fetch/admin/api/usePutNoticeDraft.ts create mode 100644 src/api/fetch/admin/types/NoticeDraftType.ts create mode 100644 src/app/(admin)/admin/notice/write/[id]/_components/NoticeDeleteButton/NoticeDeleteButton.tsx create mode 100644 src/app/(admin)/admin/notice/write/_hooks/useSaveNoticeDraft.ts create mode 100644 src/app/(admin)/admin/notice/write/_hooks/useUploadNoticeImages.ts create mode 100644 src/app/(admin)/admin/notice/write/_utils/toNoticeWriteFormValues.ts diff --git a/src/api/fetch/admin/api/useDeleteNotice.ts b/src/api/fetch/admin/api/useDeleteNotice.ts new file mode 100644 index 0000000..5303600 --- /dev/null +++ b/src/api/fetch/admin/api/useDeleteNotice.ts @@ -0,0 +1,22 @@ +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; + +export const useDeleteNotice = (noticeId: number) => { + const queryClient = useQueryClient(); + const { addToast } = useToast(); + const router = useRouter(); + + return useAppMutation("auth", `/admin/notices/${noticeId}`, "delete", { + onSuccess: () => { + queryClient.removeQueries({ queryKey: ["notice-detail", noticeId] }); + queryClient.invalidateQueries({ queryKey: ["notices"] }); + addToast("공지사항이 삭제되었어요", "success"); + router.replace("/admin/notice"); + }, + onError: () => { + addToast("공지사항 삭제에 실패했어요", "error"); + }, + }); +}; diff --git a/src/api/fetch/admin/api/useGetNoticeDraft.ts b/src/api/fetch/admin/api/useGetNoticeDraft.ts new file mode 100644 index 0000000..125e811 --- /dev/null +++ b/src/api/fetch/admin/api/useGetNoticeDraft.ts @@ -0,0 +1,10 @@ +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { NoticeDraftResponse } from "../types/NoticeDraftType"; + +export const useGetNoticeDraft = () => { + return useAppQuery("auth", ["notice-draft"], "/admin/notices/draft", { + // 임시저장본이 없을 때 서버가 오류로 응답할 수 있으므로 재시도하지 않는다. + retry: false, + staleTime: 0, + }); +}; diff --git a/src/api/fetch/admin/api/usePostNoticeDraft.ts b/src/api/fetch/admin/api/usePostNoticeDraft.ts new file mode 100644 index 0000000..f8bb162 --- /dev/null +++ b/src/api/fetch/admin/api/usePostNoticeDraft.ts @@ -0,0 +1,26 @@ +import { useQueryClient } from "@tanstack/react-query"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeSaveRequest } from "../types/NoticeDraftType"; + +/** 공지를 처음 임시저장한다. 응답의 `result`가 새로 만들어진 공지 ID다. */ +export const usePostNoticeDraft = () => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + + return useAppMutation>( + "auth", + "/admin/notices", + "post", + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notice-draft"] }); + addToast("임시저장했어요", "success"); + }, + onError: () => { + addToast("임시저장에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/api/usePutNoticeDraft.ts b/src/api/fetch/admin/api/usePutNoticeDraft.ts new file mode 100644 index 0000000..3a24e95 --- /dev/null +++ b/src/api/fetch/admin/api/usePutNoticeDraft.ts @@ -0,0 +1,39 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeSaveRequest } from "../types/NoticeDraftType"; + +/** + * 임시저장한 공지를 갱신한다. `draft: true`면 임시저장을 덮어쓰고, + * `draft: false`면 그 공지를 발행하고 공지 목록으로 이동한다. + */ +export const usePutNoticeDraft = (draftId: number | null) => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + const router = useRouter(); + + return useAppMutation>( + "auth", + `/admin/notices/${draftId}`, + "put", + { + onSuccess: (_, { draft }) => { + queryClient.invalidateQueries({ queryKey: ["notice-draft"] }); + + if (draft) { + addToast("임시저장했어요", "success"); + return; + } + + queryClient.invalidateQueries({ queryKey: ["notices"] }); + addToast("공지사항이 등록되었습니다.", "success"); + router.replace("/admin/notice"); + }, + onError: (_, { draft }) => { + addToast(draft ? "임시저장에 실패했어요" : "공지사항 등록에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/index.ts b/src/api/fetch/admin/index.ts index d243ab5..3ccf95c 100644 --- a/src/api/fetch/admin/index.ts +++ b/src/api/fetch/admin/index.ts @@ -20,3 +20,9 @@ export { usePutInquiryStatus } from "./api/usePutInquiryStatus"; export { usePostReportComments } from "./api/usePostReportComments"; export { usePutNoticeDetail } from "./api/usePutNoticeDetail"; export { useGetMarketingPosts } from "./api/useGetMarketingPosts"; +export * from "./types/NoticeDraftType"; + +export { useDeleteNotice } from "./api/useDeleteNotice"; +export { useGetNoticeDraft } from "./api/useGetNoticeDraft"; +export { usePostNoticeDraft } from "./api/usePostNoticeDraft"; +export { usePutNoticeDraft } from "./api/usePutNoticeDraft"; diff --git a/src/api/fetch/admin/types/NoticeDraftType.ts b/src/api/fetch/admin/types/NoticeDraftType.ts new file mode 100644 index 0000000..bb99dd6 --- /dev/null +++ b/src/api/fetch/admin/types/NoticeDraftType.ts @@ -0,0 +1,23 @@ +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeCategory } from "@/types"; + +export interface NoticeDraft { + noticeId: number; + title: string; + content: string; + category: NoticeCategory | null; + pinned: boolean; + images: string[] | null; + createdAt: string; + updatedAt: string; +} + +export interface NoticeDraftResponse extends ApiBaseResponseType {} + +export interface NoticeSaveRequest { + title: string; + content: string; + category?: NoticeCategory; + imageUrls: string[]; + draft: boolean; +} diff --git a/src/app/(admin)/admin/notice/write/[id]/_components/NoticeDeleteButton/NoticeDeleteButton.tsx b/src/app/(admin)/admin/notice/write/[id]/_components/NoticeDeleteButton/NoticeDeleteButton.tsx new file mode 100644 index 0000000..bbc5aca --- /dev/null +++ b/src/app/(admin)/admin/notice/write/[id]/_components/NoticeDeleteButton/NoticeDeleteButton.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { ConfirmModal } from "@/components"; +import { useDeleteNotice } from "@/api/fetch/admin"; + +interface NoticeDeleteButtonProps { + noticeId: number; +} + +/** 공지 수정 화면 헤더에 두는 삭제 버튼과 확인 모달입니다. 삭제에 성공하면 공지 목록으로 이동합니다. */ +const NoticeDeleteButton = ({ noticeId }: NoticeDeleteButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + const { mutate: deleteNotice, isPending } = useDeleteNotice(noticeId); + + const close = () => setIsOpen(false); + + return ( + <> + + + deleteNotice(undefined, { onError: close })} + size="small" + /> + + ); +}; + +export default NoticeDeleteButton; diff --git a/src/app/(admin)/admin/notice/write/[id]/_components/index.ts b/src/app/(admin)/admin/notice/write/[id]/_components/index.ts index 801cc3b..1b755f3 100644 --- a/src/app/(admin)/admin/notice/write/[id]/_components/index.ts +++ b/src/app/(admin)/admin/notice/write/[id]/_components/index.ts @@ -1 +1,2 @@ export { default as NoticeEditForm } from "./NoticeEditForm/NoticeEditForm"; +export { default as NoticeDeleteButton } from "./NoticeDeleteButton/NoticeDeleteButton"; diff --git a/src/app/(admin)/admin/notice/write/[id]/_hooks/useSubmitNoticeEdit.ts b/src/app/(admin)/admin/notice/write/[id]/_hooks/useSubmitNoticeEdit.ts index fbb288c..47dd1b6 100644 --- a/src/app/(admin)/admin/notice/write/[id]/_hooks/useSubmitNoticeEdit.ts +++ b/src/app/(admin)/admin/notice/write/[id]/_hooks/useSubmitNoticeEdit.ts @@ -1,5 +1,5 @@ import { usePutNoticeDetail } from "@/api/fetch/admin"; -import { useDeleteS3, usePostS3 } from "@/api/fetch/s3"; +import { useUploadNoticeImages } from "../../_hooks/useUploadNoticeImages"; import { NoticeEditFormValues } from "../_types/NoticeEditFormValues"; interface UseSubmitNoticeEditParams { @@ -8,47 +8,13 @@ interface UseSubmitNoticeEditParams { export const useSubmitNoticeEdit = ({ noticeId }: UseSubmitNoticeEditParams) => { const { mutate: putNotice, isPending } = usePutNoticeDetail(noticeId); - const { mutate: postS3 } = usePostS3(); - const { mutate: deleteS3 } = useDeleteS3(); + const { uploadImages } = useUploadNoticeImages(); const submitNoticeEdit = (data: NoticeEditFormValues) => { const { images, ...rest } = data; - const fileItems = images.filter((item) => item.file instanceof File); - const files = fileItems.map((item) => item.file as File); - - const existingUrls = images - .filter((item) => !(item.file instanceof File)) - .map((item) => item.previewUrl) - .filter((url) => Boolean(url) && !url.startsWith("blob:")); - - const submitWithImages = (uploadedUrls: string[]) => { - const imageUrls = [...existingUrls, ...uploadedUrls]; - - putNotice( - { ...rest, imageUrls }, - { - onError: () => { - if (uploadedUrls.length > 0) { - deleteS3(uploadedUrls); - } - }, - } - ); - }; - - if (files.length === 0) { - submitWithImages([]); - return; - } - - const formData = new FormData(); - files.forEach((file) => formData.append("image", file)); - - postS3(formData, { - onSuccess: ({ result }) => { - submitWithImages(result ?? []); - }, + uploadImages(images, (imageUrls, rollbackUploads) => { + putNotice({ ...rest, imageUrls }, { onError: rollbackUploads }); }); }; diff --git a/src/app/(admin)/admin/notice/write/[id]/layout.tsx b/src/app/(admin)/admin/notice/write/[id]/layout.tsx index 88b66db..39e3634 100644 --- a/src/app/(admin)/admin/notice/write/[id]/layout.tsx +++ b/src/app/(admin)/admin/notice/write/[id]/layout.tsx @@ -1,15 +1,25 @@ import { DetailHeader } from "@/components"; import { ReactNode } from "react"; import type { Metadata } from "next"; +import { NoticeDeleteButton } from "./_components"; export const metadata: Metadata = { other: { "page-type": "admin-notice-write" }, }; -const Layout = ({ children }: { children: ReactNode }) => { +interface LayoutProps { + children: ReactNode; + params: Promise<{ id: string }>; +} + +const Layout = async ({ children, params }: LayoutProps) => { + const { id } = await params; + return (
- + + +

공지사항 수정 페이지

{children}
diff --git a/src/app/(admin)/admin/notice/write/_components/NoticeWriteForm/NoticeWriteForm.tsx b/src/app/(admin)/admin/notice/write/_components/NoticeWriteForm/NoticeWriteForm.tsx index 678ffe1..012f557 100644 --- a/src/app/(admin)/admin/notice/write/_components/NoticeWriteForm/NoticeWriteForm.tsx +++ b/src/app/(admin)/admin/notice/write/_components/NoticeWriteForm/NoticeWriteForm.tsx @@ -11,10 +11,16 @@ const IMAGE_HELP_TEXT = "*사진은 최대 5장 첨부가 가능합니다. (선 const canSubmit = (values: NoticeWriteFormValues): boolean => Boolean(values.title?.trim() && values.category && values.content?.trim()); -const NoticeWriteForm = ({ methods }: { methods: UseFormReturn }) => { +interface NoticeWriteFormProps { + methods: UseFormReturn; + /** 임시저장본을 이어 쓰는 중이면 그 공지 ID. 발행할 때 새로 만들지 않고 이 공지를 발행한다. */ + draftId: number | null; +} + +const NoticeWriteForm = ({ methods, draftId }: NoticeWriteFormProps) => { const values = useWatch({ control: methods.control }); - const isSubmitDisabled = !canSubmit(values as NoticeWriteFormValues); - const { submitNotice } = useSubmitNotice(); + const { submitNotice, isPending } = useSubmitNotice(draftId); + const isSubmitDisabled = !canSubmit(values as NoticeWriteFormValues) || isPending; const onSubmit = (data: NoticeWriteFormValues) => submitNotice(data); diff --git a/src/app/(admin)/admin/notice/write/_hooks/useSaveNoticeDraft.ts b/src/app/(admin)/admin/notice/write/_hooks/useSaveNoticeDraft.ts new file mode 100644 index 0000000..906c9c7 --- /dev/null +++ b/src/app/(admin)/admin/notice/write/_hooks/useSaveNoticeDraft.ts @@ -0,0 +1,37 @@ +import { usePostNoticeDraft, usePutNoticeDraft } from "@/api/fetch/admin"; +import { NoticeWriteFormValues } from "../_types/NoticeWriteType"; +import { useUploadNoticeImages } from "./useUploadNoticeImages"; + +interface UseSaveNoticeDraftParams { + /** 이미 저장한 임시저장본 ID. 없으면 처음 저장할 때 새로 만든다. */ + draftId: number | null; + /** 저장에 성공하면 임시저장본 ID와 서버에 올라간 이미지 URL 목록을 넘긴다. */ + onSaved: (draftId: number, imageUrls: string[]) => void; +} + +export const useSaveNoticeDraft = ({ draftId, onSaved }: UseSaveNoticeDraftParams) => { + const { mutate: postDraft, isPending: isPosting } = usePostNoticeDraft(); + const { mutate: putDraft, isPending: isPutting } = usePutNoticeDraft(draftId); + const { uploadImages, isUploading } = useUploadNoticeImages(); + + const saveDraft = (data: NoticeWriteFormValues) => { + const { images, category, ...rest } = data; + + uploadImages(images, (imageUrls, rollbackUploads) => { + // 분류는 임시저장 단계에서 비어 있을 수 있다. 빈 문자열은 서버 enum에 없으므로 보내지 않는다. + const body = { ...rest, category: category || undefined, imageUrls, draft: true as const }; + + if (draftId) { + putDraft(body, { onSuccess: () => onSaved(draftId, imageUrls), onError: rollbackUploads }); + return; + } + + postDraft(body, { + onSuccess: ({ result }) => onSaved(result, imageUrls), + onError: rollbackUploads, + }); + }); + }; + + return { saveDraft, isSaving: isPosting || isPutting || isUploading }; +}; diff --git a/src/app/(admin)/admin/notice/write/_hooks/useSubmitNotice.ts b/src/app/(admin)/admin/notice/write/_hooks/useSubmitNotice.ts index 99b449a..582fb9a 100644 --- a/src/app/(admin)/admin/notice/write/_hooks/useSubmitNotice.ts +++ b/src/app/(admin)/admin/notice/write/_hooks/useSubmitNotice.ts @@ -1,30 +1,33 @@ -import { usePostNotices } from "@/api/fetch/admin"; -import { useDeleteS3, usePostS3 } from "@/api/fetch/s3"; +import { usePostNotices, usePutNoticeDraft } from "@/api/fetch/admin"; import { NoticeWriteFormValues } from "../_types/NoticeWriteType"; +import { useUploadNoticeImages } from "./useUploadNoticeImages"; -const useSubmitNotice = () => { - const { mutate: postNotice } = usePostNotices(); - const { mutate: postS3 } = usePostS3(); - const { mutate: deleteS3 } = useDeleteS3(); +/** + * 공지를 발행한다. 임시저장본을 이어 쓰는 중이면(`draftId`) 그 공지를 발행 상태로 바꾸고, + * 아니면 새 공지를 만든다. + */ +const useSubmitNotice = (draftId: number | null) => { + const { mutate: postNotice, isPending: isPosting } = usePostNotices(); + const { mutate: publishDraft, isPending: isPublishing } = usePutNoticeDraft(draftId); + const { uploadImages, isUploading } = useUploadNoticeImages(); const submitNotice = (data: NoticeWriteFormValues) => { - const { images, ...rest } = data; - const files = images.map((i) => i.file).filter((f): f is File => f != null); + const { images, category, ...rest } = data; - const submitNotice = (imageUrls: string[]) => - postNotice( - { ...rest, imageUrls }, - { onError: () => imageUrls.length > 0 && deleteS3(imageUrls) } - ); + uploadImages(images, (imageUrls, rollbackUploads) => { + if (draftId) { + publishDraft( + { ...rest, category: category || undefined, imageUrls, draft: false }, + { onError: rollbackUploads } + ); + return; + } - if (files.length === 0) return submitNotice([]); - - const formData = new FormData(); - files.forEach((f) => formData.append("image", f)); - postS3(formData, { onSuccess: ({ result }) => submitNotice(result) }); + postNotice({ ...rest, category, imageUrls }, { onError: rollbackUploads }); + }); }; - return { submitNotice }; + return { submitNotice, isPending: isPosting || isPublishing || isUploading }; }; export default useSubmitNotice; diff --git a/src/app/(admin)/admin/notice/write/_hooks/useUploadNoticeImages.ts b/src/app/(admin)/admin/notice/write/_hooks/useUploadNoticeImages.ts new file mode 100644 index 0000000..e202cc2 --- /dev/null +++ b/src/app/(admin)/admin/notice/write/_hooks/useUploadNoticeImages.ts @@ -0,0 +1,45 @@ +import { useDeleteS3, usePostS3 } from "@/api/fetch/s3"; +import { NoticeWriteImageItem } from "../_types/NoticeWriteType"; + +type OnImagesReady = (imageUrls: string[], rollbackUploads: () => void) => void; + +/** + * 공지 작성, 임시저장, 수정이 함께 쓰는 이미지 업로드 훅입니다. + * 새로 첨부한 파일만 S3에 올리고, 이미 올라가 있던 URL과 합쳐 `onReady`에 넘깁니다. + * 공지 저장이 실패하면 `rollbackUploads`를 불러 방금 올린 파일을 지웁니다. + */ +export const useUploadNoticeImages = () => { + const { mutate: postS3, isPending: isUploading } = usePostS3(); + const { mutate: deleteS3 } = useDeleteS3(); + + const uploadImages = (images: NoticeWriteImageItem[], onReady: OnImagesReady) => { + const files = images + .map((item) => item.file) + .filter((file): file is File => file instanceof File); + + const existingUrls = images + .filter((item) => !(item.file instanceof File)) + .map((item) => item.previewUrl) + .filter((url) => Boolean(url) && !url.startsWith("blob:")); + + if (files.length === 0) { + onReady(existingUrls, () => {}); + return; + } + + const formData = new FormData(); + files.forEach((file) => formData.append("image", file)); + + postS3(formData, { + onSuccess: ({ result }) => { + const uploadedUrls = result ?? []; + + onReady([...existingUrls, ...uploadedUrls], () => { + if (uploadedUrls.length > 0) deleteS3(uploadedUrls); + }); + }, + }); + }; + + return { uploadImages, isUploading }; +}; diff --git a/src/app/(admin)/admin/notice/write/_types/NoticeWriteType.ts b/src/app/(admin)/admin/notice/write/_types/NoticeWriteType.ts index cef8886..bdfba85 100644 --- a/src/app/(admin)/admin/notice/write/_types/NoticeWriteType.ts +++ b/src/app/(admin)/admin/notice/write/_types/NoticeWriteType.ts @@ -2,8 +2,9 @@ import { NoticeCategory } from "@/types"; export type NoticeCategoryFormValue = "" | NoticeCategory; +/** 새로 첨부한 이미지는 `file`이 있고, 임시저장본에서 불러온 이미지는 서버 URL만 있다. */ export interface NoticeWriteImageItem { - file: File; + file?: File; previewUrl: string; } diff --git a/src/app/(admin)/admin/notice/write/_utils/toNoticeWriteFormValues.ts b/src/app/(admin)/admin/notice/write/_utils/toNoticeWriteFormValues.ts new file mode 100644 index 0000000..e95465b --- /dev/null +++ b/src/app/(admin)/admin/notice/write/_utils/toNoticeWriteFormValues.ts @@ -0,0 +1,13 @@ +import { NoticeDraft } from "@/api/fetch/admin"; +import { NoticeWriteFormValues } from "../_types/NoticeWriteType"; + +export function toNoticeWriteFormValues(draft: NoticeDraft): NoticeWriteFormValues { + return { + title: draft.title ?? "", + content: draft.content ?? "", + category: draft.category ?? "", + images: (draft.images ?? []) + .filter((url): url is string => Boolean(url)) + .map((url) => ({ previewUrl: url })), + }; +} diff --git a/src/app/(admin)/admin/notice/write/page.tsx b/src/app/(admin)/admin/notice/write/page.tsx index 6748993..34d4fe3 100644 --- a/src/app/(admin)/admin/notice/write/page.tsx +++ b/src/app/(admin)/admin/notice/write/page.tsx @@ -1,9 +1,13 @@ "use client"; -import { DetailHeader } from "@/components"; -import { FormProvider, useForm } from "react-hook-form"; +import { useState } from "react"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; +import { ConfirmModal, DetailHeader } from "@/components"; +import { useGetNoticeDraft } from "@/api/fetch/admin"; import { NoticeWriteForm } from "./_components"; import { NoticeWriteFormValues } from "./_types/NoticeWriteType"; +import { useSaveNoticeDraft } from "./_hooks/useSaveNoticeDraft"; +import { toNoticeWriteFormValues } from "./_utils/toNoticeWriteFormValues"; const DEFAULT_VALUES: NoticeWriteFormValues = { title: "", @@ -19,13 +23,64 @@ const NoticeWrite = () => { reValidateMode: "onChange", }); + const [draftId, setDraftId] = useState(null); + const [isDraftPromptDismissed, setIsDraftPromptDismissed] = useState(false); + + const { data: draftData } = useGetNoticeDraft(); + const savedDraft = draftData?.result ?? null; + const isDraftPromptOpen = Boolean(savedDraft) && draftId === null && !isDraftPromptDismissed; + + const { saveDraft, isSaving } = useSaveNoticeDraft({ + draftId, + onSaved: (savedId, imageUrls) => { + setDraftId(savedId); + // 다시 저장할 때 같은 파일을 또 올리지 않도록, 올라간 URL로 바꿔 둔다. + methods.setValue( + "images", + imageUrls.map((url) => ({ previewUrl: url })) + ); + }, + }); + + const [title, content] = useWatch({ control: methods.control, name: ["title", "content"] }); + // 서버가 임시저장에도 제목과 내용을 요구한다. + const isDraftSaveDisabled = !title?.trim() || !content?.trim() || isSaving; + + const loadDraft = () => { + if (!savedDraft) return; + methods.reset(toNoticeWriteFormValues(savedDraft)); + setDraftId(savedDraft.noticeId); + }; + + const dismissDraftPrompt = () => setIsDraftPromptDismissed(true); + return ( <> - + + +

공지사항 글쓰기 페이지

{...methods}> - + + + ); }; diff --git a/tests/e2e/admin-notice.spec.ts b/tests/e2e/admin-notice.spec.ts index 64001cf..4568099 100644 --- a/tests/e2e/admin-notice.spec.ts +++ b/tests/e2e/admin-notice.spec.ts @@ -85,6 +85,45 @@ test.describe("관리자 공지사항 글쓰기 페이지 (/admin/notice/write)" await expect(page.getByPlaceholder("내용을 입력해 주세요.")).toBeVisible(); }); + test("임시저장한 공지가 있으면 불러오기를 물어보고, 불러오면 폼을 채운다", async ({ page }) => { + await page.route("**/api/auth/refresh", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_AUTH_REFRESH), + }) + ); + await page.route("**/api/admin/notices/draft", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + isSuccess: true, + code: "COMMON200", + message: "성공", + result: { + noticeId: 77, + title: "임시저장 제목", + content: "임시저장 내용", + category: "GENERAL", + pinned: false, + images: [], + createdAt: "2026-09-26T10:00:00", + updatedAt: "2026-09-26T10:00:00", + }, + }), + }) + ); + + await page.goto("/admin/notice/write"); + + await expect(page.getByText("임시저장한 공지가 있어요")).toBeVisible(); + await page.getByRole("button", { name: "불러오기" }).click(); + + await expect(page.getByPlaceholder("제목을 입력해 주세요.")).toHaveValue("임시저장 제목"); + await expect(page.getByPlaceholder("내용을 입력해 주세요.")).toHaveValue("임시저장 내용"); + }); + test("필수 필드 미입력 시 등록 버튼이 비활성화된다", async ({ page }) => { await page.route("**/api/auth/refresh", (route) => route.fulfill({ From 16bad9aed2a6b7d4ee3636f18c7d05bf305c8cfb Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 26 Sep 2026 16:03:52 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(inquiry):=20=EB=B9=84=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=AC=B8=EC=9D=98=20=EB=8B=B5=EB=B3=80,=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20IP=20=EC=B0=A8=EB=8B=A8,=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EB=B3=B4=20=ED=99=94=EB=A9=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비회원 문의 상세에 답변 폼을 두어 문의자 이메일로 답변을 보냅니다. 이미 답변한 문의에는 폼 대신 안내 문구를 보여줍니다. 회원과 비회원 문의 상세에 확인 모달을 거치는 IP 차단 버튼을 추가했습니다. 유저 정보 화면(/admin/users/[userId])을 추가했습니다. 상세 응답에 사용자 ID가 없어서 신고와 회원 문의 목록에서 ?userId=로 넘기고, 상세 화면에 신고자 또는 문의자 정보 링크를 둡니다. Co-Authored-By: Claude Opus 5.5 --- src/api/fetch/admin/api/useGetUserDetail.ts | 10 ++ .../admin/api/usePostGuestInquiryReply.ts | 29 ++++ .../fetch/admin/api/usePostInquiryBlockIp.ts | 30 ++++ src/api/fetch/admin/index.ts | 4 + src/api/fetch/admin/types/UserDetailType.ts | 24 ++++ .../InquiryBlockIpButton.tsx | 44 ++++++ src/app/(admin)/admin/_components/index.ts | 1 + .../toReportsItemVM/toReportsItemVM.test.ts | 16 +++ .../_utils/toReportsItemVM/toReportsItemVM.ts | 6 +- .../GuestInquiriesDetailView.test.tsx | 28 ++++ .../GuestInquiriesDetailView.tsx | 16 ++- .../GuestInquiryReplyForm.test.tsx | 58 ++++++++ .../GuestInquiryReplyForm.tsx | 78 ++++++++++ .../admin/guest-inquiries/[id]/_docs/plan.md | 10 ++ .../AdminReportsView/AdminReportsView.tsx | 34 ++++- .../admin/reports/[reportsType]/[id]/page.tsx | 10 +- .../UserDetailView/UserDetailView.test.tsx | 76 ++++++++++ .../UserDetailView/UserDetailView.tsx | 133 ++++++++++++++++++ .../admin/users/[userId]/_components/index.ts | 1 + .../(admin)/admin/users/[userId]/layout.tsx | 13 ++ src/app/(admin)/admin/users/[userId]/page.tsx | 25 ++++ tests/e2e/admin-guest-inquiries.spec.ts | 50 +++++++ tests/e2e/admin-reports.spec.ts | 4 +- 23 files changed, 687 insertions(+), 13 deletions(-) create mode 100644 src/api/fetch/admin/api/useGetUserDetail.ts create mode 100644 src/api/fetch/admin/api/usePostGuestInquiryReply.ts create mode 100644 src/api/fetch/admin/api/usePostInquiryBlockIp.ts create mode 100644 src/api/fetch/admin/types/UserDetailType.ts create mode 100644 src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx create mode 100644 src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx create mode 100644 src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx create mode 100644 src/app/(admin)/admin/guest-inquiries/[id]/_docs/plan.md create mode 100644 src/app/(admin)/admin/users/[userId]/_components/UserDetailView/UserDetailView.test.tsx create mode 100644 src/app/(admin)/admin/users/[userId]/_components/UserDetailView/UserDetailView.tsx create mode 100644 src/app/(admin)/admin/users/[userId]/_components/index.ts create mode 100644 src/app/(admin)/admin/users/[userId]/layout.tsx create mode 100644 src/app/(admin)/admin/users/[userId]/page.tsx diff --git a/src/api/fetch/admin/api/useGetUserDetail.ts b/src/api/fetch/admin/api/useGetUserDetail.ts new file mode 100644 index 0000000..2392c08 --- /dev/null +++ b/src/api/fetch/admin/api/useGetUserDetail.ts @@ -0,0 +1,10 @@ +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { GetUserDetailResponse } from "../types/UserDetailType"; + +export const useGetUserDetail = ({ userId }: { userId: number }) => { + return useAppQuery( + "auth", + ["admin-user-detail", userId], + `/admin/users/${userId}` + ); +}; diff --git a/src/api/fetch/admin/api/usePostGuestInquiryReply.ts b/src/api/fetch/admin/api/usePostGuestInquiryReply.ts new file mode 100644 index 0000000..af1b2fa --- /dev/null +++ b/src/api/fetch/admin/api/usePostGuestInquiryReply.ts @@ -0,0 +1,29 @@ +import { useQueryClient } from "@tanstack/react-query"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; + +interface GuestInquiryReplyRequest { + content: string; +} + +export const usePostGuestInquiryReply = (inquiryId: number) => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + + return useAppMutation>( + "auth", + `/admin/guest-inquiries/${inquiryId}/reply`, + "post", + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["guest-inquiries-detail", inquiryId] }); + queryClient.invalidateQueries({ queryKey: ["guest-inquiries"] }); + addToast("답변을 이메일로 보냈어요", "success"); + }, + onError: () => { + addToast("답변 발송에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/api/usePostInquiryBlockIp.ts b/src/api/fetch/admin/api/usePostInquiryBlockIp.ts new file mode 100644 index 0000000..6fd9bfe --- /dev/null +++ b/src/api/fetch/admin/api/usePostInquiryBlockIp.ts @@ -0,0 +1,30 @@ +import { AxiosError } from "axios"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; + +interface InquiryBlockIpRequest { + reason?: string; +} + +export const usePostInquiryBlockIp = (inquiryId: number) => { + const { addToast } = useToast(); + + return useAppMutation< + InquiryBlockIpRequest, + ApiBaseResponseType, + AxiosError> + >("auth", `/admin/inquiries/${inquiryId}/block-ip`, "post", { + onSuccess: () => { + addToast("이 문의의 IP를 차단했어요", "success"); + }, + onError: (error) => { + // 409는 이미 차단 목록에 있는 IP라는 뜻이다. + if (error.response?.status === 409) { + addToast("이미 차단된 IP예요", "warning"); + return; + } + addToast("IP 차단에 실패했어요", "error"); + }, + }); +}; diff --git a/src/api/fetch/admin/index.ts b/src/api/fetch/admin/index.ts index 3ccf95c..45df116 100644 --- a/src/api/fetch/admin/index.ts +++ b/src/api/fetch/admin/index.ts @@ -21,8 +21,12 @@ export { usePostReportComments } from "./api/usePostReportComments"; export { usePutNoticeDetail } from "./api/usePutNoticeDetail"; export { useGetMarketingPosts } from "./api/useGetMarketingPosts"; export * from "./types/NoticeDraftType"; +export * from "./types/UserDetailType"; +export { usePostGuestInquiryReply } from "./api/usePostGuestInquiryReply"; +export { usePostInquiryBlockIp } from "./api/usePostInquiryBlockIp"; export { useDeleteNotice } from "./api/useDeleteNotice"; export { useGetNoticeDraft } from "./api/useGetNoticeDraft"; export { usePostNoticeDraft } from "./api/usePostNoticeDraft"; export { usePutNoticeDraft } from "./api/usePutNoticeDraft"; +export { useGetUserDetail } from "./api/useGetUserDetail"; diff --git a/src/api/fetch/admin/types/UserDetailType.ts b/src/api/fetch/admin/types/UserDetailType.ts new file mode 100644 index 0000000..f98d78d --- /dev/null +++ b/src/api/fetch/admin/types/UserDetailType.ts @@ -0,0 +1,24 @@ +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { CategoryType } from "@/types"; + +export interface AdminUserDetail { + userId: number; + nickname: string; + email: string; + emailVerified: boolean; + role: "USER" | "ADMIN"; + profileImg: string | null; + privacyPolicyAgreed: boolean; + termsOfServiceAgreed: boolean; + contentPolicyAgreed: boolean; + marketingConsent: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + postCount: number; + commentCount: number; + reportCount: number; + subscribedCategories: CategoryType[]; +} + +export interface GetUserDetailResponse extends ApiBaseResponseType {} diff --git a/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx b/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx new file mode 100644 index 0000000..4ce5a47 --- /dev/null +++ b/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { Button, ConfirmModal } from "@/components"; +import { usePostInquiryBlockIp } from "@/api/fetch/admin"; + +interface InquiryBlockIpButtonProps { + inquiryId: number; + className?: string; +} + +/** 문의를 보낸 IP를 차단 목록에 올리는 버튼과 확인 모달입니다. 회원·비회원 문의 상세에서 함께 씁니다. */ +const InquiryBlockIpButton = ({ inquiryId, className }: InquiryBlockIpButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + const { mutate: blockIp, isPending } = usePostInquiryBlockIp(inquiryId); + + const close = () => setIsOpen(false); + + const confirmBlock = () => { + blockIp({}, { onSettled: close }); + }; + + return ( + <> + + + + + ); +}; + +export default InquiryBlockIpButton; diff --git a/src/app/(admin)/admin/_components/index.ts b/src/app/(admin)/admin/_components/index.ts index 8c94030..9e23f8c 100644 --- a/src/app/(admin)/admin/_components/index.ts +++ b/src/app/(admin)/admin/_components/index.ts @@ -7,3 +7,4 @@ export { default as AdminReportsItem } from "./AdminReportsItem/AdminReportsItem export { default as AdminDetailSection } from "./AdminDetailSection/AdminDetailSection"; export { default as AdminDropdown } from "./AdminDropdown/AdminDropdown"; export { default as AdminSidebar } from "./AdminSidebar/AdminSidebar"; +export { default as InquiryBlockIpButton } from "./InquiryBlockIpButton/InquiryBlockIpButton"; diff --git a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts index 16590af..ec58744 100644 --- a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts +++ b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts @@ -30,6 +30,14 @@ describe("toReportItemVM", () => { }); }); +describe("toReportItemVM 신고자 ID", () => { + it("reporterId가 있으면 상세 주소에 userId 쿼리를 붙임", () => { + const item = { reportId: 1, reporterId: 42, reportType: "DUPLICATE" } as any; + + expect(toReportItemVM(item).href).toBe("/admin/reports/report/1?userId=42"); + }); +}); + describe("toInquiryItemVM", () => { it("문의 매핑", () => { const item = { @@ -56,6 +64,14 @@ describe("toInquiryItemVM", () => { }); }); +describe("toInquiryItemVM 문의자 ID", () => { + it("userId가 있으면 상세 주소에 userId 쿼리를 붙임", () => { + const item = { inquiryId: 10, userId: 7 } as any; + + expect(toInquiryItemVM(item).href).toBe("/admin/reports/inquiry/10?userId=7"); + }); +}); + describe("toGuestInquiryItemVM", () => { it("비회원 문의 매핑", () => { const item = { diff --git a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts index 6b2cf0d..3b50ba8 100644 --- a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts +++ b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts @@ -8,7 +8,8 @@ import { REPORT_TYPE_TITLE } from "../../_constants/REPORT_TYPE_TITLE"; export const toReportItemVM = (item: AdminReportItem): AdminReportsItemData => { return { - href: `/admin/reports/report/${item.reportId}`, + // 상세 응답에는 신고자 ID가 없어서, 유저 정보로 이어지도록 목록의 ID를 쿼리로 넘긴다. + href: `/admin/reports/report/${item.reportId}${item.reporterId ? `?userId=${item.reporterId}` : ""}`, title: REPORT_TYPE_TITLE[item.reportType], content: item.reason, nickname: item.reporterNickname, @@ -21,7 +22,8 @@ export const toReportItemVM = (item: AdminReportItem): AdminReportsItemData => { export const toInquiryItemVM = (item: AdminInquiriesItem): AdminReportsItemData => { return { - href: `/admin/reports/inquiry/${item.inquiryId}`, + // 상세 응답에는 문의자 ID가 없어서, 유저 정보로 이어지도록 목록의 ID를 쿼리로 넘긴다. + href: `/admin/reports/inquiry/${item.inquiryId}${item.userId ? `?userId=${item.userId}` : ""}`, title: item.title, content: item.content, nickname: item.nickname, diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx index c75d824..7292e74 100644 --- a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx @@ -20,6 +20,14 @@ jest.mock("@/app/(admin)/admin/_components", () => ({ AdminDetailSection: ({ data }: any) => (
), + InquiryBlockIpButton: ({ inquiryId }: any) => ( +
+ ), +})); + +jest.mock("../GuestInquiryReplyForm/GuestInquiryReplyForm", () => ({ + __esModule: true, + default: ({ email }: any) =>
, })); const addToastMock = jest.fn(); @@ -129,4 +137,24 @@ describe("GuestInquiriesDetailView", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(addToastMock).toHaveBeenCalledWith("이메일 복사에 실패했어요", "error"); }); + + it("답변하지 않은 문의는 답변 폼과 IP 차단 버튼을 보여줌", () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByTestId("reply-form")).toHaveAttribute("data-email", "admin@gmail.com"); + expect(screen.getByTestId("block-ip")).toHaveAttribute("data-inquiry-id", "1"); + }); + + it("이미 답변한 문의는 답변 폼 대신 안내 문구를 보여줌", () => { + mockedUseGetDetailGuestInquiries.mockReturnValue({ + data: { result: { email: "admin@gmail.com", answered: true } }, + isLoading: false, + isError: false, + }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByTestId("reply-form")).not.toBeInTheDocument(); + expect(screen.getByText("답변을 보낸 문의예요.")).toBeInTheDocument(); + }); }); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx index 4299bfe..04a9af5 100644 --- a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx @@ -2,8 +2,9 @@ import { Button, LoadingState } from "@/components"; import { useToast } from "@/context/ToastContext"; -import { AdminDetailSection } from "@/app/(admin)/admin/_components"; +import { AdminDetailSection, InquiryBlockIpButton } from "@/app/(admin)/admin/_components"; import { useGetDetailGuestInquiries } from "@/api/fetch/admin"; +import GuestInquiryReplyForm from "../GuestInquiryReplyForm/GuestInquiryReplyForm"; interface GuestInquiriesDetailViewProps { id: number; @@ -34,12 +35,21 @@ const GuestInquiriesDetailView = ({ id }: GuestInquiriesDetailViewProps) => {
+ + {data.result.answered ? ( +

+ 답변을 보낸 문의예요. +

+ ) : ( + + )}
-
- +
); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx new file mode 100644 index 0000000..eec7925 --- /dev/null +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import GuestInquiryReplyForm from "./GuestInquiryReplyForm"; +import { usePostGuestInquiryReply } from "@/api/fetch/admin"; + +jest.mock("@/components", () => ({ + Button: ({ children, loading, ...rest }: any) => , +})); + +jest.mock("@/api/fetch/admin", () => ({ + usePostGuestInquiryReply: jest.fn(), +})); + +const mutateMock = jest.fn(); +const mockedUsePostGuestInquiryReply = usePostGuestInquiryReply as jest.Mock; + +describe("GuestInquiryReplyForm", () => { + beforeEach(() => { + mutateMock.mockReset(); + mockedUsePostGuestInquiryReply.mockReturnValue({ mutate: mutateMock, isPending: false }); + }); + + it("답변 내용이 비어 있으면 보내기 버튼이 비활성화됨", () => { + render(); + + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + expect(screen.getByText(/guest@test.com/)).toBeInTheDocument(); + }); + + it("공백만 입력하면 보내기 버튼이 비활성화됨", () => { + render(); + + fireEvent.change(screen.getByLabelText("답변 내용"), { target: { value: " " } }); + + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + }); + + it("답변을 입력해 보내면 앞뒤 공백을 지운 내용으로 요청하고, 성공 시 입력을 비움", () => { + mutateMock.mockImplementation((_body, options) => options?.onSuccess?.()); + render(); + + const textarea = screen.getByLabelText("답변 내용") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: " 확인했습니다. " } }); + fireEvent.click(screen.getByRole("button", { name: "답변 보내기" })); + + expect(mockedUsePostGuestInquiryReply).toHaveBeenCalledWith(3); + expect(mutateMock).toHaveBeenCalledWith({ content: "확인했습니다." }, expect.any(Object)); + expect(textarea.value).toBe(""); + }); + + it("발송 중에는 입력창과 버튼이 비활성화됨", () => { + mockedUsePostGuestInquiryReply.mockReturnValue({ mutate: mutateMock, isPending: true }); + render(); + + expect(screen.getByLabelText("답변 내용")).toBeDisabled(); + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + }); +}); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx new file mode 100644 index 0000000..a95b14d --- /dev/null +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/components"; +import { cn } from "@/utils"; +import { usePostGuestInquiryReply } from "@/api/fetch/admin"; + +const MAX_LENGTH = 2000; + +interface GuestInquiryReplyFormProps { + inquiryId: number; + email: string; +} + +/** 비회원 문의에 대한 답변을 작성해 문의자 이메일로 보내는 폼입니다. */ +const GuestInquiryReplyForm = ({ inquiryId, email }: GuestInquiryReplyFormProps) => { + const [content, setContent] = useState(""); + const { mutate: sendReply, isPending } = usePostGuestInquiryReply(inquiryId); + + const isSubmitDisabled = !content.trim() || isPending; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isSubmitDisabled) return; + + sendReply({ content: content.trim() }, { onSuccess: () => setContent("") }); + }; + + return ( +
+
+

+ 이메일로 답변하기 +

+

+ {email}으로 답변이 발송되고, 문의는 처리 완료로 바뀌어요. +

+
+ +
+ +