GoalListPanel.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. 'use client';
  2. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  3. import { faPlus } from '@fortawesome/free-solid-svg-icons';
  4. import { Checkbox } from '@/components/ui/checkbox';
  5. import type { GoalConfigItem } from '@/types/response/donation/goalConfig';
  6. import { PER_PAGE_OPTIONS } from '@/constants/donation';
  7. import { GOAL_STYLES, formatDateTime } from '../types';
  8. type Props = {
  9. items: GoalConfigItem[];
  10. loading: boolean;
  11. saving: boolean;
  12. checkedIDs: Set<number>;
  13. setCheckedIDs: React.Dispatch<React.SetStateAction<Set<number>>>;
  14. page: number;
  15. setPage: React.Dispatch<React.SetStateAction<number>>;
  16. perPage: number;
  17. setPerPage: React.Dispatch<React.SetStateAction<number>>;
  18. onNew: () => void;
  19. onEdit: (item: GoalConfigItem) => void;
  20. onBatchDelete: () => void;
  21. };
  22. export default function GoalListPanel({
  23. items,
  24. loading,
  25. saving,
  26. checkedIDs,
  27. setCheckedIDs,
  28. page,
  29. setPage,
  30. perPage,
  31. setPerPage,
  32. onNew,
  33. onEdit,
  34. onBatchDelete
  35. }: Props) {
  36. // ── 페이징 ───────────────────────────────────────
  37. const totalPages = Math.max(1, Math.ceil(items.length / perPage));
  38. const pagedItems = items.slice((page - 1) * perPage, page * perPage);
  39. const handlePerPageChange = (value: number) => {
  40. setPerPage(value);
  41. setPage(1);
  42. };
  43. // ── 전체선택 ─────────────────────────────────────
  44. const visibleIDs = pagedItems.map(i => i.id);
  45. const checkedCount = visibleIDs.filter(id => checkedIDs.has(id)).length;
  46. const allChecked = pagedItems.length > 0 && checkedCount === visibleIDs.length;
  47. const isIndeterminate = checkedCount > 0 && checkedCount < visibleIDs.length;
  48. const handleSelectAll = () => {
  49. setCheckedIDs(prev => {
  50. const next = new Set(prev);
  51. if (allChecked) {
  52. visibleIDs.forEach(id => next.delete(id));
  53. } else {
  54. visibleIDs.forEach(id => next.add(id));
  55. }
  56. return next;
  57. });
  58. };
  59. const handleToggleCheck = (id: number) => {
  60. setCheckedIDs(prev => {
  61. const next = new Set(prev);
  62. if (next.has(id)) {
  63. next.delete(id);
  64. } else {
  65. next.add(id);
  66. }
  67. return next;
  68. });
  69. };
  70. return (
  71. <div className="goal-config__list-panel">
  72. <div className="goal-config__toolbar">
  73. <div className="goal-config__toolbar-left">
  74. <span className="goal-config__count">총 {items.length}개</span>
  75. {checkedIDs.size > 0 && (
  76. <span className="goal-config__count">({checkedIDs.size}개 선택)</span>
  77. )}
  78. </div>
  79. <div className="goal-config__toolbar-right">
  80. <select
  81. value={perPage}
  82. onChange={e => handlePerPageChange(Number(e.target.value))}
  83. className="goal-config__per-page"
  84. title="보여질 개수"
  85. >
  86. {PER_PAGE_OPTIONS.map(n => (
  87. <option key={n} value={n}>{n}개씩</option>
  88. ))}
  89. </select>
  90. <button type="button" className="goal-config__btn" onClick={onNew}>
  91. <FontAwesomeIcon icon={faPlus} />
  92. 추가
  93. </button>
  94. <button
  95. type="button"
  96. className="goal-config__btn goal-config__btn--danger"
  97. onClick={onBatchDelete}
  98. disabled={checkedIDs.size === 0 || saving}
  99. >
  100. 삭제
  101. </button>
  102. </div>
  103. </div>
  104. <div className="goal-config__table-wrap">
  105. {loading ? (
  106. <div className="goal-config__empty">준비 중...</div>
  107. ) : items.length === 0 ? (
  108. <div className="goal-config__empty">등록된 목표 설정이 없습니다.</div>
  109. ) : (
  110. <table className="goal-config__table">
  111. <thead>
  112. <tr>
  113. <th className="goal-config__th--check">
  114. <Checkbox
  115. checked={allChecked} indeterminate={isIndeterminate}
  116. onCheckedChange={handleSelectAll}
  117. aria-label="전체선택"
  118. />
  119. </th>
  120. <th>제목</th>
  121. <th>현황</th>
  122. <th>시작금액</th>
  123. <th>목표금액</th>
  124. <th>기간</th>
  125. <th>스타일</th>
  126. <th>활성</th>
  127. <th>비고</th>
  128. </tr>
  129. </thead>
  130. <tbody>
  131. {pagedItems.map(item => {
  132. const isChecked = checkedIDs.has(item.id);
  133. const percent = item.targetAmount > 0 ? Math.min((item.startAmount / item.targetAmount) * 100, 100) : 0;
  134. return (
  135. <tr
  136. key={item.id}
  137. className={isChecked ? 'goal-config__row--checked' : ''}
  138. >
  139. <td className="goal-config__td--check">
  140. <Checkbox
  141. checked={isChecked}
  142. onCheckedChange={() => handleToggleCheck(item.id)}
  143. aria-label={`${item.id} 선택`}
  144. />
  145. </td>
  146. <td>{item.title}</td>
  147. <td className="goal-config__td--bar">
  148. <div className="goal-config__mini-bar" style={{ background: item.barBackgroundColor }}>
  149. <div
  150. className="goal-config__mini-bar-fill"
  151. style={{ width: `${percent}%`, background: item.barColor }}
  152. />
  153. <span className="goal-config__mini-bar-text">
  154. {item.startAmount.toLocaleString()} / {item.targetAmount.toLocaleString()}
  155. {item.isShowPercent && ` (${Math.round(percent)}%)`}
  156. </span>
  157. </div>
  158. </td>
  159. <td>{item.startAmount.toLocaleString()}원</td>
  160. <td>{item.targetAmount.toLocaleString()}원</td>
  161. <td className="goal-config__td--date">
  162. {formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}
  163. </td>
  164. <td>{GOAL_STYLES.find(s => s.value === item.style)?.label ?? item.style}</td>
  165. <td>
  166. <span className={`goal-config__status-badge goal-config__status-badge--${item.isActive ? 'active' : 'inactive'}`}>
  167. {item.isActive ? '활성' : '비활성'}
  168. </span>
  169. </td>
  170. <td>
  171. <button
  172. type="button"
  173. className="goal-config__btn goal-config__btn--sm"
  174. onClick={() => onEdit(item)}
  175. disabled={isChecked}
  176. >
  177. 수정
  178. </button>
  179. </td>
  180. </tr>
  181. );
  182. })}
  183. </tbody>
  184. </table>
  185. )}
  186. </div>
  187. {totalPages > 1 && (
  188. <div className="goal-config__pagination">
  189. <button
  190. type="button"
  191. className="goal-config__page-btn"
  192. disabled={page <= 1}
  193. onClick={() => setPage(p => p - 1)}
  194. >
  195. </button>
  196. {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
  197. <button
  198. key={p}
  199. type="button"
  200. className={`goal-config__page-btn${p === page ? ' goal-config__page-btn--active' : ''}`}
  201. onClick={() => setPage(p)}
  202. >
  203. {p}
  204. </button>
  205. ))}
  206. <button
  207. type="button"
  208. className="goal-config__page-btn"
  209. disabled={page >= totalPages}
  210. onClick={() => setPage(p => p + 1)}
  211. >
  212. </button>
  213. </div>
  214. )}
  215. </div>
  216. );
  217. }