diff --git a/backend/routes/admin.js b/backend/routes/admin.js index c4cb8ff..db397fb 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1607,7 +1607,8 @@ router.put( const file = req.files[i]; currentOrder++; const orderNum = String(currentOrder).padStart(2, "0"); - const filename = `${orderNum}_${Date.now()}.webp`; + // 파일명: 01.webp, 02.webp 형식 (Date.now() 제거) + const filename = `${orderNum}.webp`; const imageBuffer = await sharp(file.buffer) .webp({ quality: 90 }) @@ -1631,6 +1632,18 @@ router.put( } } + // sort_order 재정렬 (삭제로 인한 간격 제거) + const [remainingImages] = await connection.query( + "SELECT id FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", + [id] + ); + for (let i = 0; i < remainingImages.length; i++) { + await connection.query( + "UPDATE schedule_images SET sort_order = ? WHERE id = ?", + [i + 1, remainingImages[i].id] + ); + } + await connection.commit(); // Meilisearch 동기화 diff --git a/frontend/src/pages/pc/Schedule.jsx b/frontend/src/pages/pc/Schedule.jsx index 3be511e..f6effd0 100644 --- a/frontend/src/pages/pc/Schedule.jsx +++ b/frontend/src/pages/pc/Schedule.jsx @@ -262,6 +262,26 @@ function Schedule() { return cat?.name || ''; }; + // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) + const sortedCategories = useMemo(() => { + return categories + .map(category => { + const count = isSearchMode && searchTerm + ? searchResults.filter(s => s.category_id === category.id).length + : schedules.filter(s => { + const scheduleDate = s.date ? s.date.split('T')[0] : ''; + return scheduleDate.startsWith(currentYearMonth) && s.category_id === category.id; + }).length; + return { ...category, count }; + }) + .filter(category => category.count > 0) + .sort((a, b) => { + if (a.name === '기타') return 1; + if (b.name === '기타') return -1; + return b.count - a.count; + }); + }, [categories, schedules, searchResults, isSearchMode, searchTerm, currentYearMonth]); + return (
@@ -533,15 +553,8 @@ function Schedule() { - {/* 개별 카테고리 */} - {categories.map(category => { - // 검색 모드에서는 검색 결과 기준, 일반 모드에서는 해당 월 기준 - const count = isSearchMode && searchTerm - ? searchResults.filter(s => s.category_id === category.id).length - : schedules.filter(s => { - const scheduleDate = s.date ? s.date.split('T')[0] : ''; - return scheduleDate.startsWith(currentYearMonth) && s.category_id === category.id; - }).length; + {/* 개별 카테고리 - useMemo로 정렬됨 */} + {sortedCategories.map(category => { const isSelected = selectedCategories.includes(category.id); return (
- {count} + {category.count} ); })} diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 0c11925..2062bab 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -379,6 +379,23 @@ function AdminSchedule() { return searchResults.length; }; + // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) + const sortedCategories = useMemo(() => { + return categories + .map(category => ({ + ...category, + count: category.id === 'all' ? getTotalCount() : getSearchCategoryCount(category.id) + })) + .filter(category => category.id === 'all' || category.count > 0) + .sort((a, b) => { + if (a.id === 'all') return -1; + if (b.id === 'all') return 1; + if (a.name === '기타') return 1; + if (b.name === '기타') return -1; + return b.count - a.count; + }); + }, [categories, schedules, searchResults, isSearchMode, searchTerm]); + return (
setToast(null)} /> @@ -777,17 +794,16 @@ function AdminSchedule() { >

카테고리

- {categories.map(category => { + {/* 카테고리 - useMemo로 정렬됨 */} + {sortedCategories.map(category => { const isSelected = category.id === 'all' ? selectedCategories.length === 0 : selectedCategories.includes(category.id); const handleClick = () => { if (category.id === 'all') { - // 전체 클릭 시 모든 선택 해제 setSelectedCategories([]); } else { - // 개별 카테고리 클릭 시 토글 if (selectedCategories.includes(category.id)) { setSelectedCategories(selectedCategories.filter(id => id !== category.id)); } else { @@ -812,10 +828,7 @@ function AdminSchedule() { /> {category.name} - {category.id === 'all' - ? getTotalCount() - : getSearchCategoryCount(category.id) - } + {category.count} ); diff --git a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx index 64749ac..159c838 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx @@ -1,1643 +1,1994 @@ -import { useState, useEffect, useRef } from 'react'; -import { useNavigate, Link, useParams } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { - LogOut, Home, ChevronRight, Calendar, Save, X, Upload, Link as LinkIcon, - ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, AlertTriangle, Trash2, Search -} from 'lucide-react'; -import Toast from '../../../components/Toast'; -import Lightbox from '../../../components/common/Lightbox'; +import { useState, useEffect, useRef } from "react"; +import { useNavigate, Link, useParams } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { + LogOut, + Home, + ChevronRight, + Calendar, + Save, + X, + Upload, + Link as LinkIcon, + ChevronLeft, + ChevronDown, + Clock, + Image, + Users, + Check, + Plus, + MapPin, + Settings, + AlertTriangle, + Trash2, + Search, +} from "lucide-react"; +import Toast from "../../../components/Toast"; +import Lightbox from "../../../components/common/Lightbox"; // 커스텀 데이트픽커 컴포넌트 (AdminMemberEdit.jsx에서 가져옴) -function CustomDatePicker({ value, onChange, placeholder = '날짜 선택' }) { - const [isOpen, setIsOpen] = useState(false); - const [viewMode, setViewMode] = useState('days'); - const [viewDate, setViewDate] = useState(() => { - if (value) return new Date(value); - return new Date(); - }); - const ref = useRef(null); +function CustomDatePicker({ value, onChange, placeholder = "날짜 선택" }) { + const [isOpen, setIsOpen] = useState(false); + const [viewMode, setViewMode] = useState("days"); + const [viewDate, setViewDate] = useState(() => { + if (value) return new Date(value); + return new Date(); + }); + const ref = useRef(null); - useEffect(() => { - const handleClickOutside = (e) => { - if (ref.current && !ref.current.contains(e.target)) { - setIsOpen(false); - setViewMode('days'); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const year = viewDate.getFullYear(); - const month = viewDate.getMonth(); - - const firstDay = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - - const days = []; - for (let i = 0; i < firstDay; i++) { - days.push(null); - } - for (let i = 1; i <= daysInMonth; i++) { - days.push(i); - } - - const startYear = Math.floor(year / 10) * 10 - 1; - const years = Array.from({ length: 12 }, (_, i) => startYear + i); - - const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); - const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); - const prevYearRange = () => setViewDate(new Date(year - 10, month, 1)); - const nextYearRange = () => setViewDate(new Date(year + 10, month, 1)); - - const selectDate = (day) => { - const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - onChange(dateStr); + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { setIsOpen(false); - setViewMode('days'); + setViewMode("days"); + } }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); - const selectYear = (y) => { - setViewDate(new Date(y, month, 1)); - setViewMode('months'); - }; + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); - const selectMonth = (m) => { - setViewDate(new Date(year, m, 1)); - setViewMode('days'); - }; + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); - const formatDisplayDate = (dateStr) => { - if (!dateStr) return ''; - const [y, m, d] = dateStr.split('-'); - return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`; - }; + const days = []; + for (let i = 0; i < firstDay; i++) { + days.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } - const isSelected = (day) => { - if (!value || !day) return false; - const [y, m, d] = value.split('-'); - return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day; - }; + const startYear = Math.floor(year / 10) * 10 - 1; + const years = Array.from({ length: 12 }, (_, i) => startYear + i); - const isToday = (day) => { - if (!day) return false; - const today = new Date(); - return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; - }; + const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); + const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); + const prevYearRange = () => setViewDate(new Date(year - 10, month, 1)); + const nextYearRange = () => setViewDate(new Date(year + 10, month, 1)); - const isCurrentYear = (y) => new Date().getFullYear() === y; - const isCurrentMonth = (m) => { - const today = new Date(); - return today.getFullYear() === year && today.getMonth() === m; - }; + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String( + day + ).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setViewMode("days"); + }; - const months = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; + const selectYear = (y) => { + setViewDate(new Date(y, month, 1)); + setViewMode("months"); + }; + const selectMonth = (m) => { + setViewDate(new Date(year, m, 1)); + setViewMode("days"); + }; + + const formatDisplayDate = (dateStr) => { + if (!dateStr) return ""; + const [y, m, d] = dateStr.split("-"); + const days = ['일', '월', '화', '수', '목', '금', '토']; + const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d)); + const dayOfWeek = days[date.getDay()]; + return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dayOfWeek})`; + }; + + const isSelected = (day) => { + if (!value || !day) return false; + const [y, m, d] = value.split("-"); return ( -
- - - - {isOpen && ( - -
- - - -
- - - {viewMode === 'years' && ( - -
년도
-
- {years.map((y) => ( - - ))} -
-
-
- {months.map((m, i) => ( - - ))} -
-
- )} - - {viewMode === 'months' && ( - -
월 선택
-
- {months.map((m, i) => ( - - ))} -
-
- )} - - {viewMode === 'days' && ( - -
- {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => ( -
- {d} -
- ))} -
-
- {days.map((day, i) => { - const dayOfWeek = i % 7; - return ( - - ); - })} -
-
- )} -
-
- )} -
-
+ parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day ); + }; + + const isToday = (day) => { + if (!day) return false; + const today = new Date(); + return ( + today.getFullYear() === year && + today.getMonth() === month && + today.getDate() === day + ); + }; + + const isCurrentYear = (y) => new Date().getFullYear() === y; + const isCurrentMonth = (m) => { + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === m; + }; + + const months = [ + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월", + ]; + + return ( +
+ + + + {isOpen && ( + +
+ + + +
+ + + {viewMode === "years" && ( + +
+ 년도 +
+
+ {years.map((y) => ( + + ))} +
+
+ 월 +
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === "months" && ( + +
+ 월 선택 +
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === "days" && ( + +
+ {["일", "월", "화", "수", "목", "금", "토"].map((d, i) => ( +
+ {d} +
+ ))} +
+
+ {days.map((day, i) => { + const dayOfWeek = i % 7; + return ( + + ); + })} +
+
+ )} +
+
+ )} +
+
+ ); } // 숫자 피커 컬럼 컴포넌트 (Vue 컴포넌트를 React로 변환) function NumberPicker({ items, value, onChange }) { - const ITEM_HEIGHT = 40; - const containerRef = useRef(null); - const [offset, setOffset] = useState(0); - const offsetRef = useRef(0); // 드래그용 ref - const touchStartY = useRef(0); - const startOffset = useRef(0); - const isScrolling = useRef(false); - - // offset 변경시 ref도 업데이트 - useEffect(() => { - offsetRef.current = offset; - }, [offset]); - - // 초기 위치 설정 - useEffect(() => { - if (value !== null && value !== undefined) { - const index = items.indexOf(value); - if (index !== -1) { - const newOffset = -index * ITEM_HEIGHT; - setOffset(newOffset); - offsetRef.current = newOffset; - } - } - }, []); - - // 값 변경시 위치 업데이트 - useEffect(() => { - const index = items.indexOf(value); - if (index !== -1) { - const targetOffset = -index * ITEM_HEIGHT; - if (Math.abs(offset - targetOffset) > 1) { - setOffset(targetOffset); - offsetRef.current = targetOffset; - } - } - }, [value, items]); - - const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋 - - // 아이템이 중앙에 있는지 확인 - const isItemInCenter = (item) => { - const itemIndex = items.indexOf(item); - const itemPosition = -itemIndex * ITEM_HEIGHT; - const tolerance = ITEM_HEIGHT / 2; - return Math.abs(offset - itemPosition) < tolerance; - }; - - // 오프셋 업데이트 (경계 제한) - const updateOffset = (newOffset) => { - const maxOffset = 0; - const minOffset = -(items.length - 1) * ITEM_HEIGHT; - return Math.min(maxOffset, Math.max(minOffset, newOffset)); - }; - - // 중앙 아이템 업데이트 - const updateCenterItem = (currentOffset) => { - const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT); - if (centerIndex >= 0 && centerIndex < items.length) { - const centerItem = items[centerIndex]; - if (value !== centerItem) { - onChange(centerItem); - } - } - }; - - // 가장 가까운 아이템에 스냅 - const snapToClosestItem = (currentOffset) => { - const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT; + const ITEM_HEIGHT = 40; + const containerRef = useRef(null); + const [offset, setOffset] = useState(0); + const offsetRef = useRef(0); // 드래그용 ref + const touchStartY = useRef(0); + const startOffset = useRef(0); + const isScrolling = useRef(false); + + // offset 변경시 ref도 업데이트 + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + // 초기 위치 설정 + useEffect(() => { + if (value !== null && value !== undefined) { + const index = items.indexOf(value); + if (index !== -1) { + const newOffset = -index * ITEM_HEIGHT; + setOffset(newOffset); + offsetRef.current = newOffset; + } + } + }, []); + + // 값 변경시 위치 업데이트 + useEffect(() => { + const index = items.indexOf(value); + if (index !== -1) { + const targetOffset = -index * ITEM_HEIGHT; + if (Math.abs(offset - targetOffset) > 1) { setOffset(targetOffset); offsetRef.current = targetOffset; - updateCenterItem(targetOffset); - }; - - // 터치 시작 - const handleTouchStart = (e) => { - e.stopPropagation(); - touchStartY.current = e.touches[0].clientY; - startOffset.current = offsetRef.current; - }; - - // 터치 이동 - const handleTouchMove = (e) => { - e.stopPropagation(); - const touchY = e.touches[0].clientY; - const deltaY = touchY - touchStartY.current; - const newOffset = updateOffset(startOffset.current + deltaY); - setOffset(newOffset); - offsetRef.current = newOffset; - }; - - // 터치 종료 - const handleTouchEnd = (e) => { - e.stopPropagation(); - snapToClosestItem(offsetRef.current); - }; - - // 마우스 휠 - 바깥 스크롤 방지 - const handleWheel = (e) => { - e.preventDefault(); - e.stopPropagation(); - if (isScrolling.current) return; - isScrolling.current = true; - - const newOffset = updateOffset(offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT); - setOffset(newOffset); - offsetRef.current = newOffset; - snapToClosestItem(newOffset); - - setTimeout(() => { - isScrolling.current = false; - }, 50); - }; - - // 마우스 드래그 - const handleMouseDown = (e) => { - e.preventDefault(); - e.stopPropagation(); - touchStartY.current = e.clientY; - startOffset.current = offsetRef.current; - - const handleMouseMove = (moveEvent) => { - moveEvent.preventDefault(); - const deltaY = moveEvent.clientY - touchStartY.current; - const newOffset = updateOffset(startOffset.current + deltaY); - setOffset(newOffset); - offsetRef.current = newOffset; - }; - - const handleMouseUp = () => { - snapToClosestItem(offsetRef.current); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - // wheel 이벤트 passive false로 등록 - useEffect(() => { - const container = containerRef.current; - if (container) { - container.addEventListener('wheel', handleWheel, { passive: false }); - return () => container.removeEventListener('wheel', handleWheel); - } - }, []); - - return ( -
- {/* 중앙 선택 영역 */} -
- - {/* 피커 내부 */} -
- {items.map((item) => ( -
- {item} -
- ))} -
-
+ } + } + }, [value, items]); + + const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋 + + // 아이템이 중앙에 있는지 확인 + const isItemInCenter = (item) => { + const itemIndex = items.indexOf(item); + const itemPosition = -itemIndex * ITEM_HEIGHT; + const tolerance = ITEM_HEIGHT / 2; + return Math.abs(offset - itemPosition) < tolerance; + }; + + // 오프셋 업데이트 (경계 제한) + const updateOffset = (newOffset) => { + const maxOffset = 0; + const minOffset = -(items.length - 1) * ITEM_HEIGHT; + return Math.min(maxOffset, Math.max(minOffset, newOffset)); + }; + + // 중앙 아이템 업데이트 + const updateCenterItem = (currentOffset) => { + const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT); + if (centerIndex >= 0 && centerIndex < items.length) { + const centerItem = items[centerIndex]; + if (value !== centerItem) { + onChange(centerItem); + } + } + }; + + // 가장 가까운 아이템에 스냅 + const snapToClosestItem = (currentOffset) => { + const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT; + setOffset(targetOffset); + offsetRef.current = targetOffset; + updateCenterItem(targetOffset); + }; + + // 터치 시작 + const handleTouchStart = (e) => { + e.stopPropagation(); + touchStartY.current = e.touches[0].clientY; + startOffset.current = offsetRef.current; + }; + + // 터치 이동 + const handleTouchMove = (e) => { + e.stopPropagation(); + const touchY = e.touches[0].clientY; + const deltaY = touchY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + // 터치 종료 + const handleTouchEnd = (e) => { + e.stopPropagation(); + snapToClosestItem(offsetRef.current); + }; + + // 마우스 휠 - 바깥 스크롤 방지 + const handleWheel = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isScrolling.current) return; + isScrolling.current = true; + + const newOffset = updateOffset( + offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT ); + setOffset(newOffset); + offsetRef.current = newOffset; + snapToClosestItem(newOffset); + + setTimeout(() => { + isScrolling.current = false; + }, 50); + }; + + // 마우스 드래그 + const handleMouseDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + touchStartY.current = e.clientY; + startOffset.current = offsetRef.current; + + const handleMouseMove = (moveEvent) => { + moveEvent.preventDefault(); + const deltaY = moveEvent.clientY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + const handleMouseUp = () => { + snapToClosestItem(offsetRef.current); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + // wheel 이벤트 passive false로 등록 + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + } + }, []); + + return ( +
+ {/* 중앙 선택 영역 */} +
+ + {/* 피커 내부 */} +
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+
+ ); } // 커스텀 시간 피커 (Vue 컴포넌트 기반) -function CustomTimePicker({ value, onChange, placeholder = '시간 선택' }) { - const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); - - // 현재 값 파싱 - const parseValue = () => { - if (!value) return { hour: '12', minute: '00', period: '오후' }; - const [h, m] = value.split(':'); - const hour = parseInt(h); - const isPM = hour >= 12; - const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - return { - hour: String(hour12).padStart(2, '0'), - minute: m, - period: isPM ? '오후' : '오전' - }; - }; - - const parsed = parseValue(); - const [selectedHour, setSelectedHour] = useState(parsed.hour); - const [selectedMinute, setSelectedMinute] = useState(parsed.minute); - const [selectedPeriod, setSelectedPeriod] = useState(parsed.period); - - // 외부 클릭 시 닫기 - useEffect(() => { - const handleClickOutside = (e) => { - if (ref.current && !ref.current.contains(e.target)) { - setIsOpen(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // 피커 열릴 때 현재 값으로 초기화 - useEffect(() => { - if (isOpen) { - const parsed = parseValue(); - setSelectedHour(parsed.hour); - setSelectedMinute(parsed.minute); - setSelectedPeriod(parsed.period); - } - }, [isOpen, value]); - - // 시간 확정 - const handleSave = () => { - let hour = parseInt(selectedHour); - if (selectedPeriod === '오후' && hour !== 12) hour += 12; - if (selectedPeriod === '오전' && hour === 12) hour = 0; - const timeStr = `${String(hour).padStart(2, '0')}:${selectedMinute}`; - onChange(timeStr); - setIsOpen(false); - }; - - // 취소 - const handleCancel = () => { - setIsOpen(false); - }; - - // 초기화 - const handleClear = () => { - onChange(''); - setIsOpen(false); - }; - - // 표시용 포맷 - const displayValue = () => { - if (!value) return placeholder; - const [h, m] = value.split(':'); - const hour = parseInt(h); - const isPM = hour >= 12; - const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - return `${isPM ? '오후' : '오전'} ${hour12}:${m}`; - }; - - // 피커 아이템 데이터 - const periods = ['오전', '오후']; - const hours = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']; - const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')); +function CustomTimePicker({ value, onChange, placeholder = "시간 선택" }) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); - return ( -
- + + + {isOpen && ( + + {/* 피커 영역 */} +
+ {/* 오전/오후 (맨 앞) */} + + + {/* 시간 */} + + + + : + + + {/* 분 */} + +
+ + {/* 푸터 버튼 */} +
+ - - - {isOpen && ( - - {/* 피커 영역 */} -
- {/* 오전/오후 (맨 앞) */} - - - {/* 시간 */} - - - : - - {/* 분 */} - -
- - {/* 푸터 버튼 */} -
- -
- - -
-
-
- )} -
-
- ); + onClick={handleClear} + className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors" + > + 초기화 + +
+ + +
+
+ + )} + +
+ ); } - function AdminScheduleForm() { - const navigate = useNavigate(); - const { id } = useParams(); - const isEditMode = !!id; - - const [user, setUser] = useState(null); - const [toast, setToast] = useState(null); - const [loading, setLoading] = useState(false); - const [members, setMembers] = useState([]); - - // 폼 데이터 (날짜/시간 범위 지원) - const [formData, setFormData] = useState({ - title: '', - startDate: '', - endDate: '', - startTime: '', - endTime: '', - isRange: false, // 범위 설정 여부 - category: '', - description: '', - url: '', - sourceName: '', - members: [], + const navigate = useNavigate(); + const { id } = useParams(); + const isEditMode = !!id; + + const [user, setUser] = useState(null); + const [toast, setToast] = useState(null); + const [loading, setLoading] = useState(false); + const [members, setMembers] = useState([]); + + // 폼 데이터 (날짜/시간 범위 지원) + const [formData, setFormData] = useState({ + title: "", + startDate: "", + endDate: "", + startTime: "", + endTime: "", + isRange: false, // 범위 설정 여부 + category: "", + description: "", + url: "", + sourceName: "", + members: [], + images: [], + // 장소 정보 + locationName: "", // 장소 이름 + locationAddress: "", // 주소 + locationDetail: "", // 상세주소 (예: 3관, N열 등) + locationLat: null, // 위도 + locationLng: null, // 경도 + }); + + // 이미지 미리보기 + const [imagePreviews, setImagePreviews] = useState([]); + + // 라이트박스 상태 + const [lightboxOpen, setLightboxOpen] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(0); + + // 삭제 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); + + // 카테고리 목록 (API에서 로드) + const [categories, setCategories] = useState([]); + + // 저장 중 상태 + const [saving, setSaving] = useState(false); + + // 장소 검색 관련 상태 + const [locationDialogOpen, setLocationDialogOpen] = useState(false); + const [locationSearch, setLocationSearch] = useState(""); + const [locationResults, setLocationResults] = useState([]); + const [locationSearching, setLocationSearching] = useState(false); + + // 수정 모드용 기존 이미지 ID 추적 + const [existingImageIds, setExistingImageIds] = useState([]); + + // 카테고리 색상 맵핑 + const colorMap = { + blue: "bg-blue-500", + green: "bg-green-500", + purple: "bg-purple-500", + red: "bg-red-500", + pink: "bg-pink-500", + yellow: "bg-yellow-500", + orange: "bg-orange-500", + gray: "bg-gray-500", + cyan: "bg-cyan-500", + indigo: "bg-indigo-500", + }; + + // 색상 스타일 (기본 색상 또는 커스텀 HEX) + const getColorStyle = (color) => { + if (!color) return { className: "bg-gray-500" }; + if (color.startsWith("#")) { + return { style: { backgroundColor: color } }; + } + return { className: colorMap[color] || "bg-gray-500" }; + }; + + // 카테고리 색상 + const getCategoryColor = (categoryId) => { + const cat = categories.find((c) => c.id === categoryId); + if (cat && colorMap[cat.color]) { + return colorMap[cat.color]; + } + return "bg-gray-500"; + }; + + // 날짜에서 요일 가져오기 (월, 화, 수 등) + const getDayOfWeek = (dateStr) => { + if (!dateStr) return ''; + const days = ['일', '월', '화', '수', '목', '금', '토']; + const date = new Date(dateStr); + return days[date.getDay()]; + }; + + // 카테고리 로드 + const fetchCategories = async () => { + try { + const res = await fetch("/api/admin/schedule-categories"); + const data = await res.json(); + setCategories(data); + // 첫 번째 카테고리를 기본값으로 설정 + if (data.length > 0 && !formData.category) { + setFormData((prev) => ({ ...prev, category: data[0].id })); + } + } catch (error) { + console.error("카테고리 로드 오류:", error); + } + }; + + // Toast 자동 숨김 + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + useEffect(() => { + const token = localStorage.getItem("adminToken"); + const userData = localStorage.getItem("adminUser"); + + if (!token || !userData) { + navigate("/admin"); + return; + } + + setUser(JSON.parse(userData)); + fetchMembers(); + fetchCategories(); + + // 수정 모드일 경우 기존 데이터 로드 + if (isEditMode && id) { + fetchSchedule(); + } + }, [navigate, isEditMode, id]); + + // 기존 일정 데이터 로드 (수정 모드) + const fetchSchedule = async () => { + setLoading(true); + try { + const token = localStorage.getItem("adminToken"); + const res = await fetch(`/api/admin/schedules/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + throw new Error("일정을 찾을 수 없습니다."); + } + + const data = await res.json(); + + // 폼 데이터 설정 + setFormData({ + title: data.title || "", + startDate: data.date + ? new Date(data.date).toISOString().split("T")[0] + : "", + endDate: data.end_date + ? new Date(data.end_date).toISOString().split("T")[0] + : "", + startTime: data.time?.slice(0, 5) || "", + endTime: data.end_time?.slice(0, 5) || "", + isRange: !!data.end_date, + category: data.category_id || "", + description: data.description || "", + url: data.source_url || "", + sourceName: data.source_name || "", + members: data.members?.map((m) => m.id) || [], images: [], - // 장소 정보 - locationName: '', // 장소 이름 - locationAddress: '', // 주소 - locationDetail: '', // 상세주소 (예: 3관, N열 등) - locationLat: null, // 위도 - locationLng: null, // 경도 + locationName: data.location_name || "", + locationAddress: data.location_address || "", + locationDetail: data.location_detail || "", + locationLat: data.location_lat || null, + locationLng: data.location_lng || null, + }); + + // 기존 이미지 설정 + if (data.images && data.images.length > 0) { + // 기존 이미지를 formData.images에 저장 (id 포함) + setFormData(prev => ({ + ...prev, + title: data.title || "", + isRange: data.is_range || false, + startDate: data.date?.split("T")[0] || "", + endDate: data.end_date?.split("T")[0] || "", + startTime: data.time?.slice(0, 5) || "", + endTime: data.end_time?.slice(0, 5) || "", + category: data.category_id || 1, + description: data.description || "", + url: data.source_url || "", + sourceName: data.source_name || "", + members: data.members?.map((m) => m.id) || [], + images: data.images.map(img => ({ id: img.id, url: img.image_url })), + locationName: data.location_name || "", + locationAddress: data.location_address || "", + locationDetail: data.location_detail || "", + locationLat: data.location_lat || null, + locationLng: data.location_lng || null, + })); + setImagePreviews(data.images.map((img) => img.image_url)); + setExistingImageIds(data.images.map((img) => img.id)); + } + } catch (error) { + console.error("일정 로드 오류:", error); + setToast({ + type: "error", + message: error.message || "일정을 불러오는 중 오류가 발생했습니다.", + }); + navigate("/admin/schedule"); + } finally { + setLoading(false); + } + }; + + const fetchMembers = async () => { + try { + const res = await fetch("/api/members"); + const data = await res.json(); + setMembers(data.filter((m) => !m.is_former)); + } catch (error) { + console.error("멤버 로드 오류:", error); + } + }; + + const handleLogout = () => { + localStorage.removeItem("adminToken"); + localStorage.removeItem("adminUser"); + navigate("/admin"); + }; + + // 멤버 토글 + const toggleMember = (memberId) => { + const newMembers = formData.members.includes(memberId) + ? formData.members.filter((id) => id !== memberId) + : [...formData.members, memberId]; + setFormData({ ...formData, members: newMembers }); + }; + + // 전체 선택/해제 + const toggleAllMembers = () => { + if (formData.members.length === members.length) { + setFormData({ ...formData, members: [] }); + } else { + setFormData({ ...formData, members: members.map((m) => m.id) }); + } + }; + + // 다중 이미지 업로드 + const handleImagesUpload = (e) => { + const files = Array.from(e.target.files); + // 파일을 {file: File} 형태로 저장 (제출 시 image.file로 접근하기 위함) + const newImageObjects = files.map((file) => ({ file })); + const newImages = [...formData.images, ...newImageObjects]; + setFormData({ ...formData, images: newImages }); + + files.forEach((file) => { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreviews((prev) => [...prev, reader.result]); + }; + reader.readAsDataURL(file); }); - - // 이미지 미리보기 - const [imagePreviews, setImagePreviews] = useState([]); - - // 라이트박스 상태 - const [lightboxOpen, setLightboxOpen] = useState(false); - const [lightboxIndex, setLightboxIndex] = useState(0); - - // 삭제 다이얼로그 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); - - // 카테고리 목록 (API에서 로드) - const [categories, setCategories] = useState([]); - - // 저장 중 상태 - const [saving, setSaving] = useState(false); - - // 장소 검색 관련 상태 - const [locationDialogOpen, setLocationDialogOpen] = useState(false); - const [locationSearch, setLocationSearch] = useState(''); - const [locationResults, setLocationResults] = useState([]); - const [locationSearching, setLocationSearching] = useState(false); - - // 수정 모드용 기존 이미지 ID 추적 - const [existingImageIds, setExistingImageIds] = useState([]); + }; - // 카테고리 색상 맵핑 - const colorMap = { - blue: 'bg-blue-500', - green: 'bg-green-500', - purple: 'bg-purple-500', - red: 'bg-red-500', - pink: 'bg-pink-500', - yellow: 'bg-yellow-500', - orange: 'bg-orange-500', - gray: 'bg-gray-500', - cyan: 'bg-cyan-500', - indigo: 'bg-indigo-500', - }; + // 이미지 삭제 다이얼로그 열기 + const openDeleteDialog = (index) => { + setDeleteTargetIndex(index); + setDeleteDialogOpen(true); + }; - // 색상 스타일 (기본 색상 또는 커스텀 HEX) - const getColorStyle = (color) => { - if (!color) return { className: 'bg-gray-500' }; - if (color.startsWith('#')) { - return { style: { backgroundColor: color } }; + // 이미지 삭제 확인 + const confirmDeleteImage = () => { + if (deleteTargetIndex !== null) { + const deletedImage = formData.images[deleteTargetIndex]; + const newImages = formData.images.filter( + (_, i) => i !== deleteTargetIndex + ); + const newPreviews = imagePreviews.filter( + (_, i) => i !== deleteTargetIndex + ); + setFormData({ ...formData, images: newImages }); + setImagePreviews(newPreviews); + + // 기존 이미지(서버에 있는)를 삭제한 경우 existingImageIds에서도 제거 + if (deletedImage && deletedImage.id) { + setExistingImageIds(prev => prev.filter(id => id !== deletedImage.id)); + } + } + setDeleteDialogOpen(false); + setDeleteTargetIndex(null); + }; + + // 라이트박스 열기 + const openLightbox = (index) => { + setLightboxIndex(index); + setLightboxOpen(true); + }; + + // 드래그 앤 드롭 상태 + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // 드래그 시작 + const handleDragStart = (e, index) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + // 드래그 이미지 설정 + e.dataTransfer.setData("text/plain", index); + }; + + // 드래그 오버 + const handleDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverIndex !== index) { + setDragOverIndex(index); + } + }; + + // 드래그 종료 + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // 드롭 - 이미지 순서 변경 + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + handleDragEnd(); + return; + } + + // 새 배열 생성 + const newPreviews = [...imagePreviews]; + const newImages = [...formData.images]; + + // 드래그된 아이템 제거 후 새 위치에 삽입 + const [movedPreview] = newPreviews.splice(draggedIndex, 1); + const [movedImage] = newImages.splice(draggedIndex, 1); + + newPreviews.splice(dropIndex, 0, movedPreview); + newImages.splice(dropIndex, 0, movedImage); + + setImagePreviews(newPreviews); + setFormData({ ...formData, images: newImages }); + handleDragEnd(); + }; + + // 카카오 장소 검색 API 호출 (엔터 키로 검색) + const handleLocationSearch = async () => { + if (!locationSearch.trim()) { + setLocationResults([]); + return; + } + + setLocationSearching(true); + try { + const token = localStorage.getItem("adminToken"); + const response = await fetch( + `/api/admin/kakao/places?query=${encodeURIComponent(locationSearch)}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, } - return { className: colorMap[color] || 'bg-gray-500' }; - }; + ); - // 카테고리 색상 - const getCategoryColor = (categoryId) => { - const cat = categories.find(c => c.id === categoryId); - if (cat && colorMap[cat.color]) { - return colorMap[cat.color]; + if (response.ok) { + const data = await response.json(); + setLocationResults(data.documents || []); + } + } catch (error) { + console.error("장소 검색 오류:", error); + } finally { + setLocationSearching(false); + } + }; + + // 장소 선택 + const selectLocation = (place) => { + setFormData({ + ...formData, + locationName: place.place_name, + locationAddress: place.road_address_name || place.address_name, + locationLat: parseFloat(place.y), + locationLng: parseFloat(place.x), + }); + setLocationDialogOpen(false); + setLocationSearch(""); + setLocationResults([]); + }; + + // 폼 제출 + const handleSubmit = async (e) => { + e.preventDefault(); + + // 유효성 검사 + if (!formData.title.trim()) { + setToast({ type: "error", message: "제목을 입력해주세요." }); + return; + } + // 날짜 검증: 단일/기간 모드 모두 startDate를 사용함 + if (!formData.startDate) { + setToast({ type: "error", message: "날짜를 선택해주세요." }); + return; + } + + if (!formData.category) { + setToast({ type: "error", message: "카테고리를 선택해주세요." }); + return; + } + + setSaving(true); + + try { + const token = localStorage.getItem("adminToken"); + + // FormData 생성 + const submitData = new FormData(); + + // JSON 데이터 - 항상 startDate를 date로 사용 (UI에서 단일/기간 모드 모두 startDate 사용) + const jsonData = { + title: formData.title.trim(), + date: formData.startDate, + time: formData.startTime || null, + endDate: formData.isRange ? formData.endDate : null, + endTime: formData.isRange ? formData.endTime : null, + isRange: formData.isRange, + category: formData.category, + description: formData.description.trim() || null, + url: formData.url.trim() || null, + sourceName: formData.sourceName.trim() || null, + members: formData.members, + locationName: formData.locationName.trim() || null, + locationAddress: formData.locationAddress.trim() || null, + locationDetail: formData.locationDetail?.trim() || null, + locationLat: formData.locationLat, + locationLng: formData.locationLng, + }; + + // 수정 모드일 경우 유지할 기존 이미지 ID 추가 + if (isEditMode) { + jsonData.existingImages = existingImageIds; + } + + submitData.append("data", JSON.stringify(jsonData)); + + // 이미지 파일 추가 (새로 추가된 이미지만) + for (const image of formData.images) { + if (image.file) { + submitData.append("images", image.file); } - return 'bg-gray-500'; - }; + } - // 카테고리 로드 - const fetchCategories = async () => { - try { - const res = await fetch('/api/admin/schedule-categories'); - const data = await res.json(); - setCategories(data); - // 첫 번째 카테고리를 기본값으로 설정 - if (data.length > 0 && !formData.category) { - setFormData(prev => ({ ...prev, category: data[0].id })); - } - } catch (error) { - console.error('카테고리 로드 오류:', error); - } - }; + // 수정 모드면 PUT, 생성 모드면 POST + const url = isEditMode + ? `/api/admin/schedules/${id}` + : "/api/admin/schedules"; + const method = isEditMode ? "PUT" : "POST"; - // Toast 자동 숨김 - useEffect(() => { - if (toast) { - const timer = setTimeout(() => setToast(null), 3000); - return () => clearTimeout(timer); - } - }, [toast]); + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + }, + body: submitData, + }); - useEffect(() => { - const token = localStorage.getItem('adminToken'); - const userData = localStorage.getItem('adminUser'); + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || + (isEditMode + ? "일정 수정에 실패했습니다." + : "일정 생성에 실패했습니다.") + ); + } - if (!token || !userData) { - navigate('/admin'); - return; - } + // 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동 + sessionStorage.setItem( + "scheduleToast", + JSON.stringify({ + type: "success", + message: isEditMode + ? "일정이 수정되었습니다." + : "일정이 추가되었습니다.", + }) + ); + navigate("/admin/schedule"); + } catch (error) { + console.error("일정 저장 오류:", error); + setToast({ + type: "error", + message: error.message || "일정 저장 중 오류가 발생했습니다.", + }); + } finally { + setSaving(false); + } + }; - setUser(JSON.parse(userData)); - fetchMembers(); - fetchCategories(); - - // 수정 모드일 경우 기존 데이터 로드 - if (isEditMode && id) { - fetchSchedule(); - } - }, [navigate, isEditMode, id]); + return ( +
+ setToast(null)} /> - // 기존 일정 데이터 로드 (수정 모드) - const fetchSchedule = async () => { - setLoading(true); - try { - const token = localStorage.getItem('adminToken'); - const res = await fetch(`/api/admin/schedules/${id}`, { - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!res.ok) { - throw new Error('일정을 찾을 수 없습니다.'); - } - - const data = await res.json(); - - // 폼 데이터 설정 - setFormData({ - title: data.title || '', - startDate: data.date ? new Date(data.date).toISOString().split('T')[0] : '', - endDate: data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : '', - startTime: data.time?.slice(0, 5) || '', - endTime: data.end_time?.slice(0, 5) || '', - isRange: !!data.end_date, - category: data.category_id || '', - description: data.description || '', - url: data.source_url || '', - sourceName: data.source_name || '', - members: data.members?.map(m => m.id) || [], - images: [], - locationName: data.location_name || '', - locationAddress: data.location_address || '', - locationDetail: data.location_detail || '', - locationLat: data.location_lat || null, - locationLng: data.location_lng || null, - }); - - // 기존 이미지 설정 - if (data.images && data.images.length > 0) { - setImagePreviews(data.images.map(img => img.image_url)); - setExistingImageIds(data.images.map(img => img.id)); - } - } catch (error) { - console.error('일정 로드 오류:', error); - setToast({ type: 'error', message: error.message || '일정을 불러오는 중 오류가 발생했습니다.' }); - navigate('/admin/schedule'); - } finally { - setLoading(false); - } - }; + {/* 삭제 확인 다이얼로그 */} + + {deleteDialogOpen && ( + setDeleteDialogOpen(false)} + > + e.stopPropagation()} + > +
+
+ +
+

이미지 삭제

+
- const fetchMembers = async () => { - try { - const res = await fetch('/api/members'); - const data = await res.json(); - setMembers(data.filter(m => !m.is_former)); - } catch (error) { - console.error('멤버 로드 오류:', error); - } - }; +

+ 이 이미지를 삭제하시겠습니까? +
+ + 이 작업은 되돌릴 수 없습니다. + +

- const handleLogout = () => { - localStorage.removeItem('adminToken'); - localStorage.removeItem('adminUser'); - navigate('/admin'); - }; +
+ + +
+
+
+ )} +
- // 멤버 토글 - const toggleMember = (memberId) => { - const newMembers = formData.members.includes(memberId) - ? formData.members.filter(id => id !== memberId) - : [...formData.members, memberId]; - setFormData({ ...formData, members: newMembers }); - }; + {/* 장소 검색 다이얼로그 */} + + {locationDialogOpen && ( + { + setLocationDialogOpen(false); + setLocationSearch(""); + setLocationResults([]); + }} + > + e.stopPropagation()} + > +
+

장소 검색

+ +
- // 전체 선택/해제 - const toggleAllMembers = () => { - if (formData.members.length === members.length) { - setFormData({ ...formData, members: [] }); - } else { - setFormData({ ...formData, members: members.map(m => m.id) }); - } - }; - - // 다중 이미지 업로드 - const handleImagesUpload = (e) => { - const files = Array.from(e.target.files); - // 파일을 {file: File} 형태로 저장 (제출 시 image.file로 접근하기 위함) - const newImageObjects = files.map(file => ({ file })); - const newImages = [...formData.images, ...newImageObjects]; - setFormData({ ...formData, images: newImages }); - - files.forEach(file => { - const reader = new FileReader(); - reader.onloadend = () => { - setImagePreviews(prev => [...prev, reader.result]); - }; - reader.readAsDataURL(file); - }); - }; - - // 이미지 삭제 다이얼로그 열기 - const openDeleteDialog = (index) => { - setDeleteTargetIndex(index); - setDeleteDialogOpen(true); - }; - - // 이미지 삭제 확인 - const confirmDeleteImage = () => { - if (deleteTargetIndex !== null) { - const newImages = formData.images.filter((_, i) => i !== deleteTargetIndex); - const newPreviews = imagePreviews.filter((_, i) => i !== deleteTargetIndex); - setFormData({ ...formData, images: newImages }); - setImagePreviews(newPreviews); - } - setDeleteDialogOpen(false); - setDeleteTargetIndex(null); - }; - - // 라이트박스 열기 - const openLightbox = (index) => { - setLightboxIndex(index); - setLightboxOpen(true); - }; - - // 드래그 앤 드롭 상태 - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - - // 드래그 시작 - const handleDragStart = (e, index) => { - setDraggedIndex(index); - e.dataTransfer.effectAllowed = 'move'; - // 드래그 이미지 설정 - e.dataTransfer.setData('text/plain', index); - }; - - // 드래그 오버 - const handleDragOver = (e, index) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragOverIndex !== index) { - setDragOverIndex(index); - } - }; - - // 드래그 종료 - const handleDragEnd = () => { - setDraggedIndex(null); - setDragOverIndex(null); - }; - - // 드롭 - 이미지 순서 변경 - const handleDrop = (e, dropIndex) => { - e.preventDefault(); - if (draggedIndex === null || draggedIndex === dropIndex) { - handleDragEnd(); - return; - } - - // 새 배열 생성 - const newPreviews = [...imagePreviews]; - const newImages = [...formData.images]; - - // 드래그된 아이템 제거 후 새 위치에 삽입 - const [movedPreview] = newPreviews.splice(draggedIndex, 1); - const [movedImage] = newImages.splice(draggedIndex, 1); - - newPreviews.splice(dropIndex, 0, movedPreview); - newImages.splice(dropIndex, 0, movedImage); - - setImagePreviews(newPreviews); - setFormData({ ...formData, images: newImages }); - handleDragEnd(); - }; - - // 카카오 장소 검색 API 호출 (엔터 키로 검색) - const handleLocationSearch = async () => { - if (!locationSearch.trim()) { - setLocationResults([]); - return; - } - - setLocationSearching(true); - try { - const token = localStorage.getItem('token'); - const response = await fetch( - `/api/admin/kakao/places?query=${encodeURIComponent(locationSearch)}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - } - } - ); - - if (response.ok) { - const data = await response.json(); - setLocationResults(data.documents || []); - } - } catch (error) { - console.error('장소 검색 오류:', error); - } finally { - setLocationSearching(false); - } - }; - - // 장소 선택 - const selectLocation = (place) => { - setFormData({ - ...formData, - locationName: place.place_name, - locationAddress: place.road_address_name || place.address_name, - locationLat: parseFloat(place.y), - locationLng: parseFloat(place.x) - }); - setLocationDialogOpen(false); - setLocationSearch(''); - setLocationResults([]); - }; - - - // 폼 제출 - const handleSubmit = async (e) => { - e.preventDefault(); - - // 유효성 검사 - if (!formData.title.trim()) { - setToast({ type: 'error', message: '제목을 입력해주세요.' }); - return; - } - // 날짜 검증: 단일/기간 모드 모두 startDate를 사용함 - if (!formData.startDate) { - setToast({ type: 'error', message: '날짜를 선택해주세요.' }); - return; - } - - if (!formData.category) { - setToast({ type: 'error', message: '카테고리를 선택해주세요.' }); - return; - } - - setSaving(true); - - try { - const token = localStorage.getItem('adminToken'); - - // FormData 생성 - const submitData = new FormData(); - - // JSON 데이터 - 항상 startDate를 date로 사용 (UI에서 단일/기간 모드 모두 startDate 사용) - const jsonData = { - title: formData.title.trim(), - date: formData.startDate, - time: formData.startTime || null, - endDate: formData.isRange ? formData.endDate : null, - endTime: formData.isRange ? formData.endTime : null, - isRange: formData.isRange, - category: formData.category, - description: formData.description.trim() || null, - url: formData.url.trim() || null, - sourceName: formData.sourceName.trim() || null, - members: formData.members, - locationName: formData.locationName.trim() || null, - locationAddress: formData.locationAddress.trim() || null, - locationDetail: formData.locationDetail?.trim() || null, - locationLat: formData.locationLat, - locationLng: formData.locationLng, - }; - - // 수정 모드일 경우 유지할 기존 이미지 ID 추가 - if (isEditMode) { - jsonData.existingImages = existingImageIds; - } - - submitData.append('data', JSON.stringify(jsonData)); - - // 이미지 파일 추가 (새로 추가된 이미지만) - for (const image of formData.images) { - if (image.file) { - submitData.append('images', image.file); - } - } - - // 수정 모드면 PUT, 생성 모드면 POST - const url = isEditMode ? `/api/admin/schedules/${id}` : '/api/admin/schedules'; - const method = isEditMode ? 'PUT' : 'POST'; - - const response = await fetch(url, { - method, - headers: { - 'Authorization': `Bearer ${token}`, - }, - body: submitData, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.')); - } - - // 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동 - sessionStorage.setItem('scheduleToast', JSON.stringify({ - type: 'success', - message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' - })); - navigate('/admin/schedule'); - - } catch (error) { - console.error('일정 저장 오류:', error); - setToast({ type: 'error', message: error.message || '일정 저장 중 오류가 발생했습니다.' }); - } finally { - setSaving(false); - } - }; - - return ( -
- setToast(null)} /> - - {/* 삭제 확인 다이얼로그 */} - - {deleteDialogOpen && ( + {/* 검색 입력 */} +
+
+ + setLocationSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleLocationSearch(); + } + }} + placeholder="장소명을 입력하세요" + 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 + /> +
+ +
+ + {/* 검색 결과 */} +
+ {locationResults.length > 0 ? ( +
+ {locationResults.map((place, index) => ( + - -
- - + )} +
+ + ))} +
+ ) : locationSearch && !locationSearching ? ( +
+ +

검색어를 입력하고 검색 버튼을 눌러주세요

+
+ ) : ( +
+ +

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

+
)} -
+
+ + + )} + - {/* 장소 검색 다이얼로그 */} - - {locationDialogOpen && ( - { - setLocationDialogOpen(false); - setLocationSearch(''); - setLocationResults([]); - }} - > - e.stopPropagation()} - > -
-

장소 검색

- -
- - {/* 검색 입력 */} -
-
- - setLocationSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleLocationSearch(); - } - }} - placeholder="장소명을 입력하세요" - 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 - /> -
- -
- - {/* 검색 결과 */} -
- {locationResults.length > 0 ? ( -
- {locationResults.map((place, index) => ( - - ))} -
- ) : locationSearch && !locationSearching ? ( -
- -

검색어를 입력하고 검색 버튼을 눌러주세요

-
- ) : ( -
- -

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

-
- )} -
-
-
- )} -
+ {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} + setLightboxOpen(false)} + onIndexChange={setLightboxIndex} + /> - {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} - setLightboxOpen(false)} - onIndexChange={setLightboxIndex} - /> - - {/* 헤더 */} -
-
-
- - fromis_9 - - - Admin - -
-
- - 안녕하세요, {user?.username}님 - - -
-
-
- - {/* 메인 콘텐츠 */} -
- {/* 브레드크럼 */} -
- - - - - 일정 관리 - - {isEditMode ? '일정 수정' : '일정 추가'} -
- - {/* 타이틀 */} -
-

{isEditMode ? '일정 수정' : '일정 추가'}

-

새로운 일정을 등록합니다

-
- - {/* 폼 */} -
- {/* 기본 정보 카드 */} -
-

기본 정보

- -
- {/* 제목 */} -
- - setFormData({ ...formData, title: e.target.value })} - placeholder="일정 제목을 입력하세요" - className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - required - /> -
- - {/* 범위 설정 토글 */} -
- - 기간 설정 (시작~종료) -
- - {/* 날짜 + 시간 */} - {formData.isRange ? ( - // 범위 설정 모드 -
- {/* 시작 */} -
-
- - setFormData({ ...formData, startDate: date })} - placeholder="시작 날짜 선택" - /> -
-
- - setFormData({ ...formData, startTime: time })} - placeholder="시작 시간 선택" - /> -
-
- {/* 종료 */} -
-
- - setFormData({ ...formData, endDate: date })} - placeholder="종료 날짜 선택" - /> -
-
- - setFormData({ ...formData, endTime: time })} - placeholder="종료 시간 선택" - /> -
-
-
- ) : ( - // 단일 날짜 모드 -
-
- - setFormData({ ...formData, startDate: date })} - /> -
-
- - setFormData({ ...formData, startTime: time })} - /> -
-
- )} - - {/* 카테고리 */} -
-
- - - - 카테고리 관리 - -
-
- {categories.map(category => ( - - ))} -
-
- - {/* 장소 */} -
- -
- {/* 장소 이름 + 검색 버튼 */} -
-
- - setFormData({ ...formData, locationName: e.target.value })} - placeholder="장소 이름 (예: 잠실실내체육관)" - 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" - /> -
- -
- - {/* 주소 */} - setFormData({ ...formData, locationAddress: e.target.value })} - placeholder="주소 (예: 서울특별시 송파구 올림픽로 25)" - className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - /> - - {/* 상세주소 */} - setFormData({ ...formData, locationDetail: e.target.value })} - placeholder="상세주소 (예: 3관 A구역, N열 등)" - className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - /> -
-
- - {/* 설명 */} -
- -