diff --git a/frontend/src/App.js b/frontend/src/App.js index f9a9e38..929ddeb 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -5,6 +5,8 @@ import OnboardingPage from './pages/OnboardingPage'; import QnAMainPage from './pages/qna/QnAMainPage'; import QnAListPage from './pages/qna/QnAListPage'; import CurriculumPage from './pages/curriculum/CurriculumPage'; +import PiroCheckMain from './pages/pirocheck/PIroCheckMain'; +import Attendance from './pages/pirocheck/attendance/Attendance' function App() { return ( @@ -16,12 +18,18 @@ function App() { } /> } /> - {/* 헤더 있는 페이지 */} - }> + {/* 라이트 헤더 페이지 */} + }> } /> } /> } /> + + {/* 다크 헤더 페이지 */} + }> + }/> + }/> + diff --git a/frontend/src/assets/images/AngryIcon.svg b/frontend/src/assets/images/AngryIcon.svg new file mode 100644 index 0000000..c3898a6 --- /dev/null +++ b/frontend/src/assets/images/AngryIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/CloverEmpty.svg b/frontend/src/assets/images/CloverEmpty.svg new file mode 100644 index 0000000..d531553 --- /dev/null +++ b/frontend/src/assets/images/CloverEmpty.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/images/CloverGreen.svg b/frontend/src/assets/images/CloverGreen.svg new file mode 100644 index 0000000..a1e3f9b --- /dev/null +++ b/frontend/src/assets/images/CloverGreen.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/images/CloverRed.svg b/frontend/src/assets/images/CloverRed.svg new file mode 100644 index 0000000..880d37f --- /dev/null +++ b/frontend/src/assets/images/CloverRed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/images/Coin1.svg b/frontend/src/assets/images/Coin1.svg new file mode 100644 index 0000000..9c0d880 --- /dev/null +++ b/frontend/src/assets/images/Coin1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/images/Coin2.svg b/frontend/src/assets/images/Coin2.svg new file mode 100644 index 0000000..4904651 --- /dev/null +++ b/frontend/src/assets/images/Coin2.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/Coin3.svg b/frontend/src/assets/images/Coin3.svg new file mode 100644 index 0000000..02a0a2b --- /dev/null +++ b/frontend/src/assets/images/Coin3.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 2408fce..a994e66 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,9 +1,9 @@ import { NavLink } from 'react-router-dom'; import styles from './Header.module.css'; -function Header() { +function Header({ type }) { return ( - + PIROIN isActive ? styles.active : ''}>PIROCHECK diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css index a13c12f..585049b 100644 --- a/frontend/src/components/Header.module.css +++ b/frontend/src/components/Header.module.css @@ -1,12 +1,26 @@ +/* 라이트 헤더 */ +.light { + --header-bg: var(--white); + --header-color: var(--gray600); + --logo-color: var(--dark); +} + +/* 다크 헤더 */ +.dark { + --header-bg: var(--black); + --header-color: var(--white); + --logo-color: var(--main); + border-bottom: none; /* 추가 */ +} + .header { width: 100%; - height: 100px; - background: #fff; + height: 70px; + background: var(--header-bg); display: flex; align-items: center; padding: 0 80px; box-sizing: border-box; - border-bottom: 1px solid #eee; position: relative; position: sticky; top: 0; @@ -14,9 +28,9 @@ } .logo { - color: var(--dark); + color: var(--logo-color); font-family: var(--font-title); - font-size: 3rem; + font-size: 2.8rem; font-weight: 800; text-decoration: none; } @@ -31,19 +45,19 @@ } .nav a { - color: var(--gray600); + color: var(--header-color); text-align: center; font-family: var(--font-main); - font-size: 1.5rem; + font-size: 1.4rem; font-weight: 500; text-decoration: none; } .nav a:hover { - color: var(--dark); + color: var(--logo-color); } .active { - color: var(--dark) !important; + color: var(--logo-color) !important; font-weight: 700; } \ No newline at end of file diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index d7d3da3..4a971e0 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,10 +1,10 @@ import Header from './Header'; import { Outlet } from 'react-router-dom'; -function Layout() { +function Layout({ headerType }) { return ( <> - + > ); diff --git a/frontend/src/pages/login/LoginPage.js b/frontend/src/pages/login/LoginPage.js index aa8bff7..930239a 100644 --- a/frontend/src/pages/login/LoginPage.js +++ b/frontend/src/pages/login/LoginPage.js @@ -24,6 +24,7 @@ function LoginPage() { if (response.ok) { const data = await response.json(); localStorage.setItem('token', data.token); + localStorage.setItem('role', data.role); navigate('/home'); } else { alert('이름 또는 비밀번호가 올바르지 않습니다.'); diff --git a/frontend/src/pages/pirocheck/PIroCheckMain.js b/frontend/src/pages/pirocheck/PIroCheckMain.js new file mode 100644 index 0000000..d9f8a4a --- /dev/null +++ b/frontend/src/pages/pirocheck/PIroCheckMain.js @@ -0,0 +1,7 @@ +import { useState, useEffect } from 'react'; + + Pirocheck + +function PIroCheckMain() { } + +export default PIroCheckMain; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.js b/frontend/src/pages/pirocheck/attendance/Attendance.js new file mode 100644 index 0000000..64d25e2 --- /dev/null +++ b/frontend/src/pages/pirocheck/attendance/Attendance.js @@ -0,0 +1,192 @@ +import { useState, useEffect } from 'react'; +import styles from './Attendance.module.css'; +import CloverGreen from '../../../assets/images/CloverGreen.svg'; +import CloverRed from '../../../assets/images/CloverRed.svg'; +import CloverEmpty from '../../../assets/images/CloverEmpty.svg'; +import Coin1 from '../../../assets/images/Coin1.svg'; +import Coin2 from '../../../assets/images/Coin2.svg'; +import Coin3 from '../../../assets/images/Coin3.svg'; +import AngryIcon from '../../../assets/images/AngryIcon.svg'; + +function cloverForSlot(status) { + if (status === true) return ; + if (status === false) return ; + return ; +} + +function slotIcon(status) { + if (status === true) return ; + return ; +} + +// ── ADMIN 뷰 ────────────────────────────────────────── +function AdminView() { + const [code, setCode] = useState(null); + const [hasCode, setHasCode] = useState(false); + + useEffect(() => { + const fetchActiveCode = async () => { + try { + const res = await fetch('/api/admin/attendance/active-code'); + if (res.ok) { + const data = await res.json(); + if (!data.isExpired) { + setCode(data.code); + setHasCode(true); + } + } + } catch (e) {} + }; + fetchActiveCode(); + }, []); + + const handleGenerate = async () => { + const res = await fetch('/api/admin/attendance/start', { method: 'POST' }); + const data = await res.json(); + setCode(data.code); + setHasCode(true); + }; + + const handleExpire = async () => { + await fetch('/api/admin/attendance/active-code/expire', { method: 'PUT' }); + setCode(null); + setHasCode(false); + }; + + return ( + <> + ATTENDANCE CHECK + + {[0, 1, 2, 3].map((i) => ( + + {code ? code[i] : ''} + + ))} + + + + {hasCode ? '재생성' : '출석코드 생성'} + + {hasCode && ( + + 종료 + + )} + 출석 관리 + + > + ); +} + +// ── MEMBER 뷰 ───────────────────────────────────────── +function MemberView() { + const [inputCode, setInputCode] = useState(''); + const [message, setMessage] = useState(''); + const [todaySlots, setTodaySlots] = useState([]); + const [history, setHistory] = useState([]); + + useEffect(() => { + // 1~5주차 기본값 세팅 + const defaultHistory = [1, 2, 3, 4, 5].map(week => ({ + week, + slots: [ + { status: false }, + { status: false }, + { status: false }, + ] + })); + + fetch('/api/attendance/user') + .then(r => r.json()) + .then(data => { + const apiData = data.data || []; + // API 데이터로 해당 주차 덮어씌우기 + const merged = defaultHistory.map(def => { + const found = apiData.find(d => d.week === def.week); + return found || def; + }); + setHistory(merged); + }) + .catch(() => setHistory(defaultHistory)); +}, []); + + const handleSubmit = async () => { + if (!inputCode.trim()) return; + const res = await fetch('/api/attendance/mark', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: inputCode }), + }); + const data = await res.json(); + const result = data.data; + + if (result.statusCode === 'SUCCESS') { + setMessage('출석 성공!'); + const today = new Date().toISOString().split('T')[0]; + fetch(`/api/attendance/user/date?date=${today}`) + .then(r => r.json()) + .then(d => setTodaySlots(d.data || [])); + } else if (result.statusCode === 'INVALID_CODE') { + setMessage('출석 코드를 확인해주세요.'); + } else { + setMessage(result.message); + } + + setInputCode(''); + }; + + const displaySlots = [0, 1, 2].map(i => todaySlots[i] ?? { status: null }); + + return ( + <> + ATTENDANCE CHECK + + + setInputCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + maxLength={4} + /> + 출석 + + + {message && {message}} + + + {displaySlots.map((slot, i) => ( + {cloverForSlot(slot.status)} + ))} + + + + {history.map((row, i) => ( + + {row.week}주차 + + {[0, 1, 2].map(j => { + const slot = row.slots[j] ?? { status: false }; + return {slotIcon(slot.status)}; + })} + + + ))} + + > + ); +} + +// ── 메인 컴포넌트 ───────────────────────────────────── +function Attendance() { + const role = localStorage.getItem('role') || 'MEMBER'; + + return ( + + {role === "ADMIN" ? : } + + ); +} + +export default Attendance; \ No newline at end of file diff --git a/frontend/src/pages/pirocheck/attendance/Attendance.module.css b/frontend/src/pages/pirocheck/attendance/Attendance.module.css new file mode 100644 index 0000000..8d2bf75 --- /dev/null +++ b/frontend/src/pages/pirocheck/attendance/Attendance.module.css @@ -0,0 +1,167 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 20px; + min-height: 80vh; + background: var(--black); + justify-content: center; +} + +.title { + font-family: var(--font-title); + font-size: 3.5rem; + font-weight: 800; + color: var(--main); + margin-bottom: 50px; + letter-spacing: 0.05em; +} + +/* ── ADMIN ── */ +.codebox { + display: flex; + gap: 16px; + margin-bottom: 40px; +} + +.code { + width: 90px; + height: 110px; + background: var(--gray600); + border-radius: 12px; + border: 1.5px solid var(--gray50); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-title); + font-size: 3rem; + font-weight: 700; + color: var(--white); +} + +.manage { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} + +.createBtn { + width: 220px; + padding: 14px 0; + background: var(--dark); + color: var(--white); + border: none; + border-radius: 10px; + font-family: var(--font-main); + font-size: 1.2rem; + font-weight: 550; + cursor: pointer; + transition: opacity 0.2s; +} + +.createBtn:hover { background: var(--main); transition: all ease-in-out 0.2s;} + +.manageLink { + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + text-decoration: none; + opacity: 0.7; + cursor: pointer; +} + +.manageLink:hover { opacity: 1; } + +/* ── MEMBER ── */ +.inputRow { + display: flex; + align-items: center; + background: var(--gray600); + border-radius: 10px; + overflow: hidden; + width: 480px; + margin-bottom: 16px; + padding: 3px 3px 3px 20px; +} + +.codeInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; +} + +.codeInput::placeholder { color: var(--gray200); } + +.submitBtn { + padding: 10px 20px; + background: var(--dark); + border: none; + border-radius: 10px; + color: var(--white); + font-family: var(--font-main); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; +} + +.submitBtn:hover { background: var(--main); transition: all ease-in-out 0.2s; } + +.msg { + color: var(--main); + font-family: var(--font-main); + font-size: 0.95rem; + margin-bottom: 20px; + opacity: 0.9; +} + +.cloverSvg { + width: 70px; + height: 70px; +} + +.cloverRow { + display: flex; + gap: 50px; + margin-top: 40px; + margin-bottom: 40px; +} + +.weekLabel { + color: var(--white); + font-family: var(--font-main); + font-size: 1.1rem; + font-weight: 500; + min-width: 60px; +} + +.historyRow { + display: flex; + align-items: center; + justify-content: space-between; +} + +.histSvg { + width: 30px; + height: 30px; +} + +.historySlots { + display: flex; + gap: 30px; +} + +.historyBox { + background: var(--gray600); + border-radius: 16px; + padding: 28px 50px; + width: 250px; + display: flex; + flex-direction: column; + gap: 16px; +} \ No newline at end of file