AlertListPanel.tsx 9.8 KB

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