AlertListPanel.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. 'use client';
  2. import { useEffect, useRef, useState } from 'react';
  3. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  4. import { faPlus, faImage, faVolumeHigh } from '@fortawesome/free-solid-svg-icons';
  5. import { Checkbox } from '@/components/ui/checkbox';
  6. import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
  7. import { PER_PAGE_OPTIONS } from '@/constants/donation';
  8. type Props = {
  9. items: AlertConfigItem[];
  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: AlertConfigItem) => void;
  20. onBatchDelete: () => void;
  21. };
  22. export default function AlertListPanel({
  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. const [playingAudio, setPlayingAudio] = useState<number|null>(null);
  37. const audioRef = useRef<HTMLAudioElement|null>(null);
  38. // ── 페이징 ───────────────────────────────────────
  39. const totalPages = Math.max(1, Math.ceil(items.length / perPage));
  40. const pagedItems = items.slice((page - 1) * perPage, page * perPage);
  41. const handlePerPageChange = (value: number) => {
  42. setPerPage(value);
  43. setPage(1);
  44. };
  45. // ── 전체선택 ─────────────────────────────────────
  46. const visibleIDs = pagedItems.map(i => i.id);
  47. const checkedCount = visibleIDs.filter(id => checkedIDs.has(id)).length;
  48. const allChecked = pagedItems.length > 0 && checkedCount === visibleIDs.length;
  49. const isIndeterminate = checkedCount > 0 && checkedCount < visibleIDs.length;
  50. const handleSelectAll = () => {
  51. setCheckedIDs(prev => {
  52. const next = new Set(prev);
  53. if (allChecked) {
  54. visibleIDs.forEach(id => next.delete(id));
  55. } else {
  56. visibleIDs.forEach(id => next.add(id));
  57. }
  58. return next;
  59. });
  60. };
  61. const handleToggleCheck = (id: number) => {
  62. setCheckedIDs(prev => {
  63. const next = new Set(prev);
  64. if (next.has(id)) {
  65. next.delete(id);
  66. } else {
  67. next.add(id);
  68. }
  69. return next;
  70. });
  71. };
  72. // ── 사운드 재생/정지 ─────────────────────────────
  73. const handlePlaySound = (itemId: number, soundUrl: string) => {
  74. // 이미 재생 중이면 정지
  75. if (playingAudio === itemId && audioRef.current) {
  76. audioRef.current.pause();
  77. audioRef.current = null;
  78. setPlayingAudio(null);
  79. return;
  80. }
  81. // 기존 재생 정지
  82. if (audioRef.current) {
  83. audioRef.current.pause();
  84. audioRef.current = null;
  85. }
  86. const audio = new Audio(soundUrl);
  87. audioRef.current = audio;
  88. setPlayingAudio(itemId);
  89. audio.play().catch(() => {});
  90. audio.addEventListener('ended', () => {
  91. setPlayingAudio(null);
  92. audioRef.current = null;
  93. });
  94. };
  95. // unmount 시 오디오 정리
  96. useEffect(() => {
  97. return () => {
  98. if (audioRef.current) {
  99. audioRef.current.pause();
  100. audioRef.current = null;
  101. }
  102. };
  103. }, []);
  104. return (
  105. <div className="alert-config__list-panel">
  106. <div className="alert-config__toolbar">
  107. <div className="alert-config__toolbar-left">
  108. <span className="alert-config__count">총 {items.length}개</span>
  109. {checkedIDs.size > 0 && (
  110. <span className="alert-config__count">({checkedIDs.size}개 선택)</span>
  111. )}
  112. </div>
  113. <div className="alert-config__toolbar-right">
  114. <select
  115. value={perPage}
  116. onChange={e => handlePerPageChange(Number(e.target.value))}
  117. className="alert-config__per-page"
  118. title="보여질 개수"
  119. >
  120. {PER_PAGE_OPTIONS.map(n => (
  121. <option key={n} value={n}>{n}개씩</option>
  122. ))}
  123. </select>
  124. <button type="button" className="alert-config__btn" onClick={onNew}>
  125. <FontAwesomeIcon icon={faPlus} />
  126. 추가
  127. </button>
  128. <button
  129. type="button"
  130. className="alert-config__btn alert-config__btn--danger"
  131. onClick={onBatchDelete}
  132. disabled={checkedIDs.size === 0 || saving}
  133. >
  134. 삭제
  135. </button>
  136. </div>
  137. </div>
  138. <div className="alert-config__table-wrap">
  139. {loading ? (
  140. <div className="alert-config__empty">준비 중...</div>
  141. ) : items.length === 0 ? (
  142. <div className="alert-config__empty">등록된 알림 설정이 없습니다.</div>
  143. ) : (
  144. <table className="alert-config__table">
  145. <thead>
  146. <tr>
  147. <th className="alert-config__th--check">
  148. <Checkbox
  149. checked={isIndeterminate ? 'indeterminate' : allChecked}
  150. onCheckedChange={handleSelectAll}
  151. aria-label="전체선택"
  152. />
  153. </th>
  154. <th>조건</th>
  155. <th>금액</th>
  156. <th>제목</th>
  157. <th>보낼 내용</th>
  158. <th>노출(초)</th>
  159. <th>활성</th>
  160. <th>미디어</th>
  161. <th>비고</th>
  162. </tr>
  163. </thead>
  164. <tbody>
  165. {pagedItems.map(item => {
  166. const isChecked = checkedIDs.has(item.id);
  167. const hasImage = item.enableImage && item.imageUrl;
  168. const hasSound = item.enableSound && item.soundUrl;
  169. return (
  170. <tr
  171. key={item.id}
  172. className={isChecked ? 'alert-config__row--checked' : ''}
  173. >
  174. <td className="alert-config__td--check">
  175. <Checkbox
  176. checked={isChecked}
  177. onCheckedChange={() => handleToggleCheck(item.id)}
  178. aria-label={`${item.id} 선택`}
  179. />
  180. </td>
  181. <td>
  182. <span className={`alert-config__match-badge alert-config__match-badge--${item.matchType === 1 ? 'exact' : 'min'}`}>
  183. {item.matchType === 1 ? '정확히' : '이상'}
  184. </span>
  185. </td>
  186. <td>{item.amount.toLocaleString()}원</td>
  187. <td>{item.title || <span className="text-muted-foreground">미입력</span>}</td>
  188. <td className="alert-config__td--message">{item.message}</td>
  189. <td>{item.displayDurationSec}초</td>
  190. <td>
  191. <span className={`alert-config__status-badge alert-config__status-badge--${item.isActive ? 'active' : 'inactive'}`}>
  192. {item.isActive ? '활성' : '비활성'}
  193. </span>
  194. </td>
  195. <td className="alert-config__td--media">
  196. <div>
  197. {hasImage && (
  198. <button
  199. type="button"
  200. className="alert-config__media-btn alert-config__media-btn--image"
  201. title="이미지 보기"
  202. onClick={() => window.open(item.imageUrl!, '_blank')}
  203. >
  204. <FontAwesomeIcon icon={faImage} />
  205. </button>
  206. )}
  207. {hasSound && (
  208. <button
  209. type="button"
  210. className={`alert-config__media-btn alert-config__media-btn--sound${playingAudio === item.id ? ' alert-config__media-btn--playing' : ''}`}
  211. title={playingAudio === item.id ? '사운드 정지' : '사운드 재생'}
  212. onClick={() => handlePlaySound(item.id, item.soundUrl!)}
  213. >
  214. <FontAwesomeIcon icon={faVolumeHigh} />
  215. </button>
  216. )}
  217. {!hasImage && !hasSound && (
  218. <span className="text-muted-foreground">-</span>
  219. )}
  220. </div>
  221. </td>
  222. <td>
  223. <button
  224. type="button"
  225. className="alert-config__btn alert-config__btn--sm"
  226. onClick={() => onEdit(item)}
  227. disabled={isChecked}
  228. >
  229. 수정
  230. </button>
  231. </td>
  232. </tr>
  233. );
  234. })}
  235. </tbody>
  236. </table>
  237. )}
  238. </div>
  239. {totalPages > 1 && (
  240. <div className="alert-config__pagination">
  241. <button
  242. type="button"
  243. className="alert-config__page-btn"
  244. disabled={page <= 1}
  245. onClick={() => setPage(p => p - 1)}
  246. >
  247. </button>
  248. {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
  249. <button
  250. key={p}
  251. type="button"
  252. className={`alert-config__page-btn${p === page ? ' alert-config__page-btn--active' : ''}`}
  253. onClick={() => setPage(p)}
  254. >
  255. {p}
  256. </button>
  257. ))}
  258. <button
  259. type="button"
  260. className="alert-config__page-btn"
  261. disabled={page >= totalPages}
  262. onClick={() => setPage(p => p + 1)}
  263. >
  264. </button>
  265. </div>
  266. )}
  267. </div>
  268. );
  269. }