From 4da5ea58ef5c302992746f3d15ab224a37cd7321 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 5 Jan 2026 11:20:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일정 카테고리 CRUD API 추가 (backend/routes/admin.js) - AdminScheduleCategory 페이지 신규 추가 - 카테고리 추가/수정/삭제 기능 - 드래그 앤 드롭 정렬 (framer-motion Reorder) - react-colorful 기반 커스텀 색상 선택기 - 중복 카테고리 체크 - AdminScheduleForm에 동적 카테고리 로드 기능 추가 - 삭제 다이얼로그 앨범 스타일로 통일 (AlertTriangle 아이콘, 경고 메시지) - Toast 컴포넌트 exit 애니메이션 수정 - 토스트 3초 자동 닫힘 기능 추가 --- backend/routes/admin.js | 139 +++++ frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/App.jsx | 2 + frontend/src/components/Toast.jsx | 2 - frontend/src/pages/pc/admin/AdminLogin.jsx | 34 +- .../pages/pc/admin/AdminScheduleCategory.jsx | 572 ++++++++++++++++++ .../src/pages/pc/admin/AdminScheduleForm.jsx | 128 +++- frontend/vite.config.js | 2 +- 9 files changed, 859 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/pc/admin/AdminScheduleCategory.jsx diff --git a/backend/routes/admin.js b/backend/routes/admin.js index bab0329..9700f21 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1002,4 +1002,143 @@ router.put( } ); +// ==================== 일정 카테고리 관리 API ==================== + +// 카테고리 목록 조회 (인증 불필요 - 폼에서 사용) +router.get("/schedule-categories", async (req, res) => { + try { + const [categories] = await pool.query( + "SELECT * FROM schedule_categories ORDER BY sort_order ASC" + ); + res.json(categories); + } catch (error) { + console.error("카테고리 조회 오류:", error); + res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); + } +}); + +// 카테고리 생성 +router.post("/schedule-categories", authenticateToken, async (req, res) => { + try { + const { name, color } = req.body; + + if (!name || !color) { + return res.status(400).json({ error: "이름과 색상은 필수입니다." }); + } + + // 현재 최대 sort_order 조회 + const [maxOrder] = await pool.query( + "SELECT MAX(sort_order) as maxOrder FROM schedule_categories" + ); + const nextOrder = (maxOrder[0].maxOrder || 0) + 1; + + const [result] = await pool.query( + "INSERT INTO schedule_categories (name, color, sort_order) VALUES (?, ?, ?)", + [name, color, nextOrder] + ); + + res.json({ + message: "카테고리가 생성되었습니다.", + id: result.insertId, + sort_order: nextOrder, + }); + } catch (error) { + console.error("카테고리 생성 오류:", error); + res.status(500).json({ error: "카테고리 생성 중 오류가 발생했습니다." }); + } +}); + +// 카테고리 수정 +router.put("/schedule-categories/:id", authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const { name, color, sort_order } = req.body; + + const [existing] = await pool.query( + "SELECT * FROM schedule_categories WHERE id = ?", + [id] + ); + if (existing.length === 0) { + return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); + } + + await pool.query( + "UPDATE schedule_categories SET name = ?, color = ?, sort_order = ? WHERE id = ?", + [ + name || existing[0].name, + color || existing[0].color, + sort_order !== undefined ? sort_order : existing[0].sort_order, + id, + ] + ); + + res.json({ message: "카테고리가 수정되었습니다." }); + } catch (error) { + console.error("카테고리 수정 오류:", error); + res.status(500).json({ error: "카테고리 수정 중 오류가 발생했습니다." }); + } +}); + +// 카테고리 삭제 +router.delete( + "/schedule-categories/:id", + authenticateToken, + async (req, res) => { + try { + const { id } = req.params; + + const [existing] = await pool.query( + "SELECT * FROM schedule_categories WHERE id = ?", + [id] + ); + if (existing.length === 0) { + return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); + } + + // TODO: 해당 카테고리를 사용하는 일정이 있는지 확인 + await pool.query("DELETE FROM schedule_categories WHERE id = ?", [id]); + + res.json({ message: "카테고리가 삭제되었습니다." }); + } catch (error) { + console.error("카테고리 삭제 오류:", error); + res.status(500).json({ error: "카테고리 삭제 중 오류가 발생했습니다." }); + } + } +); + +// 카테고리 순서 일괄 업데이트 +router.put( + "/schedule-categories-order", + authenticateToken, + async (req, res) => { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const { orders } = req.body; // [{ id: 1, sort_order: 1 }, { id: 2, sort_order: 2 }, ...] + + if (!orders || !Array.isArray(orders)) { + return res.status(400).json({ error: "순서 데이터가 필요합니다." }); + } + + for (const item of orders) { + await connection.query( + "UPDATE schedule_categories SET sort_order = ? WHERE id = ?", + [item.sort_order, item.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/package-lock.json b/frontend/package-lock.json index a59484e..deeef76 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", "react": "^18.2.0", + "react-colorful": "^5.6.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-ios-time-picker": "^0.2.2", @@ -2233,6 +2234,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-device-detect": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f7e6bbe..ee51484 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", "react": "^18.2.0", + "react-colorful": "^5.6.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-ios-time-picker": "^0.2.2", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 00aa258..5ba44a8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; import AdminSchedule from './pages/pc/admin/AdminSchedule'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; +import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -40,6 +41,7 @@ function App() { } /> } /> } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} {toast && ( diff --git a/frontend/src/pages/pc/admin/AdminLogin.jsx b/frontend/src/pages/pc/admin/AdminLogin.jsx index e77f185..f326cad 100644 --- a/frontend/src/pages/pc/admin/AdminLogin.jsx +++ b/frontend/src/pages/pc/admin/AdminLogin.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react'; @@ -10,6 +10,29 @@ function AdminLogin() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [checkingAuth, setCheckingAuth] = useState(true); + + // 이미 로그인되어 있으면 대시보드로 리다이렉트 + useEffect(() => { + const token = localStorage.getItem('adminToken'); + if (token) { + // 토큰 유효성 검증 + fetch('/api/admin/verify', { + headers: { 'Authorization': `Bearer ${token}` } + }) + .then(res => res.json()) + .then(data => { + if (data.valid) { + navigate('/admin/dashboard'); + } else { + setCheckingAuth(false); + } + }) + .catch(() => setCheckingAuth(false)); + } else { + setCheckingAuth(false); + } + }, [navigate]); const handleSubmit = async (e) => { e.preventDefault(); @@ -42,6 +65,15 @@ function AdminLogin() { } }; + // 인증 확인 중 로딩 화면 + if (checkingAuth) { + return ( +
+
+
+ ); + } + return (
{ + // 기본 색상인지 확인 + const preset = colorOptions.find(c => c.id === colorValue); + if (preset) { + return { className: preset.bg }; + } + // HEX 색상인 경우 + if (colorValue?.startsWith('#')) { + return { style: { backgroundColor: colorValue } }; + } + return { className: 'bg-gray-500' }; +}; + +function AdminScheduleCategory() { + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [toast, setToast] = useState(null); + + // 토스트 표시 (3초 후 자동 닫힘) + const showToast = (type, message) => { + setToast({ type, message }); + setTimeout(() => setToast(null), 3000); + }; + + // 모달 상태 + const [modalOpen, setModalOpen] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [formData, setFormData] = useState({ name: '', color: 'blue' }); + + // 삭제 다이얼로그 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + // 카스텀 컴러 피커 팝업 + const [colorPickerOpen, setColorPickerOpen] = useState(false); + + // 사용자 인증 확인 + useEffect(() => { + const token = localStorage.getItem('adminToken'); + if (!token) { + navigate('/admin'); + return; + } + + fetch('/api/admin/verify', { + headers: { 'Authorization': `Bearer ${token}` } + }) + .then(res => res.json()) + .then(data => { + if (data.valid) { + setUser(data.user); + fetchCategories(); + } else { + navigate('/admin'); + } + }) + .catch(() => navigate('/admin')); + }, [navigate]); + + // 카테고리 목록 조회 + const fetchCategories = async () => { + try { + const res = await fetch('/api/admin/schedule-categories'); + const data = await res.json(); + setCategories(data); + } catch (error) { + console.error('카테고리 조회 오류:', error); + showToast('error', '카테고리를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + // 로그아웃 + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + // 모달 열기 (추가/수정) + const openModal = (category = null) => { + if (category) { + setEditingCategory(category); + setFormData({ name: category.name, color: category.color }); + } else { + setEditingCategory(null); + setFormData({ name: '', color: 'blue' }); + } + setColorPickerOpen(false); // 컬러 피커는 닫힌 상태로 + setModalOpen(true); + }; + + // 카테고리 저장 + const handleSave = async () => { + if (!formData.name.trim()) { + showToast('error', '카테고리 이름을 입력해주세요.'); + return; + } + + // 중복 체크 (수정시 자기 자신 제외) + const isDuplicate = categories.some( + cat => cat.name.toLowerCase() === formData.name.trim().toLowerCase() + && cat.id !== editingCategory?.id + ); + if (isDuplicate) { + showToast('error', '이미 존재하는 카테고리입니다.'); + return; + } + + const token = localStorage.getItem('adminToken'); + + try { + const url = editingCategory + ? `/api/admin/schedule-categories/${editingCategory.id}` + : '/api/admin/schedule-categories'; + + const res = await fetch(url, { + method: editingCategory ? 'PUT' : 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(formData) + }); + + if (res.ok) { + showToast('success', editingCategory ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.'); + setModalOpen(false); + fetchCategories(); + } else { + const error = await res.json(); + showToast('error', error.error || '저장에 실패했습니다.'); + } + } catch (error) { + console.error('저장 오류:', error); + showToast('error', '저장 중 오류가 발생했습니다.'); + } + }; + + // 삭제 다이얼로그 열기 + const openDeleteDialog = (category) => { + setDeleteTarget(category); + setDeleteDialogOpen(true); + }; + + // 카테고리 삭제 + const handleDelete = async () => { + if (!deleteTarget) return; + + const token = localStorage.getItem('adminToken'); + + try { + const res = await fetch(`/api/admin/schedule-categories/${deleteTarget.id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + showToast('success', '카테고리가 삭제되었습니다.'); + setDeleteDialogOpen(false); + setDeleteTarget(null); + fetchCategories(); + } else { + const error = await res.json(); + showToast('error', error.error || '삭제에 실패했습니다.'); + } + } catch (error) { + console.error('삭제 오류:', error); + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }; + + // Reorder 핸들러 (부드러운 애니메이션) + const handleReorder = async (newOrder) => { + setCategories(newOrder); + + // 순서 업데이트 API 호출 + const orders = newOrder.map((cat, idx) => ({ + id: cat.id, + sort_order: idx + 1 + })); + + const token = localStorage.getItem('adminToken'); + try { + await fetch('/api/admin/schedule-categories-order', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ orders }) + }); + } catch (error) { + console.error('순서 업데이트 오류:', error); + fetchCategories(); // 실패시 원래 데이터 다시 불러오기 + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ setToast(null)} /> + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + 대시보드 + + + 일정 관리 + + 카테고리 관리 +
+ + {/* 타이틀 */} +
+
+

카테고리 관리

+

일정 카테고리를 추가, 수정, 삭제할 수 있습니다.

+
+ +
+ + {/* 카테고리 목록 */} +
+ {categories.length === 0 ? ( +
+ 등록된 카테고리가 없습니다. +
+ ) : ( + + {categories.map((category) => ( + + {/* 드래그 핸들 */} +
+ +
+ + {/* 색상 표시 */} + {(() => { + const colorStyle = getColorStyle(category.color); + return ( +
+ ); + })()} + + {/* 이름 */} + {category.name} + + {/* 액션 버튼 */} +
+ + +
+ + ))} + + )} +
+
+ + {/* 추가/수정 모달 */} + + {modalOpen && ( + setModalOpen(false)} + > + e.stopPropagation()} + > +

+ {editingCategory ? '카테고리 수정' : '카테고리 추가'} +

+ + {/* 카테고리 이름 */} +
+ + setFormData({ ...formData, name: 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" + /> +
+ + {/* 색상 선택 */} +
+ +
+ {colorOptions.map((color) => ( + + +
+ + + )} + +
+
+
+ + {/* 버튼 */} +
+ + +
+ + + )} + + + {/* 삭제 확인 다이얼로그 */} + + {deleteDialogOpen && ( + setDeleteDialogOpen(false)} + > + e.stopPropagation()} + > +
+
+ +
+

카테고리 삭제

+
+ +

+ "{deleteTarget?.name}" 카테고리를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +

+ +
+ + +
+
+
+ )} +
+ + ); +} + +export default AdminScheduleCategory; diff --git a/frontend/src/pages/pc/admin/AdminScheduleForm.jsx b/frontend/src/pages/pc/admin/AdminScheduleForm.jsx index e8c9e08..fd7f141 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 + ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, AlertTriangle, Trash2 } from 'lucide-react'; import Toast from '../../../components/Toast'; import Lightbox from '../../../components/common/Lightbox'; @@ -606,11 +606,16 @@ function AdminScheduleForm() { startTime: '', endTime: '', isRange: false, // 범위 설정 여부 - category: 'broadcast', + category: '', description: '', url: '', members: [], - images: [] + images: [], + // 장소 정보 + locationName: '', // 장소 이름 + locationAddress: '', // 주소 + locationLat: null, // 위도 + locationLng: null, // 경도 }); // 이미지 미리보기 @@ -624,26 +629,44 @@ function AdminScheduleForm() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); - // 카테고리 목록 - const categories = [ - { 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([]); + // 카테고리 색상 맵핑 + 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', + cyan: 'bg-cyan-500', + indigo: 'bg-indigo-500', + }; // 카테고리 색상 const getCategoryColor = (categoryId) => { - const colors = { - broadcast: 'bg-blue-500', - event: 'bg-green-500', - release: 'bg-purple-500', - concert: 'bg-red-500', - fansign: 'bg-pink-500', - }; - return colors[categoryId] || 'bg-gray-500'; + const cat = categories.find(c => c.id === categoryId); + if (cat && colorMap[cat.color]) { + return colorMap[cat.color]; + } + 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); + } }; // Toast 자동 숨김 @@ -665,6 +688,7 @@ function AdminScheduleForm() { setUser(JSON.parse(userData)); fetchMembers(); + fetchCategories(); }, [navigate]); const fetchMembers = async () => { @@ -809,31 +833,43 @@ function AdminScheduleForm() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setDeleteDialogOpen(false)} > e.stopPropagation()} > -

이미지 삭제

-

이 이미지를 삭제하시겠습니까?

+
+
+ +
+

이미지 삭제

+
+ +

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

+
@@ -993,8 +1029,17 @@ function AdminScheduleForm() { {/* 카테고리 */}
- -
+
+ + + + 카테고리 관리 + +
+
{categories.map(category => ( ))}
+ {/* 장소 */} +
+ +
+ {/* 장소 이름 */} +
+ + setFormData({ ...formData, locationName: e.target.value })} + placeholder="장소 이름 (예: 잠실실내체육관)" + className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ {/* 주소 */} + setFormData({ ...formData, locationAddress: e.target.value })} + placeholder="주소 (예: 서울특별시 송파구 올림픽로 25)" + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> + {/* TODO: 카카오 맵 API 키 설정 후 지도 검색 및 미리보기 추가 */} +
+
+ {/* 설명 */}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b7e6236..0f7834e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,7 +6,7 @@ export default defineConfig({ server: { host: true, port: 5173, - allowedHosts: ["fromis9.caadiq.co.kr"], + allowedHosts: true, proxy: { "/api": { target: "http://fromis9-backend:3000",