리팩토링 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:
parent
4da16abc10
commit
c6ac3366cc
6 changed files with 105 additions and 134 deletions
31
frontend/src/components/common/FormField.jsx
Normal file
31
frontend/src/components/common/FormField.jsx
Normal 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)',
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
20
frontend/src/utils/formatting.js
Normal file
20
frontend/src/utils/formatting.js
Normal 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
|
||||
Loading…
Add table
Reference in a new issue