import { useState, useRef, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '../../../api/client' import Select from '../../../components/Select' import ConfirmDialog from '../../../components/ConfirmDialog' const TYPE_OPTIONS = [ { value: '아케인', label: '아케인' }, { value: '어센틱', label: '어센틱' }, { value: '그랜드 어센틱', label: '그랜드 어센틱' }, ] const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition' function formatMesoKorean(n) { if (!n || n <= 0) return '' const eok = Math.floor(n / 100_000_000) const man = Math.floor((n % 100_000_000) / 10_000) const parts = [] if (eok) parts.push(`${eok}억`) if (man) parts.push(`${man.toLocaleString()}만`) if (!parts.length) return `${n.toLocaleString()}` return parts.join(' ') } function MesoInput({ value, onChange, ...rest }) { const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString() const korean = formatMesoKorean(Number(String(value).replace(/[^\d]/g, '')) || 0) return (
{ const digits = e.target.value.replace(/[^\d]/g, '') onChange(digits) }} className={`${inputCls} tabular-nums text-right`} {...rest} />
{korean || '\u00A0'}
) } function Field({ label, hint, error, required, children }) { return (
{hint && {hint}}
{children} {error &&
{error}
}
) } export default function SymbolForm() { const navigate = useNavigate() const queryClient = useQueryClient() const { id } = useParams() const isEdit = !!id const fileInputRef = useRef(null) const [type, setType] = useState('아케인') const [region, setRegion] = useState('') const [maxLevel, setMaxLevel] = useState('') const [dailyDefault, setDailyDefault] = useState('') const [weeklyDefault, setWeeklyDefault] = useState('') const [imageFile, setImageFile] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [existingImageUrl, setExistingImageUrl] = useState(null) const [levels, setLevels] = useState([]) const [confirmDelete, setConfirmDelete] = useState(false) const [error, setError] = useState('') // 편집 시 데이터 로드 const { data: symbolData } = useQuery({ queryKey: ['admin', 'symbol', 'symbols', id], queryFn: () => api(`/api/admin/symbol/symbols/${id}`), enabled: isEdit, }) useEffect(() => { if (!symbolData) return setType(symbolData.type) setRegion(symbolData.region) setMaxLevel(String(symbolData.max_level)) setDailyDefault(String(symbolData.daily_default ?? '')) setWeeklyDefault(String(symbolData.weekly_default ?? '')) setExistingImageUrl(symbolData.image_url) const rows = Array.from({ length: symbolData.max_level - 1 }, (_, i) => { const level = i + 1 const existing = symbolData.levels.find((l) => l.level === level) return { level, required_count: existing?.required_count ?? '', meso_cost: existing?.meso_cost ?? '', } }) setLevels(rows) }, [symbolData]) const handleFile = (e) => { const file = e.target.files?.[0] if (!file) return setImageFile(file) setImagePreview(URL.createObjectURL(file)) } const updateLevel = (idx, field, val) => { setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: val } : l))) } const adjustLevelRows = (newMax) => { const n = Number(newMax) if (!n || n < 2) return setLevels((prev) => { const rows = Array.from({ length: n - 1 }, (_, i) => { const level = i + 1 return prev.find((l) => l.level === level) || { level, required_count: '', meso_cost: '' } }) return rows }) } const saveMutation = useMutation({ mutationFn: async () => { const formData = new FormData() formData.append('type', type) formData.append('region', region.trim()) formData.append('max_level', String(maxLevel)) formData.append('daily_default', String(Number(dailyDefault) || 0)) formData.append('weekly_default', String(Number(weeklyDefault) || 0)) formData.append('levels', JSON.stringify( levels .filter((l) => l.required_count !== '' || l.meso_cost !== '') .map((l) => ({ level: l.level, required_count: Number(l.required_count) || 0, meso_cost: Number(l.meso_cost) || 0, })) )) if (imageFile) formData.append('image', imageFile) const adminKey = localStorage.getItem('maple-admin-key') const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols' const res = await fetch(url, { method: isEdit ? 'PATCH' : 'POST', headers: { 'x-admin-key': adminKey }, body: formData, }) const json = await res.json() if (!res.ok) throw new Error(json.error || '저장 실패') return json }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] }) queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] }) navigate('..') }, onError: (err) => setError(err.message), }) const deleteMutation = useMutation({ mutationFn: () => api(`/api/admin/symbol/symbols/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] }) queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] }) navigate('..') }, onError: (err) => alert(err.message), }) const handleSubmit = () => { setError('') if (!type) return setError('심볼 종류를 선택해주세요') if (!region.trim()) return setError('지역 이름을 입력해주세요') if (!maxLevel || Number(maxLevel) < 2) return setError('만렙을 입력해주세요') if (!isEdit && !imageFile) return setError('심볼 이미지를 업로드해주세요') saveMutation.mutate() } const displayImage = imagePreview || existingImageUrl return (

{isEdit ? '심볼 편집' : '심볼 추가'}

심볼 정보와 레벨별 필요 개수/메소를 입력합니다

{/* 기본 정보 */}
기본 정보
setRegion(e.target.value)} className={inputCls} placeholder="소멸의 여로" />
{ setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }} className={inputCls} min="2" /> setDailyDefault(e.target.value)} className={inputCls} /> setWeeklyDefault(e.target.value)} className={inputCls} />
{/* 레벨별 설정 */}
레벨별 필요 개수 · 메소
레벨 N → N+1 업그레이드 기준 (만렙-1행)
{levels.map((l, idx) => ( ))}
레벨 필요 심볼 수 메소
Lv.{l.level} {l.level + 1} updateLevel(idx, 'required_count', e.target.value)} className={`${inputCls} max-w-36`} placeholder="0" />
updateLevel(idx, 'meso_cost', v)} placeholder="0" />
{/* 하단 버튼 */}
{isEdit && ( )}
{error && (
{error}
)} setConfirmDelete(false)} onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }} title="심볼 삭제" description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'} confirmText="삭제" destructive />
) }