page.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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. return (
  105. <div className="studio-page crew-config">
  106. <div className="studio-page__header">
  107. <h1 className="studio-page__title">크루 후원 설정</h1>
  108. <Button onClick={openAdd}>+ 크루 추가</Button>
  109. </div>
  110. <div className="studio-page__table-wrap">
  111. {loading ? (
  112. <p className="studio-page__empty">준비 중...</p>
  113. ) : (
  114. <table className="studio-page__table">
  115. <thead>
  116. <tr>
  117. <th>크루명</th>
  118. <th>설명</th>
  119. <th>최소 후원금</th>
  120. <th>멤버 수</th>
  121. <th>활성</th>
  122. <th>작업</th>
  123. </tr>
  124. </thead>
  125. <tbody>
  126. {items.length === 0 ? (
  127. <tr>
  128. <td colSpan={6} className="studio-page__empty">
  129. 등록된 크루가 없습니다.
  130. </td>
  131. </tr>
  132. ) : (
  133. items.map(item => (
  134. <tr key={item.id}>
  135. <td className="crew-config__name">
  136. <button type="button" className="crew-config__link" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>
  137. {item.name}
  138. </button>
  139. </td>
  140. <td className="crew-config__desc">{item.description ?? '-'}</td>
  141. <td>{item.minAmount ? `${item.minAmount.toLocaleString()}원` : '-'}</td>
  142. <td>{item.memberCount}명</td>
  143. <td>
  144. <span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>
  145. {item.isActive ? '활성' : '비활성'}
  146. </span>
  147. </td>
  148. <td>
  149. <div className="studio-page__actions">
  150. <Button variant="outline" size="sm" onClick={() => openEdit(item)}>수정</Button>
  151. <Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>관리</Button>
  152. </div>
  153. </td>
  154. </tr>
  155. ))
  156. )}
  157. </tbody>
  158. </table>
  159. )}
  160. </div>
  161. <Dialog open={modal.open} onOpenChange={open => { if (!open) closeModal(); }}>
  162. <DialogContent>
  163. <DialogHeader>
  164. <DialogTitle>{modal.editing ? '크루 수정' : '크루 추가'}</DialogTitle>
  165. </DialogHeader>
  166. <div className="space-y-4">
  167. <div className="space-y-2">
  168. <RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
  169. <Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
  170. </div>
  171. <div className="space-y-2">
  172. <Label htmlFor="crew-desc">설명</Label>
  173. <Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
  174. </div>
  175. <div className="space-y-2">
  176. <Label htmlFor="crew-min">최소 후원금 (원)</Label>
  177. <MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
  178. <p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
  179. </div>
  180. <div className="flex items-center gap-2">
  181. <Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
  182. <Label htmlFor="crew-active">활성화</Label>
  183. </div>
  184. </div>
  185. <DialogFooter className="flex flex-row justify-center gap-0 sm:justify-center">
  186. <Button variant="outline" className="flex-1 sm:flex-none" onClick={closeModal}>취소</Button>
  187. <Button className="flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
  188. </DialogFooter>
  189. </DialogContent>
  190. </Dialog>
  191. </div>
  192. );
  193. }