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_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
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;

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 {string} text - 툴팁에 표시할 텍스트 (content prop과 호환)
* @param {string} content - 툴팁에 표시할 텍스트 (text prop과 호환)
* @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환)
* @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환)
*/
const Tooltip = ({ children, text, content, className = "" }) => {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ bottom: 0, left: 0 });
const triggerRef = useRef(null);
// text content prop
const tooltipText = text || content;
// text content prop ( React )
const tooltipContent = text || content;
const handleMouseEnter = (e) => {
// ( )
@ -45,7 +45,7 @@ const Tooltip = ({ children, text, content, className = "" }) => {
>
{children}
</div>
{isVisible && tooltipText && ReactDOM.createPortal(
{isVisible && tooltipContent && ReactDOM.createPortal(
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 5, scale: 0.95 }}
@ -56,9 +56,9 @@ const Tooltip = ({ children, text, content, className = "" }) => {
bottom: position.bottom,
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>
</AnimatePresence>,
document.body

View file

@ -3,9 +3,10 @@ import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown
ChevronLeft, Search, ChevronDown, AlertTriangle
} from 'lucide-react';
import Toast from '../../../components/Toast';
import Tooltip from '../../../components/Tooltip';
function AdminSchedule() {
const navigate = useNavigate();
@ -13,7 +14,7 @@ function AdminSchedule() {
const [user, setUser] = useState(null);
const [toast, setToast] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [selectedCategories, setSelectedCategories] = useState([]); // =
const [selectedDate, setSelectedDate] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const [slideDirection, setSlideDirection] = useState(0);
@ -44,54 +45,60 @@ function AdminSchedule() {
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
//
const categories = [
{ 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' },
];
// (API )
const [categories, setCategories] = useState([
{ id: 'all', name: '전체', color: 'gray' }
]);
// (UI)
const dummySchedules = [
{ 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: '위버스 진행' },
];
// (API )
const [schedules, setSchedules] = useState([]);
//
const getCategoryColor = (categoryId) => {
//
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',
gray: 'bg-gray-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 = (color) => {
const colors = {
broadcast: 'bg-blue-100 text-blue-700',
event: 'bg-green-100 text-green-700',
release: 'bg-purple-100 text-purple-700',
concert: 'bg-red-100 text-red-700',
fansign: 'bg-pink-100 text-pink-700',
blue: 'bg-blue-100 text-blue-700',
green: 'bg-green-100 text-green-700',
purple: 'bg-purple-100 text-purple-700',
red: 'bg-red-100 text-red-700',
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-100 text-gray-700';
};
//
const getCategoryDotColor = (categoryId) => {
const colors = {
broadcast: 'bg-blue-500',
event: 'bg-green-500',
release: 'bg-purple-500',
concert: 'bg-red-500',
fansign: 'bg-pink-500',
};
return colors[categoryId] || 'bg-gray-500';
if (color?.startsWith('#')) {
return 'bg-gray-100 text-gray-700';
}
return colors[color] || 'bg-gray-100 text-gray-700';
};
//
const hasSchedule = (day) => {
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
@ -112,8 +119,44 @@ function AdminSchedule() {
}
setUser(JSON.parse(userData));
//
fetchCategories();
}, [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(() => {
const handleClickOutside = (event) => {
@ -170,17 +213,139 @@ function AdminSchedule() {
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 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 (
<div className="min-h-screen bg-gray-50">
<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">
<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">
{/* 달력 (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
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
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>
<ChevronDown size={16} className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
<ChevronDown size={20} className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
</button>
<button
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>
</div>
@ -384,7 +549,7 @@ function AdminSchedule() {
transition={{ duration: 0.08 }}
>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-2">
<div className="grid grid-cols-7 mb-4">
{days.map((day, i) => (
<div
key={day}
@ -404,7 +569,7 @@ function AdminSchedule() {
const prevMonthDays = getDaysInMonth(year, month - 1);
const day = prevMonthDays - firstDay + i + 1;
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}
</div>
);
@ -423,8 +588,8 @@ function AdminSchedule() {
<button
key={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
${isSelected ? 'bg-primary text-white hover:bg-primary' : ''}
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 shadow-lg hover:bg-primary' : ''}
${isToday && !isSelected ? 'bg-primary/10 text-primary font-bold hover:bg-primary/20' : ''}
${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
@ -432,7 +597,7 @@ function AdminSchedule() {
>
<span>{day}</span>
{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>
);
@ -444,7 +609,7 @@ function AdminSchedule() {
const remainder = totalCells % 7;
const nextDays = remainder === 0 ? 0 : 7 - remainder;
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}
</div>
));
@ -452,40 +617,73 @@ function AdminSchedule() {
</div>
</motion.div>
</AnimatePresence>
{/* 범례 */}
<div className="mt-4 pt-3 border-t border-gray-100 flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
{/* 범례 및 전체보기 */}
<div className="mt-6 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5 text-gray-500">
<span className="w-2 h-2 rounded-full bg-primary flex-shrink-0" />
<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 className="bg-white rounded-2xl shadow-sm p-6">
<h3 className="font-bold text-gray-900 mb-4">카테고리</h3>
<div className="space-y-2">
{categories.map(category => (
{categories.map(category => {
const isSelected = category.id === 'all'
? selectedCategories.length === 0
: selectedCategories.includes(category.id);
const handleClick = () => {
if (category.id === 'all') {
//
setSelectedCategories([]);
} else {
//
if (selectedCategories.includes(category.id)) {
setSelectedCategories(selectedCategories.filter(id => id !== category.id));
} else {
setSelectedCategories([...selectedCategories, category.id]);
}
}
};
return (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
onClick={handleClick}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-colors ${
selectedCategory === category.id
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' : getCategoryDotColor(category.id)
}`} />
<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'
? dummySchedules.length
: dummySchedules.filter(s => s.category === category.id).length
? schedules.length
: schedules.filter(s => s.category_id === category.id).length
}
</span>
</button>
))}
);
})}
</div>
</div>
</div>
@ -510,9 +708,38 @@ function AdminSchedule() {
<div className="bg-white rounded-2xl shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100">
<div className="flex items-center justify-between">
{selectedCategories.length > 1 ? (
<Tooltip
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">
{selectedCategory === 'all' ? '전체 일정' : categories.find(c => c.id === selectedCategory)?.name}
{`${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>
</div>
</div>
@ -550,10 +777,10 @@ function AdminSchedule() {
{/* 내용 */}
<div className="flex-1 min-w-0">
<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)}`}>
{categories.find(c => c.id === schedule.category)?.name}
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getCategoryColor(schedule.category_color)}`}>
{schedule.category_name || '미지정'}
</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>
<h4 className="font-medium text-gray-900 mb-1">{schedule.title}</h4>
<p className="text-sm text-gray-500">{schedule.description}</p>
@ -567,7 +794,10 @@ function AdminSchedule() {
>
<Edit2 size={18} />
</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} />
</button>
</div>

View file

@ -3,7 +3,7 @@ import { useNavigate, Link, useParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
LogOut, Home, ChevronRight, Calendar, Save, X, Upload, Link as LinkIcon,
ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, AlertTriangle, Trash2
ChevronLeft, ChevronDown, Clock, Image, Users, Check, Plus, MapPin, Settings, AlertTriangle, Trash2, Search
} from 'lucide-react';
import Toast from '../../../components/Toast';
import Lightbox from '../../../components/common/Lightbox';
@ -221,17 +221,27 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택' }) {
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => (
{days.map((day, i) => {
const dayOfWeek = i % 7;
return (
<button
key={i}
type="button"
disabled={!day}
onClick={() => day && selectDate(day)}
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' : ''}`}
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
${!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>
</motion.div>
)}
@ -614,6 +624,7 @@ function AdminScheduleForm() {
//
locationName: '', //
locationAddress: '', //
locationDetail: '', // (: 3, N )
locationLat: null, //
locationLng: null, //
});
@ -632,6 +643,18 @@ function AdminScheduleForm() {
// (API )
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 = {
blue: 'bg-blue-500',
@ -641,10 +664,20 @@ function AdminScheduleForm() {
pink: 'bg-pink-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
gray: 'bg-gray-500',
cyan: 'bg-cyan-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 cat = categories.find(c => c.id === categoryId);
@ -689,7 +722,63 @@ function AdminScheduleForm() {
setUser(JSON.parse(userData));
fetchMembers();
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 () => {
try {
@ -727,7 +816,9 @@ function AdminScheduleForm() {
//
const handleImagesUpload = (e) => {
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 });
files.forEach(file => {
@ -814,12 +905,137 @@ function AdminScheduleForm() {
handleDragEnd();
};
// API ( )
const handleLocationSearch = async () => {
if (!locationSearch.trim()) {
setLocationResults([]);
return;
}
// (UI)
const handleSubmit = (e) => {
setLocationSearching(true);
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();
//
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'), 1000);
setTimeout(() => navigate('/admin/schedule'), 1500);
} catch (error) {
console.error('일정 저장 오류:', error);
setToast({ type: 'error', message: error.message || '일정 저장 중 오류가 발생했습니다.' });
} finally {
setSaving(false);
}
};
return (
@ -878,6 +1094,119 @@ function AdminScheduleForm() {
)}
</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
images={imagePreviews}
@ -1051,7 +1380,10 @@ function AdminScheduleForm() {
: '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>
</button>
))}
@ -1062,8 +1394,9 @@ function AdminScheduleForm() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">장소</label>
<div className="space-y-3">
{/* 장소 이름 */}
<div className="relative">
{/* 장소 이름 + 검색 버튼 */}
<div className="flex gap-2">
<div className="flex-1 relative">
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
@ -1073,6 +1406,16 @@ function AdminScheduleForm() {
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>
{/* 주소 */}
<input
type="text"
@ -1081,7 +1424,15 @@ function AdminScheduleForm() {
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 키 설정 후 지도 검색 및 미리보기 추가 */}
{/* 상세주소 */}
<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>
@ -1247,11 +1598,20 @@ function AdminScheduleForm() {
</button>
<button
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"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
{isEditMode ? '수정하기' : '추가하기'}
</>
)}
</button>
</div>
</form>