diff --git a/backend/routes/admin.js b/backend/routes/admin.js
index 3159013..bab0329 100644
--- a/backend/routes/admin.js
+++ b/backend/routes/admin.js
@@ -907,4 +907,99 @@ router.delete(
}
);
+// ==================== 멤버 관리 API ====================
+
+// 멤버 상세 조회 (이름으로)
+router.get("/members/:name", authenticateToken, async (req, res) => {
+ try {
+ const memberName = decodeURIComponent(req.params.name);
+ const [members] = await pool.query("SELECT * FROM members WHERE name = ?", [
+ memberName,
+ ]);
+
+ if (members.length === 0) {
+ return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
+ }
+
+ res.json(members[0]);
+ } catch (error) {
+ console.error("멤버 조회 오류:", error);
+ res.status(500).json({ error: "멤버 조회 중 오류가 발생했습니다." });
+ }
+});
+
+// 멤버 수정 (이름으로)
+router.put(
+ "/members/:name",
+ authenticateToken,
+ upload.single("image"),
+ async (req, res) => {
+ try {
+ const memberName = decodeURIComponent(req.params.name);
+ const { name, birth_date, position, instagram, is_former } = req.body;
+
+ // 기존 멤버 확인
+ const [existing] = await pool.query(
+ "SELECT * FROM members WHERE name = ?",
+ [memberName]
+ );
+ if (existing.length === 0) {
+ return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
+ }
+
+ const memberId = existing[0].id;
+
+ let imageUrl = existing[0].image_url;
+
+ // 새 이미지 업로드
+ if (req.file) {
+ const webpBuffer = await sharp(req.file.buffer)
+ .webp({ quality: 90 })
+ .toBuffer();
+
+ const key = `member/${memberId}/profile.webp`;
+
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: key,
+ Body: webpBuffer,
+ ContentType: "image/webp",
+ })
+ );
+
+ const publicUrl =
+ process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
+ imageUrl = `${publicUrl}/${BUCKET}/${key}`;
+ }
+
+ // 멤버 업데이트
+ await pool.query(
+ `UPDATE members SET
+ name = ?,
+ birth_date = ?,
+ position = ?,
+ instagram = ?,
+ is_former = ?,
+ image_url = ?
+ WHERE id = ?`,
+ [
+ name,
+ birth_date || null,
+ position || null,
+ instagram || null,
+ is_former === "true" || is_former === true ? 1 : 0,
+ imageUrl,
+ memberId,
+ ]
+ );
+
+ res.json({ message: "멤버 정보가 수정되었습니다." });
+ } catch (error) {
+ console.error("멤버 수정 오류:", error);
+ res.status(500).json({ error: "멤버 수정 중 오류가 발생했습니다." });
+ }
+ }
+);
+
export default router;
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index eecf5ec..24c09c2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,6 +13,7 @@ import PCSchedule from './pages/pc/Schedule';
import AdminLogin from './pages/pc/admin/AdminLogin';
import AdminDashboard from './pages/pc/admin/AdminDashboard';
import AdminMembers from './pages/pc/admin/AdminMembers';
+import AdminMemberEdit from './pages/pc/admin/AdminMemberEdit';
import AdminAlbums from './pages/pc/admin/AdminAlbums';
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
@@ -29,6 +30,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/pc/Members.jsx b/frontend/src/pages/pc/Members.jsx
index b09117d..35d2ef7 100644
--- a/frontend/src/pages/pc/Members.jsx
+++ b/frontend/src/pages/pc/Members.jsx
@@ -127,7 +127,7 @@ function Members() {
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + index * 0.05 }}
- className="group flex items-center gap-3 bg-gray-50 rounded-full pr-4 hover:bg-gray-100 transition-colors"
+ className="group flex items-center gap-3 bg-gray-100 rounded-full pr-4 hover:bg-gray-200 transition-colors"
>
{/* 작은 원형 이미지 */}
@@ -137,11 +137,8 @@ function Members() {
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-300"
/>
- {/* 이름과 포지션 */}
-
-
{member.name}
-
{member.position || ''}
-
+ {/* 이름 */}
+ {member.name}
))}
diff --git a/frontend/src/pages/pc/admin/AdminMemberEdit.jsx b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx
new file mode 100644
index 0000000..a3bf200
--- /dev/null
+++ b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx
@@ -0,0 +1,595 @@
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate, useParams, Link } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ Save, Upload, LogOut,
+ Home, ChevronRight, ChevronLeft, ChevronDown, User, Instagram, Calendar, Briefcase
+} from 'lucide-react';
+import Toast from '../../../components/Toast';
+
+// 커스텀 데이트픽커 컴포넌트
+function CustomDatePicker({ value, onChange }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [viewMode, setViewMode] = useState('days');
+ const [viewDate, setViewDate] = useState(() => {
+ if (value) return new Date(value);
+ return new Date();
+ });
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setIsOpen(false);
+ setViewMode('days');
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const year = viewDate.getFullYear();
+ const month = viewDate.getMonth();
+
+ const firstDay = new Date(year, month, 1).getDay();
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+
+ const days = [];
+ for (let i = 0; i < firstDay; i++) {
+ days.push(null);
+ }
+ for (let i = 1; i <= daysInMonth; i++) {
+ days.push(i);
+ }
+
+ const startYear = Math.floor(year / 10) * 10 - 1;
+ const years = Array.from({ length: 12 }, (_, i) => startYear + i);
+
+ const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
+ const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
+ const prevYearRange = () => setViewDate(new Date(year - 10, month, 1));
+ const nextYearRange = () => setViewDate(new Date(year + 10, month, 1));
+
+ const selectDate = (day) => {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ onChange(dateStr);
+ setIsOpen(false);
+ setViewMode('days');
+ };
+
+ const selectYear = (y) => {
+ setViewDate(new Date(y, month, 1));
+ setViewMode('months');
+ };
+
+ const selectMonth = (m) => {
+ setViewDate(new Date(year, m, 1));
+ setViewMode('days');
+ };
+
+ const formatDisplayDate = (dateStr) => {
+ if (!dateStr) return '';
+ const [y, m, d] = dateStr.split('-');
+ return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`;
+ };
+
+ const isSelected = (day) => {
+ if (!value || !day) return false;
+ const [y, m, d] = value.split('-');
+ return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day;
+ };
+
+ const isToday = (day) => {
+ if (!day) return false;
+ const today = new Date();
+ return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
+ };
+
+ const isCurrentYear = (y) => new Date().getFullYear() === y;
+ const isCurrentMonth = (m) => {
+ const today = new Date();
+ return today.getFullYear() === year && today.getMonth() === m;
+ };
+
+ const months = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
+
+ return (
+
+
+
+
+ {isOpen && (
+
+
+
+
+
+
+
+
+ {viewMode === 'years' && (
+
+ 년도
+
+ {years.map((y) => (
+
+ ))}
+
+ 월
+
+ {months.map((m, i) => (
+
+ ))}
+
+
+ )}
+
+ {viewMode === 'months' && (
+
+ 월 선택
+
+ {months.map((m, i) => (
+
+ ))}
+
+
+ )}
+
+ {viewMode === 'days' && (
+
+
+ {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ {days.map((day, i) => (
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+
+function AdminMemberEdit() {
+ const navigate = useNavigate();
+ const { name } = useParams();
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [toast, setToast] = useState(null);
+ const [imagePreview, setImagePreview] = useState(null);
+ const [imageFile, setImageFile] = useState(null);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ birth_date: '',
+ position: '',
+ instagram: '',
+ is_former: false
+ });
+
+ // Toast 자동 숨김
+ useEffect(() => {
+ if (toast) {
+ const timer = setTimeout(() => setToast(null), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [toast]);
+
+ useEffect(() => {
+ // 로그인 확인
+ const token = localStorage.getItem('adminToken');
+ const userData = localStorage.getItem('adminUser');
+
+ if (!token || !userData) {
+ navigate('/admin');
+ return;
+ }
+
+ setUser(JSON.parse(userData));
+ fetchMember();
+ }, [navigate, name]);
+
+ const fetchMember = async () => {
+ try {
+ const token = localStorage.getItem('adminToken');
+ const res = await fetch(`/api/admin/members/${encodeURIComponent(name)}`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+
+ if (!res.ok) throw new Error('멤버 조회 실패');
+
+ const data = await res.json();
+ setFormData({
+ name: data.name || '',
+ birth_date: data.birth_date ? data.birth_date.split('T')[0] : '',
+ position: data.position || '',
+ instagram: data.instagram || '',
+ is_former: !!data.is_former
+ });
+ setImagePreview(data.image_url);
+ setLoading(false);
+ } catch (error) {
+ console.error('멤버 로드 오류:', error);
+ setToast({ message: '멤버 정보를 불러올 수 없습니다.', type: 'error' });
+ setLoading(false);
+ }
+ };
+
+ const handleLogout = () => {
+ localStorage.removeItem('adminToken');
+ localStorage.removeItem('adminUser');
+ navigate('/admin');
+ };
+
+ const handleImageChange = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ setImageFile(file);
+ const reader = new FileReader();
+ reader.onloadend = () => setImagePreview(reader.result);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSaving(true);
+
+ try {
+ const token = localStorage.getItem('adminToken');
+ const formDataToSend = new FormData();
+
+ formDataToSend.append('name', formData.name);
+ formDataToSend.append('birth_date', formData.birth_date);
+ formDataToSend.append('position', formData.position);
+ formDataToSend.append('instagram', formData.instagram);
+ formDataToSend.append('is_former', formData.is_former);
+
+ if (imageFile) {
+ formDataToSend.append('image', imageFile);
+ }
+
+ const res = await fetch(`/api/admin/members/${encodeURIComponent(name)}`, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${token}` },
+ body: formDataToSend
+ });
+
+ if (!res.ok) throw new Error('수정 실패');
+
+ setToast({ message: '멤버 정보가 수정되었습니다.', type: 'success' });
+ setTimeout(() => navigate('/admin/members'), 1000);
+ } catch (error) {
+ console.error('수정 오류:', error);
+ setToast({ message: '수정 중 오류가 발생했습니다.', type: 'error' });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {/* Toast */}
+
setToast(null)} />
+
+ {/* 헤더 */}
+
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 멤버 관리
+
+
+ 멤버 수정
+
+
+ {/* 타이틀 */}
+
+
+ {loading ? (
+
+ ) : (
+
+
+ {/* 이미지 업로드 영역 */}
+
+
+
document.getElementById('imageInput').click()}
+ >
+ {imagePreview ? (
+ <>
+

+
+ >
+ ) : (
+
+
+ 클릭하여 업로드
+
+ )}
+
+
+
+
+ {/* 입력 폼 영역 */}
+
+ {/* 이름 */}
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ required
+ 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"
+ placeholder="멤버 이름"
+ />
+
+
+ {/* 생년월일 */}
+
+
+ setFormData({ ...formData, birth_date: date })}
+ />
+
+
+ {/* 포지션 */}
+
+
+ setFormData({ ...formData, position: e.target.value })}
+ 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"
+ placeholder="메인보컬, 리드댄서 등"
+ />
+
+
+ {/* 인스타그램 */}
+
+
+ setFormData({ ...formData, instagram: e.target.value })}
+ 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"
+ placeholder="@username"
+ />
+
+
+ {/* 활동 상태 */}
+
+
+
+
+
+
+
+
+
+
+ {/* 버튼 영역 */}
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default AdminMemberEdit;
diff --git a/frontend/src/pages/pc/admin/AdminMembers.jsx b/frontend/src/pages/pc/admin/AdminMembers.jsx
index 3ef7a40..2cf089f 100644
--- a/frontend/src/pages/pc/admin/AdminMembers.jsx
+++ b/frontend/src/pages/pc/admin/AdminMembers.jsx
@@ -71,7 +71,7 @@ function AdminMembers() {
delay: index * 0.06
}}
className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`}
- onClick={() => setToast({ message: '수정 기능은 준비 중입니다.', type: 'info' })}
+ onClick={() => navigate(`/admin/members/${encodeURIComponent(member.name)}/edit`)}
>
{/* 프로필 이미지 */}