diff --git a/.env b/.env
index 0ef6e6f..ec421bb 100644
--- a/.env
+++ b/.env
@@ -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
diff --git a/backend/routes/admin.js b/backend/routes/admin.js
index 9700f21..8dfdc22 100644
--- a/backend/routes/admin.js
+++ b/backend/routes/admin.js
@@ -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;
diff --git a/frontend/.env b/frontend/.env
new file mode 100644
index 0000000..6605747
--- /dev/null
+++ b/frontend/.env
@@ -0,0 +1,2 @@
+VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb
+VITE_KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8
diff --git a/frontend/src/components/Tooltip.jsx b/frontend/src/components/Tooltip.jsx
index 7872d8d..e2cefeb 100644
--- a/frontend/src/components/Tooltip.jsx
+++ b/frontend/src/components/Tooltip.jsx
@@ -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}
- {isVisible && tooltipText && ReactDOM.createPortal(
+ {isVisible && tooltipContent && ReactDOM.createPortal(
+ 다음 일정을 삭제하시겠습니까? +
++ {scheduleToDelete?.title} +
++ 이 작업은 되돌릴 수 없습니다. +
+ +{schedule.description}
@@ -567,7 +794,10 @@ function AdminSchedule() { >{place.place_name}
+{place.road_address_name || place.address_name}
+ {place.category_name && ( +{place.category_name}
+ )} +검색어를 입력하고 검색 버튼을 눌러주세요
+장소명을 입력하고 검색해주세요
+