| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465 |
- 'use client';
- import './style.scss';
- import { useState, useEffect, useCallback, useMemo } from 'react';
- import { fetchApi, getDateTime } from '@/lib/utils/client';
- import useAuth from '@/hooks/useAuth';
- import { useConfigContext } from '@/contexts/configProvider';
- import type { AttendanceListResponse, AttendanceItem, AttendanceCalendarResponse, CalendarDay, AttendanceCheckInResponse } from '@/types/response/attendance';
- import type { RankBonusEntry } from '@/types/config';
- import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
- import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
- import { Stamp, Users, CircleHelp } from 'lucide-react';
- import Loading from '@/app/component/Loading';
- import Pagination from '@/app/component/Pagination';
- export default function AttendancePage()
- {
- const { loginCheck } = useAuth();
- const config = useConfigContext();
- const rankBonuses = useMemo<RankBonusEntry[]>(() => {
- if (!config.attendance?.useRankBonus || !config.attendance?.rankBonusConfig) {
- return [];
- }
- try {
- return JSON.parse(config.attendance.rankBonusConfig);
- } catch {
- return [];
- }
- }, [config.attendance?.useRankBonus, config.attendance?.rankBonusConfig]);
- // Calendar
- const now = new Date();
- const [calYear, setCalYear] = useState(now.getFullYear());
- const [calMonth, setCalMonth] = useState(now.getMonth() + 1);
- const [calDays, setCalDays] = useState<CalendarDay[]>([]);
- const [calLoading, setCalLoading] = useState(false);
- // Check-in
- const [greeting, setGreeting] = useState('');
- const [checkInLoading, setCheckInLoading] = useState(false);
- // List (선택 날짜 연동)
- const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
- const [selectedDate, setSelectedDate] = useState(todayStr);
- const [listLoading, setListLoading] = useState(true);
- const [listPage, setListPage] = useState(1);
- const [listData, setListData] = useState<AttendanceListResponse>({ total: 0, list: [] });
- const [sortBy, setSortBy] = useState<'time'|'count'|'streak'>('time');
- // selectedDate를 오늘 기준 offset (0~7)로 환산, 범위 밖이면 ''
- const dayOffset = useMemo(() => {
- const sel = new Date(selectedDate + 'T00:00:00');
- const today = new Date(todayStr + 'T00:00:00');
- const diff = Math.round((today.getTime() - sel.getTime()) / (1000 * 60 * 60 * 24));
- return diff >= 0 && diff <= 7 ? String(diff) : '';
- }, [selectedDate, todayStr]);
- // 오늘 이미 출석했는지 (개인 출석 여부)
- const attendedToday = useMemo(() => {
- return calDays.some((cd) => {
- if (!cd.attendedAt) {
- return false;
- }
- const d = new Date(cd.attendedAt);
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, '0');
- const day = String(d.getDate()).padStart(2, '0');
- return `${y}-${m}-${day}` === todayStr;
- });
- }, [calDays, todayStr]);
- const handleOffsetChange = (offsetStr: string) => {
- const offset = parseInt(offsetStr, 10);
- const target = new Date(todayStr + 'T00:00:00');
- target.setDate(target.getDate() - offset);
- const y = target.getFullYear();
- const m = String(target.getMonth() + 1).padStart(2, '0');
- const d = String(target.getDate()).padStart(2, '0');
- setSelectedDate(`${y}-${m}-${d}`);
- setListPage(1);
- };
- // 캘린더 데이터 로드
- const loadCalendar = useCallback(() => {
- setCalLoading(true);
- fetchApi<AttendanceCalendarResponse>(`/api/attendance/calendar?year=${calYear}&month=${calMonth}`, { silent: true }).then((res) => {
- setCalDays(res.data?.days ?? []);
- }).catch(() => {
- setCalDays([]);
- }).finally(() => {
- setCalLoading(false);
- });
- }, [calYear, calMonth]);
- useEffect(() => {
- loadCalendar();
- }, [loadCalendar]);
- // 리스트 로드 (선택 날짜 기준)
- const loadList = useCallback(() => {
- setListLoading(true);
- fetchApi<AttendanceListResponse>(`/api/attendance/list?page=${listPage}&perPage=20&date=${selectedDate}&sortBy=${sortBy}`, { silent: true }).then((res) => {
- setListData(res.data ?? { total: 0, list: [] });
- }).catch(() => {
- setListData({ total: 0, list: [] });
- }).finally(() => {
- setListLoading(false);
- });
- }, [listPage, selectedDate, sortBy]);
- useEffect(() => {
- loadList();
- }, [loadList]);
- // 출석 체크
- const handleCheckIn = () => {
- if (!loginCheck()) {
- return;
- }
- setCheckInLoading(true);
- fetchApi<AttendanceCheckInResponse>('/api/attendance/check-in', {
- method: 'POST',
- body: { greeting: greeting.trim() || '' }
- }).then((res) => {
- const data = res.data!;
- let msg = `출석 완료! 순위: ${data.rank}등 | 연속: ${data.consecutiveDays}일 | 경험치: +${data.expRewarded} | 토큰: +${data.pointRewarded}P`;
- if (data.rankBonusExp > 0 || data.rankBonusPoint > 0) {
- msg += ` (순위 보상: +${data.rankBonusExp}EXP, +${data.rankBonusPoint}P)`;
- }
- alert(msg);
- setGreeting('');
- setSelectedDate(todayStr);
- if (listPage === 1) {
- loadList();
- } else {
- setListPage(1);
- }
- loadCalendar();
- }).catch((err) => {
- alert(err.message);
- }).finally(() => {
- setCheckInLoading(false);
- });
- };
- // 오늘 자정 기준 (미래 날짜 비교용)
- const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- // 날짜 클릭 → 하단 리스트 전환 (미래 날짜는 무시)
- const handleDateClick = (dateStr: string) => {
- const clicked = new Date(dateStr + 'T00:00:00');
- if (clicked > todayMidnight) {
- return;
- }
- setSelectedDate(dateStr);
- setListPage(1);
- };
- // 캘린더 렌더링
- const renderCalendar = () =>
- {
- const firstDay = new Date(calYear, calMonth - 1, 1).getDay();
- const daysInMonth = new Date(calYear, calMonth, 0).getDate();
- const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
- const cells: React.ReactNode[] = [];
- // 빈 칸
- for (let i = 0; i < firstDay; i++) {
- cells.push(<div key={`empty-${i}`} className="attendance__calendar-cell attendance__calendar-cell--empty" />);
- }
- // 날짜
- for (let d = 1; d <= daysInMonth; d++) {
- const dateStr = `${calYear}-${String(calMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
- const dayData = calDays.find(cd => cd.date?.startsWith(dateStr));
- const attended = dayData?.attendedAt != null;
- const isToday = calYear === now.getFullYear() && calMonth === now.getMonth() + 1 && d === now.getDate();
- const isSelected = selectedDate === dateStr;
- const totalCount = dayData?.totalCount ?? 0;
- const isFuture = new Date(calYear, calMonth - 1, d) > todayMidnight;
- const classNames = [
- 'attendance__calendar-cell',
- attended ? 'attendance__calendar-cell--attended' : '',
- isToday ? 'attendance__calendar-cell--today' : '',
- isSelected ? 'attendance__calendar-cell--selected' : '',
- isFuture ? 'attendance__calendar-cell--future' : ''
- ].filter(Boolean).join(' ');
- cells.push(
- <div key={d} className={classNames} onClick={() => handleDateClick(dateStr)}>
- <span className="attendance__calendar-day">{d}</span>
- {(attended || totalCount > 0) && (
- <span className="attendance__calendar-bottom">
- <span className="attendance__calendar-stamp">
- {attended && <Stamp size={16} />}
- </span>
- <span className="attendance__calendar-count">
- {totalCount > 0 && (
- <>
- <Users size={14} />
- {totalCount}
- </>
- )}
- </span>
- </span>
- )}
- </div>
- );
- }
- const isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth() + 1;
- return (
- <div className="attendance__calendar">
- <div className="attendance__calendar-nav">
- <button type="button" onClick={() => {
- if (calMonth === 1) {
- setCalYear(calYear - 1);
- setCalMonth(12);
- } else {
- setCalMonth(calMonth - 1);
- }
- }}><</button>
- <span>{calYear}년 {calMonth}월</span>
- <button type="button" disabled={isCurrentMonth} onClick={() => {
- if (calMonth === 12) {
- setCalYear(calYear + 1);
- setCalMonth(1);
- } else {
- setCalMonth(calMonth + 1);
- }
- }}>></button>
- </div>
- <div className="attendance__calendar-grid">
- {weekdays.map(w => (
- <div key={w} className="attendance__calendar-weekday">{w}</div>
- ))}
- {cells}
- </div>
- </div>
- );
- };
- return (
- <div id="attendancePage">
- <div>
- <br/>
- </div>
- <div className="attendance__title-row">
- <h1>출석부</h1>
- <Dialog>
- <DialogTrigger asChild>
- <button type="button" className="attendance__help-btn" title='출석부 이용안내'>
- <CircleHelp size={20} />
- </button>
- </DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>출석부 안내</DialogTitle>
- </DialogHeader>
- <DialogDescription asChild>
- <div className="attendance__guide">
- <section className="attendance__guide-section">
- <h3>기본 보상</h3>
- <p>매일 출석 시 경험치 <strong>{config.attendance?.baseExp ?? 0}</strong>, 토큰 <strong>{config.attendance?.basePoint ?? 0}P</strong>가 지급됩니다.</p>
- </section>
- {config.attendance?.useStreakBonus && (
- <section className="attendance__guide-section">
- <h3>연속 출석 보상</h3>
- <p>연속 출석 시 1일당 추가 경험치 <strong>+{config.attendance.streakBonusPerDay}</strong>, 토큰 <strong>+{config.attendance.streakBonusPointPerDay}P</strong>가 지급됩니다.</p>
- {config.attendance.streakBonusMaxDays > 0 && (
- <p>최대 <strong>{config.attendance.streakBonusMaxDays}일</strong>까지 적용됩니다.</p>
- )}
- </section>
- )}
- {config.attendance?.useRankBonus && rankBonuses.length > 0 && (
- <section className="attendance__guide-section">
- <h3>순위 보상</h3>
- <p>출석 순위에 따라 추가 보상이 지급됩니다.</p>
- <table className="attendance__guide-table" aria-label="순위별 추가 보상">
- <thead>
- <tr>
- <th scope="col">순위</th>
- <th scope="col">추가 경험치</th>
- <th scope="col">추가 토큰</th>
- </tr>
- </thead>
- <tbody>
- {rankBonuses.map((entry) => (
- <tr key={entry.rank}>
- <th scope="row">{entry.rank}등</th>
- <td>+{entry.exp}</td>
- <td>+{entry.point}P</td>
- </tr>
- ))}
- </tbody>
- </table>
- </section>
- )}
- </div>
- </DialogDescription>
- </DialogContent>
- </Dialog>
- </div>
- {/* 캘린더 */}
- <section className="attendance__section">
- {calLoading && <Loading type={2} />}
- {!calLoading && renderCalendar()}
- </section>
- {/* 출석 입력 (오늘 미출석 시만 노출) */}
- {!attendedToday && (
- <section className="attendance__form">
- <input
- type="text"
- className="attendance__form-input"
- placeholder="출석 인사를 입력하세요 (선택)"
- maxLength={200}
- value={greeting}
- onChange={(e) => setGreeting(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- handleCheckIn();
- }
- }}
- disabled={checkInLoading}
- />
- <button
- type="button"
- className="attendance__form-btn"
- onClick={handleCheckIn}
- disabled={checkInLoading}
- >
- {checkInLoading ? '처리중...' : '출석하기'}
- </button>
- </section>
- )}
- {/* 리스트 */}
- <section className="attendance__section">
- <div className="attendance__list-toolbar">
- <div className="attendance__list-toolbar-left">
- <span className="attendance__list-toolbar-total">총 {listData.total}명</span>
- </div>
- <div className="attendance__list-toolbar-right">
- <select
- title='기간 선택'
- className="attendance__list-toolbar-select"
- value={dayOffset}
- onChange={(e) => handleOffsetChange(e.target.value)}
- >
- <option value="" disabled>기간 선택</option>
- <option value="0">오늘</option>
- <option value="1">1일전</option>
- <option value="2">2일전</option>
- <option value="3">3일전</option>
- <option value="4">4일전</option>
- <option value="5">5일전</option>
- <option value="6">6일전</option>
- <option value="7">7일전</option>
- </select>
- <select
- title='정렬'
- className="attendance__list-toolbar-select"
- value={sortBy}
- onChange={(e) => {
- setSortBy(e.target.value as 'time'|'count'|'streak');
- setListPage(1);
- }}
- >
- <option value="time">시간순</option>
- <option value="count">출석순</option>
- <option value="streak">개근순</option>
- </select>
- </div>
- </div>
- {listLoading && <Loading type={2} />}
- <div className="attendance__list">
- {/* 헤더 */}
- <div className="attendance__list-header">
- <span>순위</span>
- <span>별명</span>
- <span>출석 인사</span>
- <span>출석 일시</span>
- <span>토큰</span>
- <span>경험치</span>
- <span>개근(일)</span>
- </div>
- {/* 데이터 */}
- {listData.list.length > 0 ? (
- listData.list.map((item: AttendanceItem) => (
- <div key={item.rank} className="attendance__list-row">
- {/* PC */}
- <div className="attendance__list-row-pc">
- <span className="attendance__list-rank">{item.rank}</span>
- <span className="attendance__list-member">
- <Avatar className="h-6 w-6">
- {item.memberThumb ? (
- <AvatarImage src={item.memberThumb} alt={item.memberName?.trim() || item.memberSID} />
- ) : null}
- <AvatarFallback>{(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'}</AvatarFallback>
- </Avatar>
- <span>{item.memberName?.trim() || item.memberSID}</span>
- </span>
- <span className="attendance__list-greeting">{item.greeting || '-'}</span>
- <span>{getDateTime(item.createdAt)}</span>
- <span className="attendance__list-point">+{item.pointRewarded}P</span>
- <span className="attendance__list-exp">+{item.expRewarded}</span>
- <span className="attendance__list-streak">{item.consecutiveDays}일</span>
- </div>
- {/* Mobile */}
- <div className="attendance__list-row-mobile">
- <div className="attendance__list-row-mobile-top">
- <span className="attendance__list-rank">{item.rank}</span>
- <Avatar className="h-5 w-5">
- {item.memberThumb ? (
- <AvatarImage src={item.memberThumb} alt={item.memberName?.trim() || item.memberSID} />
- ) : null}
- <AvatarFallback>{(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'}</AvatarFallback>
- </Avatar>
- <span className="attendance__list-member-name">{item.memberName?.trim() || item.memberSID}</span>
- <span className="attendance__list-streak">{item.consecutiveDays}일</span>
- </div>
- <div className="attendance__list-row-mobile-bottom">
- <span className="attendance__list-greeting">{item.greeting || '-'}</span>
- <span className="attendance__list-meta">
- <span className="attendance__list-point">+{item.pointRewarded}P</span>
- <span className="attendance__list-exp">+{item.expRewarded} EXP</span>
- <span>{getDateTime(item.createdAt)}</span>
- </span>
- </div>
- </div>
- </div>
- ))
- ) : (
- <p className="attendance__empty">출석한 회원이 없습니다.</p>
- )}
- </div>
- {listData.total > 0 && (
- <Pagination total={listData.total} page={listPage} perPage={20} onChange={setListPage} />
- )}
- </section>
- </div>
- );
- }
|