{isEditMode ? "일정 수정" : "일정 추가"}
새로운 일정을 등록합니다
import { useState, useEffect, useRef } from "react"; import { useNavigate, Link, useParams } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { formatDate } from "../../../utils/date"; 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 CustomDatePicker from "../../../components/admin/CustomDatePicker"; import CustomTimePicker from "../../../components/admin/CustomTimePicker"; import AdminHeader from "../../../components/admin/AdminHeader"; import useToast from "../../../hooks/useToast"; import * as authApi from "../../../api/admin/auth"; import * as categoriesApi from "../../../api/admin/categories"; import * as schedulesApi from "../../../api/admin/schedules"; import { getMembers } from "../../../api/public/members"; function AdminScheduleForm() { const navigate = useNavigate(); const { id } = useParams(); const isEditMode = !!id; const [user, setUser] = useState(null); const { toast, setToast } = useToast(); 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 data = await categoriesApi.getCategories(); setCategories(data); // 첫 번째 카테고리를 기본값으로 설정 if (data.length > 0 && !formData.category) { setFormData((prev) => ({ ...prev, category: data[0].id })); } } catch (error) { console.error("카테고리 로드 오류:", error); } }; useEffect(() => { if (!authApi.hasToken()) { navigate("/admin"); return; } setUser(authApi.getCurrentUser()); fetchMembers(); fetchCategories(); // 수정 모드일 경우 기존 데이터 로드 if (isEditMode && id) { fetchSchedule(); } }, [navigate, isEditMode, id]); // 기존 일정 데이터 로드 (수정 모드) const fetchSchedule = async () => { setLoading(true); try { const data = await schedulesApi.getSchedule(id); // 폼 데이터 설정 setFormData({ title: data.title || "", startDate: data.date ? formatDate(data.date) : "", endDate: data.end_date ? formatDate(data.end_date) : "", 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) { // 기존 이미지를 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 data = await getMembers(); setMembers(data.filter((m) => !m.is_former)); } catch (error) { console.error("멤버 로드 오류:", error); } }; // 멤버 토글 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 openDeleteDialog = (index) => { setDeleteTargetIndex(index); setDeleteDialogOpen(true); }; // 이미지 삭제 확인 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}`, }, } ); 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 (
이 이미지를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
검색어를 입력하고 검색 버튼을 눌러주세요
장소명을 입력하고 검색해주세요
새로운 일정을 등록합니다