'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(() => { 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([]); 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({ 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(`/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(`/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('/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(
); } // 날짜 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(
handleDateClick(dateStr)}> {d} {(attended || totalCount > 0) && ( {attended && } {totalCount > 0 && ( <> {totalCount} )} )}
); } const isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth() + 1; return (
{calYear}년 {calMonth}월
{weekdays.map(w => (
{w}
))} {cells}
); }; return (

출석부

출석부 안내

기본 보상

매일 출석 시 경험치 {config.attendance?.baseExp ?? 0}, 토큰 {config.attendance?.basePoint ?? 0}P가 지급됩니다.

{config.attendance?.useStreakBonus && (

연속 출석 보상

연속 출석 시 1일당 추가 경험치 +{config.attendance.streakBonusPerDay}, 토큰 +{config.attendance.streakBonusPointPerDay}P가 지급됩니다.

{config.attendance.streakBonusMaxDays > 0 && (

최대 {config.attendance.streakBonusMaxDays}일까지 적용됩니다.

)}
)} {config.attendance?.useRankBonus && rankBonuses.length > 0 && (

순위 보상

출석 순위에 따라 추가 보상이 지급됩니다.

{rankBonuses.map((entry) => ( ))}
순위 추가 경험치 추가 토큰
{entry.rank}등 +{entry.exp} +{entry.point}P
)}
{/* 캘린더 */}
{calLoading && } {!calLoading && renderCalendar()}
{/* 출석 입력 (오늘 미출석 시만 노출) */} {!attendedToday && (
setGreeting(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleCheckIn(); } }} disabled={checkInLoading} />
)} {/* 리스트 */}
총 {listData.total}명
{listLoading && }
{/* 헤더 */}
순위 별명 출석 인사 출석 일시 토큰 경험치 개근(일)
{/* 데이터 */} {listData.list.length > 0 ? ( listData.list.map((item: AttendanceItem) => (
{/* PC */}
{item.rank} {item.memberThumb ? ( ) : null} {(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'} {item.memberName?.trim() || item.memberSID} {item.greeting || '-'} {getDateTime(item.createdAt)} +{item.pointRewarded}P +{item.expRewarded} {item.consecutiveDays}일
{/* Mobile */}
{item.rank} {item.memberThumb ? ( ) : null} {(item.memberName?.trim() || item.memberSID)?.[0] ?? '?'} {item.memberName?.trim() || item.memberSID} {item.consecutiveDays}일
{item.greeting || '-'} +{item.pointRewarded}P +{item.expRewarded} EXP {getDateTime(item.createdAt)}
)) ) : (

출석한 회원이 없습니다.

)}
{listData.total > 0 && ( )}
); }