feat: 일정 카테고리 관리 기능 추가 및 다이얼로그 스타일 통일

- 일정 카테고리 CRUD API 추가 (backend/routes/admin.js)
- AdminScheduleCategory 페이지 신규 추가
  - 카테고리 추가/수정/삭제 기능
  - 드래그 앤 드롭 정렬 (framer-motion Reorder)
  - react-colorful 기반 커스텀 색상 선택기
  - 중복 카테고리 체크
- AdminScheduleForm에 동적 카테고리 로드 기능 추가
- 삭제 다이얼로그 앨범 스타일로 통일 (AlertTriangle 아이콘, 경고 메시지)
- Toast 컴포넌트 exit 애니메이션 수정
- 토스트 3초 자동 닫힘 기능 추가
This commit is contained in:
caadiq 2026-01-05 11:20:44 +09:00
parent 2a952f39ab
commit 4da5ea58ef
9 changed files with 859 additions and 32 deletions

View file

@ -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;

View file

@ -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",

View file

@ -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",

View file

@ -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={

View file

@ -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 && (

View file

@ -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

View 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;

View file

@ -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>

View file

@ -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",