리팩토링 1단계: 공용 utils/FormField 추출

- utils/formatting.js 신설 (formatMeso, formatMesoKorean 통합)
- components/common/FormField.jsx 신설 (label+hint+error 공용 래퍼
  + formInputClass/formInputStyle 상수)
- 중복 정의 제거:
  * BossForm, SymbolForm, AdminMenuForm의 Field 로컬 정의 삭제
  * boss-crystal constants.js의 formatMeso → utils re-export
  * SymbolForm의 formatMesoKorean 로컬 정의 삭제
  * 3개 폼의 inputCls/inputStyle 상수 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-19 11:39:23 +09:00
parent 4da16abc10
commit c6ac3366cc
6 changed files with 105 additions and 134 deletions

View file

@ -0,0 +1,31 @@
/**
* 관리자 공용 필드 래퍼
* <FormField label="제목" required hint="설명" error={errors.title}>
* <input ... />
* </FormField>
*/
export default function FormField({ label, hint, error, required, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
/**
* 관리자 공용 input 스타일
*/
export const formInputClass = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
export const formInputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}

View file

@ -4,28 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import ImagePicker from './components/ImagePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
function Field({ label, hint, error, required, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
import FormField, { formInputClass, formInputStyle } from '../../../components/common/FormField'
export default function AdminMenuForm() {
const navigate = useNavigate()
@ -175,29 +154,29 @@ export default function AdminMenuForm() {
</div>
</div>
<Field label="제목" required error={errors.title}>
<FormField label="제목" required error={errors.title}>
<input
type="text"
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="예: 주간 보스 수익 계산기"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
<Field label="설명" hint="카드에 표시되는 부가 설명">
<FormField label="설명" hint="카드에 표시되는 부가 설명">
<input
type="text"
value={form.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
<Field label="경로" required error={errors.slug}>
<FormField label="경로" required error={errors.slug}>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
@ -234,9 +213,9 @@ export default function AdminMenuForm() {
</code>
</div>
)}
</Field>
</FormField>
<Field label="아이콘 이미지" hint="선택사항">
<FormField label="아이콘 이미지" hint="선택사항">
<div className="flex items-center gap-3">
<button
type="button"
@ -271,7 +250,7 @@ export default function AdminMenuForm() {
)}
</div>
</div>
</Field>
</FormField>
<div className="flex items-center gap-2 pt-2">
{isEdit && (

View file

@ -5,33 +5,12 @@ import { api } from '../../../../api/client'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Checkbox from '../../../../components/common/Checkbox'
import Select from '../../../../components/common/Select'
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
import { useAuthStore } from '../../../../stores/auth'
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
function Field({ label, hint, error, required, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
function emptyDifficultyState() {
const obj = {}
DIFFICULTIES.forEach((d) => {
@ -197,28 +176,28 @@ export default function BossForm() {
>
{/* 이름 + 최대 인원 */}
<div className="grid grid-cols-[1fr_auto] gap-3">
<Field label="보스 이름" required error={errors.name}>
<FormField label="보스 이름" required error={errors.name}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 검은 마법사"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
<Field label="최대 인원">
</FormField>
<FormField label="최대 인원">
<Select
value={maxPartySize}
onChange={setMaxPartySize}
options={PARTY_OPTIONS}
className="w-24"
/>
</Field>
</FormField>
</div>
{/* 이미지 */}
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
<FormField label="보스 이미지" required={!isEdit} error={errors.image}>
<label
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
@ -256,10 +235,10 @@ export default function BossForm() {
className="hidden"
/>
</label>
</Field>
</FormField>
{/* 난이도 */}
<Field label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
<FormField label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
<div className="space-y-2">
{DIFFICULTIES.map((d) => {
const v = difficulties[d.key]
@ -326,7 +305,7 @@ export default function BossForm() {
)
})}
</div>
</Field>
</FormField>
<div className="flex items-center gap-2 pt-2">
{isEdit && (

View file

@ -32,15 +32,8 @@ export function getDifficultyBadgeStyle(key) {
}
}
export function formatMeso(n) {
if (!n || n < 10000) return (n || 0).toLocaleString()
if (n >= 100_000_000) {
const uk = Math.floor(n / 100_000_000)
const man = Math.floor((n % 100_000_000) / 10_000)
return man > 0 ? `${uk}${man.toLocaleString()}` : `${uk}`
}
return `${Math.floor(n / 10_000).toLocaleString()}`
}
// formatMeso는 utils/formatting 에서 재-export (모든 기능 공통)
export { formatMeso } from '../../../../utils/formatting'
// difficulty 이미지 URL (S3)
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'

View file

@ -4,7 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../../api/client'
import Select from '../../../../components/common/Select'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
import { useAuthStore } from '../../../../stores/auth'
import { formatMeso } from '../../../../utils/formatting'
const TYPE_OPTIONS = [
{ value: '아케인', label: '아케인' },
@ -12,27 +14,9 @@ const TYPE_OPTIONS = [
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
]
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
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)
const korean = formatMeso(Number(String(value).replace(/[^\d]/g, '')) || 0)
return (
<div>
<input
@ -43,35 +27,20 @@ function MesoInput({ value, onChange, ...rest }) {
const digits = e.target.value.replace(/[^\d]/g, '')
onChange(digits)
}}
className={`${inputCls} tabular-nums text-right`}
style={inputStyle}
className={`${formInputClass} tabular-nums text-right`}
style={formInputStyle}
{...rest}
/>
<div
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
style={{ color: 'var(--warning-text-bright)' }}
>
{korean || '\u00A0'}
{korean === '0' ? '\u00A0' : korean}
</div>
</div>
)
}
function Field({ label, hint, error, required, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
export default function SymbolForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@ -216,7 +185,7 @@ export default function SymbolForm() {
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
<Field label="심볼 이미지" required={!isEdit}>
<FormField label="심볼 이미지" required={!isEdit}>
<label
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
@ -248,54 +217,54 @@ export default function SymbolForm() {
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
</label>
</Field>
</FormField>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="심볼 종류" required>
<FormField label="심볼 종류" required>
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
</Field>
<Field label="지역 이름" required hint="예: 소멸의 여로">
</FormField>
<FormField label="지역 이름" required hint="예: 소멸의 여로">
<input
type="text"
value={region}
onChange={(e) => setRegion(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
placeholder="소멸의 여로"
/>
</Field>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="만렙" required>
<FormField label="만렙" required>
<input
type="number"
value={maxLevel}
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
min="2"
/>
</Field>
<Field label="기본 일퀘 획득량">
</FormField>
<FormField label="기본 일퀘 획득량">
<input
type="number"
value={dailyDefault}
onChange={(e) => setDailyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
<Field label="기본 주간퀘 획득량">
</FormField>
<FormField label="기본 주간퀘 획득량">
<input
type="number"
value={weeklyDefault}
onChange={(e) => setWeeklyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
</div>
</div>
</div>
@ -329,8 +298,8 @@ export default function SymbolForm() {
type="number"
value={l.required_count}
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
className={`${inputCls} max-w-36`}
style={inputStyle}
className={`${formInputClass} max-w-36`}
style={formInputStyle}
placeholder="0"
/>
</td>

View file

@ -0,0 +1,20 @@
/**
* 메소를 "N억 N,NNN만" 형식의 한국어 문자열로 반환
* formatMeso(123456789) "1억 2,345만"
* formatMeso(10000) "1만"
* formatMeso(500) "500"
* formatMeso(0) "0"
*/
export function formatMeso(n) {
const v = Number(n) || 0
if (v <= 0) return '0'
const eok = Math.floor(v / 100_000_000)
const man = Math.floor((v % 100_000_000) / 10_000)
const parts = []
if (eok) parts.push(`${eok.toLocaleString()}`)
if (man) parts.push(`${man.toLocaleString()}`)
return parts.length ? parts.join(' ') : v.toLocaleString()
}
// 과거 이름 alias (symbol에서 formatMesoKorean으로 쓰던 것)
export const formatMesoKorean = formatMeso