diff --git a/frontend/src/pages/pc/admin/schedules/ScheduleForm.jsx b/frontend/src/pages/pc/admin/schedules/ScheduleForm.jsx index ff44322..35ea97e 100644 --- a/frontend/src/pages/pc/admin/schedules/ScheduleForm.jsx +++ b/frontend/src/pages/pc/admin/schedules/ScheduleForm.jsx @@ -1,364 +1,151 @@ -import { useState, useEffect } from 'react'; -import { useNavigate, Link, useParams } from 'react-router-dom'; +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate, Link, useParams, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { formatDate } from '@/utils/date'; -import { - Home, - ChevronRight, - Save, - X, - Link as LinkIcon, - MapPin, - Settings, - Search, -} from 'lucide-react'; -import { Toast, Lightbox } from '@/components/common'; -import { AdminLayout, ConfirmDialog, DatePicker, TimePicker } from '@/components/pc/admin'; -import { - LocationSearchDialog, - MemberSelector, - ImageUploader, -} from '@/components/pc/admin/schedule'; +import { Home, ChevronRight, Save, Settings } from 'lucide-react'; +import { AdminLayout, DatePicker, TimePicker } from '@/components/pc/admin'; +import { Toast } from '@/components/common'; +import { MemberSelector } from '@/components/pc/admin/schedule'; import { useAdminAuth } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; import * as categoriesApi from '@/api/admin/categories'; -import * as schedulesApi from '@/api/admin/schedules'; +import { getSchedule } from '@/api/admin/schedules'; import { getMembers } from '@/api/public/members'; import { getColorStyle } from '@/utils/color'; import useAuthStore from '@/stores/useAuthStore'; +// 전용 폼이 없는 단순 카테고리만 이 공용 폼에서 처리 +const SHARED_CATEGORIES = ['컴백', '팬사인회', '기타']; +// "날짜 미정(월만)" 토글을 노출할 카테고리 (추후 확장 가능) +const DATE_PRECISION_CATEGORIES = ['컴백']; + +/** + * 일반 일정 추가/수정 폼 (컴백·팬사인회·기타) + * 제목·날짜·시간·멤버 + 컴백의 "날짜 미정(월만)" 토글 + */ function ScheduleForm() { const navigate = useNavigate(); const { id } = useParams(); + const [searchParams] = useSearchParams(); const isEditMode = !!id; - const { user, isAuthenticated } = useAdminAuth(); - + const { user } = useAdminAuth(); const { toast, setToast } = useToast(); - const [loading, setLoading] = useState(false); - // 폼 데이터 (날짜/시간 범위 지원) const [formData, setFormData] = useState({ title: '', - startDate: '', - endDate: '', - startTime: '', - endTime: '', - isRange: false, // 범위 설정 여부 + date: '', + time: '', category: '', - description: '', - url: '', - sourceName: '', members: [], - images: [], - // 장소 정보 - locationName: '', // 장소 이름 - locationAddress: '', // 주소 - locationDetail: '', // 상세주소 (예: 3관, N열 등) - locationLat: null, // 위도 - locationLng: null, // 경도 + datePrecision: 'day', }); - - // 이미지 미리보기 - const [imagePreviews, setImagePreviews] = useState([]); - - // 라이트박스 상태 - const [lightboxOpen, setLightboxOpen] = useState(false); - const [lightboxIndex, setLightboxIndex] = useState(0); - - // 삭제 다이얼로그 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTargetIndex, setDeleteTargetIndex] = useState(null); - - // 멤버 목록 조회 - const { data: membersData = [] } = useQuery({ - queryKey: ['members'], - queryFn: getMembers, - enabled: isAuthenticated, - staleTime: 5 * 60 * 1000, - }); - const members = membersData.filter((m) => !m.is_former); - - // 카테고리 목록 조회 - const { data: categories = [] } = useQuery({ - queryKey: ['admin', 'categories'], - queryFn: categoriesApi.getCategories, - enabled: isAuthenticated, - staleTime: 5 * 60 * 1000, - }); - - // 저장 중 상태 const [saving, setSaving] = useState(false); - // 장소 검색 다이얼로그 상태 - const [locationDialogOpen, setLocationDialogOpen] = useState(false); + // 카테고리 / 멤버 로드 + const { data: allCategories = [] } = useQuery({ + queryKey: ['scheduleCategories'], + queryFn: categoriesApi.getCategories, + staleTime: 10 * 60 * 1000, + }); + const { data: members = [] } = useQuery({ + queryKey: ['members'], + queryFn: getMembers, + staleTime: 10 * 60 * 1000, + }); - // 수정 모드용 기존 이미지 ID 추적 - const [existingImageIds, setExistingImageIds] = useState([]); + const categories = useMemo( + () => allCategories.filter((c) => SHARED_CATEGORIES.includes(c.name)), + [allCategories] + ); - // 첫 번째 카테고리를 기본값으로 설정 + const selectedCategoryName = allCategories.find((c) => c.id === formData.category)?.name; + const showPrecisionToggle = DATE_PRECISION_CATEGORIES.includes(selectedCategoryName); + const isMonthPrecision = formData.datePrecision === 'month'; + + // 카테고리 기본값: URL ?category가 공용 카테고리면 그걸로, 아니면 첫 공용 카테고리 useEffect(() => { - if (categories.length > 0 && !formData.category && !isEditMode) { - setFormData((prev) => ({ ...prev, category: categories[0].id })); - } - }, [categories, isEditMode]); + if (isEditMode || categories.length === 0 || formData.category) return; + const fromUrl = parseInt(searchParams.get('category'), 10); + const preselect = categories.find((c) => c.id === fromUrl); + setFormData((p) => ({ ...p, category: (preselect || categories[0]).id })); + }, [categories, isEditMode, formData.category, searchParams]); - // 수정 모드일 경우 기존 데이터 로드 + // 수정 모드: 기존 일정 로드 + const { data: existing } = useQuery({ + queryKey: ['schedule', id], + queryFn: () => getSchedule(id), + enabled: isEditMode, + }); useEffect(() => { - if (isAuthenticated && isEditMode && id) { - fetchSchedule(); - } - }, [isAuthenticated, isEditMode, id]); - - // 기존 일정 데이터 로드 (수정 모드) - const fetchSchedule = async () => { - setLoading(true); - try { - const data = await schedulesApi.getSchedule(id); - - // 폼 데이터 설정 - setFormData({ - title: data.title || '', - startDate: data.date ? formatDate(data.date) : '', - endDate: data.end_date ? formatDate(data.end_date) : '', - 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 || '', - sourceName: data.source?.name || '', - 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) { - // 기존 이미지를 formData.images에 저장 (id 포함) - setFormData((prev) => ({ - ...prev, - title: data.title || '', - isRange: data.is_range || false, - startDate: data.date?.split('T')[0] || '', - endDate: data.end_date?.split('T')[0] || '', - startTime: data.time?.slice(0, 5) || '', - endTime: data.end_time?.slice(0, 5) || '', - category: data.category_id || 1, - description: data.description || '', - url: data.source?.url || '', - sourceName: data.source?.name || '', - members: data.members?.map((m) => m.id) || [], - images: data.images.map((img) => ({ id: img.id, url: img.image_url })), - locationName: data.location_name || '', - locationAddress: data.location_address || '', - locationDetail: data.location_detail || '', - locationLat: data.location_lat || null, - locationLng: data.location_lng || null, - })); - 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 toggleMember = (memberId) => { - const newMembers = formData.members.includes(memberId) - ? formData.members.filter((id) => id !== memberId) - : [...formData.members, memberId]; - setFormData({ ...formData, members: newMembers }); - }; - - // 전체 선택/해제 - const toggleAllMembers = () => { - if (formData.members.length === members.length) { - setFormData({ ...formData, members: [] }); - } else { - setFormData({ ...formData, members: members.map((m) => m.id) }); - } - }; - - - // 이미지 삭제 확인 - const confirmDeleteImage = () => { - if (deleteTargetIndex !== null) { - const deletedImage = formData.images[deleteTargetIndex]; - const newImages = formData.images.filter((_, i) => i !== deleteTargetIndex); - const newPreviews = imagePreviews.filter((_, i) => i !== deleteTargetIndex); - setFormData({ ...formData, images: newImages }); - setImagePreviews(newPreviews); - - // 기존 이미지(서버에 있는)를 삭제한 경우 existingImageIds에서도 제거 - if (deletedImage && deletedImage.id) { - setExistingImageIds((prev) => prev.filter((id) => id !== deletedImage.id)); - } - } - setDeleteDialogOpen(false); - setDeleteTargetIndex(null); - }; - - // 라이트박스 열기 - const openLightbox = (index) => { - setLightboxIndex(index); - setLightboxOpen(true); - }; - - // 장소 선택 핸들러 (LocationSearchDialog에서 호출) - const handleLocationSelect = (place) => { + if (!existing) return; setFormData({ - ...formData, - locationName: place.name, - locationAddress: place.address, - locationLat: place.lat, - locationLng: place.lng, + title: existing.title || '', + date: existing.date ? existing.date.slice(0, 10) : '', + time: existing.time ? existing.time.slice(0, 5) : '', + category: existing.category?.id || existing.category_id || '', + members: (existing.members || []).filter((m) => m.id).map((m) => m.id), + datePrecision: existing.datePrecision || 'day', }); - }; + }, [existing]); - // 이미지 업로드 핸들러 (ImageUploader에서 호출) - const handleImagesUploadFromUploader = (files) => { - const newImageObjects = files.map((file) => ({ file })); - const newImages = [...formData.images, ...newImageObjects]; - setFormData({ ...formData, images: newImages }); + const toggleMember = (memberId) => + setFormData((p) => ({ + ...p, + members: p.members.includes(memberId) + ? p.members.filter((x) => x !== memberId) + : [...p.members, memberId], + })); + const toggleAllMembers = () => + setFormData((p) => ({ + ...p, + members: p.members.length === members.length ? [] : members.map((m) => m.id), + })); - files.forEach((file) => { - const reader = new FileReader(); - reader.onloadend = () => { - setImagePreviews((prev) => [...prev, reader.result]); - }; - reader.readAsDataURL(file); - }); - }; + const setPrecision = (month) => + setFormData((p) => ({ ...p, datePrecision: month ? 'month' : 'day', time: month ? '' : p.time })); - // 이미지 삭제 핸들러 (ImageUploader에서 호출) - const handleImageDelete = (index) => { - setDeleteTargetIndex(index); - setDeleteDialogOpen(true); - }; - - // 이미지 순서 변경 핸들러 (ImageUploader에서 호출) - const handleImageReorder = (fromIndex, toIndex) => { - const newPreviews = [...imagePreviews]; - const newImages = [...formData.images]; - - const [movedPreview] = newPreviews.splice(fromIndex, 1); - const [movedImage] = newImages.splice(fromIndex, 1); - - newPreviews.splice(toIndex, 0, movedPreview); - newImages.splice(toIndex, 0, movedImage); - - setImagePreviews(newPreviews); - setFormData({ ...formData, images: newImages }); - }; - - // 폼 제출 const handleSubmit = async (e) => { e.preventDefault(); - - // 유효성 검사 if (!formData.title.trim()) { setToast({ type: 'error', message: '제목을 입력해주세요.' }); return; } - // 날짜 검증: 단일/기간 모드 모두 startDate를 사용함 - if (!formData.startDate) { + if (!formData.date) { setToast({ type: 'error', message: '날짜를 선택해주세요.' }); return; } - if (!formData.category) { setToast({ type: 'error', message: '카테고리를 선택해주세요.' }); return; } setSaving(true); - try { const token = useAuthStore.getState().token; - - // FormData 생성 - const submitData = new FormData(); - - // JSON 데이터 - 항상 startDate를 date로 사용 (UI에서 단일/기간 모드 모두 startDate 사용) - const jsonData = { + const body = { 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, + date: formData.date, + time: isMonthPrecision ? null : formData.time || null, category: formData.category, - description: formData.description.trim() || null, - url: formData.url.trim() || null, - sourceName: formData.sourceName.trim() || null, + datePrecision: showPrecisionToggle ? formData.datePrecision : 'day', 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, + const res = await fetch(url, { + method: isEditMode ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), }); - - if (!response.ok) { - const error = await response.json(); - throw new Error( - error.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.') - ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.')); } - - // 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동 sessionStorage.setItem( 'scheduleToast', - JSON.stringify({ - type: 'success', - message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', - }) + JSON.stringify({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' }) ); navigate('/admin/schedule'); } catch (error) { - console.error('일정 저장 오류:', error); - setToast({ - type: 'error', - message: error.message || '일정 저장 중 오류가 발생했습니다.', - }); + setToast({ type: 'error', message: error.message }); } finally { setSaving(false); } @@ -368,38 +155,6 @@ function ScheduleForm() { setToast(null)} /> - {/* 삭제 확인 다이얼로그 */} - setDeleteDialogOpen(false)} - onConfirm={confirmDeleteImage} - title="이미지 삭제" - message={ - <> - 이 이미지를 삭제하시겠습니까? -
- 이 작업은 되돌릴 수 없습니다. - - } - /> - - {/* 장소 검색 다이얼로그 */} - setLocationDialogOpen(false)} - onSelect={handleLocationSelect} - /> - - {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} - setLightboxOpen(false)} - onIndexChange={setLightboxIndex} - /> - - {/* 메인 콘텐츠 */}
{/* 브레드크럼 */}
@@ -414,17 +169,14 @@ function ScheduleForm() { {isEditMode ? '일정 수정' : '일정 추가'}
- {/* 타이틀 */}

{isEditMode ? '일정 수정' : '일정 추가'}

-

새로운 일정을 등록합니다

+

제목·날짜·멤버 중심의 일반 일정을 등록합니다

- {/* 폼 */}
- {/* 기본 정보 카드 */}

기본 정보

@@ -436,96 +188,12 @@ function ScheduleForm() { type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} - placeholder="일정 제목을 입력하세요" + placeholder="일정 제목을 입력하세요 (예: 9월 컴백)" 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" required />
- {/* 범위 설정 토글 */} -
- - 기간 설정 (시작~종료) -
- - {/* 날짜 + 시간 */} - {formData.isRange ? ( - // 범위 설정 모드 -
- {/* 시작 */} -
-
- - setFormData({ ...formData, startDate: date })} - placeholder="시작 날짜 선택" - minYear={2017} - /> -
-
- - setFormData({ ...formData, startTime: time })} - placeholder="시작 시간 선택" - /> -
-
- {/* 종료 */} -
-
- - setFormData({ ...formData, endDate: date })} - placeholder="종료 날짜 선택" - minYear={2017} - /> -
-
- - setFormData({ ...formData, endTime: time })} - placeholder="종료 시간 선택" - /> -
-
-
- ) : ( - // 단일 날짜 모드 -
-
- - setFormData({ ...formData, startDate: date })} - minYear={2017} - /> -
-
- - setFormData({ ...formData, startTime: time })} - /> -
-
- )} - {/* 카테고리 */}
@@ -560,133 +228,55 @@ function ScheduleForm() {
- {/* 장소 */} -
-
- - {/* 검색으로 입력된 경우(좌표가 있는 경우) 초기화 버튼 표시 */} - {formData.locationLat && formData.locationLng && ( - + {/* 날짜 미정 토글 (컴백 등) */} + {showPrecisionToggle && ( +
+
+

날짜 미정 (월만)

+

+ "9월 컴백 예정"처럼 날짜는 미정이고 월만 확정일 때 +

+
+ +
+ )} + + {/* 날짜 + 시간 */} +
+
+ + setFormData({ ...formData, date })} + minYear={2017} + /> + {isMonthPrecision && ( +

선택한 날짜의 "월"만 사용됩니다 (일자는 무시)

)}
-
- {/* 장소 이름 + 검색 버튼 */} -
-
- - - setFormData({ - ...formData, - locationName: e.target.value, - }) - } - placeholder="장소 이름 (예: 잠실실내체육관)" - disabled={!!(formData.locationLat && formData.locationLng)} - 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 ${formData.locationLat && formData.locationLng ? 'bg-gray-50 text-gray-700 cursor-not-allowed' : ''}`} - /> -
- + {!isMonthPrecision && ( +
+ + setFormData({ ...formData, time })} + />
- - {/* 주소 */} - - setFormData({ - ...formData, - locationAddress: e.target.value, - }) - } - placeholder="주소 (예: 서울특별시 송파구 올림픽로 25)" - disabled={!!(formData.locationLat && formData.locationLng)} - 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 ${formData.locationLat && formData.locationLng ? 'bg-gray-50 text-gray-700 cursor-not-allowed' : ''}`} - /> - - {/* 상세주소 */} - - 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" - /> -
-
- - {/* 설명 */} -
- -
-