diff --git a/.env b/.env index 0ef6e6f..ec421bb 100644 --- a/.env +++ b/.env @@ -16,3 +16,6 @@ RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd RUSTFS_BUCKET=fromis-9 + +# Kakao API +KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8 diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 9700f21..8dfdc22 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1140,5 +1140,505 @@ router.put( } } ); +// ==================== 일정 관리 API ==================== + +// 일정 목록 조회 +router.get("/schedules", async (req, res) => { + try { + const { year, month } = req.query; + + let whereClause = ""; + let params = []; + + // 년/월 필터링 + if (year && month) { + whereClause = "WHERE YEAR(s.date) = ? AND MONTH(s.date) = ?"; + params = [parseInt(year), parseInt(month)]; + } else if (year) { + whereClause = "WHERE YEAR(s.date) = ?"; + params = [parseInt(year)]; + } + + const [schedules] = await pool.query( + `SELECT + s.id, s.title, s.date, s.time, s.end_date, s.end_time, + s.category_id, s.description, s.source_url, + s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng, + s.created_at, + c.name as category_name, c.color as category_color + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + ${whereClause} + ORDER BY s.date ASC, s.time ASC`, + params + ); + + // 각 일정의 이미지와 멤버 조회 + const schedulesWithDetails = await Promise.all( + schedules.map(async (schedule) => { + const [images] = await pool.query( + "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", + [schedule.id] + ); + const [members] = await pool.query( + `SELECT m.id, m.name FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id = ?`, + [schedule.id] + ); + return { ...schedule, images, members }; + }) + ); + + res.json(schedulesWithDetails); + } catch (error) { + console.error("일정 조회 오류:", error); + res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); + } +}); + +// 일정 생성 +router.post( + "/schedules", + authenticateToken, + upload.array("images", 20), + async (req, res) => { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const data = JSON.parse(req.body.data); + const { + title, + date, + time, + endDate, + endTime, + isRange, + category, + description, + url, + members, + locationName, + locationAddress, + locationDetail, + locationLat, + locationLng, + } = data; + + // 필수 필드 검증 + if (!title || !date) { + return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); + } + + // 일정 삽입 + const [scheduleResult] = await connection.query( + `INSERT INTO schedules + (title, date, time, end_date, end_time, category_id, description, source_url, + location_name, location_address, location_detail, location_lat, location_lng) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + title, + date, + time || null, + isRange && endDate ? endDate : null, + isRange && endTime ? endTime : null, + category || null, + description || null, + url || null, + locationName || null, + locationAddress || null, + locationDetail || null, + locationLat || null, + locationLng || null, + ] + ); + + const scheduleId = scheduleResult.insertId; + + // 멤버 연결 처리 (schedule_members 테이블) + if (members && members.length > 0) { + const memberValues = members.map((memberId) => [scheduleId, memberId]); + await connection.query( + `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, + [memberValues] + ); + } + + // 이미지 업로드 처리 + if (req.files && req.files.length > 0) { + const publicUrl = + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; + const basePath = `schedule/${scheduleId}`; + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + const orderNum = String(i + 1).padStart(2, "0"); + const filename = `${orderNum}.webp`; + + // WebP 변환 (원본만) + const imageBuffer = await sharp(file.buffer) + .webp({ quality: 90 }) + .toBuffer(); + + // RustFS 업로드 (원본만) + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/${filename}`, + Body: imageBuffer, + ContentType: "image/webp", + }) + ); + + const imageUrl = `${publicUrl}/${BUCKET}/${basePath}/${filename}`; + + // DB 저장 + await connection.query( + `INSERT INTO schedule_images (schedule_id, image_url, sort_order) + VALUES (?, ?, ?)`, + [scheduleId, imageUrl, i + 1] + ); + } + } + + await connection.commit(); + + res.json({ message: "일정이 생성되었습니다.", scheduleId }); + } catch (error) { + await connection.rollback(); + console.error("일정 생성 오류:", error); + res.status(500).json({ error: "일정 생성 중 오류가 발생했습니다." }); + } finally { + connection.release(); + } + } +); + +// 카카오 장소 검색 프록시 (CORS 우회) +router.get("/kakao/places", authenticateToken, async (req, res) => { + try { + const { query } = req.query; + + if (!query) { + return res.status(400).json({ error: "검색어를 입력해주세요." }); + } + + const response = await fetch( + `https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent( + query + )}`, + { + headers: { + Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Kakao API error: ${response.status}`); + } + + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("카카오 장소 검색 오류:", error); + res.status(500).json({ error: "장소 검색 중 오류가 발생했습니다." }); + } +}); + +// 일정 단일 조회 +router.get("/schedules/:id", authenticateToken, async (req, res) => { + try { + const { id } = req.params; + + // 일정 기본 정보 조회 + const [schedules] = await pool.query( + `SELECT s.*, sc.name as category_name, sc.color as category_color + FROM schedules s + LEFT JOIN schedule_categories sc ON s.category_id = sc.id + WHERE s.id = ?`, + [id] + ); + + if (schedules.length === 0) { + return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); + } + + const schedule = schedules[0]; + + // 이미지 조회 + const [images] = await pool.query( + "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", + [id] + ); + + // 멤버 조회 + const [members] = await pool.query( + `SELECT m.id, m.name, m.image_url + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id = ?`, + [id] + ); + + res.json({ ...schedule, images, members }); + } catch (error) { + console.error("일정 조회 오류:", error); + res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); + } +}); + +// 일정 수정 +router.put( + "/schedules/:id", + authenticateToken, + upload.array("images", 20), + async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + + const { id } = req.params; + const data = JSON.parse(req.body.data || "{}"); + const { + title, + date, + time, + endDate, + endTime, + isRange, + category, + description, + url, + members, + locationName, + locationAddress, + locationDetail, + locationLat, + locationLng, + existingImages, // 유지할 기존 이미지 ID 배열 + } = data; + + // 필수 필드 검증 + if (!title || !date) { + return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); + } + + // 일정 업데이트 + await connection.query( + `UPDATE schedules SET + title = ?, + date = ?, + time = ?, + end_date = ?, + end_time = ?, + category_id = ?, + description = ?, + source_url = ?, + location_name = ?, + location_address = ?, + location_detail = ?, + location_lat = ?, + location_lng = ? + WHERE id = ?`, + [ + title, + date, + time || null, + isRange && endDate ? endDate : null, + isRange && endTime ? endTime : null, + category || null, + description || null, + url || null, + locationName || null, + locationAddress || null, + locationDetail || null, + locationLat || null, + locationLng || null, + id, + ] + ); + + // 멤버 업데이트 (기존 삭제 후 새로 추가) + await connection.query( + "DELETE FROM schedule_members WHERE schedule_id = ?", + [id] + ); + if (members && members.length > 0) { + const memberValues = members.map((memberId) => [id, memberId]); + await connection.query( + `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, + [memberValues] + ); + } + + // 삭제할 이미지 처리 (existingImages에 없는 이미지 삭제) + const existingImageIds = existingImages || []; + if (existingImageIds.length > 0) { + // 삭제할 이미지 조회 + const [imagesToDelete] = await connection.query( + `SELECT id, image_url FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, + [id, existingImageIds] + ); + + // S3에서 이미지 삭제 + for (const img of imagesToDelete) { + try { + const key = img.image_url.replace( + `${ + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT + }/${process.env.RUSTFS_BUCKET}/`, + "" + ); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.RUSTFS_BUCKET, + Key: key, + }) + ); + } catch (err) { + console.error("이미지 삭제 오류:", err); + } + } + + // DB에서 삭제 + await connection.query( + `DELETE FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, + [id, existingImageIds] + ); + } else { + // 기존 이미지 모두 삭제 + const [allImages] = await connection.query( + `SELECT id, image_url FROM schedule_images WHERE schedule_id = ?`, + [id] + ); + + for (const img of allImages) { + try { + const key = img.image_url.replace( + `${ + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT + }/${process.env.RUSTFS_BUCKET}/`, + "" + ); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.RUSTFS_BUCKET, + Key: key, + }) + ); + } catch (err) { + console.error("이미지 삭제 오류:", err); + } + } + + await connection.query( + `DELETE FROM schedule_images WHERE schedule_id = ?`, + [id] + ); + } + + // 새 이미지 업로드 + if (req.files && req.files.length > 0) { + const publicUrl = + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; + const basePath = `schedule/${id}`; + + // 현재 최대 sort_order 조회 + const [maxOrder] = await connection.query( + "SELECT COALESCE(MAX(sort_order), 0) as max_order FROM schedule_images WHERE schedule_id = ?", + [id] + ); + let currentOrder = maxOrder[0].max_order; + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + currentOrder++; + const orderNum = String(currentOrder).padStart(2, "0"); + const filename = `${orderNum}_${Date.now()}.webp`; + + const imageBuffer = await sharp(file.buffer) + .webp({ quality: 90 }) + .toBuffer(); + + await s3Client.send( + new PutObjectCommand({ + Bucket: process.env.RUSTFS_BUCKET, + Key: `${basePath}/${filename}`, + Body: imageBuffer, + ContentType: "image/webp", + }) + ); + + const imageUrl = `${publicUrl}/${process.env.RUSTFS_BUCKET}/${basePath}/${filename}`; + + await connection.query( + "INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)", + [id, imageUrl, currentOrder] + ); + } + } + + await connection.commit(); + res.json({ message: "일정이 수정되었습니다." }); + } catch (error) { + await connection.rollback(); + console.error("일정 수정 오류:", error); + res.status(500).json({ error: "일정 수정 중 오류가 발생했습니다." }); + } finally { + connection.release(); + } + } +); + +// 일정 삭제 +router.delete("/schedules/:id", authenticateToken, async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + + const { id } = req.params; + + // 이미지 조회 + const [images] = await connection.query( + "SELECT image_url FROM schedule_images WHERE schedule_id = ?", + [id] + ); + + // S3에서 이미지 삭제 + for (const img of images) { + try { + const key = img.image_url.replace( + `${process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT}/${ + process.env.RUSTFS_BUCKET + }/`, + "" + ); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.RUSTFS_BUCKET, + Key: key, + }) + ); + } catch (err) { + console.error("이미지 삭제 오류:", err); + } + } + + // 일정 삭제 (CASCADE로 schedule_images, schedule_members도 자동 삭제) + await connection.query("DELETE FROM schedules WHERE id = ?", [id]); + + await connection.commit(); + res.json({ message: "일정이 삭제되었습니다." }); + } catch (error) { + await connection.rollback(); + console.error("일정 삭제 오류:", error); + res.status(500).json({ error: "일정 삭제 중 오류가 발생했습니다." }); + } finally { + connection.release(); + } +}); export default router; diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..6605747 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb +VITE_KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8 diff --git a/frontend/src/components/Tooltip.jsx b/frontend/src/components/Tooltip.jsx index 7872d8d..e2cefeb 100644 --- a/frontend/src/components/Tooltip.jsx +++ b/frontend/src/components/Tooltip.jsx @@ -6,16 +6,16 @@ import { motion, AnimatePresence } from 'framer-motion'; * 커스텀 툴팁 컴포넌트 * 마우스 커서를 따라다니는 방식 * @param {React.ReactNode} children - 툴팁을 표시할 요소 - * @param {string} text - 툴팁에 표시할 텍스트 (content prop과 호환) - * @param {string} content - 툴팁에 표시할 텍스트 (text prop과 호환) + * @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환) + * @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환) */ const Tooltip = ({ children, text, content, className = "" }) => { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ bottom: 0, left: 0 }); const triggerRef = useRef(null); - // text 또는 content prop 사용 - const tooltipText = text || content; + // text 또는 content prop 사용 (문자열 또는 React 노드) + const tooltipContent = text || content; const handleMouseEnter = (e) => { // 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로) @@ -45,7 +45,7 @@ const Tooltip = ({ children, text, content, className = "" }) => { > {children} - {isVisible && tooltipText && ReactDOM.createPortal( + {isVisible && tooltipContent && ReactDOM.createPortal( { bottom: position.bottom, left: position.left, }} - className="fixed z-[9999] -translate-x-1/2 px-2.5 py-1.5 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none whitespace-nowrap" + className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none" > - {tooltipText} + {tooltipContent} , document.body diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 4389c74..8666038 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -3,9 +3,10 @@ import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2, - ChevronLeft, Search, ChevronDown + ChevronLeft, Search, ChevronDown, AlertTriangle } from 'lucide-react'; import Toast from '../../../components/Toast'; +import Tooltip from '../../../components/Tooltip'; function AdminSchedule() { const navigate = useNavigate(); @@ -13,7 +14,7 @@ function AdminSchedule() { const [user, setUser] = useState(null); const [toast, setToast] = useState(null); const [searchTerm, setSearchTerm] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedCategories, setSelectedCategories] = useState([]); // 빈 배열 = 전체 const [selectedDate, setSelectedDate] = useState(null); const [currentDate, setCurrentDate] = useState(new Date()); const [slideDirection, setSlideDirection] = useState(0); @@ -44,54 +45,60 @@ function AdminSchedule() { const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); - // 카테고리 목록 - const categories = [ - { id: 'all', name: '전체', color: 'gray' }, - { id: 'broadcast', name: '방송', color: 'blue' }, - { id: 'event', name: '이벤트', color: 'green' }, - { id: 'release', name: '발매', color: 'purple' }, - { id: 'concert', name: '콘서트', color: 'red' }, - { id: 'fansign', name: '팬사인회', color: 'pink' }, - ]; + // 카테고리 목록 (API에서 로드) + const [categories, setCategories] = useState([ + { id: 'all', name: '전체', color: 'gray' } + ]); + + // 일정 목록 (API에서 로드) + const [schedules, setSchedules] = useState([]); - // 더미 일정 데이터 (UI용) - const dummySchedules = [ - { id: 1, title: 'SBS 인기가요 출연', date: '2026-01-05', time: '15:30', category: 'broadcast', description: '컴백 무대' }, - { id: 2, title: '유튜브 라이브', date: '2026-01-05', time: '19:00', category: 'broadcast', description: '팬들과 소통 시간' }, - { id: 3, title: '팬미팅', date: '2026-01-10', time: '18:00', category: 'event', description: '서울 올림픽홀' }, - { id: 4, title: '신곡 발매', date: '2026-01-15', time: '18:00', category: 'release', description: '미니앨범 8집' }, - { id: 5, title: '콘서트 투어', date: '2026-01-20', time: '19:00', category: 'concert', description: '부산 벡스코' }, - { id: 6, title: '온라인 팬사인회', date: '2026-01-25', time: '17:00', category: 'fansign', description: '위버스 진행' }, - ]; - - // 카테고리별 색상 - const getCategoryColor = (categoryId) => { - const colors = { - broadcast: 'bg-blue-100 text-blue-700', - event: 'bg-green-100 text-green-700', - release: 'bg-purple-100 text-purple-700', - concert: 'bg-red-100 text-red-700', - fansign: 'bg-pink-100 text-pink-700', - }; - return colors[categoryId] || 'bg-gray-100 text-gray-700'; + // 카테고리 색상 맵핑 + 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', }; - // 카테고리별 도트 색상 - const getCategoryDotColor = (categoryId) => { + // 색상 스타일 (기본 색상 또는 커스텀 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 = (color) => { const colors = { - broadcast: 'bg-blue-500', - event: 'bg-green-500', - release: 'bg-purple-500', - concert: 'bg-red-500', - fansign: 'bg-pink-500', + blue: 'bg-blue-100 text-blue-700', + green: 'bg-green-100 text-green-700', + purple: 'bg-purple-100 text-purple-700', + red: 'bg-red-100 text-red-700', + pink: 'bg-pink-100 text-pink-700', + yellow: 'bg-yellow-100 text-yellow-700', + orange: 'bg-orange-100 text-orange-700', + gray: 'bg-gray-100 text-gray-700', }; - return colors[categoryId] || 'bg-gray-500'; + if (color?.startsWith('#')) { + return 'bg-gray-100 text-gray-700'; + } + return colors[color] || 'bg-gray-100 text-gray-700'; }; // 해당 날짜에 일정이 있는지 확인 const hasSchedule = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - return dummySchedules.some(s => s.date === dateStr); + return schedules.some(s => { + const scheduleDate = new Date(s.date).toISOString().split('T')[0]; + return scheduleDate === dateStr; + }); }; // Toast 자동 숨김 @@ -112,7 +119,43 @@ function AdminSchedule() { } setUser(JSON.parse(userData)); + + // 카테고리 로드 + fetchCategories(); }, [navigate]); + + // 월이 변경될 때마다 일정 로드 + useEffect(() => { + fetchSchedules(); + }, [year, month]); + + // 카테고리 로드 함수 + const fetchCategories = async () => { + try { + const res = await fetch('/api/admin/schedule-categories'); + const data = await res.json(); + setCategories([ + { id: 'all', name: '전체', color: 'gray' }, + ...data + ]); + } catch (error) { + console.error('카테고리 로드 오류:', error); + } + }; + + // 일정 로드 함수 + const fetchSchedules = async () => { + setLoading(true); + try { + const res = await fetch(`/api/admin/schedules?year=${year}&month=${month + 1}`); + const data = await res.json(); + setSchedules(data); + } catch (error) { + console.error('일정 로드 오류:', error); + } finally { + setLoading(false); + } + }; // 외부 클릭 시 피커 닫기 useEffect(() => { @@ -169,18 +212,140 @@ function AdminSchedule() { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(selectedDate === dateStr ? null : dateStr); }; + + // 전체보기 + const showAll = () => { + setSelectedDate(null); + }; + + // 삭제 관련 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [scheduleToDelete, setScheduleToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + // 삭제 확인 다이얼로그 열기 + const openDeleteDialog = (schedule) => { + setScheduleToDelete(schedule); + setDeleteDialogOpen(true); + }; + + // 일정 삭제 + const handleDelete = async () => { + if (!scheduleToDelete) return; + + setDeleting(true); + try { + const token = localStorage.getItem('adminToken'); + const response = await fetch(`/api/admin/schedules/${scheduleToDelete.id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + setToast({ type: 'success', message: '일정이 삭제되었습니다.' }); + fetchSchedules(); // 일정 목록 새로고침 + } else { + const data = await response.json(); + setToast({ type: 'error', message: data.error || '삭제 실패' }); + } + } catch (error) { + console.error('삭제 오류:', error); + setToast({ type: 'error', message: '삭제 중 오류가 발생했습니다.' }); + } finally { + setDeleting(false); + setDeleteDialogOpen(false); + setScheduleToDelete(null); + } + }; // 필터링된 일정 - const filteredSchedules = dummySchedules.filter(schedule => { + const filteredSchedules = schedules.filter(schedule => { const matchesSearch = schedule.title.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesCategory = selectedCategory === 'all' || schedule.category === selectedCategory; - return matchesSearch && matchesCategory; + // 카테고리 필터링: 빈 배열이면 전체, 아니면 선택된 카테고리들에 포함되는지 확인 + const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id); + // 날짜 필터링 추가 + const scheduleDate = new Date(schedule.date).toISOString().split('T')[0]; + const matchesDate = !selectedDate || scheduleDate === selectedDate; + return matchesSearch && matchesCategory && matchesDate; }); return (
setToast(null)} /> + {/* 삭제 확인 다이얼로그 */} + + {deleteDialogOpen && ( + !deleting && setDeleteDialogOpen(false)} + > + e.stopPropagation()} + > +
+
+ +
+

일정 삭제

+
+ +

+ 다음 일정을 삭제하시겠습니까? +

+

+ {scheduleToDelete?.title} +

+

+ 이 작업은 되돌릴 수 없습니다. +

+ +
+ + +
+
+
+ )} +
+ {/* 헤더 */}
@@ -237,27 +402,27 @@ function AdminSchedule() { {/* 왼쪽: 달력 + 카테고리 필터 */}
{/* 달력 (Schedule.jsx와 동일한 패턴) */} -
+
{/* 달력 헤더 */} -
+
@@ -384,7 +549,7 @@ function AdminSchedule() { transition={{ duration: 0.08 }} > {/* 요일 헤더 */} -
+
{days.map((day, i) => (
+
{day}
); @@ -423,8 +588,8 @@ function AdminSchedule() { ); @@ -444,7 +609,7 @@ function AdminSchedule() { const remainder = totalCells % 7; const nextDays = remainder === 0 ? 0 : 7 - remainder; return Array.from({ length: nextDays }).map((_, i) => ( -
+
{i + 1}
)); @@ -452,11 +617,23 @@ function AdminSchedule() {
- - {/* 범례 */} -
- - 일정 있음 + {/* 범례 및 전체보기 */} +
+
+ + 일정 있음 +
+
@@ -464,28 +641,49 @@ function AdminSchedule() {

카테고리

- {categories.map(category => ( - - ))} + } + }; + + return ( + + ); + })}
@@ -510,9 +708,38 @@ function AdminSchedule() {
-

- {selectedCategory === 'all' ? '전체 일정' : categories.find(c => c.id === selectedCategory)?.name} -

+ {selectedCategories.length > 1 ? ( + + {selectedCategories.map(id => { + const cat = categories.find(c => c.id === id); + if (!cat) return null; + return ( +
+ + {cat.name} +
+ ); + })} +
+ } + > +

+ {`${selectedCategories.length}개 카테고리 선택됨`} +

+ + ) : ( +

+ {selectedCategories.length === 0 + ? '전체 일정' + : categories.find(c => c.id === selectedCategories[0])?.name + } +

+ )} {filteredSchedules.length}개의 일정
@@ -550,10 +777,10 @@ function AdminSchedule() { {/* 내용 */}
- - {categories.find(c => c.id === schedule.category)?.name} + + {schedule.category_name || '미지정'} - {schedule.time} + {schedule.time?.slice(0, 5)}

{schedule.title}

{schedule.description}

@@ -567,7 +794,10 @@ function AdminSchedule() { > -
diff --git a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx index fd7f141..152c66c 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx @@ -3,7 +3,7 @@ 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 + 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'; @@ -221,17 +221,27 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택' }) { ))}
- {days.map((day, i) => ( - - ))} + {days.map((day, i) => { + const dayOfWeek = i % 7; + return ( + + ); + })}
)} @@ -614,6 +624,7 @@ function AdminScheduleForm() { // 장소 정보 locationName: '', // 장소 이름 locationAddress: '', // 주소 + locationDetail: '', // 상세주소 (예: 3관, N열 등) locationLat: null, // 위도 locationLng: null, // 경도 }); @@ -631,6 +642,18 @@ function AdminScheduleForm() { // 카테고리 목록 (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 = { @@ -641,10 +664,20 @@ function AdminScheduleForm() { 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); @@ -689,7 +722,63 @@ function AdminScheduleForm() { setUser(JSON.parse(userData)); fetchMembers(); fetchCategories(); - }, [navigate]); + + // 수정 모드일 경우 기존 데이터 로드 + 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 || '', + 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); + } + }; const fetchMembers = async () => { try { @@ -727,7 +816,9 @@ function AdminScheduleForm() { // 다중 이미지 업로드 const handleImagesUpload = (e) => { const files = Array.from(e.target.files); - const newImages = [...formData.images, ...files]; + // 파일을 {file: File} 형태로 저장 (제출 시 image.file로 접근하기 위함) + const newImageObjects = files.map(file => ({ file })); + const newImages = [...formData.images, ...newImageObjects]; setFormData({ ...formData, images: newImages }); files.forEach(file => { @@ -814,12 +905,137 @@ function AdminScheduleForm() { 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([]); + }; - // 폼 제출 (UI만) - const handleSubmit = (e) => { + + // 폼 제출 + const handleSubmit = async (e) => { e.preventDefault(); - setToast({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' }); - setTimeout(() => navigate('/admin/schedule'), 1000); + + // 유효성 검사 + 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, + 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 ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.')); + } + + setToast({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' }); + setTimeout(() => navigate('/admin/schedule'), 1500); + } catch (error) { + console.error('일정 저장 오류:', error); + setToast({ type: 'error', message: error.message || '일정 저장 중 오류가 발생했습니다.' }); + } finally { + setSaving(false); + } }; return ( @@ -878,6 +1094,119 @@ function AdminScheduleForm() { )} + {/* 장소 검색 다이얼로그 */} + + {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 ? ( +
+ +

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

+
+ ) : ( +
+ +

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

+
+ )} +
+
+
+ )} +
+ {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} - + {category.name} ))} @@ -1062,17 +1394,28 @@ function AdminScheduleForm() {
- {/* 장소 이름 */} -
- - 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, 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" + /> +
+
+ {/* 주소 */} - {/* TODO: 카카오 맵 API 키 설정 후 지도 검색 및 미리보기 추가 */} + + {/* 상세주소 */} + 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" + />
@@ -1247,11 +1598,20 @@ function AdminScheduleForm() {