page.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect, useCallback } from 'react';
  4. import { useRouter } from 'next/navigation';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import { useStudioContext } from '@/app/studio/context';
  7. import type { CrewListResponse, CrewItem } from '@/types/response/crew/list';
  8. import { Button } from '@/components/ui/button';
  9. import { Input } from '@/components/ui/input';
  10. import { Label } from '@/components/ui/label';
  11. import { Checkbox } from '@/components/ui/checkbox';
  12. import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
  13. function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
  14. return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
  15. }
  16. function MoneyInput({ id, value, onChange, placeholder }: { id: string; value: string|number; onChange: (v: string) => void; placeholder?: string }) {
  17. const [focused, setFocused] = useState(false);
  18. const raw = String(value);
  19. const display = focused || !raw ? raw : (Number(raw) ? Number(raw).toLocaleString() : raw);
  20. return (
  21. <Input
  22. id={id} type={focused ? 'number' : 'text'} min={0} placeholder={placeholder}
  23. value={display}
  24. onKeyDown={e => { if (!/^[0-9]$/.test(e.key) && !['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End'].includes(e.key)) e.preventDefault(); }}
  25. onCompositionStart={e => e.preventDefault()}
  26. onChange={e => onChange(e.target.value.replace(/[^0-9]/g, ''))}
  27. onFocus={() => setFocused(true)}
  28. onBlur={() => setFocused(false)}
  29. />
  30. );
  31. }
  32. const EMPTY_FORM = {
  33. name: '',
  34. description: '',
  35. minAmount: '' as string|number,
  36. isActive: true
  37. };
  38. export default function CrewConfigPage()
  39. {
  40. const router = useRouter();
  41. const { channelID } = useStudioContext();
  42. const [items, setItems] = useState<CrewItem[]>([]);
  43. const [loading, setLoading] = useState(true);
  44. const [error, setError] = useState('');
  45. const [modal, setModal] = useState<{ open: boolean; editing: CrewItem|null }>({ open: false, editing: null });
  46. const [form, setForm] = useState(EMPTY_FORM);
  47. const [saving, setSaving] = useState(false);
  48. useEffect(() => {
  49. if (error) { alert(error); setError(''); }
  50. }, [error]);
  51. const fetchList = useCallback(() => {
  52. if (!channelID) {
  53. setLoading(false);
  54. return;
  55. }
  56. setLoading(true);
  57. fetchApi<CrewListResponse>(`/api/studio/crew/list/${channelID}`).then(res => {
  58. setItems(res.data?.list ?? []);
  59. })
  60. .catch(err => setError(err.message))
  61. .finally(() => setLoading(false));
  62. }, [channelID]);
  63. useEffect(() => { fetchList(); }, [fetchList]);
  64. const openAdd = () => {
  65. setForm(EMPTY_FORM);
  66. setModal({ open: true, editing: null });
  67. };
  68. const openEdit = (item: CrewItem) => {
  69. setForm({
  70. name: item.name,
  71. description: item.description ?? '',
  72. minAmount: item.minAmount ?? '',
  73. isActive: item.isActive
  74. });
  75. setModal({ open: true, editing: item });
  76. };
  77. const closeModal = () => setModal({ open: false, editing: null });
  78. const handleSave = async () => {
  79. if (!form.name.trim()) {
  80. alert('크루명을 입력해 주세요.');
  81. return;
  82. }
  83. setSaving(true);
  84. try {
  85. await fetchApi('/api/studio/crew/save', {
  86. method: 'POST',
  87. body: {
  88. channelID,
  89. id: modal.editing?.id ?? undefined,
  90. name: form.name,
  91. description: form.description || undefined,
  92. minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
  93. isActive: form.isActive
  94. }
  95. });
  96. closeModal();
  97. fetchList();
  98. } catch (err: unknown) {
  99. setError(err instanceof Error ? err.message : '저장에 실패했습니다.');
  100. } finally {
  101. setSaving(false);
  102. }
  103. };
  104. if (!channelID) {
  105. return (
  106. <div className="studio-page">
  107. <p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
  108. </div>
  109. );
  110. }
  111. return (
  112. <div className="studio-page crew-config">
  113. <div className="studio-page__header">
  114. <h1 className="studio-page__title">크루 후원 설정</h1>
  115. <Button onClick={openAdd}>+ 크루 추가</Button>
  116. </div>
  117. <div className="studio-page__table-wrap">
  118. {loading ? (
  119. <p className="studio-page__empty">준비 중...</p>
  120. ) : (
  121. <table className="studio-page__table">
  122. <thead>
  123. <tr>
  124. <th>크루명</th>
  125. <th>설명</th>
  126. <th>최소 후원금</th>
  127. <th>멤버 수</th>
  128. <th>활성</th>
  129. <th>작업</th>
  130. </tr>
  131. </thead>
  132. <tbody>
  133. {items.length === 0 ? (
  134. <tr>
  135. <td colSpan={6} className="studio-page__empty">
  136. 등록된 크루가 없습니다.
  137. </td>
  138. </tr>
  139. ) : (
  140. items.map(item => (
  141. <tr key={item.id}>
  142. <td className="crew-config__name">
  143. <button type="button" className="crew-config__link" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>
  144. {item.name}
  145. </button>
  146. </td>
  147. <td className="crew-config__desc">{item.description ?? '-'}</td>
  148. <td>{item.minAmount ? `${item.minAmount.toLocaleString()}원` : '-'}</td>
  149. <td>{item.memberCount}명</td>
  150. <td>
  151. <span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>
  152. {item.isActive ? '활성' : '비활성'}
  153. </span>
  154. </td>
  155. <td>
  156. <div className="studio-page__actions">
  157. <Button variant="outline" size="sm" onClick={() => openEdit(item)}>수정</Button>
  158. <Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>관리</Button>
  159. </div>
  160. </td>
  161. </tr>
  162. ))
  163. )}
  164. </tbody>
  165. </table>
  166. )}
  167. </div>
  168. <Dialog open={modal.open} onOpenChange={open => { if (!open) closeModal(); }}>
  169. <DialogContent>
  170. <DialogHeader>
  171. <DialogTitle>{modal.editing ? '크루 수정' : '크루 추가'}</DialogTitle>
  172. </DialogHeader>
  173. <div className="space-y-4">
  174. <div className="space-y-2">
  175. <RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
  176. <Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
  177. </div>
  178. <div className="space-y-2">
  179. <Label htmlFor="crew-desc">설명</Label>
  180. <Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
  181. </div>
  182. <div className="space-y-2">
  183. <Label htmlFor="crew-min">최소 후원금 (원)</Label>
  184. <MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
  185. <p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
  186. </div>
  187. <div className="flex items-center gap-2">
  188. <Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
  189. <Label htmlFor="crew-active">활성화</Label>
  190. </div>
  191. </div>
  192. <DialogFooter className="flex flex-row justify-center gap-0 sm:justify-center">
  193. <Button variant="outline" className="flex-1 sm:flex-none" onClick={closeModal}>취소</Button>
  194. <Button className="flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
  195. </DialogFooter>
  196. </DialogContent>
  197. </Dialog>
  198. </div>
  199. );
  200. }