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 { Home, ChevronRight, Calendar, Save, X, Upload, Link as LinkIcon, ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, 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 ConfirmDialog from "../../../components/admin/ConfirmDialog"; import useToast from "../../../hooks/useToast"; import useScheduleStore from "../../../stores/useScheduleStore"; 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 { setPreserveState } = useScheduleStore(); 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 ? "일정이 수정되었습니다." : "일정이 추가되었습니다.", }) ); setPreserveState(true); navigate("/admin/schedule"); } catch (error) { console.error("일정 저장 오류:", error); setToast({ type: "error", message: error.message || "일정 저장 중 오류가 발생했습니다.", }); } finally { setSaving(false); } }; return (
setToast(null)} /> {/* 삭제 확인 다이얼로그 */} setDeleteDialogOpen(false)} onConfirm={confirmDeleteImage} title="이미지 삭제" message={ <> 이 이미지를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다. } /> {/* 장소 검색 다이얼로그 */} {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} /> {/* 헤더 */} {/* 메인 콘텐츠 */}
{/* 브레드크럼 */}
{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) => ( ))}
{/* 장소 */}
{/* 검색으로 입력된 경우(좌표가 있는 경우) 초기화 버튼 표시 */} {formData.locationLat && formData.locationLng && ( )}
{/* 장소 이름 + 검색 버튼 */}
setFormData({ ...formData, locationName: e.target.value, }) } placeholder="장소 이름 (예: 잠실실내체육관)" disabled={!!(formData.locationLat && formData.locationLng)} 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 ${formData.locationLat && formData.locationLng ? 'bg-gray-50 text-gray-700 cursor-not-allowed' : ''}`} />
{/* 주소 */} setFormData({ ...formData, locationAddress: e.target.value, }) } placeholder="주소 (예: 서울특별시 송파구 올림픽로 25)" disabled={!!(formData.locationLat && formData.locationLng)} 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 ${formData.locationLat && formData.locationLng ? 'bg-gray-50 text-gray-700 cursor-not-allowed' : ''}`} /> {/* 상세주소 */} 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" />
{/* 설명 */}