RankFormPanel.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. 'use client';
  2. import type { RankConfigItem } from '@/types/response/donation/rankConfig';
  3. import { Checkbox } from '@/components/ui/checkbox';
  4. import { RANK_PERIODS, RANK_THEMES, NAME_DISPLAY_TYPES, FONT_FAMILIES } from '../constants';
  5. import type { FormState } from '../types';
  6. type Props = {
  7. form: FormState;
  8. editingItem: RankConfigItem|null;
  9. saving: boolean;
  10. onFormChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
  11. onSave: () => void;
  12. onCancel: () => void;
  13. };
  14. /** 색상 입력 (color picker + hex text) */
  15. function ColorInput({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
  16. return (
  17. <div className="rank-config__color-field">
  18. <input
  19. id={id}
  20. type="color"
  21. className="rank-config__color-picker"
  22. value={value}
  23. onChange={e => onChange(e.target.value)}
  24. />
  25. <input
  26. type="text"
  27. className="rank-config__input rank-config__input--color-text"
  28. value={value}
  29. onChange={e => onChange(e.target.value)}
  30. maxLength={7}
  31. />
  32. </div>
  33. );
  34. }
  35. export default function RankFormPanel({
  36. form,
  37. editingItem,
  38. saving,
  39. onFormChange,
  40. onSave,
  41. onCancel
  42. }: Props) {
  43. return (
  44. <main className="rank-config__form-panel">
  45. {/* ── 기본 설정 ────────────────────────────── */}
  46. <section className="rank-config__section">
  47. <h3 className="rank-config__section-title">기본 설정</h3>
  48. <div className="rank-config__section-body">
  49. <div className="rank-config__field">
  50. <label htmlFor="rank-title" className="rank-config__field-label">제목</label>
  51. <input
  52. id="rank-title"
  53. type="text"
  54. className="rank-config__input"
  55. value={form.title}
  56. onChange={e => onFormChange('title', e.target.value)}
  57. placeholder="후원 순위"
  58. />
  59. </div>
  60. <div className="rank-config__field-row">
  61. <div className="rank-config__field">
  62. <label htmlFor="rank-theme" className="rank-config__field-label">테마</label>
  63. <select
  64. id="rank-theme"
  65. className="rank-config__select"
  66. value={form.theme}
  67. onChange={e => onFormChange('theme', parseInt(e.target.value))}
  68. >
  69. {RANK_THEMES.map(t => (
  70. <option key={t.value} value={t.value}>{t.label}</option>
  71. ))}
  72. </select>
  73. </div>
  74. <div className="rank-config__field">
  75. <label htmlFor="rank-period" className="rank-config__field-label">기간</label>
  76. <select
  77. id="rank-period"
  78. className="rank-config__select"
  79. value={form.period}
  80. onChange={e => onFormChange('period', parseInt(e.target.value))}
  81. >
  82. {RANK_PERIODS.map(p => (
  83. <option key={p.value} value={p.value}>{p.label}</option>
  84. ))}
  85. </select>
  86. </div>
  87. </div>
  88. <div className="rank-config__field">
  89. <label htmlFor="rank-maxRankCount" className="rank-config__field-label">최대 순위 (명)</label>
  90. <input
  91. id="rank-maxRankCount"
  92. type="number"
  93. className="rank-config__input"
  94. min={1}
  95. max={10}
  96. value={form.maxRankCount}
  97. onChange={e => onFormChange('maxRankCount', parseInt(e.target.value) || 5)}
  98. />
  99. </div>
  100. </div>
  101. </section>
  102. {/* ── 기간 설정 (사용자 지정일 때만) ────────── */}
  103. {form.period === 5 && (
  104. <section className="rank-config__section">
  105. <h3 className="rank-config__section-title">기간 설정</h3>
  106. <div className="rank-config__section-body">
  107. <div className="rank-config__field-row">
  108. <div className="rank-config__field">
  109. <label htmlFor="rank-startAt" className="rank-config__field-label">시작일시</label>
  110. <input
  111. id="rank-startAt"
  112. type="text"
  113. className="rank-config__input"
  114. value={form.startAt ?? ''}
  115. onChange={e => onFormChange('startAt', e.target.value || null)}
  116. placeholder="2026.03.30 14:00"
  117. maxLength={16}
  118. />
  119. </div>
  120. <div className="rank-config__field">
  121. <label htmlFor="rank-endAt" className="rank-config__field-label">종료일시</label>
  122. <input
  123. id="rank-endAt"
  124. type="text"
  125. className="rank-config__input"
  126. value={form.endAt ?? ''}
  127. onChange={e => onFormChange('endAt', e.target.value || null)}
  128. placeholder="2026.03.31 14:00"
  129. maxLength={16}
  130. />
  131. </div>
  132. </div>
  133. </div>
  134. </section>
  135. )}
  136. {/* ── 표시 설정 ───────────────────────────── */}
  137. <section className="rank-config__section">
  138. <h3 className="rank-config__section-title">표시 설정</h3>
  139. <div className="rank-config__section-body">
  140. <div className="rank-config__field">
  141. <label htmlFor="rank-nameDisplayType" className="rank-config__field-label">이름 유형</label>
  142. <select
  143. id="rank-nameDisplayType"
  144. className="rank-config__select"
  145. value={form.nameDisplayType}
  146. onChange={e => onFormChange('nameDisplayType', parseInt(e.target.value))}
  147. >
  148. {NAME_DISPLAY_TYPES.map(n => (
  149. <option key={n.value} value={n.value}>{n.label}</option>
  150. ))}
  151. </select>
  152. </div>
  153. <div className="rank-config__field">
  154. <label className="rank-config__checkbox-label">
  155. <Checkbox
  156. checked={form.isShowAmount}
  157. onCheckedChange={v => onFormChange('isShowAmount', !!v)}
  158. />
  159. 금액 표시
  160. </label>
  161. </div>
  162. <div className="rank-config__field">
  163. <label className="rank-config__checkbox-label">
  164. <Checkbox
  165. checked={form.isShowDonationCount}
  166. onCheckedChange={v => onFormChange('isShowDonationCount', !!v)}
  167. />
  168. 후원 횟수 표시
  169. </label>
  170. </div>
  171. <div className="rank-config__field">
  172. <label className="rank-config__checkbox-label">
  173. <Checkbox
  174. checked={form.isShowGradeIcon}
  175. onCheckedChange={v => onFormChange('isShowGradeIcon', !!v)}
  176. />
  177. 회원 등급 아이콘 표시
  178. </label>
  179. </div>
  180. <div className="rank-config__field">
  181. <label className="rank-config__checkbox-label">
  182. <Checkbox
  183. checked={form.isShowMemberIcon}
  184. onCheckedChange={v => onFormChange('isShowMemberIcon', !!v)}
  185. />
  186. 사용자 아이콘 표시
  187. </label>
  188. </div>
  189. <div className="rank-config__field">
  190. <label className="rank-config__checkbox-label">
  191. <Checkbox
  192. checked={form.isActive}
  193. onCheckedChange={v => onFormChange('isActive', !!v)}
  194. />
  195. 활성화
  196. </label>
  197. </div>
  198. </div>
  199. </section>
  200. {/* ── 제목 폰트 ──────────────────────────────── */}
  201. <section className="rank-config__section">
  202. <h3 className="rank-config__section-title">제목 폰트</h3>
  203. <div className="rank-config__section-body">
  204. <div className="rank-config__field">
  205. <label htmlFor="rank-titleFontFamily" className="rank-config__field-label">글꼴</label>
  206. <select
  207. id="rank-titleFontFamily"
  208. className="rank-config__select"
  209. value={form.titleFontFamily ?? ''}
  210. onChange={e => onFormChange('titleFontFamily', e.target.value || null)}
  211. >
  212. {FONT_FAMILIES.map(f => (
  213. <option key={f.value} value={f.value}>{f.label}</option>
  214. ))}
  215. </select>
  216. </div>
  217. <div className="rank-config__field-row">
  218. <div className="rank-config__field">
  219. <label htmlFor="rank-titleFontSizePx" className="rank-config__field-label">크기 (px)</label>
  220. <input
  221. id="rank-titleFontSizePx"
  222. type="number"
  223. className="rank-config__input"
  224. min={10}
  225. max={48}
  226. value={form.titleFontSizePx}
  227. onChange={e => onFormChange('titleFontSizePx', parseInt(e.target.value) || 18)}
  228. />
  229. </div>
  230. <div className="rank-config__field">
  231. <label htmlFor="rank-titleFontColor" className="rank-config__field-label">색상</label>
  232. <ColorInput
  233. id="rank-titleFontColor"
  234. value={form.titleFontColor}
  235. onChange={v => onFormChange('titleFontColor', v)}
  236. />
  237. </div>
  238. </div>
  239. </div>
  240. </section>
  241. {/* ── 순위별 폰트 (details 접이식) ────────── */}
  242. <section className="rank-config__section">
  243. <h3 className="rank-config__section-title">순위별 폰트</h3>
  244. <div className="rank-config__section-body">
  245. {/* 1등 */}
  246. <details className="rank-config__details">
  247. <summary className="rank-config__details-summary">1등 폰트 설정</summary>
  248. <div className="rank-config__details-body">
  249. <div className="rank-config__field">
  250. <label htmlFor="rank-rank1FontFamily" className="rank-config__field-label">글꼴</label>
  251. <select
  252. id="rank-rank1FontFamily"
  253. className="rank-config__select"
  254. value={form.rank1FontFamily ?? ''}
  255. onChange={e => onFormChange('rank1FontFamily', e.target.value || null)}
  256. >
  257. {FONT_FAMILIES.map(f => (
  258. <option key={f.value} value={f.value}>{f.label}</option>
  259. ))}
  260. </select>
  261. </div>
  262. <div className="rank-config__field-row">
  263. <div className="rank-config__field">
  264. <label htmlFor="rank-rank1FontSizePx" className="rank-config__field-label">크기 (px)</label>
  265. <input
  266. id="rank-rank1FontSizePx"
  267. type="number"
  268. className="rank-config__input"
  269. min={10}
  270. max={36}
  271. value={form.rank1FontSizePx}
  272. onChange={e => onFormChange('rank1FontSizePx', parseInt(e.target.value) || 15)}
  273. />
  274. </div>
  275. <div className="rank-config__field">
  276. <label htmlFor="rank-rank1FontColor" className="rank-config__field-label">색상</label>
  277. <ColorInput
  278. id="rank-rank1FontColor"
  279. value={form.rank1FontColor}
  280. onChange={v => onFormChange('rank1FontColor', v)}
  281. />
  282. </div>
  283. </div>
  284. </div>
  285. </details>
  286. {/* 2등 */}
  287. <details className="rank-config__details">
  288. <summary className="rank-config__details-summary">2등 폰트 설정</summary>
  289. <div className="rank-config__details-body">
  290. <div className="rank-config__field">
  291. <label htmlFor="rank-rank2FontFamily" className="rank-config__field-label">글꼴</label>
  292. <select
  293. id="rank-rank2FontFamily"
  294. className="rank-config__select"
  295. value={form.rank2FontFamily ?? ''}
  296. onChange={e => onFormChange('rank2FontFamily', e.target.value || null)}
  297. >
  298. {FONT_FAMILIES.map(f => (
  299. <option key={f.value} value={f.value}>{f.label}</option>
  300. ))}
  301. </select>
  302. </div>
  303. <div className="rank-config__field-row">
  304. <div className="rank-config__field">
  305. <label htmlFor="rank-rank2FontSizePx" className="rank-config__field-label">크기 (px)</label>
  306. <input
  307. id="rank-rank2FontSizePx"
  308. type="number"
  309. className="rank-config__input"
  310. min={10}
  311. max={36}
  312. value={form.rank2FontSizePx}
  313. onChange={e => onFormChange('rank2FontSizePx', parseInt(e.target.value) || 15)}
  314. />
  315. </div>
  316. <div className="rank-config__field">
  317. <label htmlFor="rank-rank2FontColor" className="rank-config__field-label">색상</label>
  318. <ColorInput
  319. id="rank-rank2FontColor"
  320. value={form.rank2FontColor}
  321. onChange={v => onFormChange('rank2FontColor', v)}
  322. />
  323. </div>
  324. </div>
  325. </div>
  326. </details>
  327. {/* 3등 */}
  328. <details className="rank-config__details">
  329. <summary className="rank-config__details-summary">3등 폰트 설정</summary>
  330. <div className="rank-config__details-body">
  331. <div className="rank-config__field">
  332. <label htmlFor="rank-rank3FontFamily" className="rank-config__field-label">글꼴</label>
  333. <select
  334. id="rank-rank3FontFamily"
  335. className="rank-config__select"
  336. value={form.rank3FontFamily ?? ''}
  337. onChange={e => onFormChange('rank3FontFamily', e.target.value || null)}
  338. >
  339. {FONT_FAMILIES.map(f => (
  340. <option key={f.value} value={f.value}>{f.label}</option>
  341. ))}
  342. </select>
  343. </div>
  344. <div className="rank-config__field-row">
  345. <div className="rank-config__field">
  346. <label htmlFor="rank-rank3FontSizePx" className="rank-config__field-label">크기 (px)</label>
  347. <input
  348. id="rank-rank3FontSizePx"
  349. type="number"
  350. className="rank-config__input"
  351. min={10}
  352. max={36}
  353. value={form.rank3FontSizePx}
  354. onChange={e => onFormChange('rank3FontSizePx', parseInt(e.target.value) || 15)}
  355. />
  356. </div>
  357. <div className="rank-config__field">
  358. <label htmlFor="rank-rank3FontColor" className="rank-config__field-label">색상</label>
  359. <ColorInput
  360. id="rank-rank3FontColor"
  361. value={form.rank3FontColor}
  362. onChange={v => onFormChange('rank3FontColor', v)}
  363. />
  364. </div>
  365. </div>
  366. </div>
  367. </details>
  368. </div>
  369. </section>
  370. {/* ── 하단 버튼 ────────────────────────────── */}
  371. <div className="rank-config__form-footer flex-1 w-full sm:justify-end gap-2">
  372. <button type="button" className="rank-config__btn flex-1 sm:flex-none" onClick={onCancel}>
  373. 취소
  374. </button>
  375. <button
  376. type="button"
  377. className="rank-config__btn rank-config__btn--primary flex-1 sm:flex-none"
  378. onClick={onSave}
  379. disabled={saving}
  380. >
  381. {saving ? '저장 중...' : (editingItem ? '수정하기' : '등록하기')}
  382. </button>
  383. </div>
  384. </main>
  385. );
  386. }