feat(admin-schedule): 공용 일정 폼 정비 (컴백·팬사인회·기타)
ScheduleForm을 지원 필드(제목·날짜·시간·멤버)만으로 재작성 + 컴백에 "날짜 미정(월만)" 토글 추가, JSON으로 /admin/schedules 저장. 카테고리 선택은 컴백·팬사인회·기타만 노출(전용 폼 카테고리·생일·기념일 제외). DB에 저장 못 하던 범위·장소·설명·링크·이미지 필드 제거. form 라우팅: 컴백/팬사인회/기타→공용폼, 티켓팅 등→준비 중 안내. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a11a027682
commit
72c65021fb
2 changed files with 151 additions and 560 deletions
|
|
@ -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() {
|
|||
<AdminLayout user={user}>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDeleteImage}
|
||||
title="이미지 삭제"
|
||||
message={
|
||||
<>
|
||||
이 이미지를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 장소 검색 다이얼로그 */}
|
||||
<LocationSearchDialog
|
||||
isOpen={locationDialogOpen}
|
||||
onClose={() => setLocationDialogOpen(false)}
|
||||
onSelect={handleLocationSelect}
|
||||
/>
|
||||
|
||||
{/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
|
||||
<Lightbox
|
||||
images={imagePreviews}
|
||||
currentIndex={lightboxIndex}
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||
|
|
@ -414,17 +169,14 @@ function ScheduleForm() {
|
|||
<span className="text-gray-700">{isEditMode ? '일정 수정' : '일정 추가'}</span>
|
||||
</div>
|
||||
|
||||
{/* 타이틀 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{isEditMode ? '일정 수정' : '일정 추가'}
|
||||
</h1>
|
||||
<p className="text-gray-500">새로운 일정을 등록합니다</p>
|
||||
<p className="text-gray-500">제목·날짜·멤버 중심의 일반 일정을 등록합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* 기본 정보 카드 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
|
||||
|
||||
|
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 범위 설정 토글 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, isRange: !formData.isRange })}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
formData.isRange ? 'bg-primary' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${
|
||||
formData.isRange ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">기간 설정 (시작~종료)</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 + 시간 */}
|
||||
{formData.isRange ? (
|
||||
// 범위 설정 모드
|
||||
<div className="space-y-4">
|
||||
{/* 시작 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">시작 날짜 *</label>
|
||||
<DatePicker
|
||||
value={formData.startDate}
|
||||
onChange={(date) => setFormData({ ...formData, startDate: date })}
|
||||
placeholder="시작 날짜 선택"
|
||||
minYear={2017}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">시작 시간</label>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={(time) => setFormData({ ...formData, startTime: time })}
|
||||
placeholder="시작 시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 종료 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">종료 날짜 *</label>
|
||||
<DatePicker
|
||||
value={formData.endDate}
|
||||
onChange={(date) => setFormData({ ...formData, endDate: date })}
|
||||
placeholder="종료 날짜 선택"
|
||||
minYear={2017}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">종료 시간</label>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={(time) => setFormData({ ...formData, endTime: time })}
|
||||
placeholder="종료 시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 단일 날짜 모드
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">날짜 *</label>
|
||||
<DatePicker
|
||||
value={formData.startDate}
|
||||
onChange={(date) => setFormData({ ...formData, startDate: date })}
|
||||
minYear={2017}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">시간</label>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={(time) => setFormData({ ...formData, startTime: time })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
|
@ -560,133 +228,55 @@ function ScheduleForm() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장소 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">장소</label>
|
||||
{/* 검색으로 입력된 경우(좌표가 있는 경우) 초기화 버튼 표시 */}
|
||||
{formData.locationLat && formData.locationLng && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData({
|
||||
...formData,
|
||||
locationName: '',
|
||||
locationAddress: '',
|
||||
locationDetail: '',
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
})
|
||||
}
|
||||
className="text-xs text-gray-400 hover:text-red-500 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<X size={12} />
|
||||
초기화
|
||||
</button>
|
||||
{/* 날짜 미정 토글 (컴백 등) */}
|
||||
{showPrecisionToggle && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">날짜 미정 (월만)</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
"9월 컴백 예정"처럼 날짜는 미정이고 월만 확정일 때
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrecision(!isMonthPrecision)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
isMonthPrecision ? 'bg-primary' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${
|
||||
isMonthPrecision ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜 + 시간 */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{isMonthPrecision ? '월 *' : '날짜 *'}
|
||||
</label>
|
||||
<DatePicker
|
||||
value={formData.date}
|
||||
onChange={(date) => setFormData({ ...formData, date })}
|
||||
minYear={2017}
|
||||
/>
|
||||
{isMonthPrecision && (
|
||||
<p className="text-xs text-gray-400 mt-1">선택한 날짜의 "월"만 사용됩니다 (일자는 무시)</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* 장소 이름 + 검색 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<MapPin
|
||||
size={18}
|
||||
className={`absolute left-4 top-1/2 -translate-y-1/2 ${formData.locationLat ? 'text-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.locationName}
|
||||
onChange={(e) =>
|
||||
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' : ''}`}
|
||||
/>
|
||||
</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>
|
||||
{!isMonthPrecision && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">시간</label>
|
||||
<TimePicker
|
||||
value={formData.time}
|
||||
onChange={(time) => setFormData({ ...formData, time })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주소 */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.locationAddress}
|
||||
onChange={(e) =>
|
||||
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' : ''}`}
|
||||
/>
|
||||
|
||||
{/* 상세주소 */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.locationDetail}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
locationDetail: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="상세주소 (예: 3관 A구역, N열 등)"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<div className="border border-gray-200 rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-primary focus-within:border-transparent">
|
||||
<textarea
|
||||
rows={6}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="일정에 대한 설명을 입력하세요"
|
||||
className="w-full px-4 py-3 border-none focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">관련 URL</label>
|
||||
<div className="relative">
|
||||
<LinkIcon size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 출처 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">출처 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sourceName}
|
||||
onChange={(e) => setFormData({ ...formData, sourceName: e.target.value })}
|
||||
placeholder="예: 스프:스튜디오 프로미스나인, MUSINSA TV"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -699,15 +289,6 @@ function ScheduleForm() {
|
|||
onToggleAll={toggleAllMembers}
|
||||
/>
|
||||
|
||||
{/* 다중 이미지 업로드 카드 */}
|
||||
<ImageUploader
|
||||
previews={imagePreviews}
|
||||
onUpload={handleImagesUploadFromUploader}
|
||||
onDelete={handleImageDelete}
|
||||
onReorder={handleImageReorder}
|
||||
onOpenLightbox={openLightbox}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
|
|
@ -719,7 +300,7 @@ function ScheduleForm() {
|
|||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || saving}
|
||||
disabled={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 ? (
|
||||
|
|
|
|||
|
|
@ -84,21 +84,31 @@ function ScheduleFormPage() {
|
|||
case '행사':
|
||||
return <EventForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
default:
|
||||
// 컴백·팬사인회·기타: 공용 기본 폼으로
|
||||
case '컴백':
|
||||
case '팬사인회':
|
||||
case '기타':
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
|
||||
<p className="text-gray-500 mb-4">
|
||||
이 카테고리는 아직 전용 폼이 없습니다.
|
||||
제목·날짜·멤버 중심의 기본 폼으로 추가합니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/new-legacy?category=${selectedCategory}`)}
|
||||
className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
기존 폼으로 추가하기
|
||||
일정 추가 폼 열기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 그 외(티켓팅 등): 전용 폼 준비 중
|
||||
default:
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
|
||||
<p className="text-gray-500">이 카테고리의 전용 폼은 준비 중입니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue