From 7f3fe7e251446efd1c3be5c3a677c277118953d2 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 27 Jan 2026 23:57:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BD=98=EC=84=9C=ED=8A=B8=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=ED=8F=BC=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콘서트 정보 섹션: 공연명, 포스터, 참여 멤버 선택 - 공연 일정 섹션: 다회차 지원 (날짜, 시간, 장소) - VenueSearchDialog 컴포넌트 추가 (국내/해외 장소 검색) - 회차 추가/삭제 애니메이션 적용 Co-Authored-By: Claude Opus 4.5 --- .../pc/admin/common/VenueSearchDialog.jsx | 288 ++++++++++++++++++ .../form/concert/ConcertInfoSection.jsx | 160 ++++++++++ .../form/concert/ScheduleSection.jsx | 265 ++++++++++++++++ .../pc/admin/schedules/form/concert/index.jsx | 148 +++++++++ .../pages/pc/admin/schedules/form/index.jsx | 4 + 5 files changed, 865 insertions(+) create mode 100644 frontend/src/components/pc/admin/common/VenueSearchDialog.jsx create mode 100644 frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx create mode 100644 frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx create mode 100644 frontend/src/pages/pc/admin/schedules/form/concert/index.jsx diff --git a/frontend/src/components/pc/admin/common/VenueSearchDialog.jsx b/frontend/src/components/pc/admin/common/VenueSearchDialog.jsx new file mode 100644 index 0000000..7e0968e --- /dev/null +++ b/frontend/src/components/pc/admin/common/VenueSearchDialog.jsx @@ -0,0 +1,288 @@ +/** + * 장소 검색 다이얼로그 컴포넌트 + * - 국내: 카카오맵 API + * - 해외: 구글맵 API + */ +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, Search, MapPin, Globe } from "lucide-react"; + +/** + * @param {Object} props + * @param {boolean} props.isOpen - 다이얼로그 열림 여부 + * @param {Function} props.onClose - 닫기 핸들러 + * @param {Function} props.onSelect - 장소 선택 핸들러 ({ name, address, country, lat, lng }) + */ +function VenueSearchDialog({ isOpen, onClose, onSelect }) { + const [region, setRegion] = useState("domestic"); // domestic | overseas + const [searchQuery, setSearchQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + // 다이얼로그 닫기 시 상태 초기화 + const handleClose = () => { + setSearchQuery(""); + setResults([]); + setError(null); + onClose(); + }; + + // 지역 변경 시 결과 초기화 + const handleRegionChange = (newRegion) => { + setRegion(newRegion); + setResults([]); + setError(null); + }; + + // 검색 실행 + const handleSearch = async () => { + if (!searchQuery.trim()) { + setResults([]); + return; + } + + setSearching(true); + setError(null); + + try { + const token = localStorage.getItem("adminToken"); + + if (region === "domestic") { + // 카카오맵 API + const response = await fetch( + `/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (response.ok) { + const data = await response.json(); + const places = (data.documents || []).map((place) => ({ + id: place.id, + name: place.place_name, + address: place.road_address_name || place.address_name, + country: "한국", + lat: parseFloat(place.y), + lng: parseFloat(place.x), + category: place.category_name, + })); + setResults(places); + } else { + setError("검색 중 오류가 발생했습니다."); + } + } else { + // 구글맵 API + const response = await fetch( + `/api/admin/google/places?query=${encodeURIComponent(searchQuery)}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (response.ok) { + const data = await response.json(); + const places = (data.results || []).map((place) => ({ + id: place.place_id, + name: place.name, + address: place.formatted_address, + country: extractCountry(place.formatted_address), + lat: place.geometry?.location?.lat, + lng: place.geometry?.location?.lng, + category: place.types?.[0]?.replace(/_/g, " "), + })); + setResults(places); + } else { + setError("검색 중 오류가 발생했습니다."); + } + } + } catch (err) { + console.error("장소 검색 오류:", err); + setError("검색 중 오류가 발생했습니다."); + } finally { + setSearching(false); + } + }; + + // 주소에서 국가 추출 (구글맵용) + const extractCountry = (address) => { + if (!address) return ""; + const parts = address.split(", "); + return parts[parts.length - 1] || ""; + }; + + // 장소 선택 + const handleSelectPlace = (place) => { + onSelect({ + name: place.name, + address: place.address, + country: place.country, + lat: place.lat, + lng: place.lng, + }); + handleClose(); + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > + {/* 헤더 */} +
+

장소 검색

+ +
+ + {/* 지역 선택 탭 */} +
+ + +
+ + {/* 검색 입력 */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSearch(); + } + }} + placeholder={ + region === "domestic" + ? "장소명을 입력하세요 (예: 올림픽홀)" + : "장소명을 입력하세요 (예: Tokyo Dome)" + } + className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + autoFocus + /> +
+ +
+ + {/* 에러 메시지 */} + {error && ( +
+ {error} +
+ )} + + {/* 검색 결과 */} +
+ {results.length > 0 ? ( +
+ {results.map((place) => ( + + ))} +
+ ) : ( +
+ +

장소명을 입력하고 검색해주세요

+
+ )} +
+
+
+ )} +
+ ); +} + +export default VenueSearchDialog; diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx new file mode 100644 index 0000000..d487912 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx @@ -0,0 +1,160 @@ +import { useRef } from "react"; +import { Image, Users, Check } from "lucide-react"; + +/** + * 콘서트 정보 섹션 + * - 공연명 + * - 포스터 + * - 참여 멤버 + */ +function ConcertInfoSection({ + title, + setTitle, + posterPreview, + onPosterChange, + onPosterRemove, + members, + selectedMemberIds, + onToggleMember, + onToggleAllMembers, +}) { + const posterInputRef = useRef(null); + + const handlePosterChange = (e) => { + const file = e.target.files[0]; + if (file) { + onPosterChange(file); + } + }; + + const isAllSelected = members.length > 0 && selectedMemberIds.length === members.length; + + return ( +
+

콘서트 정보

+ +
+ {/* 공연명 */} +
+ + setTitle(e.target.value)} + placeholder="예: fromis_9 WORLD TOUR NOW TOMORROW." + className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* 포스터 */} +
+ +
+
posterInputRef.current?.click()} + className="w-40 h-56 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden" + > + {posterPreview ? ( + 포스터 미리보기 + ) : ( +
+ +

클릭하여 업로드

+
+ )} +
+ +
+

+ 권장 크기: 세로형 포스터 (예: 700x1000px) +

+

지원 형식: JPG, PNG, WebP

+ {posterPreview && ( + + )} +
+
+
+ + {/* 참여 멤버 */} +
+
+ + +
+ +
+ {members.map((member) => { + const isSelected = selectedMemberIds.includes(member.id); + return ( + + ); + })} +
+
+
+
+ ); +} + +export default ConcertInfoSection; diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx new file mode 100644 index 0000000..25ff031 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx @@ -0,0 +1,265 @@ +import { useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Plus, Trash2, MapPin, Search } from "lucide-react"; + +import DatePicker from "@/components/pc/admin/common/DatePicker"; +import TimePicker from "@/components/pc/admin/common/TimePicker"; +import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; +import VenueSearchDialog from "@/components/pc/admin/common/VenueSearchDialog"; + +/** + * 공연 일정 섹션 + * - 다회차 지원 (날짜, 시간, 장소) + */ +function ScheduleSection({ rounds, setRounds }) { + const containerRef = useRef(null); + const [nextId, setNextId] = useState(() => { + const maxId = rounds.reduce((max, r) => Math.max(max, r.id || 0), 0); + return maxId + 1; + }); + + // 삭제 확인 다이얼로그 + const [deleteConfirm, setDeleteConfirm] = useState({ + isOpen: false, + roundId: null, + roundIndex: null, + }); + + // 장소 검색 다이얼로그 + const [locationSearch, setLocationSearch] = useState({ + isOpen: false, + roundId: null, + }); + + // 회차 추가 + const addRound = () => { + const newRound = { + id: nextId, + date: "", + time: "", + venue: null, // { name, address, lat, lng } + }; + setRounds([...rounds, newRound]); + setNextId(nextId + 1); + + // 새 회차로 스크롤 + setTimeout(() => { + if (containerRef.current) { + const lastChild = containerRef.current.lastElementChild; + if (lastChild) { + lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }, 100); + }; + + // 회차 삭제 시도 + const handleRemoveRound = (id) => { + if (rounds.length <= 1) return; + + const round = rounds.find((r) => r.id === id); + const roundIndex = rounds.findIndex((r) => r.id === id); + + // 입력값이 있으면 확인 다이얼로그 표시 + if (round && (round.date || round.time || round.venue)) { + setDeleteConfirm({ + isOpen: true, + roundId: id, + roundIndex: roundIndex + 1, + }); + } else { + removeRound(id); + } + }; + + // 회차 삭제 실행 + const removeRound = (id) => { + setRounds(rounds.filter((round) => round.id !== id)); + }; + + // 삭제 확인 + const handleConfirmDelete = () => { + if (deleteConfirm.roundId !== null) { + removeRound(deleteConfirm.roundId); + } + setDeleteConfirm({ isOpen: false, roundId: null, roundIndex: null }); + }; + + // 회차 업데이트 + const updateRound = (id, field, value) => { + setRounds( + rounds.map((round) => + round.id === id ? { ...round, [field]: value } : round + ) + ); + }; + + // 장소 검색 열기 + const openLocationSearch = (roundId) => { + setLocationSearch({ isOpen: true, roundId }); + }; + + // 장소 선택 + const handleLocationSelect = (place) => { + if (locationSearch.roundId !== null) { + updateRound(locationSearch.roundId, "venue", place); + } + setLocationSearch({ isOpen: false, roundId: null }); + }; + + // 장소 제거 + const removeVenue = (roundId) => { + updateRound(roundId, "venue", null); + }; + + return ( + <> + {/* 삭제 확인 다이얼로그 */} + + setDeleteConfirm({ isOpen: false, roundId: null, roundIndex: null }) + } + onConfirm={handleConfirmDelete} + title="회차 삭제" + message={ +

+ {deleteConfirm.roundIndex}회차에 + 입력된 정보가 있습니다. +
+ 정말 삭제하시겠습니까? +

+ } + confirmText="삭제" + cancelText="취소" + /> + + {/* 장소 검색 다이얼로그 */} + setLocationSearch({ isOpen: false, roundId: null })} + onSelect={handleLocationSelect} + /> + +
+
+

공연 일정

+ +
+ +
+ + {rounds.map((round, index) => ( + +
+ {/* 헤더 */} +
+ + {index + 1}회차 + + {rounds.length > 1 && ( + + )} +
+ + {/* 날짜 & 시간 */} +
+
+ + updateRound(round.id, "date", val)} + placeholder="날짜 선택" + minYear={2017} + /> +
+
+ + updateRound(round.id, "time", val)} + placeholder="시간 선택" + /> +
+
+ + {/* 장소 */} +
+ + {round.venue ? ( +
+ +
+
+

+ {round.venue.name} +

+ {round.venue.country && ( + + {round.venue.country} + + )} +
+

+ {round.venue.address} +

+
+ +
+ ) : ( + + )} +
+
+
+ ))} +
+
+ +

+ 시간과 장소는 선택사항입니다. 미정인 경우 비워두세요. +

+
+ + ); +} + +export default ScheduleSection; diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx new file mode 100644 index 0000000..da216e1 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { Save } from "lucide-react"; + +import Toast from "@/components/common/Toast"; +import { useToast } from "@/hooks/common"; +import { useAdminAuth } from "@/hooks/pc/admin"; +import { getMembers } from "@/api/public/members"; + +import ConcertInfoSection from "./ConcertInfoSection"; +import ScheduleSection from "./ScheduleSection"; + +/** + * 콘서트 일정 추가 폼 + */ +function ConcertForm() { + const navigate = useNavigate(); + const { toast, setToast } = useToast(); + const { isAuthenticated } = useAdminAuth(); + + // 멤버 목록 조회 + const { data: membersData = [] } = useQuery({ + queryKey: ["members"], + queryFn: getMembers, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + const members = membersData.filter((m) => !m.is_former); + + // 콘서트 정보 + const [title, setTitle] = useState(""); + const [posterFile, setPosterFile] = useState(null); + const [posterPreview, setPosterPreview] = useState(null); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + + // 공연 일정 (다회차) + const [rounds, setRounds] = useState([ + { id: 1, date: "", time: "", venue: null }, + ]); + + // 로딩 상태 + const [saving, setSaving] = useState(false); + + // 멤버 토글 + const toggleMember = (memberId) => { + setSelectedMemberIds((prev) => + prev.includes(memberId) + ? prev.filter((id) => id !== memberId) + : [...prev, memberId] + ); + }; + + // 전체 선택/해제 + const toggleAllMembers = () => { + if (selectedMemberIds.length === members.length) { + setSelectedMemberIds([]); + } else { + setSelectedMemberIds(members.map((m) => m.id)); + } + }; + + // 포스터 변경 + const handlePosterChange = (file) => { + setPosterFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setPosterPreview(reader.result); + }; + reader.readAsDataURL(file); + }; + + // 포스터 제거 + const handlePosterRemove = () => { + setPosterFile(null); + setPosterPreview(null); + }; + + // 폼 제출 (UI만 - 실제 저장 로직 없음) + const handleSubmit = async (e) => { + e.preventDefault(); + setToast({ + type: "info", + message: "UI만 구현된 상태입니다. 저장 기능은 아직 구현되지 않았습니다.", + }); + }; + + return ( + <> + setToast(null)} /> + + + {/* 콘서트 정보 */} + + + {/* 공연 일정 */} + + + {/* 버튼 */} +
+ + +
+
+ + ); +} + +export default ConcertForm; diff --git a/frontend/src/pages/pc/admin/schedules/form/index.jsx b/frontend/src/pages/pc/admin/schedules/form/index.jsx index a58d481..0362bfa 100644 --- a/frontend/src/pages/pc/admin/schedules/form/index.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/index.jsx @@ -8,6 +8,7 @@ import * as categoriesApi from "@/api/admin/categories"; import CategorySelector from "@/components/pc/admin/schedule/CategorySelector"; import YouTubeForm from "./YouTubeForm"; import XForm from "./XForm"; +import ConcertForm from "./concert"; // 애니메이션 variants const containerVariants = { @@ -74,6 +75,9 @@ function ScheduleFormPage() { case 'X': return ; + case '콘서트': + return ; + // 다른 카테고리는 기존 폼으로 리다이렉트 default: return (