diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cc3532a5..1dcf3d26 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,9 @@ import SignUp from './pages/SignUp'; import QuantTradingDashboard from './pages/QuantTradingDashboard'; import BacktestResult from './pages/BacktestResult.jsx'; +import CheckInPage from './components/attendancemanage/qrmanagement/CheckInPage.jsx'; +import QrRenderPage from './components/attendancemanage/qrmanagement/QrRenderPage.jsx'; + import OAuthSuccess from './pages/OAuthSuccess.jsx'; import Main from './pages/external/Main.jsx'; @@ -62,6 +65,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/attendancemanage/RoundDayPicker.jsx b/frontend/src/components/attendancemanage/RoundDayPicker.jsx index 20b6619b..30e4fdc6 100644 --- a/frontend/src/components/attendancemanage/RoundDayPicker.jsx +++ b/frontend/src/components/attendancemanage/RoundDayPicker.jsx @@ -1,18 +1,21 @@ import { useState, useEffect } from 'react'; import styles from '../VerificationModal.module.css'; - import { DayPicker } from 'react-day-picker'; import 'react-day-picker/style.css'; import { useAttendance } from '../../contexts/AttendanceContext'; const RoundDayPicker = () => { - const { sessions, selectedSessionId, handleAddRounds, closeAddRoundsModal } = + const { selectedSessionId, handleAddRounds, closeAddRoundsModal } = useAttendance(); - const [selectedRounds, setSelectedRounds] = useState([]); + const [selectedDate, setSelectedDate] = useState(); + const [roundName, setRoundName] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [locationName, setLocationName] = useState(''); + const today = new Date(); - // ESC 키로 모달을 닫는 기능 useEffect(() => { const handleKeyDown = (event) => { if (event.key === 'Escape') { @@ -21,44 +24,49 @@ const RoundDayPicker = () => { }; document.addEventListener('keydown', handleKeyDown); - return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [closeAddRoundsModal]); - const handleComplete = () => { - const currentSession = sessions.find( - (s) => s.attendanceSessionId === selectedSessionId - ); - - if (!currentSession) { + const handleComplete = async () => { + if (!selectedSessionId) { alert('세션을 먼저 선택해주세요.'); return; } - if (selectedRounds.length === 0) { - alert('추가할 날짜를 1개 이상 선택해주세요.'); + + if ( + !selectedDate || + !roundName || + !startTime || + !endTime || + !locationName + ) { + alert('모든 항목을 입력해주세요.'); return; } - const newRounds = selectedRounds.map((date) => { - const timeZoneOffset = date.getTimezoneOffset() * 60000; - const dateWithoutOffset = new Date(date.getTime() - timeZoneOffset); - const dateString = dateWithoutOffset.toISOString().split('T')[0]; + // 날짜 문자열 (YYYY-MM-DD) + const roundDate = selectedDate.toLocaleDateString('sv-SE'); - return { - // id: `round-${uuid()}`, - roundDate: dateString, - startTime: currentSession.defaultStartTime, - availableMinutes: currentSession.defaultAvailableMinutes, - // status: 'opened', - // participants: [], - }; - }); + const startAt = `${roundDate}T${startTime}:00`; + const closeAt = `${roundDate}T${endTime}:00`; - handleAddRounds(selectedSessionId, newRounds); + const newRound = { + roundDate, + startAt, + closeAt, + roundName, + locationName, + }; - closeAddRoundsModal(); + try { + await handleAddRounds(selectedSessionId, [newRound]); + console.log('새로운 라운드 데이터:', newRound); + closeAddRoundsModal(); + } catch (err) { + alert('라운드 생성에 실패했습니다.'); + } }; return ( @@ -69,23 +77,48 @@ const RoundDayPicker = () => { + + +
+ setRoundName(e.target.value)} + /> + + setStartTime(e.target.value)} + /> + + setEndTime(e.target.value)} + /> + + setLocationName(e.target.value)} + /> +
+
-

세션에 추가하고 싶은 날짜를 선택하세요.

-

(출석 시작 시간 & 인정 시간은 세션의 디폴트 값으로 설정됨)

+ + + ); + }) ) : ( - + 회차 정보가 없습니다. diff --git a/frontend/src/components/attendancemanage/SessionSettingCard.jsx b/frontend/src/components/attendancemanage/SessionSettingCard.jsx index 631ab939..c32ac6ce 100644 --- a/frontend/src/components/attendancemanage/SessionSettingCard.jsx +++ b/frontend/src/components/attendancemanage/SessionSettingCard.jsx @@ -6,58 +6,43 @@ const SessionSettingCard = ({ styles: commonStyles }) => { const { handleAddSession } = useAttendance(); const [sessionTitle, setSessionTitle] = useState(''); - const [hh, setHh] = useState(''); - const [mm, setMm] = useState(''); - const [ss, setSs] = useState(''); - const [availableTimeMm, setAvailableTimeMm] = useState(''); + const [description, setDescription] = useState(''); + const [allowedMinutes, setAllowedMinutes] = useState(''); + const [status, setStatus] = useState('OPEN'); - const isFormValid = (title, hour, minute, second, availableMinute) => { - if (!title) { + const handleCreateClick = async () => { + const parsedMinutes = parseInt(allowedMinutes, 10); + + if (!sessionTitle.trim()) { alert('세션 이름을 입력해주세요.'); - return false; - } - if (isNaN(hour) || hour < 0 || hour > 23) { - alert('출석 시작 시간(시)은 0-23 사이의 숫자로 입력해주세요.'); - return false; - } - if (isNaN(minute) || minute < 0 || minute > 59) { - alert('출석 시작 시간(분)은 0-59 사이의 숫자로 입력해주세요.'); - return false; - } - if (isNaN(second) || second < 0 || second > 59) { - alert('출석 시작 시간(초)은 0-59 사이의 숫자로 입력해주세요.'); - return false; + return; } - if (isNaN(availableMinute) || availableMinute < 0 || availableMinute > 59) { - alert('출석 가능 시간(분)은 0-59 사이의 숫자로 입력해주세요.'); - return false; + + if (isNaN(parsedMinutes) || parsedMinutes <= 0) { + alert('출석 가능 시간을 올바르게 입력해주세요.'); + return; } - return true; - }; - const handleCreateClick = () => { - const title = sessionTitle.trim(); - const hour = parseInt(hh, 10); - const minute = parseInt(mm, 10); - const second = parseInt(ss, 10); - const availableMinute = parseInt(availableTimeMm, 10); - - // 유효성 검사 - if (!isFormValid(title, hour, minute, second, availableMinute)) return; - - handleAddSession(sessionTitle, { - hh: hh.padStart(2, '0'), - mm: mm.padStart(2, '0'), - ss: ss.padStart(2, '0'), - availableTimeMm: availableMinute, - }); - - // 입력 창 초기화 - setSessionTitle(''); - setHh(''); - setMm(''); - setSs(''); - setAvailableTimeMm(''); + const requestBody = { + title: sessionTitle.trim(), + description: description.trim(), + allowedMinutes: parsedMinutes, + status: status, + }; + + try { + await handleAddSession(requestBody); + + // 초기화 + setSessionTitle(''); + setDescription(''); + setAllowedMinutes(''); + setStatus('OPEN'); + + alert('세션이 생성되었습니다.'); + } catch (err) { + alert('세션 생성 실패'); + } }; return ( @@ -65,7 +50,9 @@ const SessionSettingCard = ({ styles: commonStyles }) => {

세션 설정

+
+ {/* 세션 이름 */}
+ + {/* 세션 설명 */}
-
+ + {/* 출석 가능 시간 */}
setAvailableTimeMm(e.target.value)} + value={allowedMinutes} + maxLength="3" + onChange={(e) => setAllowedMinutes(e.target.value)} placeholder="분(MM)" /> -
+ + {/* 세션 상태 */} +
+ + +
+ +
+ +
); diff --git a/frontend/src/components/attendancemanage/qrmanagement/CheckInPage.jsx b/frontend/src/components/attendancemanage/qrmanagement/CheckInPage.jsx new file mode 100644 index 00000000..fb7ea800 --- /dev/null +++ b/frontend/src/components/attendancemanage/qrmanagement/CheckInPage.jsx @@ -0,0 +1,48 @@ +// import { useEffect, useState } from 'react'; +// import { useNavigate, useSearchParams } from 'react-router-dom'; +// // import api from '../../../utils/attendanceManage'; + +// const CheckInPage = () => { +// const [searchParams] = useSearchParams(); +// const token = searchParams.get('token'); +// const navigate = useNavigate(); +// const [message, setMessage] = useState('출석 처리 중...'); + +// useEffect(() => { +// const accessToken = localStorage.getItem('accessToken'); + +// if (!accessToken) { +// navigate(`/login?returnUrl=${encodeURIComponent(window.location.href)}`); +// return; +// } + +// const checkIn = async () => { +// try { +// await api.post('/api/attendance/check-in', { +// token, +// }); + +// setMessage('출석이 완료되었습니다!'); +// setTimeout(() => navigate('/'), 2000); +// } catch (err) { +// setMessage( +// err.response?.data?.message || '이미 출석했거나 만료된 QR입니다.' +// ); +// } +// }; + +// if (token) { +// checkIn(); +// } else { +// setMessage('잘못된 접근입니다.'); +// } +// }, [token, navigate]); + +// return ( +//
+//

{message}

+//
+// ); +// }; + +// export default CheckInPage; diff --git a/frontend/src/components/attendancemanage/qrmanagement/QrRenderPage.jsx b/frontend/src/components/attendancemanage/qrmanagement/QrRenderPage.jsx new file mode 100644 index 00000000..2b72d32f --- /dev/null +++ b/frontend/src/components/attendancemanage/qrmanagement/QrRenderPage.jsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { QRCodeSVG } from 'qrcode.react'; +import { connectRoundQrStream } from '../../../utils/qrManage'; + +const QrRenderPage = () => { + const [searchParams] = useSearchParams(); + const roundId = searchParams.get('roundId'); + const [qrData, setQrData] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + if (!roundId) { + setError('roundId가 없습니다.'); + return; + } + + const es = connectRoundQrStream( + roundId, + (data) => { + setQrData(data); + }, + (err) => { + setError('QR 스트림 연결에 실패했습니다.'); + } + ); + + return () => es.close(); + }, [roundId]); + + return ( +
+

QR 코드

+ + {error ? ( +

{error}

+ ) : qrData ? ( + + ) : ( +

QR 생성 중...

+ )} +
+ ); +}; + +export default QrRenderPage; diff --git a/frontend/src/contexts/AttendanceContext.jsx b/frontend/src/contexts/AttendanceContext.jsx index 9d3cd457..7b167826 100644 --- a/frontend/src/contexts/AttendanceContext.jsx +++ b/frontend/src/contexts/AttendanceContext.jsx @@ -19,7 +19,7 @@ import { getRounds, } from '../utils/attendanceManage'; -const AttendanceContext = createContext(null); +export const AttendanceContext = createContext(null); // 세션 목 데이터 const sessionData = [ @@ -81,18 +81,19 @@ export const AttendanceProvider = ({ children }) => { const [roundAttendanceVersion, setRoundAttendanceVersion] = useState(0); // 최초, setSessions가 호출될때마다 모든 세션 불러오기 + const fetchSessions = useCallback(async () => { + try { + const res = await getAttendanceSessions(); + setSessions(res || []); + } catch (error) { + console.error('모든 세션 데이터를 가져오는 데 실패했습니다: ', error); + setSessions([]); + } + }, [setSessions]); + useEffect(() => { - const fetchSessions = async () => { - try { - const res = await getAttendanceSessions(); - setSessions(res || []); - } catch (error) { - console.error('모든 세션 데이터를 가져오는 데 실패했습니다: ', error); - setSessions([]); - } - }; fetchSessions(); - }, [setSessions]); + }, [fetchSessions]); const handleAttendanceChange = async (memberId, newAttendance) => { // setSessions((draft) => { @@ -179,44 +180,15 @@ export const AttendanceProvider = ({ children }) => { const openAddRoundsModal = () => setAddRoundsModalOpen(true); const closeAddRoundsModal = () => setAddRoundsModalOpen(false); - const handleAddSession = async (sessionTitle, sessionDetails) => { - const newSession = { - // id: `session-${uuid()}`, - title: sessionTitle, - defaultStartTime: `${sessionDetails.hh}:${sessionDetails.mm}:${sessionDetails.ss}`, - // defaultAvailableMinutes: parseInt(roundDetails.availableTimeMm, 10), - allowedMinutes: sessionDetails.availableTimeMm, // 최소 5분 이상이여야 함 - rewardPoints: 100, - // 위도, 경도, 범위 미터는 임시로 지정 - latitude: 1, - longitude: 2, - radiusMeters: 3, - // isVisible: true, - // rounds: [ - // { - // id: `round-${uuid()}`, - // date: new Date().toISOString().slice(0, 10), - // startTime: `${roundDetails.hh}:${roundDetails.mm}:${roundDetails.ss}`, - // availableMinutes: parseInt(roundDetails.availableTimeMm, 10), - // status: 'opened', - // participants: [], - // }, - // ], - }; - // setSessions((draft) => { - // draft.push(newSession); - // }); - + const handleAddSession = async (sessionData) => { try { - await createAttendanceSession(newSession); - - const updatedSessions = await getAttendanceSessions(); - setSessions(updatedSessions || []); + await createAttendanceSession(sessionData); + await fetchSessions(); } catch (error) { - console.error('세션 추가에 실패했습니다. ', error); + console.error('세션 생성에 실패했습니다.', error); + throw error; } }; - const handleAddRounds = async (sessionId, newRounds) => { // setSessions((draft) => { // const session = draft.find((session) => session.id === sessionId); diff --git a/frontend/src/utils/attendanceManage.js b/frontend/src/utils/attendanceManage.js index 27444296..861ff3b1 100644 --- a/frontend/src/utils/attendanceManage.js +++ b/frontend/src/utils/attendanceManage.js @@ -19,7 +19,6 @@ export const getAttendanceSessions = async () => { try { const res = await api.get('/api/attendance/sessions'); // console.log('API BASE URL:', import.meta.env.VITE_API_URL); - console.log(res.data); return res.data; } catch (err) { console.error('출석 세션 불러오기 중 오류 발생: ', err); @@ -62,17 +61,18 @@ export const getRounds = async (sessionId) => { // 회차 추가 export const addRound = async (sessionId, newRound) => { - const paylaod = { - sessionId, + const payload = { roundDate: newRound.roundDate, - startTime: newRound.startTime, - allowedMinutes: newRound.availableMinutes, + startAt: newRound.startAt, + closeAt: newRound.closeAt, + roundName: newRound.roundName, + locationName: newRound.locationName, }; try { const res = await api.post( `/api/attendance/sessions/${sessionId}/rounds`, - paylaod + payload ); return res.data; } catch (err) { @@ -172,7 +172,7 @@ export const getSessionAttendance = async (sessionId) => { export const changeUserAttendance = async (roundId, userId, statusDetails) => { try { const res = await api.put( - `/api/attendance/rounds/${roundId}/attendances/${userId}`, + `/api/attendance/rounds/${roundId}/users/${userId}`, statusDetails ); return res.data; @@ -185,7 +185,7 @@ export const changeUserAttendance = async (roundId, userId, statusDetails) => { // 라운드 별 출석 조회 export const getRoundUserAttendance = async (roundId) => { try { - const res = await api.get(`/api/attendance/rounds/${roundId}/attendances`); + const res = await api.get(`/api/attendance/rounds/${roundId}/records`); return res.data; } catch (err) { console.error('라운드별 출석 조회 중 오류 발생', err); diff --git a/frontend/src/utils/qrManage.js b/frontend/src/utils/qrManage.js new file mode 100644 index 00000000..a69de80a --- /dev/null +++ b/frontend/src/utils/qrManage.js @@ -0,0 +1,44 @@ +import { api } from './axios.js'; + +// 라운드 QR SSE 연결 +export const connectRoundQrStream = (roundId, onMessage, onError) => { + const baseURL = api.defaults.baseURL; + + const url = `${baseURL}/api/attendance/rounds/${roundId}/qr-stream`; + + const eventSource = new EventSource(url, { + withCredentials: true, + }); + + eventSource.onopen = () => { + console.log('SSE 연결 성공'); + }; + + // 중요 + eventSource.addEventListener('qrToken', (event) => { + const parsed = JSON.parse(event.data); + onMessage?.(parsed); + }); + + eventSource.onerror = (err) => { + console.error('QR SSE 연결 오류', err); + onError?.(err); + eventSource.close(); + }; + + return eventSource; +}; + +// QR 체크인 요청 +export const checkInWithQr = async (token) => { + try { + const res = await api.post('/api/attendance/check-in', { + token, + }); + + return res.data; + } catch (err) { + console.error('QR 출석 체크 중 오류 발생', err); + throw err; + } +}; diff --git a/package-lock.json b/package-lock.json index f84f7a38..2b2ae07d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,30 @@ "name": "sisc-web", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "qrcode.react": "^4.2.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 00000000..29135d9e --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "qrcode.react": "^4.2.0" + } +}