- {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 (
-
-
+ );
}
-
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');
- };
+
+ setDeleteDialogOpen(false)}
+ className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
+ >
+ 취소
+
+
+
+ 삭제
+
+
+
+
+ )}
+
- // 멤버 토글
- 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()}
+ >
+
+
장소 검색
+ {
+ setLocationDialogOpen(false);
+ setLocationSearch("");
+ setLocationResults([]);
+ }}
+ className="text-gray-400 hover:text-gray-600"
+ >
+
+
+
- // 전체 선택/해제
- 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
+ />
+
+
+ {locationSearching ? (
setDeleteDialogOpen(false)}
+ animate={{ rotate: 360 }}
+ transition={{
+ duration: 1,
+ repeat: Infinity,
+ ease: "linear",
+ }}
>
- e.stopPropagation()}
- >
-
-
-
- 이 이미지를 삭제하시겠습니까?
-
- 이 작업은 되돌릴 수 없습니다.
+
+
+ ) : (
+ "검색"
+ )}
+
+
+
+ {/* 검색 결과 */}
+
+ {locationResults.length > 0 ? (
+
+ {locationResults.map((place, index) => (
+
selectLocation(place)}
+ className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
+ >
+
+
+
+ {place.place_name}
+
+
+ {place.road_address_name || place.address_name}
+
+ {place.category_name && (
+
+ {place.category_name}
-
-
- setDeleteDialogOpen(false)}
- className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
- >
- 취소
-
-
-
- 삭제
-
-
-
-
+ )}
+
+
+ ))}
+
+ ) : locationSearch && !locationSearching ? (
+
+
+
검색어를 입력하고 검색 버튼을 눌러주세요
+
+ ) : (
+
)}
-
+
+
+
+ )}
+
- {/* 장소 검색 다이얼로그 */}
-
- {locationDialogOpen && (
- {
- setLocationDialogOpen(false);
- setLocationSearch('');
- setLocationResults([]);
- }}
- >
- e.stopPropagation()}
- >
-
-
장소 검색
- {
- setLocationDialogOpen(false);
- setLocationSearch('');
- setLocationResults([]);
- }}
- className="text-gray-400 hover:text-gray-600"
- >
-
-
-
-
- {/* 검색 입력 */}
-
-
-
- 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
- />
-
-
- {locationSearching ? (
-
-
-
- ) : (
- '검색'
- )}
-
-
-
- {/* 검색 결과 */}
-
- {locationResults.length > 0 ? (
-
- {locationResults.map((place, index) => (
-
selectLocation(place)}
- className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
- >
-
-
-
{place.place_name}
-
{place.road_address_name || place.address_name}
- {place.category_name && (
-
{place.category_name}
- )}
-
-
- ))}
-
- ) : locationSearch && !locationSearching ? (
-
-
-
검색어를 입력하고 검색 버튼을 눌러주세요
-
- ) : (
-
- )}
-
-
-
- )}
-
+ {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
+ setLightboxOpen(false)}
+ onIndexChange={setLightboxIndex}
+ />
- {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
- setLightboxOpen(false)}
- onIndexChange={setLightboxIndex}
- />
-
- {/* 헤더 */}
-
-
- {/* 메인 콘텐츠 */}
-
- {/* 브레드크럼 */}
-
-
-
-
-
- 일정 관리
-
- {isEditMode ? '일정 수정' : '일정 추가'}
-
-
- {/* 타이틀 */}
-
-
{isEditMode ? '일정 수정' : '일정 추가'}
-
새로운 일정을 등록합니다
-
-
- {/* 폼 */}
-
-
+ {/* 헤더 */}
+
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 일정 관리
+
+
+
+ {isEditMode ? "일정 수정" : "일정 추가"}
+
+
+
+ {/* 타이틀 */}
+
+
+ {isEditMode ? "일정 수정" : "일정 추가"}
+
+
새로운 일정을 등록합니다
+
+
+ {/* 폼 */}
+
+
+
+ );
}
export default AdminScheduleForm;