page.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { LoginLogType } from '@/constants/common';
  4. import { fetchApi, getDateTime } from '@/lib/utils/client';
  5. import type { WalletRevenueResponse, RevenueChartItem } from '@/types/response/wallet/revenue';
  6. import { PERIOD_TABS, REVENUE_TYPE_MAP } from '../constants';
  7. import Loading from '@/app/component/Loading';
  8. import Pagination from '@/app/component/Pagination';
  9. import {
  10. ResponsiveContainer,
  11. AreaChart,
  12. Area,
  13. XAxis,
  14. YAxis,
  15. CartesianGrid,
  16. Tooltip,
  17. } from 'recharts';
  18. function RevenueChart({ chartData }: { chartData: RevenueChartItem[] }) {
  19. if (chartData.length === 0) {
  20. return <div className="wallet__chart-empty">데이터가 없습니다.</div>;
  21. }
  22. return (
  23. <ResponsiveContainer width="100%" height={280}>
  24. <AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
  25. <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
  26. <XAxis
  27. dataKey="date"
  28. tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
  29. axisLine={{ stroke: 'hsl(var(--border))' }}
  30. tickLine={false}
  31. />
  32. <YAxis
  33. tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
  34. axisLine={false}
  35. tickLine={false}
  36. tickFormatter={(v: number) => v >= 10000 ? `${(v / 10000).toFixed(0)}만` : v.toLocaleString()}
  37. width={50}
  38. />
  39. <Tooltip
  40. formatter={(value, name) => [
  41. `${Number(value).toLocaleString()}원`,
  42. name === 'netAmount' ? '순수익' : '수수료',
  43. ]}
  44. labelFormatter={(label) => String(label)}
  45. contentStyle={{
  46. background: 'hsl(var(--background))',
  47. border: '1px solid hsl(var(--border))',
  48. borderRadius: '6px',
  49. fontSize: '0.8125rem',
  50. }}
  51. />
  52. <Area
  53. type="monotone"
  54. dataKey="netAmount"
  55. name="netAmount"
  56. stroke="hsl(var(--primary))"
  57. fill="hsl(var(--primary))"
  58. fillOpacity={0.15}
  59. strokeWidth={2}
  60. />
  61. <Area
  62. type="monotone"
  63. dataKey="platformFee"
  64. name="platformFee"
  65. stroke="hsl(var(--muted-foreground))"
  66. fill="hsl(var(--muted-foreground))"
  67. fillOpacity={0.08}
  68. strokeWidth={1.5}
  69. />
  70. </AreaChart>
  71. </ResponsiveContainer>
  72. );
  73. }
  74. export default function WalletRevenuePage() {
  75. const [loading, setLoading] = useState(true);
  76. const [page, setPage] = useState(1);
  77. const [period, setPeriod] = useState<LoginLogType>(LoginLogType.Month);
  78. const [data, setData] = useState<WalletRevenueResponse>({ total: 0, summary: { grossAmount: 0, platformFee: 0, netAmount: 0 }, list: [] });
  79. const [chartData, setChartData] = useState<RevenueChartItem[]>([]);
  80. useEffect(() => {
  81. setLoading(true);
  82. fetchApi<WalletRevenueResponse & { chartData?: RevenueChartItem[] }>(`/api/studio/wallet/revenue?period=${period}&page=${page}&perPage=20`)
  83. .then(res => {
  84. if (res.data) {
  85. setData(res.data);
  86. setChartData(res.data.chartData ?? []);
  87. }
  88. })
  89. .catch(() => {})
  90. .finally(() => setLoading(false));
  91. }, [period, page]);
  92. useEffect(() => {
  93. setPage(1);
  94. }, [period]);
  95. return (
  96. <div className="studio-page wallet">
  97. <div className="studio-page__header">
  98. <h1 className="studio-page__title">수익 내역</h1>
  99. </div>
  100. {loading && <Loading />}
  101. {/* Summary Cards */}
  102. <div className="wallet__cards">
  103. <div className="wallet__card">
  104. <span className="wallet__card-label">총 후원 금액</span>
  105. <div className="wallet__card-value">
  106. {data.summary.grossAmount.toLocaleString()}원
  107. </div>
  108. </div>
  109. <div className="wallet__card">
  110. <span className="wallet__card-label">플랫폼 수수료</span>
  111. <div className="wallet__card-value wallet__card-value--danger">
  112. -{data.summary.platformFee.toLocaleString()}원
  113. </div>
  114. </div>
  115. <div className="wallet__card">
  116. <span className="wallet__card-label">순수익</span>
  117. <div className="wallet__card-value wallet__card-value--money">
  118. {data.summary.netAmount.toLocaleString()}원
  119. </div>
  120. </div>
  121. </div>
  122. {/* Period Filter */}
  123. <div className="wallet__header">
  124. <div />
  125. <div className="wallet__tabs">
  126. {PERIOD_TABS.map((tab) => (
  127. <button
  128. type="button"
  129. key={tab.value}
  130. className={period === tab.value ? 'active' : ''}
  131. onClick={() => setPeriod(tab.value)}
  132. >
  133. {tab.label}
  134. </button>
  135. ))}
  136. </div>
  137. </div>
  138. {/* Chart */}
  139. <div className="wallet__chart-wrap">
  140. <RevenueChart chartData={chartData} />
  141. <div style={{ display: 'flex', justifyContent: 'center', gap: '20px', marginTop: '8px', fontSize: '0.8125rem' }}>
  142. <span><span style={{ color: 'hsl(var(--primary))' }}>■</span> 순수익</span>
  143. <span><span style={{ color: 'hsl(var(--muted-foreground))' }}>■</span> 수수료</span>
  144. </div>
  145. </div>
  146. {/* Table Header */}
  147. <div className="wallet__header">
  148. <div className="wallet__summary">합계: {data.total}건</div>
  149. </div>
  150. {/* Table */}
  151. <div className="wallet__table-wrap">
  152. <table className="wallet__table">
  153. <thead>
  154. <tr>
  155. <th>일시</th>
  156. <th>후원자</th>
  157. <th>유형</th>
  158. <th style={{ textAlign: 'right' }}>후원 금액</th>
  159. <th style={{ textAlign: 'right' }}>수수료</th>
  160. <th style={{ textAlign: 'right' }}>순수익</th>
  161. </tr>
  162. </thead>
  163. <tbody>
  164. {data.list.length > 0 ? (
  165. data.list.map((row) => (
  166. <tr key={row.id}>
  167. <td>{getDateTime(row.createdAt)}</td>
  168. <td>{row.donorName}</td>
  169. <td>
  170. {REVENUE_TYPE_MAP[row.type] ?? row.type}
  171. {row.crewName && <small style={{ marginLeft: '4px', color: 'hsl(var(--muted-foreground))' }}>({row.crewName})</small>}
  172. </td>
  173. <td style={{ textAlign: 'right' }}>{row.grossAmount.toLocaleString()}원</td>
  174. <td style={{ textAlign: 'right' }}>
  175. <span className="wallet__amount--minus">-{row.platformFee.toLocaleString()}원</span>
  176. </td>
  177. <td style={{ textAlign: 'right' }}>
  178. <span className="wallet__amount--plus">{row.netAmount.toLocaleString()}원</span>
  179. </td>
  180. </tr>
  181. ))
  182. ) : (
  183. <tr>
  184. <td colSpan={6} className="wallet__empty">수익 내역이 없습니다.</td>
  185. </tr>
  186. )}
  187. </tbody>
  188. </table>
  189. </div>
  190. {data.total > 0 && (
  191. <Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
  192. )}
  193. </div>
  194. );
  195. }