page.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect, useCallback, useMemo } from 'react';
  4. import { fetchApi, getDateTime } from '@/lib/utils/client';
  5. import useAuth from '@/hooks/useAuth';
  6. import { useConfigContext } from '@/contexts/configProvider';
  7. import type { AttendanceListResponse, AttendanceItem, AttendanceCalendarResponse, CalendarDay, AttendanceCheckInResponse } from '@/types/response/attendance';
  8. import type { RankBonusEntry } from '@/types/config';
  9. import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
  10. import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
  11. import { Stamp, Users, CircleHelp } from 'lucide-react';
  12. import Loading from '@/app/component/Loading';
  13. import Pagination from '@/app/component/Pagination';
  14. export default function AttendancePage()
  15. {
  16. const { loginCheck } = useAuth();
  17. const config = useConfigContext();
  18. const rankBonuses = useMemo<RankBonusEntry[]>(() => {
  19. if (!config.attendance?.useRankBonus || !config.attendance?.rankBonusConfig) {
  20. return [];
  21. }
  22. try {
  23. return JSON.parse(config.attendance.rankBonusConfig);
  24. } catch {
  25. return [];
  26. }
  27. }, [config.attendance?.useRankBonus, config.attendance?.rankBonusConfig]);
  28. // Calendar
  29. const now = new Date();
  30. const [calYear, setCalYear] = useState(now.getFullYear());
  31. const [calMonth, setCalMonth] = useState(now.getMonth() + 1);
  32. const [calDays, setCalDays] = useState<CalendarDay[]>([]);
  33. const [calLoading, setCalLoading] = useState(false);
  34. // Check-in
  35. const [greeting, setGreeting] = useState('');
  36. const [checkInLoading, setCheckInLoading] = useState(false);
  37. // List (선택 날짜 연동)
  38. const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
  39. const [selectedDate, setSelectedDate] = useState(todayStr);
  40. const [listLoading, setListLoading] = useState(true);
  41. const [listPage, setListPage] = useState(1);
  42. const [listData, setListData] = useState<AttendanceListResponse>({ total: 0, list: [] });
  43. const [sortBy, setSortBy] = useState<'time'|'count'|'streak'>('time');
  44. // selectedDate를 오늘 기준 offset (0~7)로 환산, 범위 밖이면 ''
  45. const dayOffset = useMemo(() => {
  46. const sel = new Date(selectedDate + 'T00:00:00');
  47. const today = new Date(todayStr + 'T00:00:00');
  48. const diff = Math.round((today.getTime() - sel.getTime()) / (1000 * 60 * 60 * 24));
  49. return diff >= 0 && diff <= 7 ? String(diff) : '';
  50. }, [selectedDate, todayStr]);
  51. // 오늘 이미 출석했는지 (개인 출석 여부)
  52. const attendedToday = useMemo(() => {
  53. return calDays.some((cd) => {
  54. if (!cd.attendedAt) {
  55. return false;
  56. }
  57. const d = new Date(cd.attendedAt);
  58. const y = d.getFullYear();
  59. const m = String(d.getMonth() + 1).padStart(2, '0');
  60. const day = String(d.getDate()).padStart(2, '0');
  61. return `${y}-${m}-${day}` === todayStr;
  62. });
  63. }, [calDays, todayStr]);
  64. const handleOffsetChange = (offsetStr: string) => {
  65. const offset = parseInt(offsetStr, 10);
  66. const target = new Date(todayStr + 'T00:00:00');
  67. target.setDate(target.getDate() - offset);
  68. const y = target.getFullYear();
  69. const m = String(target.getMonth() + 1).padStart(2, '0');
  70. const d = String(target.getDate()).padStart(2, '0');
  71. setSelectedDate(`${y}-${m}-${d}`);
  72. setListPage(1);
  73. };
  74. // 캘린더 데이터 로드
  75. const loadCalendar = useCallback(() => {
  76. setCalLoading(true);
  77. fetchApi<AttendanceCalendarResponse>(`/api/attendance/calendar?year=${calYear}&month=${calMonth}`, { silent: true }).then((res) => {
  78. setCalDays(res.data?.days ?? []);
  79. }).catch(() => {
  80. setCalDays([]);
  81. }).finally(() => {
  82. setCalLoading(false);
  83. });
  84. }, [calYear, calMonth]);
  85. useEffect(() => {
  86. loadCalendar();
  87. }, [loadCalendar]);
  88. // 리스트 로드 (선택 날짜 기준)
  89. const loadList = useCallback(() => {
  90. setListLoading(true);
  91. fetchApi<AttendanceListResponse>(`/api/attendance/list?page=${listPage}&perPage=20&date=${selectedDate}&sortBy=${sortBy}`, { silent: true }).then((res) => {
  92. setListData(res.data ?? { total: 0, list: [] });
  93. }).catch(() => {
  94. setListData({ total: 0, list: [] });
  95. }).finally(() => {
  96. setListLoading(false);
  97. });
  98. }, [listPage, selectedDate, sortBy]);
  99. useEffect(() => {
  100. loadList();
  101. }, [loadList]);
  102. // 출석 체크
  103. const handleCheckIn = () => {
  104. if (!loginCheck()) {
  105. return;
  106. }
  107. setCheckInLoading(true);
  108. fetchApi<AttendanceCheckInResponse>('/api/attendance/check-in', {
  109. method: 'POST',
  110. body: { greeting: greeting.trim() || '' }
  111. }).then((res) => {
  112. const data = res.data!;
  113. let msg = `출석 완료! 순위: ${data.rank}등 | 연속: ${data.consecutiveDays}일 | 경험치: +${data.expRewarded} | 토큰: +${data.pointRewarded}P`;
  114. if (data.rankBonusExp > 0 || data.rankBonusPoint > 0) {
  115. msg += ` (순위 보상: +${data.rankBonusExp}EXP, +${data.rankBonusPoint}P)`;
  116. }
  117. alert(msg);
  118. setGreeting('');
  119. setSelectedDate(todayStr);
  120. if (listPage === 1) {
  121. loadList();
  122. } else {
  123. setListPage(1);
  124. }
  125. loadCalendar();
  126. }).catch((err) => {
  127. alert(err.message);
  128. }).finally(() => {
  129. setCheckInLoading(false);
  130. });
  131. };
  132. // 오늘 자정 기준 (미래 날짜 비교용)
  133. const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  134. // 날짜 클릭 → 하단 리스트 전환 (미래 날짜는 무시)
  135. const handleDateClick = (dateStr: string) => {
  136. const clicked = new Date(dateStr + 'T00:00:00');
  137. if (clicked > todayMidnight) {
  138. return;
  139. }
  140. setSelectedDate(dateStr);
  141. setListPage(1);
  142. };
  143. // 캘린더 렌더링
  144. const renderCalendar = () =>
  145. {
  146. const firstDay = new Date(calYear, calMonth - 1, 1).getDay();
  147. const daysInMonth = new Date(calYear, calMonth, 0).getDate();
  148. const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
  149. const cells: React.ReactNode[] = [];
  150. // 빈 칸
  151. for (let i = 0; i < firstDay; i++) {
  152. cells.push(<div key={`empty-${i}`} className="attendance__calendar-cell attendance__calendar-cell--empty" />);
  153. }
  154. // 날짜
  155. for (let d = 1; d <= daysInMonth; d++) {
  156. const dateStr = `${calYear}-${String(calMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
  157. const dayData = calDays.find(cd => cd.date?.startsWith(dateStr));
  158. const attended = dayData?.attendedAt != null;
  159. const isToday = calYear === now.getFullYear() && calMonth === now.getMonth() + 1 && d === now.getDate();
  160. const isSelected = selectedDate === dateStr;
  161. const totalCount = dayData?.totalCount ?? 0;
  162. const isFuture = new Date(calYear, calMonth - 1, d) > todayMidnight;
  163. const classNames = [
  164. 'attendance__calendar-cell',
  165. attended ? 'attendance__calendar-cell--attended' : '',
  166. isToday ? 'attendance__calendar-cell--today' : '',
  167. isSelected ? 'attendance__calendar-cell--selected' : '',
  168. isFuture ? 'attendance__calendar-cell--future' : ''
  169. ].filter(Boolean).join(' ');
  170. cells.push(
  171. <div key={d} className={classNames} onClick={() => handleDateClick(dateStr)}>
  172. <span className="attendance__calendar-day">{d}</span>
  173. {(attended || totalCount > 0) && (
  174. <span className="attendance__calendar-bottom">
  175. <span className="attendance__calendar-stamp">
  176. {attended && <Stamp size={16} />}
  177. </span>
  178. <span className="attendance__calendar-count">
  179. {totalCount > 0 && (
  180. <>
  181. <Users size={14} />
  182. {totalCount}
  183. </>
  184. )}
  185. </span>
  186. </span>
  187. )}
  188. </div>
  189. );
  190. }
  191. const isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth() + 1;
  192. return (
  193. <div className="attendance__calendar">
  194. <div className="attendance__calendar-nav">
  195. <button type="button" onClick={() => {
  196. if (calMonth === 1) {
  197. setCalYear(calYear - 1);
  198. setCalMonth(12);
  199. } else {
  200. setCalMonth(calMonth - 1);
  201. }
  202. }}>&lt;</button>
  203. <span>{calYear}년 {calMonth}월</span>
  204. <button type="button" disabled={isCurrentMonth} onClick={() => {
  205. if (calMonth === 12) {
  206. setCalYear(calYear + 1);
  207. setCalMonth(1);
  208. } else {
  209. setCalMonth(calMonth + 1);
  210. }
  211. }}>&gt;</button>
  212. </div>
  213. <div className="attendance__calendar-grid">
  214. {weekdays.map(w => (
  215. <div key={w} className="attendance__calendar-weekday">{w}</div>
  216. ))}
  217. {cells}
  218. </div>
  219. </div>
  220. );
  221. };
  222. return (
  223. <div id="attendancePage">
  224. <div>
  225. <br/>
  226. </div>
  227. <div className="attendance__title-row">
  228. <h1>출석부</h1>
  229. <Dialog>
  230. <DialogTrigger asChild>
  231. <button type="button" className="attendance__help-btn" title='출석부 이용안내'>
  232. <CircleHelp size={20} />
  233. </button>
  234. </DialogTrigger>
  235. <DialogContent>
  236. <DialogHeader>
  237. <DialogTitle>출석부 안내</DialogTitle>
  238. </DialogHeader>
  239. <DialogDescription asChild>
  240. <div className="attendance__guide">
  241. <section className="attendance__guide-section">
  242. <h3>기본 보상</h3>
  243. <p>매일 출석 시 경험치 <strong>{config.attendance?.baseExp ?? 0}</strong>, 토큰 <strong>{config.attendance?.basePoint ?? 0}P</strong>가 지급됩니다.</p>
  244. </section>
  245. {config.attendance?.useStreakBonus && (
  246. <section className="attendance__guide-section">
  247. <h3>연속 출석 보상</h3>
  248. <p>연속 출석 시 1일당 추가 경험치 <strong>+{config.attendance.streakBonusPerDay}</strong>, 토큰 <strong>+{config.attendance.streakBonusPointPerDay}P</strong>가 지급됩니다.</p>
  249. {config.attendance.streakBonusMaxDays > 0 && (
  250. <p>최대 <strong>{config.attendance.streakBonusMaxDays}일</strong>까지 적용됩니다.</p>
  251. )}
  252. </section>
  253. )}
  254. {config.attendance?.useRankBonus && rankBonuses.length > 0 && (
  255. <section className="attendance__guide-section">
  256. <h3>순위 보상</h3>
  257. <p>출석 순위에 따라 추가 보상이 지급됩니다.</p>
  258. <table className="attendance__guide-table" aria-label="순위별 추가 보상">
  259. <thead>
  260. <tr>
  261. <th scope="col">순위</th>
  262. <th scope="col">추가 경험치</th>
  263. <th scope="col">추가 토큰</th>
  264. </tr>
  265. </thead>
  266. <tbody>
  267. {rankBonuses.map((entry) => (
  268. <tr key={entry.rank}>
  269. <th scope="row">{entry.rank}등</th>
  270. <td>+{entry.exp}</td>
  271. <td>+{entry.point}P</td>
  272. </tr>
  273. ))}
  274. </tbody>
  275. </table>
  276. </section>
  277. )}
  278. </div>
  279. </DialogDescription>
  280. </DialogContent>
  281. </Dialog>
  282. </div>
  283. {/* 캘린더 */}
  284. <section className="attendance__section">
  285. {calLoading && <Loading type={2} />}
  286. {!calLoading && renderCalendar()}
  287. </section>
  288. {/* 출석 입력 (오늘 미출석 시만 노출) */}
  289. {!attendedToday && (
  290. <section className="attendance__form">
  291. <input
  292. type="text"
  293. className="attendance__form-input"
  294. placeholder="출석 인사를 입력하세요 (선택)"
  295. maxLength={200}
  296. value={greeting}
  297. onChange={(e) => setGreeting(e.target.value)}
  298. onKeyDown={(e) => {
  299. if (e.key === 'Enter') {
  300. handleCheckIn();
  301. }
  302. }}
  303. disabled={checkInLoading}
  304. />
  305. <button
  306. type="button"
  307. className="attendance__form-btn"
  308. onClick={handleCheckIn}
  309. disabled={checkInLoading}
  310. >
  311. {checkInLoading ? '처리중...' : '출석하기'}
  312. </button>
  313. </section>
  314. )}
  315. {/* 리스트 */}
  316. <section className="attendance__section">
  317. <div className="attendance__list-toolbar">
  318. <div className="attendance__list-toolbar-left">
  319. <span className="attendance__list-toolbar-total">총 {listData.total}명</span>
  320. </div>
  321. <div className="attendance__list-toolbar-right">
  322. <select
  323. title='기간 선택'
  324. className="attendance__list-toolbar-select"
  325. value={dayOffset}
  326. onChange={(e) => handleOffsetChange(e.target.value)}
  327. >
  328. <option value="" disabled>기간 선택</option>
  329. <option value="0">오늘</option>
  330. <option value="1">1일전</option>
  331. <option value="2">2일전</option>
  332. <option value="3">3일전</option>
  333. <option value="4">4일전</option>
  334. <option value="5">5일전</option>
  335. <option value="6">6일전</option>
  336. <option value="7">7일전</option>
  337. </select>
  338. <select
  339. title='정렬'
  340. className="attendance__list-toolbar-select"
  341. value={sortBy}
  342. onChange={(e) => {
  343. setSortBy(e.target.value as 'time'|'count'|'streak');
  344. setListPage(1);
  345. }}
  346. >
  347. <option value="time">시간순</option>
  348. <option value="count">출석순</option>
  349. <option value="streak">개근순</option>
  350. </select>
  351. </div>
  352. </div>
  353. {listLoading && <Loading type={2} />}
  354. <div className="attendance__list">
  355. {/* 헤더 */}
  356. <div className="attendance__list-header">
  357. <span>순위</span>
  358. <span>별명</span>
  359. <span>출석 인사</span>
  360. <span>출석 일시</span>
  361. <span>토큰</span>
  362. <span>경험치</span>
  363. <span>개근(일)</span>
  364. </div>
  365. {/* 데이터 */}
  366. {listData.list.length > 0 ? (
  367. listData.list.map((item: AttendanceItem) => (
  368. <div key={item.rank} className="attendance__list-row">
  369. {/* PC */}
  370. <div className="attendance__list-row-pc">
  371. <span className="attendance__list-rank">{item.rank}</span>
  372. <span className="attendance__list-member">
  373. <Avatar className="h-6 w-6">
  374. {item.memberThumb ? (
  375. <AvatarImage src={item.memberThumb} alt={item.memberName?.trim() || item.memberSID} />
  376. ) : null}
  377. <AvatarFallback>{(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'}</AvatarFallback>
  378. </Avatar>
  379. <span>{item.memberName?.trim() || item.memberSID}</span>
  380. </span>
  381. <span className="attendance__list-greeting">{item.greeting || '-'}</span>
  382. <span>{getDateTime(item.createdAt)}</span>
  383. <span className="attendance__list-point">+{item.pointRewarded}P</span>
  384. <span className="attendance__list-exp">+{item.expRewarded}</span>
  385. <span className="attendance__list-streak">{item.consecutiveDays}일</span>
  386. </div>
  387. {/* Mobile */}
  388. <div className="attendance__list-row-mobile">
  389. <div className="attendance__list-row-mobile-top">
  390. <span className="attendance__list-rank">{item.rank}</span>
  391. <Avatar className="h-5 w-5">
  392. {item.memberThumb ? (
  393. <AvatarImage src={item.memberThumb} alt={item.memberName?.trim() || item.memberSID} />
  394. ) : null}
  395. <AvatarFallback>{(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'}</AvatarFallback>
  396. </Avatar>
  397. <span className="attendance__list-member-name">{item.memberName?.trim() || item.memberSID}</span>
  398. <span className="attendance__list-streak">{item.consecutiveDays}일</span>
  399. </div>
  400. <div className="attendance__list-row-mobile-bottom">
  401. <span className="attendance__list-greeting">{item.greeting || '-'}</span>
  402. <span className="attendance__list-meta">
  403. <span className="attendance__list-point">+{item.pointRewarded}P</span>
  404. <span className="attendance__list-exp">+{item.expRewarded} EXP</span>
  405. <span>{getDateTime(item.createdAt)}</span>
  406. </span>
  407. </div>
  408. </div>
  409. </div>
  410. ))
  411. ) : (
  412. <p className="attendance__empty">출석한 회원이 없습니다.</p>
  413. )}
  414. </div>
  415. {listData.total > 0 && (
  416. <Pagination total={listData.total} page={listPage} perPage={20} onChange={setListPage} />
  417. )}
  418. </section>
  419. </div>
  420. );
  421. }