From 8b199803d6f5e01bd6aa029ca0b2e049f976d80e Mon Sep 17 00:00:00 2001 From: SEAN Date: Sat, 28 Feb 2026 15:15:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=83=9C=EA=B7=B8=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TagCard 컴포넌트 구현 (태그 정보 표시, 수정/삭제 기능) - TagAddModal 컴포넌트 구현 (태그 등록 폼) - TagDeleteModal 컴포넌트 구현 (삭제 확인 다이얼로그) - TagManagePage 구현 (서비스별 탭 필터링 + 9건 단위 페이지네이션) - 서비스 탭 오버플로우 시 가로 스크롤 처리 (scrollbar-hide 유틸 추가) - 목 데이터 10건 구성 Closes #25 --- .../features/tag/components/TagAddModal.tsx | 290 ++++++++++++++++++ react/src/features/tag/components/TagCard.tsx | 191 ++++++++++++ .../tag/components/TagDeleteModal.tsx | 77 +++++ .../src/features/tag/pages/TagManagePage.tsx | 178 ++++++++++- react/src/features/tag/types.ts | 115 ++++++- react/src/index.css | 9 + 6 files changed, 857 insertions(+), 3 deletions(-) create mode 100644 react/src/features/tag/components/TagAddModal.tsx create mode 100644 react/src/features/tag/components/TagCard.tsx create mode 100644 react/src/features/tag/components/TagDeleteModal.tsx diff --git a/react/src/features/tag/components/TagAddModal.tsx b/react/src/features/tag/components/TagAddModal.tsx new file mode 100644 index 0000000..4bd9200 --- /dev/null +++ b/react/src/features/tag/components/TagAddModal.tsx @@ -0,0 +1,290 @@ +import { useState, useRef, useEffect } from "react"; +import useShake from "@/hooks/useShake"; +import { SERVICE_OPTIONS } from "../types"; + +interface TagAddModalProps { + open: boolean; + onClose: () => void; + onSave: (data: { name: string; service: string; description: string }) => void; +} + +export default function TagAddModal({ open, onClose, onSave }: TagAddModalProps) { + const { triggerShake, cls } = useShake(); + + const [name, setName] = useState(""); + const [service, setService] = useState(""); + const [description, setDescription] = useState(""); + const [serviceOpen, setServiceOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + + // 에러 상태 + const [nameError, setNameError] = useState(false); + const [serviceError, setServiceError] = useState(false); + + const serviceRef = useRef(null); + + // 외부 클릭 시 서비스 드롭다운 닫기 + useEffect(() => { + function handleClick(e: MouseEvent) { + if (serviceRef.current && !serviceRef.current.contains(e.target as Node)) { + setServiceOpen(false); + } + } + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, []); + + const resetForm = () => { + setName(""); + setService(""); + setDescription(""); + setNameError(false); + setServiceError(false); + setServiceOpen(false); + setConfirmOpen(false); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSaveClick = () => { + const errors: string[] = []; + if (!name.trim()) { + setNameError(true); + errors.push("name"); + } + if (!service) { + setServiceError(true); + errors.push("service"); + } + if (errors.length > 0) { + triggerShake(errors); + return; + } + // 확인 모달 열기 + setConfirmOpen(true); + }; + + const handleConfirmSave = () => { + onSave({ name: name.trim(), service, description: description.trim() }); + resetForm(); + onClose(); + }; + + if (!open) return null; + + return ( + <> + {/* 태그 추가 모달 */} +
{ + if (e.target === e.currentTarget) handleClose(); + }} + > +
+
+ {/* 헤더 */} +
+
+ + sell + +
+

태그 추가

+
+ + {/* 바디 */} +
+ {/* 태그명 */} +
+ + { + setName(e.target.value); + if (e.target.value.trim()) setNameError(false); + }} + placeholder="태그 이름을 입력하세요" + className={`w-full h-[38px] px-3 border rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all ${ + nameError + ? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]" + : "border-gray-300" + } ${cls("name")}`} + /> + {nameError && ( +
+ + error + + 필수 입력 항목입니다. +
+ )} +
+ + {/* 서비스 선택 */} +
+ +
+ + {serviceOpen && ( +
    + {SERVICE_OPTIONS.map((opt) => ( +
  • { + setService(opt); + setServiceError(false); + setServiceOpen(false); + }} + className={`px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${ + opt === service ? "text-primary font-medium" : "" + }`} + > + {opt} +
  • + ))} +
+ )} +
+ {serviceError && ( +
+ + error + + 필수 입력 항목입니다. +
+ )} +
+ + {/* 설명 */} +
+ +