feat: 콘서트 일정 추가 폼 UI 구현
- 콘서트 정보 섹션: 공연명, 포스터, 참여 멤버 선택 - 공연 일정 섹션: 다회차 지원 (날짜, 시간, 장소) - VenueSearchDialog 컴포넌트 추가 (국내/해외 장소 검색) - 회차 추가/삭제 애니메이션 적용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
83c955f8a9
commit
7f3fe7e251
5 changed files with 865 additions and 0 deletions
288
frontend/src/components/pc/admin/common/VenueSearchDialog.jsx
Normal file
288
frontend/src/components/pc/admin/common/VenueSearchDialog.jsx
Normal file
|
|
@ -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 (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 지역 선택 탭 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegionChange("domestic")}
|
||||
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
||||
region === "domestic"
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
국내
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegionChange("overseas")}
|
||||
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
||||
region === "overseas"
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Globe size={16} />
|
||||
해외
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{searching ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
<Search size={18} />
|
||||
</motion.div>
|
||||
) : (
|
||||
"검색"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{results.map((place) => (
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlace(place)}
|
||||
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100 transition-colors"
|
||||
>
|
||||
<MapPin
|
||||
size={18}
|
||||
className="text-gray-400 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{place.name}</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{place.address}
|
||||
</p>
|
||||
{place.category && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{place.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{region === "overseas" && place.country && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{place.country}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p>장소명을 입력하고 검색해주세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenueSearchDialog;
|
||||
|
|
@ -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 (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">콘서트 정보</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공연명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
공연명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 포스터 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
포스터
|
||||
</label>
|
||||
<div className="flex items-start gap-6">
|
||||
<div
|
||||
onClick={() => 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 ? (
|
||||
<img
|
||||
src={posterPreview}
|
||||
alt="포스터 미리보기"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
<Image size={32} className="mx-auto mb-2" />
|
||||
<p className="text-xs">클릭하여 업로드</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={posterInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePosterChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
권장 크기: 세로형 포스터 (예: 700x1000px)
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">지원 형식: JPG, PNG, WebP</p>
|
||||
{posterPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPosterRemove}
|
||||
className="mt-3 text-sm text-red-500 hover:text-red-600"
|
||||
>
|
||||
이미지 제거
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참여 멤버 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Users size={16} />
|
||||
참여 멤버
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAllMembers}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{isAllSelected ? "전체 해제" : "전체 선택"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{members.map((member) => {
|
||||
const isSelected = selectedMemberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => onToggleMember(member.id)}
|
||||
className={`relative rounded-xl overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary ring-offset-2"
|
||||
: "hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[3/4] bg-gray-100">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||||
<p className="text-white text-xs font-medium">{member.name}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={12} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertInfoSection;
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, roundId: null, roundIndex: null })
|
||||
}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="회차 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.roundIndex}회차</span>에
|
||||
입력된 정보가 있습니다.
|
||||
<br />
|
||||
정말 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
{/* 장소 검색 다이얼로그 */}
|
||||
<VenueSearchDialog
|
||||
isOpen={locationSearch.isOpen}
|
||||
onClose={() => setLocationSearch({ isOpen: false, roundId: null })}
|
||||
onSelect={handleLocationSelect}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900">공연 일정</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRound}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
회차 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{rounds.map((round, index) => (
|
||||
<motion.div
|
||||
key={round.id}
|
||||
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{index + 1}회차
|
||||
</span>
|
||||
{rounds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRound(round.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 날짜 & 시간 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
날짜 *
|
||||
</label>
|
||||
<DatePicker
|
||||
value={round.date}
|
||||
onChange={(val) => updateRound(round.id, "date", val)}
|
||||
placeholder="날짜 선택"
|
||||
minYear={2017}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
시간 (선택)
|
||||
</label>
|
||||
<TimePicker
|
||||
value={round.time}
|
||||
onChange={(val) => updateRound(round.id, "time", val)}
|
||||
placeholder="시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장소 */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
장소 (선택)
|
||||
</label>
|
||||
{round.venue ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg">
|
||||
<MapPin size={16} className="text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{round.venue.name}
|
||||
</p>
|
||||
{round.venue.country && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{round.venue.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{round.venue.address}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVenue(round.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLocationSearch(round.id)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 border border-gray-200 rounded-lg text-gray-500 hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Search size={16} />
|
||||
<span className="text-sm">장소 검색</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
시간과 장소는 선택사항입니다. 미정인 경우 비워두세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleSection;
|
||||
148
frontend/src/pages/pc/admin/schedules/form/concert/index.jsx
Normal file
148
frontend/src/pages/pc/admin/schedules/form/concert/index.jsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 콘서트 정보 */}
|
||||
<ConcertInfoSection
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
posterPreview={posterPreview}
|
||||
onPosterChange={handlePosterChange}
|
||||
onPosterRemove={handlePosterRemove}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
onToggleMember={toggleMember}
|
||||
onToggleAllMembers={toggleAllMembers}
|
||||
/>
|
||||
|
||||
{/* 공연 일정 */}
|
||||
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertForm;
|
||||
|
|
@ -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 <XForm />;
|
||||
|
||||
case '콘서트':
|
||||
return <ConcertForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
default:
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue