feat(schedule): 일정 수정/삭제 기능 구현 및 DB 스키마 개선

- schedule_members 테이블 분리 (members 컬럼 → 별도 테이블)
- schedules 테이블 컬럼 comment 추가 및 순서 정리
- 상세주소(location_detail) 필드 추가
- 장소 검색 UI 개선 (탭 제거 → 입력 필드+검색 버튼 병합)
- 카카오 장소 검색 API 프록시 추가 (/api/admin/kakao/places)
- 백엔드 CRUD API 구현 (GET/PUT/DELETE /schedules/:id)
- 프론트엔드 삭제 기능 및 확인 다이얼로그 추가
- 프론트엔드 수정 모드 지원 (기존 데이터 로드)
This commit is contained in:
caadiq 2026-01-05 18:11:40 +09:00
parent 4da5ea58ef
commit dff43126c4
6 changed files with 1225 additions and 130 deletions

3
.env
View file

@ -16,3 +16,6 @@ RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
RUSTFS_BUCKET=fromis-9 RUSTFS_BUCKET=fromis-9
# Kakao API
KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8

View file

@ -1140,5 +1140,505 @@ router.put(
} }
} }
); );
// ==================== 일정 관리 API ====================
// 일정 목록 조회
router.get("/schedules", async (req, res) => {
try {
const { year, month } = req.query;
let whereClause = "";
let params = [];
// 년/월 필터링
if (year && month) {
whereClause = "WHERE YEAR(s.date) = ? AND MONTH(s.date) = ?";
params = [parseInt(year), parseInt(month)];
} else if (year) {
whereClause = "WHERE YEAR(s.date) = ?";
params = [parseInt(year)];
}
const [schedules] = await pool.query(
`SELECT
s.id, s.title, s.date, s.time, s.end_date, s.end_time,
s.category_id, s.description, s.source_url,
s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng,
s.created_at,
c.name as category_name, c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
${whereClause}
ORDER BY s.date ASC, s.time ASC`,
params
);
// 각 일정의 이미지와 멤버 조회
const schedulesWithDetails = await Promise.all(
schedules.map(async (schedule) => {
const [images] = await pool.query(
"SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC",
[schedule.id]
);
const [members] = await pool.query(
`SELECT m.id, m.name FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id = ?`,
[schedule.id]
);
return { ...schedule, images, members };
})
);
res.json(schedulesWithDetails);
} catch (error) {
console.error("일정 조회 오류:", error);
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
}
});
// 일정 생성
router.post(
"/schedules",
authenticateToken,
upload.array("images", 20),
async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const data = JSON.parse(req.body.data);
const {
title,
date,
time,
endDate,
endTime,
isRange,
category,
description,
url,
members,
locationName,
locationAddress,
locationDetail,
locationLat,
locationLng,
} = data;
// 필수 필드 검증
if (!title || !date) {
return res.status(400).json({ error: "제목과 날짜를 입력해주세요." });
}
// 일정 삽입
const [scheduleResult] = await connection.query(
`INSERT INTO schedules
(title, date, time, end_date, end_time, category_id, description, source_url,
location_name, location_address, location_detail, location_lat, location_lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
title,
date,
time || null,
isRange && endDate ? endDate : null,
isRange && endTime ? endTime : null,
category || null,
description || null,
url || null,
locationName || null,
locationAddress || null,
locationDetail || null,
locationLat || null,
locationLng || null,
]
);
const scheduleId = scheduleResult.insertId;
// 멤버 연결 처리 (schedule_members 테이블)
if (members && members.length > 0) {
const memberValues = members.map((memberId) => [scheduleId, memberId]);
await connection.query(
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
[memberValues]
);
}
// 이미지 업로드 처리
if (req.files && req.files.length > 0) {
const publicUrl =
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
const basePath = `schedule/${scheduleId}`;
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
const orderNum = String(i + 1).padStart(2, "0");
const filename = `${orderNum}.webp`;
// WebP 변환 (원본만)
const imageBuffer = await sharp(file.buffer)
.webp({ quality: 90 })
.toBuffer();
// RustFS 업로드 (원본만)
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/${filename}`,
Body: imageBuffer,
ContentType: "image/webp",
})
);
const imageUrl = `${publicUrl}/${BUCKET}/${basePath}/${filename}`;
// DB 저장
await connection.query(
`INSERT INTO schedule_images (schedule_id, image_url, sort_order)
VALUES (?, ?, ?)`,
[scheduleId, imageUrl, i + 1]
);
}
}
await connection.commit();
res.json({ message: "일정이 생성되었습니다.", scheduleId });
} catch (error) {
await connection.rollback();
console.error("일정 생성 오류:", error);
res.status(500).json({ error: "일정 생성 중 오류가 발생했습니다." });
} finally {
connection.release();
}
}
);
// 카카오 장소 검색 프록시 (CORS 우회)
router.get("/kakao/places", authenticateToken, async (req, res) => {
try {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: "검색어를 입력해주세요." });
}
const response = await fetch(
`https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent(
query
)}`,
{
headers: {
Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`,
},
}
);
if (!response.ok) {
throw new Error(`Kakao API error: ${response.status}`);
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("카카오 장소 검색 오류:", error);
res.status(500).json({ error: "장소 검색 중 오류가 발생했습니다." });
}
});
// 일정 단일 조회
router.get("/schedules/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// 일정 기본 정보 조회
const [schedules] = await pool.query(
`SELECT s.*, sc.name as category_name, sc.color as category_color
FROM schedules s
LEFT JOIN schedule_categories sc ON s.category_id = sc.id
WHERE s.id = ?`,
[id]
);
if (schedules.length === 0) {
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
}
const schedule = schedules[0];
// 이미지 조회
const [images] = await pool.query(
"SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC",
[id]
);
// 멤버 조회
const [members] = await pool.query(
`SELECT m.id, m.name, m.image_url
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id = ?`,
[id]
);
res.json({ ...schedule, images, members });
} catch (error) {
console.error("일정 조회 오류:", error);
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
}
});
// 일정 수정
router.put(
"/schedules/:id",
authenticateToken,
upload.array("images", 20),
async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { id } = req.params;
const data = JSON.parse(req.body.data || "{}");
const {
title,
date,
time,
endDate,
endTime,
isRange,
category,
description,
url,
members,
locationName,
locationAddress,
locationDetail,
locationLat,
locationLng,
existingImages, // 유지할 기존 이미지 ID 배열
} = data;
// 필수 필드 검증
if (!title || !date) {
return res.status(400).json({ error: "제목과 날짜를 입력해주세요." });
}
// 일정 업데이트
await connection.query(
`UPDATE schedules SET
title = ?,
date = ?,
time = ?,
end_date = ?,
end_time = ?,
category_id = ?,
description = ?,
source_url = ?,
location_name = ?,
location_address = ?,
location_detail = ?,
location_lat = ?,
location_lng = ?
WHERE id = ?`,
[
title,
date,
time || null,
isRange && endDate ? endDate : null,
isRange && endTime ? endTime : null,
category || null,
description || null,
url || null,
locationName || null,
locationAddress || null,
locationDetail || null,
locationLat || null,
locationLng || null,
id,
]
);
// 멤버 업데이트 (기존 삭제 후 새로 추가)
await connection.query(
"DELETE FROM schedule_members WHERE schedule_id = ?",
[id]
);
if (members && members.length > 0) {
const memberValues = members.map((memberId) => [id, memberId]);
await connection.query(
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
[memberValues]
);
}
// 삭제할 이미지 처리 (existingImages에 없는 이미지 삭제)
const existingImageIds = existingImages || [];
if (existingImageIds.length > 0) {
// 삭제할 이미지 조회
const [imagesToDelete] = await connection.query(
`SELECT id, image_url FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`,
[id, existingImageIds]
);
// S3에서 이미지 삭제
for (const img of imagesToDelete) {
try {
const key = img.image_url.replace(
`${
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT
}/${process.env.RUSTFS_BUCKET}/`,
""
);
await s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.RUSTFS_BUCKET,
Key: key,
})
);
} catch (err) {
console.error("이미지 삭제 오류:", err);
}
}
// DB에서 삭제
await connection.query(
`DELETE FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`,
[id, existingImageIds]
);
} else {
// 기존 이미지 모두 삭제
const [allImages] = await connection.query(
`SELECT id, image_url FROM schedule_images WHERE schedule_id = ?`,
[id]
);
for (const img of allImages) {
try {
const key = img.image_url.replace(
`${
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT
}/${process.env.RUSTFS_BUCKET}/`,
""
);
await s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.RUSTFS_BUCKET,
Key: key,
})
);
} catch (err) {
console.error("이미지 삭제 오류:", err);
}
}
await connection.query(
`DELETE FROM schedule_images WHERE schedule_id = ?`,
[id]
);
}
// 새 이미지 업로드
if (req.files && req.files.length > 0) {
const publicUrl =
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
const basePath = `schedule/${id}`;
// 현재 최대 sort_order 조회
const [maxOrder] = await connection.query(
"SELECT COALESCE(MAX(sort_order), 0) as max_order FROM schedule_images WHERE schedule_id = ?",
[id]
);
let currentOrder = maxOrder[0].max_order;
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
currentOrder++;
const orderNum = String(currentOrder).padStart(2, "0");
const filename = `${orderNum}_${Date.now()}.webp`;
const imageBuffer = await sharp(file.buffer)
.webp({ quality: 90 })
.toBuffer();
await s3Client.send(
new PutObjectCommand({
Bucket: process.env.RUSTFS_BUCKET,
Key: `${basePath}/${filename}`,
Body: imageBuffer,
ContentType: "image/webp",
})
);
const imageUrl = `${publicUrl}/${process.env.RUSTFS_BUCKET}/${basePath}/${filename}`;
await connection.query(
"INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)",
[id, imageUrl, currentOrder]
);
}
}
await connection.commit();
res.json({ message: "일정이 수정되었습니다." });
} catch (error) {
await connection.rollback();
console.error("일정 수정 오류:", error);
res.status(500).json({ error: "일정 수정 중 오류가 발생했습니다." });
} finally {
connection.release();
}
}
);
// 일정 삭제
router.delete("/schedules/:id", authenticateToken, async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { id } = req.params;
// 이미지 조회
const [images] = await connection.query(
"SELECT image_url FROM schedule_images WHERE schedule_id = ?",
[id]
);
// S3에서 이미지 삭제
for (const img of images) {
try {
const key = img.image_url.replace(
`${process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT}/${
process.env.RUSTFS_BUCKET
}/`,
""
);
await s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.RUSTFS_BUCKET,
Key: key,
})
);
} catch (err) {
console.error("이미지 삭제 오류:", err);
}
}
// 일정 삭제 (CASCADE로 schedule_images, schedule_members도 자동 삭제)
await connection.query("DELETE FROM schedules WHERE id = ?", [id]);
await connection.commit();
res.json({ message: "일정이 삭제되었습니다." });
} catch (error) {
await connection.rollback();
console.error("일정 삭제 오류:", error);
res.status(500).json({ error: "일정 삭제 중 오류가 발생했습니다." });
} finally {
connection.release();
}
});
export default router; export default router;

2
frontend/.env Normal file
View file

@ -0,0 +1,2 @@
VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb
VITE_KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8

View file

@ -6,16 +6,16 @@ import { motion, AnimatePresence } from 'framer-motion';
* 커스텀 툴팁 컴포넌트 * 커스텀 툴팁 컴포넌트
* 마우스 커서를 따라다니는 방식 * 마우스 커서를 따라다니는 방식
* @param {React.ReactNode} children - 툴팁을 표시할 요소 * @param {React.ReactNode} children - 툴팁을 표시할 요소
* @param {string} text - 툴팁에 표시할 텍스트 (content prop과 호환) * @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환)
* @param {string} content - 툴팁에 표시할 텍스트 (text prop과 호환) * @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환)
*/ */
const Tooltip = ({ children, text, content, className = "" }) => { const Tooltip = ({ children, text, content, className = "" }) => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ bottom: 0, left: 0 }); const [position, setPosition] = useState({ bottom: 0, left: 0 });
const triggerRef = useRef(null); const triggerRef = useRef(null);
// text content prop // text content prop ( React )
const tooltipText = text || content; const tooltipContent = text || content;
const handleMouseEnter = (e) => { const handleMouseEnter = (e) => {
// ( ) // ( )
@ -45,7 +45,7 @@ const Tooltip = ({ children, text, content, className = "" }) => {
> >
{children} {children}
</div> </div>
{isVisible && tooltipText && ReactDOM.createPortal( {isVisible && tooltipContent && ReactDOM.createPortal(
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
initial={{ opacity: 0, y: 5, scale: 0.95 }} initial={{ opacity: 0, y: 5, scale: 0.95 }}
@ -56,9 +56,9 @@ const Tooltip = ({ children, text, content, className = "" }) => {
bottom: position.bottom, bottom: position.bottom,
left: position.left, left: position.left,
}} }}
className="fixed z-[9999] -translate-x-1/2 px-2.5 py-1.5 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none whitespace-nowrap" className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none"
> >
{tooltipText} {tooltipContent}
</motion.div> </motion.div>
</AnimatePresence>, </AnimatePresence>,
document.body document.body

View file

@ -3,9 +3,10 @@ import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2, LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown ChevronLeft, Search, ChevronDown, AlertTriangle
} from 'lucide-react'; } from 'lucide-react';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
import Tooltip from '../../../components/Tooltip';
function AdminSchedule() { function AdminSchedule() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -13,7 +14,7 @@ function AdminSchedule() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all'); const [selectedCategories, setSelectedCategories] = useState([]); // =
const [selectedDate, setSelectedDate] = useState(null); const [selectedDate, setSelectedDate] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
@ -44,54 +45,60 @@ function AdminSchedule() {
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
// // (API )
const categories = [ const [categories, setCategories] = useState([
{ id: 'all', name: '전체', color: 'gray' }, { id: 'all', name: '전체', color: 'gray' }
{ id: 'broadcast', name: '방송', color: 'blue' }, ]);
{ id: 'event', name: '이벤트', color: 'green' },
{ id: 'release', name: '발매', color: 'purple' },
{ id: 'concert', name: '콘서트', color: 'red' },
{ id: 'fansign', name: '팬사인회', color: 'pink' },
];
// (UI) // (API )
const dummySchedules = [ const [schedules, setSchedules] = useState([]);
{ id: 1, title: 'SBS 인기가요 출연', date: '2026-01-05', time: '15:30', category: 'broadcast', description: '컴백 무대' },
{ id: 2, title: '유튜브 라이브', date: '2026-01-05', time: '19:00', category: 'broadcast', description: '팬들과 소통 시간' },
{ id: 3, title: '팬미팅', date: '2026-01-10', time: '18:00', category: 'event', description: '서울 올림픽홀' },
{ id: 4, title: '신곡 발매', date: '2026-01-15', time: '18:00', category: 'release', description: '미니앨범 8집' },
{ id: 5, title: '콘서트 투어', date: '2026-01-20', time: '19:00', category: 'concert', description: '부산 벡스코' },
{ id: 6, title: '온라인 팬사인회', date: '2026-01-25', time: '17:00', category: 'fansign', description: '위버스 진행' },
];
// //
const getCategoryColor = (categoryId) => { const colorMap = {
const colors = { blue: 'bg-blue-500',
broadcast: 'bg-blue-100 text-blue-700', green: 'bg-green-500',
event: 'bg-green-100 text-green-700', purple: 'bg-purple-500',
release: 'bg-purple-100 text-purple-700', red: 'bg-red-500',
concert: 'bg-red-100 text-red-700', pink: 'bg-pink-500',
fansign: 'bg-pink-100 text-pink-700', yellow: 'bg-yellow-500',
}; orange: 'bg-orange-500',
return colors[categoryId] || 'bg-gray-100 text-gray-700'; gray: 'bg-gray-500',
}; };
// // ( HEX)
const getCategoryDotColor = (categoryId) => { const getColorStyle = (color) => {
if (!color) return { className: 'bg-gray-500' };
if (color.startsWith('#')) {
return { style: { backgroundColor: color } };
}
return { className: colorMap[color] || 'bg-gray-500' };
};
// ()
const getCategoryColor = (color) => {
const colors = { const colors = {
broadcast: 'bg-blue-500', blue: 'bg-blue-100 text-blue-700',
event: 'bg-green-500', green: 'bg-green-100 text-green-700',
release: 'bg-purple-500', purple: 'bg-purple-100 text-purple-700',
concert: 'bg-red-500', red: 'bg-red-100 text-red-700',
fansign: 'bg-pink-500', pink: 'bg-pink-100 text-pink-700',
yellow: 'bg-yellow-100 text-yellow-700',
orange: 'bg-orange-100 text-orange-700',
gray: 'bg-gray-100 text-gray-700',
}; };
return colors[categoryId] || 'bg-gray-500'; if (color?.startsWith('#')) {
return 'bg-gray-100 text-gray-700';
}
return colors[color] || 'bg-gray-100 text-gray-700';
}; };
// //
const hasSchedule = (day) => { const hasSchedule = (day) => {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return dummySchedules.some(s => s.date === dateStr); return schedules.some(s => {
const scheduleDate = new Date(s.date).toISOString().split('T')[0];
return scheduleDate === dateStr;
});
}; };
// Toast // Toast
@ -112,8 +119,44 @@ function AdminSchedule() {
} }
setUser(JSON.parse(userData)); setUser(JSON.parse(userData));
//
fetchCategories();
}, [navigate]); }, [navigate]);
//
useEffect(() => {
fetchSchedules();
}, [year, month]);
//
const fetchCategories = async () => {
try {
const res = await fetch('/api/admin/schedule-categories');
const data = await res.json();
setCategories([
{ id: 'all', name: '전체', color: 'gray' },
...data
]);
} catch (error) {
console.error('카테고리 로드 오류:', error);
}
};
//
const fetchSchedules = async () => {
setLoading(true);
try {
const res = await fetch(`/api/admin/schedules?year=${year}&month=${month + 1}`);
const data = await res.json();
setSchedules(data);
} catch (error) {
console.error('일정 로드 오류:', error);
} finally {
setLoading(false);
}
};
// //
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
@ -170,17 +213,139 @@ function AdminSchedule() {
setSelectedDate(selectedDate === dateStr ? null : dateStr); setSelectedDate(selectedDate === dateStr ? null : dateStr);
}; };
//
const showAll = () => {
setSelectedDate(null);
};
//
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [scheduleToDelete, setScheduleToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
//
const openDeleteDialog = (schedule) => {
setScheduleToDelete(schedule);
setDeleteDialogOpen(true);
};
//
const handleDelete = async () => {
if (!scheduleToDelete) return;
setDeleting(true);
try {
const token = localStorage.getItem('adminToken');
const response = await fetch(`/api/admin/schedules/${scheduleToDelete.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
fetchSchedules(); //
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '삭제 실패' });
}
} catch (error) {
console.error('삭제 오류:', error);
setToast({ type: 'error', message: '삭제 중 오류가 발생했습니다.' });
} finally {
setDeleting(false);
setDeleteDialogOpen(false);
setScheduleToDelete(null);
}
};
// //
const filteredSchedules = dummySchedules.filter(schedule => { const filteredSchedules = schedules.filter(schedule => {
const matchesSearch = schedule.title.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = schedule.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || schedule.category === selectedCategory; // : ,
return matchesSearch && matchesCategory; const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id);
//
const scheduleDate = new Date(schedule.date).toISOString().split('T')[0];
const matchesDate = !selectedDate || scheduleDate === selectedDate;
return matchesSearch && matchesCategory && matchesDate;
}); });
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Toast toast={toast} onClose={() => setToast(null)} /> <Toast toast={toast} onClose={() => setToast(null)} />
{/* 삭제 확인 다이얼로그 */}
<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={() => !deleting && 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-2">
다음 일정을 삭제하시겠습니까?
</p>
<p className="text-gray-900 font-medium mb-4 p-3 bg-gray-50 rounded-lg">
{scheduleToDelete?.title}
</p>
<p className="text-sm text-red-500 mb-6">
작업은 되돌릴 없습니다.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{deleting ? (
<>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full"
/>
삭제 ...
</>
) : (
<>
<Trash2 size={16} />
삭제
</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 헤더 */} {/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-100"> <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="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
@ -237,27 +402,27 @@ function AdminSchedule() {
{/* 왼쪽: 달력 + 카테고리 필터 */} {/* 왼쪽: 달력 + 카테고리 필터 */}
<div className="space-y-6"> <div className="space-y-6">
{/* 달력 (Schedule.jsx와 동일한 패턴) */} {/* 달력 (Schedule.jsx와 동일한 패턴) */}
<div ref={pickerRef} className="bg-white rounded-2xl shadow-sm pt-6 px-6 pb-4 relative"> <div ref={pickerRef} className="bg-white rounded-2xl shadow-sm pt-8 px-8 pb-6 relative">
{/* 달력 헤더 */} {/* 달력 헤더 */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-8">
<button <button
onClick={prevMonth} onClick={prevMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-full transition-colors"
> >
<ChevronLeft size={20} /> <ChevronLeft size={24} />
</button> </button>
<button <button
onClick={() => setShowYearMonthPicker(!showYearMonthPicker)} onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}
className="flex items-center gap-1 font-bold hover:text-primary transition-colors" className="flex items-center gap-1 text-xl font-bold hover:text-primary transition-colors"
> >
<span>{year} {month + 1}</span> <span>{year} {month + 1}</span>
<ChevronDown size={16} className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} /> <ChevronDown size={20} className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
</button> </button>
<button <button
onClick={nextMonth} onClick={nextMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-full transition-colors"
> >
<ChevronRight size={20} /> <ChevronRight size={24} />
</button> </button>
</div> </div>
@ -384,7 +549,7 @@ function AdminSchedule() {
transition={{ duration: 0.08 }} transition={{ duration: 0.08 }}
> >
{/* 요일 헤더 */} {/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-2"> <div className="grid grid-cols-7 mb-4">
{days.map((day, i) => ( {days.map((day, i) => (
<div <div
key={day} key={day}
@ -404,7 +569,7 @@ function AdminSchedule() {
const prevMonthDays = getDaysInMonth(year, month - 1); const prevMonthDays = getDaysInMonth(year, month - 1);
const day = prevMonthDays - firstDay + i + 1; const day = prevMonthDays - firstDay + i + 1;
return ( return (
<div key={`prev-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-sm"> <div key={`prev-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-base">
{day} {day}
</div> </div>
); );
@ -423,8 +588,8 @@ function AdminSchedule() {
<button <button
key={day} key={day}
onClick={() => selectDate(day)} onClick={() => selectDate(day)}
className={`aspect-square flex flex-col items-center justify-center rounded-full text-sm font-medium transition-all relative hover:bg-gray-100 className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100
${isSelected ? 'bg-primary text-white hover:bg-primary' : ''} ${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
${isToday && !isSelected ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''} ${isToday && !isSelected ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''}
${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''} ${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''} ${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
@ -432,7 +597,7 @@ function AdminSchedule() {
> >
<span>{day}</span> <span>{day}</span>
{hasEvent && ( {hasEvent && (
<span className={`w-1 h-1 rounded-full mt-0.5 ${isSelected ? 'bg-white' : 'bg-primary'}`} /> <span className={`w-1.5 h-1.5 rounded-full mt-0.5 ${isSelected ? 'bg-white' : 'bg-primary'}`} />
)} )}
</button> </button>
); );
@ -444,7 +609,7 @@ function AdminSchedule() {
const remainder = totalCells % 7; const remainder = totalCells % 7;
const nextDays = remainder === 0 ? 0 : 7 - remainder; const nextDays = remainder === 0 ? 0 : 7 - remainder;
return Array.from({ length: nextDays }).map((_, i) => ( return Array.from({ length: nextDays }).map((_, i) => (
<div key={`next-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-sm"> <div key={`next-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-base">
{i + 1} {i + 1}
</div> </div>
)); ));
@ -452,11 +617,23 @@ function AdminSchedule() {
</div> </div>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* 범례 및 전체보기 */}
{/* 범례 */} <div className="mt-6 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="mt-4 pt-3 border-t border-gray-100 flex items-center gap-1.5 text-xs text-gray-500"> <div className="flex items-center gap-1.5 text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" /> <span className="w-2 h-2 rounded-full bg-primary flex-shrink-0" />
<span className="leading-none">일정 있음</span> <span className="leading-none">일정 있음</span>
</div>
<button
onClick={showAll}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedDate
? 'bg-primary text-white hover:bg-primary-dark'
: 'bg-gray-100 text-gray-400 cursor-default'
}`}
disabled={!selectedDate}
>
전체 보기
</button>
</div> </div>
</div> </div>
@ -464,28 +641,49 @@ function AdminSchedule() {
<div className="bg-white rounded-2xl shadow-sm p-6"> <div className="bg-white rounded-2xl shadow-sm p-6">
<h3 className="font-bold text-gray-900 mb-4">카테고리</h3> <h3 className="font-bold text-gray-900 mb-4">카테고리</h3>
<div className="space-y-2"> <div className="space-y-2">
{categories.map(category => ( {categories.map(category => {
<button const isSelected = category.id === 'all'
key={category.id} ? selectedCategories.length === 0
onClick={() => setSelectedCategory(category.id)} : selectedCategories.includes(category.id);
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-colors ${
selectedCategory === category.id const handleClick = () => {
? 'bg-primary/10 text-primary' if (category.id === 'all') {
: 'hover:bg-gray-50 text-gray-700' //
}`} setSelectedCategories([]);
> } else {
<span className={`w-3 h-3 rounded-full ${ //
category.id === 'all' ? 'bg-gray-400' : getCategoryDotColor(category.id) if (selectedCategories.includes(category.id)) {
}`} /> setSelectedCategories(selectedCategories.filter(id => id !== category.id));
<span className="font-medium">{category.name}</span> } else {
<span className="ml-auto text-sm text-gray-400"> setSelectedCategories([...selectedCategories, category.id]);
{category.id === 'all'
? dummySchedules.length
: dummySchedules.filter(s => s.category === category.id).length
} }
</span> }
</button> };
))}
return (
<button
key={category.id}
onClick={handleClick}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-colors ${
isSelected
? 'bg-primary/10 text-primary'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<span
className={`w-3 h-3 rounded-full ${category.id === 'all' ? 'bg-gray-400' : (getColorStyle(category.color).className || '')}`}
style={category.id !== 'all' ? getColorStyle(category.color).style : undefined}
/>
<span className="font-medium">{category.name}</span>
<span className="ml-auto text-sm text-gray-400">
{category.id === 'all'
? schedules.length
: schedules.filter(s => s.category_id === category.id).length
}
</span>
</button>
);
})}
</div> </div>
</div> </div>
</div> </div>
@ -510,9 +708,38 @@ function AdminSchedule() {
<div className="bg-white rounded-2xl shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100"> <div className="p-6 border-b border-gray-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-bold text-gray-900"> {selectedCategories.length > 1 ? (
{selectedCategory === 'all' ? '전체 일정' : categories.find(c => c.id === selectedCategory)?.name} <Tooltip
</h3> text={
<div className="flex flex-col gap-1.5">
{selectedCategories.map(id => {
const cat = categories.find(c => c.id === id);
if (!cat) return null;
return (
<div key={id} className="flex items-center gap-2">
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getColorStyle(cat.color).className || ''}`}
style={getColorStyle(cat.color).style}
/>
<span>{cat.name}</span>
</div>
);
})}
</div>
}
>
<h3 className="font-bold text-gray-900">
{`${selectedCategories.length}개 카테고리 선택됨`}
</h3>
</Tooltip>
) : (
<h3 className="font-bold text-gray-900">
{selectedCategories.length === 0
? '전체 일정'
: categories.find(c => c.id === selectedCategories[0])?.name
}
</h3>
)}
<span className="text-sm text-gray-500">{filteredSchedules.length}개의 일정</span> <span className="text-sm text-gray-500">{filteredSchedules.length}개의 일정</span>
</div> </div>
</div> </div>
@ -550,10 +777,10 @@ function AdminSchedule() {
{/* 내용 */} {/* 내용 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getCategoryColor(schedule.category)}`}> <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getCategoryColor(schedule.category_color)}`}>
{categories.find(c => c.id === schedule.category)?.name} {schedule.category_name || '미지정'}
</span> </span>
<span className="text-sm text-gray-400">{schedule.time}</span> <span className="text-sm text-gray-400">{schedule.time?.slice(0, 5)}</span>
</div> </div>
<h4 className="font-medium text-gray-900 mb-1">{schedule.title}</h4> <h4 className="font-medium text-gray-900 mb-1">{schedule.title}</h4>
<p className="text-sm text-gray-500">{schedule.description}</p> <p className="text-sm text-gray-500">{schedule.description}</p>
@ -567,7 +794,10 @@ function AdminSchedule() {
> >
<Edit2 size={18} /> <Edit2 size={18} />
</button> </button>
<button className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"> <button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div> </div>

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, MapPin, Settings, AlertTriangle, Trash2 ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, AlertTriangle, Trash2, Search
} 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';
@ -221,17 +221,27 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택' }) {
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{days.map((day, i) => ( {days.map((day, i) => {
<button const dayOfWeek = i % 7;
key={i} return (
type="button" <button
disabled={!day} key={i}
onClick={() => day && selectDate(day)} type="button"
className={`aspect-square rounded-lg text-sm flex items-center justify-center transition-colors ${!day ? '' : 'hover:bg-gray-100'} ${isSelected(day) ? 'bg-primary text-white hover:bg-primary-dark' : ''} ${isToday(day) && !isSelected(day) ? 'border border-primary text-primary' : ''} ${day && !isSelected(day) && !isToday(day) ? 'text-gray-700' : ''}`} disabled={!day}
> onClick={() => day && selectDate(day)}
{day} className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
</button> ${!day ? '' : 'hover:bg-gray-100'}
))} ${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
${isToday(day) && !isSelected(day) ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
`}
>
{day}
</button>
);
})}
</div> </div>
</motion.div> </motion.div>
)} )}
@ -614,6 +624,7 @@ function AdminScheduleForm() {
// //
locationName: '', // locationName: '', //
locationAddress: '', // locationAddress: '', //
locationDetail: '', // (: 3, N )
locationLat: null, // locationLat: null, //
locationLng: null, // locationLng: null, //
}); });
@ -632,6 +643,18 @@ function AdminScheduleForm() {
// (API ) // (API )
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
//
const [saving, setSaving] = useState(false);
//
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [locationSearch, setLocationSearch] = useState('');
const [locationResults, setLocationResults] = useState([]);
const [locationSearching, setLocationSearching] = useState(false);
// ID
const [existingImageIds, setExistingImageIds] = useState([]);
// //
const colorMap = { const colorMap = {
blue: 'bg-blue-500', blue: 'bg-blue-500',
@ -641,10 +664,20 @@ function AdminScheduleForm() {
pink: 'bg-pink-500', pink: 'bg-pink-500',
yellow: 'bg-yellow-500', yellow: 'bg-yellow-500',
orange: 'bg-orange-500', orange: 'bg-orange-500',
gray: 'bg-gray-500',
cyan: 'bg-cyan-500', cyan: 'bg-cyan-500',
indigo: 'bg-indigo-500', indigo: 'bg-indigo-500',
}; };
// ( HEX)
const getColorStyle = (color) => {
if (!color) return { className: 'bg-gray-500' };
if (color.startsWith('#')) {
return { style: { backgroundColor: color } };
}
return { className: colorMap[color] || 'bg-gray-500' };
};
// //
const getCategoryColor = (categoryId) => { const getCategoryColor = (categoryId) => {
const cat = categories.find(c => c.id === categoryId); const cat = categories.find(c => c.id === categoryId);
@ -689,7 +722,63 @@ function AdminScheduleForm() {
setUser(JSON.parse(userData)); setUser(JSON.parse(userData));
fetchMembers(); fetchMembers();
fetchCategories(); fetchCategories();
}, [navigate]);
//
if (isEditMode && id) {
fetchSchedule();
}
}, [navigate, isEditMode, id]);
// ( )
const fetchSchedule = async () => {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`/api/admin/schedules/${id}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!res.ok) {
throw new Error('일정을 찾을 수 없습니다.');
}
const data = await res.json();
//
setFormData({
title: data.title || '',
startDate: data.date ? new Date(data.date).toISOString().split('T')[0] : '',
endDate: data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : '',
startTime: data.time?.slice(0, 5) || '',
endTime: data.end_time?.slice(0, 5) || '',
isRange: !!data.end_date,
category: data.category_id || '',
description: data.description || '',
url: data.source_url || '',
members: data.members?.map(m => m.id) || [],
images: [],
locationName: data.location_name || '',
locationAddress: data.location_address || '',
locationDetail: data.location_detail || '',
locationLat: data.location_lat || null,
locationLng: data.location_lng || null,
});
//
if (data.images && data.images.length > 0) {
setImagePreviews(data.images.map(img => img.image_url));
setExistingImageIds(data.images.map(img => img.id));
}
} catch (error) {
console.error('일정 로드 오류:', error);
setToast({ type: 'error', message: error.message || '일정을 불러오는 중 오류가 발생했습니다.' });
navigate('/admin/schedule');
} finally {
setLoading(false);
}
};
const fetchMembers = async () => { const fetchMembers = async () => {
try { try {
@ -727,7 +816,9 @@ function AdminScheduleForm() {
// //
const handleImagesUpload = (e) => { const handleImagesUpload = (e) => {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
const newImages = [...formData.images, ...files]; // {file: File} ( image.file )
const newImageObjects = files.map(file => ({ file }));
const newImages = [...formData.images, ...newImageObjects];
setFormData({ ...formData, images: newImages }); setFormData({ ...formData, images: newImages });
files.forEach(file => { files.forEach(file => {
@ -814,12 +905,137 @@ function AdminScheduleForm() {
handleDragEnd(); handleDragEnd();
}; };
// API ( )
const handleLocationSearch = async () => {
if (!locationSearch.trim()) {
setLocationResults([]);
return;
}
// (UI) setLocationSearching(true);
const handleSubmit = (e) => { try {
const token = localStorage.getItem('token');
const response = await fetch(
`/api/admin/kakao/places?query=${encodeURIComponent(locationSearch)}`,
{
headers: {
'Authorization': `Bearer ${token}`,
}
}
);
if (response.ok) {
const data = await response.json();
setLocationResults(data.documents || []);
}
} catch (error) {
console.error('장소 검색 오류:', error);
} finally {
setLocationSearching(false);
}
};
//
const selectLocation = (place) => {
setFormData({
...formData,
locationName: place.place_name,
locationAddress: place.road_address_name || place.address_name,
locationLat: parseFloat(place.y),
locationLng: parseFloat(place.x)
});
setLocationDialogOpen(false);
setLocationSearch('');
setLocationResults([]);
};
//
const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setToast({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' });
setTimeout(() => navigate('/admin/schedule'), 1000); //
if (!formData.title.trim()) {
setToast({ type: 'error', message: '제목을 입력해주세요.' });
return;
}
// : / startDate
if (!formData.startDate) {
setToast({ type: 'error', message: '날짜를 선택해주세요.' });
return;
}
if (!formData.category) {
setToast({ type: 'error', message: '카테고리를 선택해주세요.' });
return;
}
setSaving(true);
try {
const token = localStorage.getItem('adminToken');
// FormData
const submitData = new FormData();
// JSON - startDate date (UI / startDate )
const jsonData = {
title: formData.title.trim(),
date: formData.startDate,
time: formData.startTime || null,
endDate: formData.isRange ? formData.endDate : null,
endTime: formData.isRange ? formData.endTime : null,
isRange: formData.isRange,
category: formData.category,
description: formData.description.trim() || null,
url: formData.url.trim() || null,
members: formData.members,
locationName: formData.locationName.trim() || null,
locationAddress: formData.locationAddress.trim() || null,
locationDetail: formData.locationDetail?.trim() || null,
locationLat: formData.locationLat,
locationLng: formData.locationLng,
};
// ID
if (isEditMode) {
jsonData.existingImages = existingImageIds;
}
submitData.append('data', JSON.stringify(jsonData));
// ( )
for (const image of formData.images) {
if (image.file) {
submitData.append('images', image.file);
}
}
// PUT, POST
const url = isEditMode ? `/api/admin/schedules/${id}` : '/api/admin/schedules';
const method = isEditMode ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
},
body: submitData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.'));
}
setToast({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' });
setTimeout(() => navigate('/admin/schedule'), 1500);
} catch (error) {
console.error('일정 저장 오류:', error);
setToast({ type: 'error', message: error.message || '일정 저장 중 오류가 발생했습니다.' });
} finally {
setSaving(false);
}
}; };
return ( return (
@ -878,6 +1094,119 @@ function AdminScheduleForm() {
)} )}
</AnimatePresence> </AnimatePresence>
{/* 장소 검색 다이얼로그 */}
<AnimatePresence>
{locationDialogOpen && (
<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={() => {
setLocationDialogOpen(false);
setLocationSearch('');
setLocationResults([]);
}}
>
<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-lg w-full mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
<button
type="button"
onClick={() => {
setLocationDialogOpen(false);
setLocationSearch('');
setLocationResults([]);
}}
className="text-gray-400 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
{/* 검색 입력 */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={locationSearch}
onChange={(e) => setLocationSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleLocationSearch();
}
}}
placeholder="장소명을 입력하세요"
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
autoFocus
/>
</div>
<button
type="button"
onClick={handleLocationSearch}
disabled={locationSearching}
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{locationSearching ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<Search size={18} />
</motion.div>
) : (
'검색'
)}
</button>
</div>
{/* 검색 결과 */}
<div className="max-h-80 overflow-y-auto">
{locationResults.length > 0 ? (
<div className="space-y-2">
{locationResults.map((place, index) => (
<button
key={index}
type="button"
onClick={() => selectLocation(place)}
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
>
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{place.place_name}</p>
<p className="text-sm text-gray-500 truncate">{place.road_address_name || place.address_name}</p>
{place.category_name && (
<p className="text-xs text-gray-400 mt-1">{place.category_name}</p>
)}
</div>
</button>
))}
</div>
) : locationSearch && !locationSearching ? (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>검색어를 입력하고 검색 버튼을 눌러주세요</p>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>장소명을 입력하고 검색해주세요</p>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
<Lightbox <Lightbox
images={imagePreviews} images={imagePreviews}
@ -1051,7 +1380,10 @@ 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 ${colorMap[category.color] || 'bg-gray-500'}`} /> <span
className={`w-2.5 h-2.5 rounded-full ${getColorStyle(category.color).className || ''}`}
style={getColorStyle(category.color).style}
/>
<span className="text-sm font-medium">{category.name}</span> <span className="text-sm font-medium">{category.name}</span>
</button> </button>
))} ))}
@ -1062,17 +1394,28 @@ function AdminScheduleForm() {
<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>
<div className="space-y-3"> <div className="space-y-3">
{/* 장소 이름 */} {/* 장소 이름 + 검색 버튼 */}
<div className="relative"> <div className="flex gap-2">
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" /> <div className="flex-1 relative">
<input <MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
type="text" <input
value={formData.locationName} type="text"
onChange={(e) => setFormData({ ...formData, locationName: e.target.value })} value={formData.locationName}
placeholder="장소 이름 (예: 잠실실내체육관)" onChange={(e) => setFormData({ ...formData, locationName: e.target.value })}
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" 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>
<button
type="button"
onClick={() => setLocationDialogOpen(true)}
className="px-4 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors flex items-center gap-2"
>
<Search size={18} />
<span className="hidden sm:inline">검색</span>
</button>
</div> </div>
{/* 주소 */} {/* 주소 */}
<input <input
type="text" type="text"
@ -1081,7 +1424,15 @@ function AdminScheduleForm() {
placeholder="주소 (예: 서울특별시 송파구 올림픽로 25)" 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" 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 키 설정 후 지도 검색 및 미리보기 추가 */}
{/* 상세주소 */}
<input
type="text"
value={formData.locationDetail}
onChange={(e) => setFormData({ ...formData, locationDetail: e.target.value })}
placeholder="상세주소 (예: 3관 A구역, N열 등)"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div> </div>
</div> </div>
@ -1247,11 +1598,20 @@ function AdminScheduleForm() {
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50" className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50"
> >
<Save size={18} /> {saving ? (
{isEditMode ? '수정하기' : '추가하기'} <>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
{isEditMode ? '수정하기' : '추가하기'}
</>
)}
</button> </button>
</div> </div>
</form> </form>