feat(admin-schedule): 공용 일정 폼 정비 (컴백·팬사인회·기타)

ScheduleForm을 지원 필드(제목·날짜·시간·멤버)만으로 재작성 +
컴백에 "날짜 미정(월만)" 토글 추가, JSON으로 /admin/schedules 저장.
카테고리 선택은 컴백·팬사인회·기타만 노출(전용 폼 카테고리·생일·기념일
제외). DB에 저장 못 하던 범위·장소·설명·링크·이미지 필드 제거.
form 라우팅: 컴백/팬사인회/기타→공용폼, 티켓팅 등→준비 중 안내.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-16 22:03:50 +09:00
parent a11a027682
commit 72c65021fb
2 changed files with 151 additions and 560 deletions

View file

@ -1,364 +1,151 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate, Link, useParams } from 'react-router-dom'; import { useNavigate, Link, useParams, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { formatDate } from '@/utils/date'; import { Home, ChevronRight, Save, Settings } from 'lucide-react';
import { import { AdminLayout, DatePicker, TimePicker } from '@/components/pc/admin';
Home, import { Toast } from '@/components/common';
ChevronRight, import { MemberSelector } from '@/components/pc/admin/schedule';
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 { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common'; import { useToast } from '@/hooks/common';
import * as categoriesApi from '@/api/admin/categories'; 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 { getMembers } from '@/api/public/members';
import { getColorStyle } from '@/utils/color'; import { getColorStyle } from '@/utils/color';
import useAuthStore from '@/stores/useAuthStore'; import useAuthStore from '@/stores/useAuthStore';
//
const SHARED_CATEGORIES = ['컴백', '팬사인회', '기타'];
// " ()" ( )
const DATE_PRECISION_CATEGORIES = ['컴백'];
/**
* 일반 일정 추가/수정 (컴백·팬사인회·기타)
* 제목·날짜·시간·멤버 + 컴백의 "날짜 미정(월만)" 토글
*/
function ScheduleForm() { function ScheduleForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const [searchParams] = useSearchParams();
const isEditMode = !!id; const isEditMode = !!id;
const { user, isAuthenticated } = useAdminAuth(); const { user } = useAdminAuth();
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const [loading, setLoading] = useState(false);
// (/ )
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
startDate: '', date: '',
endDate: '', time: '',
startTime: '',
endTime: '',
isRange: false, //
category: '', category: '',
description: '',
url: '',
sourceName: '',
members: [], members: [],
images: [], datePrecision: 'day',
//
locationName: '', //
locationAddress: '', //
locationDetail: '', // (: 3, N )
locationLat: null, //
locationLng: null, //
}); });
//
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 [saving, setSaving] = useState(false);
// // /
const [locationDialogOpen, setLocationDialogOpen] = useState(false); const { data: allCategories = [] } = useQuery({
queryKey: ['scheduleCategories'],
// ID queryFn: categoriesApi.getCategories,
const [existingImageIds, setExistingImageIds] = useState([]); staleTime: 10 * 60 * 1000,
});
// const { data: members = [] } = useQuery({
useEffect(() => { queryKey: ['members'],
if (categories.length > 0 && !formData.category && !isEditMode) { queryFn: getMembers,
setFormData((prev) => ({ ...prev, category: categories[0].id })); staleTime: 10 * 60 * 1000,
}
}, [categories, 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,
}); });
// const categories = useMemo(
if (data.images && data.images.length > 0) { () => allCategories.filter((c) => SHARED_CATEGORIES.includes(c.name)),
// formData.images (id ) [allCategories]
setFormData((prev) => ({ );
...prev,
title: data.title || '', const selectedCategoryName = allCategories.find((c) => c.id === formData.category)?.name;
isRange: data.is_range || false, const showPrecisionToggle = DATE_PRECISION_CATEGORIES.includes(selectedCategoryName);
startDate: data.date?.split('T')[0] || '', const isMonthPrecision = formData.datePrecision === 'month';
endDate: data.end_date?.split('T')[0] || '',
startTime: data.time?.slice(0, 5) || '', // : URL ?category ,
endTime: data.end_time?.slice(0, 5) || '', useEffect(() => {
category: data.category_id || 1, if (isEditMode || categories.length === 0 || formData.category) return;
description: data.description || '', const fromUrl = parseInt(searchParams.get('category'), 10);
url: data.source?.url || '', const preselect = categories.find((c) => c.id === fromUrl);
sourceName: data.source?.name || '', setFormData((p) => ({ ...p, category: (preselect || categories[0]).id }));
members: data.members?.map((m) => m.id) || [], }, [categories, isEditMode, formData.category, searchParams]);
images: data.images.map((img) => ({ id: img.id, url: img.image_url })),
locationName: data.location_name || '', // :
locationAddress: data.location_address || '', const { data: existing } = useQuery({
locationDetail: data.location_detail || '', queryKey: ['schedule', id],
locationLat: data.location_lat || null, queryFn: () => getSchedule(id),
locationLng: data.location_lng || null, enabled: isEditMode,
});
useEffect(() => {
if (!existing) return;
setFormData({
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]);
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),
})); }));
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 setPrecision = (month) =>
const toggleMember = (memberId) => { setFormData((p) => ({ ...p, datePrecision: month ? 'month' : 'day', time: month ? '' : p.time }));
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) => {
setFormData({
...formData,
locationName: place.name,
locationAddress: place.address,
locationLat: place.lat,
locationLng: place.lng,
});
};
// (ImageUploader )
const handleImagesUploadFromUploader = (files) => {
const newImageObjects = files.map((file) => ({ file }));
const newImages = [...formData.images, ...newImageObjects];
setFormData({ ...formData, images: newImages });
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setImagePreviews((prev) => [...prev, reader.result]);
};
reader.readAsDataURL(file);
});
};
// (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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
//
if (!formData.title.trim()) { if (!formData.title.trim()) {
setToast({ type: 'error', message: '제목을 입력해주세요.' }); setToast({ type: 'error', message: '제목을 입력해주세요.' });
return; return;
} }
// : / startDate if (!formData.date) {
if (!formData.startDate) {
setToast({ type: 'error', message: '날짜를 선택해주세요.' }); setToast({ type: 'error', message: '날짜를 선택해주세요.' });
return; return;
} }
if (!formData.category) { if (!formData.category) {
setToast({ type: 'error', message: '카테고리를 선택해주세요.' }); setToast({ type: 'error', message: '카테고리를 선택해주세요.' });
return; return;
} }
setSaving(true); setSaving(true);
try { try {
const token = useAuthStore.getState().token; const token = useAuthStore.getState().token;
const body = {
// FormData
const submitData = new FormData();
// JSON - startDate date (UI / startDate )
const jsonData = {
title: formData.title.trim(), title: formData.title.trim(),
date: formData.startDate, date: formData.date,
time: formData.startTime || null, time: isMonthPrecision ? null : formData.time || null,
endDate: formData.isRange ? formData.endDate : null,
endTime: formData.isRange ? formData.endTime : null,
isRange: formData.isRange,
category: formData.category, category: formData.category,
description: formData.description.trim() || null, datePrecision: showPrecisionToggle ? formData.datePrecision : 'day',
url: formData.url.trim() || null,
sourceName: formData.sourceName.trim() || null,
members: formData.members, 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 url = isEditMode ? `/api/admin/schedules/${id}` : '/api/admin/schedules';
const method = isEditMode ? 'PUT' : 'POST'; const res = await fetch(url, {
method: isEditMode ? 'PUT' : 'POST',
const response = await fetch(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
method, body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${token}`,
},
body: submitData,
}); });
if (!res.ok) {
if (!response.ok) { const err = await res.json().catch(() => ({}));
const error = await response.json(); throw new Error(err.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.'));
throw new Error(
error.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.')
);
} }
// sessionStorage
sessionStorage.setItem( sessionStorage.setItem(
'scheduleToast', 'scheduleToast',
JSON.stringify({ JSON.stringify({ type: 'success', message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.' })
type: 'success',
message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.',
})
); );
navigate('/admin/schedule'); navigate('/admin/schedule');
} catch (error) { } catch (error) {
console.error('일정 저장 오류:', error); setToast({ type: 'error', message: error.message });
setToast({
type: 'error',
message: error.message || '일정 저장 중 오류가 발생했습니다.',
});
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -368,38 +155,6 @@ function ScheduleForm() {
<AdminLayout user={user}> <AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} /> <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="max-w-4xl mx-auto px-6 py-8">
{/* 브레드크럼 */} {/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-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> <span className="text-gray-700">{isEditMode ? '일정 수정' : '일정 추가'}</span>
</div> </div>
{/* 타이틀 */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> <h1 className="text-3xl font-bold text-gray-900 mb-2">
{isEditMode ? '일정 수정' : '일정 추가'} {isEditMode ? '일정 수정' : '일정 추가'}
</h1> </h1>
<p className="text-gray-500">새로운 일정을 등록합니다</p> <p className="text-gray-500">제목·날짜·멤버 중심의 일반 일정을 등록합니다</p>
</div> </div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
{/* 기본 정보 카드 */}
<div className="bg-white rounded-2xl shadow-sm p-8"> <div className="bg-white rounded-2xl shadow-sm p-8">
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2> <h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
@ -436,96 +188,12 @@ function ScheduleForm() {
type="text" type="text"
value={formData.title} value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} 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" 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 required
/> />
</div> </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>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@ -560,133 +228,55 @@ function ScheduleForm() {
</div> </div>
</div> </div>
{/* 장소 */} {/* 날짜 미정 토글 (컴백 등) */}
{showPrecisionToggle && (
<div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center justify-between mb-2"> <p className="text-sm font-medium text-gray-700">날짜 미정 (월만)</p>
<label className="block text-sm font-medium text-gray-700">장소</label> <p className="text-xs text-gray-400 mt-0.5">
{/* 검색으로 입력된 경우(좌표가 있는 경우) 초기화 버튼 표시 */} "9월 컴백 예정"처럼 날짜는 미정이고 월만 확정일
{formData.locationLat && formData.locationLng && ( </p>
</div>
<button <button
type="button" type="button"
onClick={() => onClick={() => setPrecision(!isMonthPrecision)}
setFormData({ className={`relative w-12 h-6 rounded-full transition-colors ${
...formData, isMonthPrecision ? 'bg-primary' : 'bg-gray-300'
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} /> <span
초기화 className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${
isMonthPrecision ? 'left-6' : 'left-0.5'
}`}
/>
</button> </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>
<div className="space-y-3"> {!isMonthPrecision && (
{/* 장소 이름 + 검색 버튼 */}
<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>
</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> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">설명</label> <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"> <TimePicker
<textarea value={formData.time}
rows={6} onChange={(time) => setFormData({ ...formData, time })}
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>
</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> </div>
</div> </div>
@ -699,15 +289,6 @@ function ScheduleForm() {
onToggleAll={toggleAllMembers} onToggleAll={toggleAllMembers}
/> />
{/* 다중 이미지 업로드 카드 */}
<ImageUploader
previews={imagePreviews}
onUpload={handleImagesUploadFromUploader}
onDelete={handleImageDelete}
onReorder={handleImageReorder}
onOpenLightbox={openLightbox}
/>
{/* 버튼 */} {/* 버튼 */}
<div className="flex items-center justify-end gap-4"> <div className="flex items-center justify-end gap-4">
<button <button
@ -719,7 +300,7 @@ function ScheduleForm() {
</button> </button>
<button <button
type="submit" 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" 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 ? ( {saving ? (

View file

@ -84,21 +84,31 @@ function ScheduleFormPage() {
case '행사': case '행사':
return <EventForm />; return <EventForm />;
// // ··:
default: case '컴백':
case '팬사인회':
case '기타':
return ( return (
<div className="bg-white rounded-2xl shadow-sm p-8 text-center"> <div className="bg-white rounded-2xl shadow-sm p-8 text-center">
<p className="text-gray-500 mb-4"> <p className="text-gray-500 mb-4">
카테고리는 아직 전용 폼이 없습니다. 제목·날짜·멤버 중심의 기본 폼으로 추가합니다.
</p> </p>
<button <button
onClick={() => navigate(`/admin/schedule/new-legacy?category=${selectedCategory}`)} 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" className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
> >
기존 폼으로 추가하 일정 추가
</button> </button>
</div> </div>
); );
// ( ):
default:
return (
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
<p className="text-gray-500"> 카테고리의 전용 폼은 준비 중입니다.</p>
</div>
);
} }
}; };