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:
parent
4da5ea58ef
commit
dff43126c4
6 changed files with 1225 additions and 130 deletions
3
.env
3
.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
|
||||
|
|
|
|||
|
|
@ -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
2
frontend/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb
|
||||
VITE_KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]);
|
||||
|
||||
// 일정 목록 (API에서 로드)
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
|
||||
// 더미 일정 데이터 (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: '위버스 진행' },
|
||||
];
|
||||
|
||||
// 카테고리별 색상
|
||||
const getCategoryColor = (categoryId) => {
|
||||
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',
|
||||
};
|
||||
return colors[categoryId] || 'bg-gray-100 text-gray-700';
|
||||
// 카테고리 색상 맵핑
|
||||
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',
|
||||
};
|
||||
|
||||
// 카테고리별 도트 색상
|
||||
const getCategoryDotColor = (categoryId) => {
|
||||
// 색상 스타일 (기본 색상 또는 커스텀 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-500',
|
||||
event: 'bg-green-500',
|
||||
release: 'bg-purple-500',
|
||||
concert: 'bg-red-500',
|
||||
fansign: 'bg-pink-500',
|
||||
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-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,7 +119,43 @@ 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(() => {
|
||||
|
|
@ -169,18 +212,140 @@ function AdminSchedule() {
|
|||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
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,11 +617,23 @@ 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" />
|
||||
<span className="leading-none">일정 있음</span>
|
||||
{/* 범례 및 전체보기 */}
|
||||
<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>
|
||||
|
||||
|
|
@ -464,28 +641,49 @@ function AdminSchedule() {
|
|||
<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 => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-colors ${
|
||||
selectedCategory === category.id
|
||||
? '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="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
|
||||
{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]);
|
||||
}
|
||||
</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>
|
||||
|
|
@ -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">
|
||||
<h3 className="font-bold text-gray-900">
|
||||
{selectedCategory === 'all' ? '전체 일정' : categories.find(c => c.id === selectedCategory)?.name}
|
||||
</h3>
|
||||
{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">
|
||||
{`${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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<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' : ''}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
{days.map((day, i) => {
|
||||
const dayOfWeek = i % 7;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={() => day && selectDate(day)}
|
||||
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, // 경도
|
||||
});
|
||||
|
|
@ -631,6 +642,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 = {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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([]);
|
||||
};
|
||||
|
||||
// 폼 제출 (UI만)
|
||||
const handleSubmit = (e) => {
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e) => {
|
||||
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 (
|
||||
|
|
@ -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,17 +1394,28 @@ function AdminScheduleForm() {
|
|||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">장소</label>
|
||||
<div className="space-y-3">
|
||||
{/* 장소 이름 */}
|
||||
<div className="relative">
|
||||
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.locationName}
|
||||
onChange={(e) => setFormData({ ...formData, locationName: e.target.value })}
|
||||
placeholder="장소 이름 (예: 잠실실내체육관)"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
{/* 장소 이름 + 검색 버튼 */}
|
||||
<div 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"
|
||||
value={formData.locationName}
|
||||
onChange={(e) => setFormData({ ...formData, locationName: e.target.value })}
|
||||
placeholder="장소 이름 (예: 잠실실내체육관)"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<Save size={18} />
|
||||
{isEditMode ? '수정하기' : '추가하기'}
|
||||
{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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue