feat: 일정 카테고리 관리 기능 추가 및 다이얼로그 스타일 통일
- 일정 카테고리 CRUD API 추가 (backend/routes/admin.js) - AdminScheduleCategory 페이지 신규 추가 - 카테고리 추가/수정/삭제 기능 - 드래그 앤 드롭 정렬 (framer-motion Reorder) - react-colorful 기반 커스텀 색상 선택기 - 중복 카테고리 체크 - AdminScheduleForm에 동적 카테고리 로드 기능 추가 - 삭제 다이얼로그 앨범 스타일로 통일 (AlertTriangle 아이콘, 경고 메시지) - Toast 컴포넌트 exit 애니메이션 수정 - 토스트 3초 자동 닫힘 기능 추가
This commit is contained in:
parent
2a952f39ab
commit
4da5ea58ef
9 changed files with 859 additions and 32 deletions
|
|
@ -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;
|
export default router;
|
||||||
|
|
|
||||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"framer-motion": "^11.0.8",
|
"framer-motion": "^11.0.8",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-ios-time-picker": "^0.2.2",
|
"react-ios-time-picker": "^0.2.2",
|
||||||
|
|
@ -2233,6 +2234,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-device-detect": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"framer-motion": "^11.0.8",
|
"framer-motion": "^11.0.8",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-ios-time-picker": "^0.2.2",
|
"react-ios-time-picker": "^0.2.2",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
||||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
||||||
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
||||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||||
|
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||||
|
|
||||||
// PC 레이아웃
|
// PC 레이아웃
|
||||||
import PCLayout from './components/pc/Layout';
|
import PCLayout from './components/pc/Layout';
|
||||||
|
|
@ -40,6 +41,7 @@ function App() {
|
||||||
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
||||||
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
||||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
* - type: 'success' | 'error' | 'warning'
|
* - type: 'success' | 'error' | 'warning'
|
||||||
*/
|
*/
|
||||||
function Toast({ toast, onClose }) {
|
function Toast({ toast, onClose }) {
|
||||||
if (!toast) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
@ -10,6 +10,29 @@ function AdminLogin() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = 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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -42,6 +65,15 @@ function AdminLogin() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 인증 확인 중 로딩 화면
|
||||||
|
if (checkingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
572
frontend/src/pages/pc/admin/AdminScheduleCategory.jsx
Normal file
572
frontend/src/pages/pc/admin/AdminScheduleCategory.jsx
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||||
|
import { LogOut, Home, ChevronRight, Plus, Edit3, Trash2, GripVertical, X, AlertTriangle } from 'lucide-react';
|
||||||
|
import { HexColorPicker } from 'react-colorful';
|
||||||
|
import Toast from '../../../components/Toast';
|
||||||
|
|
||||||
|
// 기본 색상 (8개)
|
||||||
|
const colorOptions = [
|
||||||
|
{ id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
|
||||||
|
{ id: 'green', name: '초록색', bg: 'bg-green-500', hex: '#22c55e' },
|
||||||
|
{ id: 'purple', name: '보라색', bg: 'bg-purple-500', hex: '#a855f7' },
|
||||||
|
{ id: 'red', name: '빨간색', bg: 'bg-red-500', hex: '#ef4444' },
|
||||||
|
{ id: 'pink', name: '분홍색', bg: 'bg-pink-500', hex: '#ec4899' },
|
||||||
|
{ id: 'yellow', name: '노란색', bg: 'bg-yellow-500', hex: '#eab308' },
|
||||||
|
{ id: 'orange', name: '주황색', bg: 'bg-orange-500', hex: '#f97316' },
|
||||||
|
{ id: 'gray', name: '회색', bg: 'bg-gray-500', hex: '#6b7280' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 색상 헬퍼 (커스텀 HEX 지원)
|
||||||
|
const getColorStyle = (colorValue) => {
|
||||||
|
// 기본 색상인지 확인
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||||
|
fromis_9
|
||||||
|
</Link>
|
||||||
|
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary flex items-center gap-1">
|
||||||
|
<Home size={14} />
|
||||||
|
대시보드
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/schedule" className="hover:text-primary">일정 관리</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-900">카테고리 관리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">카테고리 관리</h1>
|
||||||
|
<p className="text-gray-500 mt-1">일정 카테고리를 추가, 수정, 삭제할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
카테고리 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 목록 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
등록된 카테고리가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={categories}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="divide-y divide-gray-100"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={category.id}
|
||||||
|
value={category}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white cursor-grab active:cursor-grabbing"
|
||||||
|
whileDrag={{
|
||||||
|
scale: 1.02,
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.15)",
|
||||||
|
zIndex: 50
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<div className="text-gray-400 hover:text-gray-600">
|
||||||
|
<GripVertical size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 표시 */}
|
||||||
|
{(() => {
|
||||||
|
const colorStyle = getColorStyle(category.color);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full ${colorStyle.className || ''}`}
|
||||||
|
style={colorStyle.style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
<span className="flex-1 font-medium text-gray-900">{category.name}</span>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); openModal(category); }}
|
||||||
|
className="p-2 text-gray-400 hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit3 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); openDeleteDialog(category); }}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 추가/수정 모달 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{modalOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-white rounded-2xl p-6 w-full mx-4 shadow-xl"
|
||||||
|
style={{ maxWidth: '452px', minWidth: '452px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||||
|
{editingCategory ? '카테고리 수정' : '카테고리 추가'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 카테고리 이름 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
카테고리 이름 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 선택 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
색상 선택 *
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{colorOptions.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, color: color.id })}
|
||||||
|
className={`w-10 h-10 rounded-full ${color.bg} transition-all ${
|
||||||
|
formData.color === color.id
|
||||||
|
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 커스텀 색상 - 무지개 그라디언트 버튼 */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColorPickerOpen(!colorPickerOpen);
|
||||||
|
}}
|
||||||
|
className={`w-10 h-10 rounded-full transition-all ${
|
||||||
|
formData.color?.startsWith('#')
|
||||||
|
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: formData.color?.startsWith('#')
|
||||||
|
? formData.color
|
||||||
|
: 'conic-gradient(from 0deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080, #ff0000)'
|
||||||
|
}}
|
||||||
|
title="커스텀 색상"
|
||||||
|
/>
|
||||||
|
{/* 색상 선택 팝업 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{colorPickerOpen && (
|
||||||
|
<>
|
||||||
|
{/* 바깥 영역 클릭시 컬러피커만 닫기 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||||
|
className="absolute top-12 left-0 z-50 p-4 bg-white rounded-2xl shadow-xl border border-gray-100"
|
||||||
|
style={{ width: '240px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<HexColorPicker
|
||||||
|
color={formData.color?.startsWith('#') ? formData.color : '#6b7280'}
|
||||||
|
onChange={(color) => setFormData({ ...formData, color })}
|
||||||
|
style={{ width: '100%', height: '180px' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<span className="px-3 py-2 text-sm bg-gray-100 border border-r-0 border-gray-200 rounded-l-lg text-gray-500">#</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.color?.startsWith('#') ? formData.color.slice(1) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||||
|
if (val) {
|
||||||
|
setFormData({ ...formData, color: '#' + val });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="FFFFFF"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, color: 'blue' });
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColorPickerOpen(false)}
|
||||||
|
className="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{editingCategory ? '수정' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteDialogOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="text-red-500" size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">카테고리 삭제</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
<span className="font-medium text-gray-900">"{deleteTarget?.name}"</span> 카테고리를 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminScheduleCategory;
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate, Link, useParams } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
LogOut, Home, ChevronRight, Calendar, Save, X, Upload, Link as LinkIcon,
|
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';
|
} from 'lucide-react';
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
import Lightbox from '../../../components/common/Lightbox';
|
import Lightbox from '../../../components/common/Lightbox';
|
||||||
|
|
@ -606,11 +606,16 @@ function AdminScheduleForm() {
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
isRange: false, // 범위 설정 여부
|
isRange: false, // 범위 설정 여부
|
||||||
category: 'broadcast',
|
category: '',
|
||||||
description: '',
|
description: '',
|
||||||
url: '',
|
url: '',
|
||||||
members: [],
|
members: [],
|
||||||
images: []
|
images: [],
|
||||||
|
// 장소 정보
|
||||||
|
locationName: '', // 장소 이름
|
||||||
|
locationAddress: '', // 주소
|
||||||
|
locationLat: null, // 위도
|
||||||
|
locationLng: null, // 경도
|
||||||
});
|
});
|
||||||
|
|
||||||
// 이미지 미리보기
|
// 이미지 미리보기
|
||||||
|
|
@ -624,26 +629,44 @@ function AdminScheduleForm() {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteTargetIndex, setDeleteTargetIndex] = useState(null);
|
const [deleteTargetIndex, setDeleteTargetIndex] = useState(null);
|
||||||
|
|
||||||
// 카테고리 목록
|
// 카테고리 목록 (API에서 로드)
|
||||||
const categories = [
|
const [categories, setCategories] = useState([]);
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// 카테고리 색상 맵핑
|
||||||
|
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 getCategoryColor = (categoryId) => {
|
||||||
const colors = {
|
const cat = categories.find(c => c.id === categoryId);
|
||||||
broadcast: 'bg-blue-500',
|
if (cat && colorMap[cat.color]) {
|
||||||
event: 'bg-green-500',
|
return colorMap[cat.color];
|
||||||
release: 'bg-purple-500',
|
}
|
||||||
concert: 'bg-red-500',
|
return 'bg-gray-500';
|
||||||
fansign: 'bg-pink-500',
|
|
||||||
};
|
};
|
||||||
return colors[categoryId] || '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 자동 숨김
|
// Toast 자동 숨김
|
||||||
|
|
@ -665,6 +688,7 @@ function AdminScheduleForm() {
|
||||||
|
|
||||||
setUser(JSON.parse(userData));
|
setUser(JSON.parse(userData));
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
|
fetchCategories();
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
|
|
@ -809,31 +833,43 @@ function AdminScheduleForm() {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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)}
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl"
|
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-2">이미지 삭제</h3>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<p className="text-gray-500 mb-6">이 이미지를 삭제하시겠습니까?</p>
|
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="text-red-500" size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">이미지 삭제</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
이 이미지를 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={confirmDeleteImage}
|
onClick={confirmDeleteImage}
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -993,8 +1029,17 @@ function AdminScheduleForm() {
|
||||||
|
|
||||||
{/* 카테고리 */}
|
{/* 카테고리 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">카테고리 *</label>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<label className="block text-sm font-medium text-gray-700">카테고리 *</label>
|
||||||
|
<Link
|
||||||
|
to="/admin/schedule/categories"
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
카테고리 관리
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
{categories.map(category => (
|
{categories.map(category => (
|
||||||
<button
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
|
|
@ -1006,13 +1051,40 @@ function AdminScheduleForm() {
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`w-2.5 h-2.5 rounded-full ${getCategoryColor(category.id)}`} />
|
<span className={`w-2.5 h-2.5 rounded-full ${colorMap[category.color] || 'bg-gray-500'}`} />
|
||||||
<span className="text-sm font-medium">{category.name}</span>
|
<span className="text-sm font-medium">{category.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 장소 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">장소</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 장소 이름 */}
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.locationName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 주소 */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.locationAddress}
|
||||||
|
onChange={(e) => 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 키 설정 후 지도 검색 및 미리보기 추가 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["fromis9.caadiq.co.kr"],
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://fromis9-backend:3000",
|
target: "http://fromis9-backend:3000",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue