diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 51d359b..a1fe8c0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -27,7 +27,7 @@ module.exports = { "semi": ["error", "always"], "no-duplicate-imports": "error", "no-console": ["warn", { "allow": ["warn", "error", "info"] }], - "no-unused-vars": "warn", + "no-unused-vars": "off", "no-multiple-empty-lines": "error" }, } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bc1477a..f6b1d1e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,11 @@ jobs: with: node-version: '20' + - name: .env setting + run: | + echo "VITE_BASE_URL=${{secrets.VITE_BASE_URL}}" >> .env.production + echo "VITE_GTM_ID=${{secrets.VITE_GTM_ID}}" >> .env.production + - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index a547bf3..29e772c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist-ssr *.njsproj *.sln *.sw? + +.env* + +# Local Netlify folder +.netlify diff --git a/README.md b/README.md index 0d6babe..6ca693c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,239 @@ -# React + TypeScript + Vite +# Tutorial-Sejong -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +
+ +
+ Tutorial-Sejong은 실제와 유사한 환경에서 수강 신청을 연습할 수 있는 서비스를 제공해 학우들의 수강신청 준비에 도움을 드리고자 만들게 된 서비스 입니다. +
+ 수강신청이 처음이거나 오랜만 또는 연습이 필요한 세종대 학우들을 위해 최대한 세종대학교 학사시스템 UI와 비슷하게 제작했습니다. +
-Currently, two official plugins are available: +## 목차 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + 1. [개요](#개요) +
+ 2. [주요 기능](#주요-기능) +
+ 3. [팀 소개](#팀-소개) +
+ 4. [기술스택](#기술스택) +
+ 5. [화면](#화면) +
+ 6. [디렉토리 구조](#디렉토리-구조) -## Expanding the ESLint configuration +## 개요 -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +배포 주소: https://tutorial-sejong.com/ +
+프론트 깃허브 주소: https://github.com/tutorial-sejong/cr-frontend +
+백엔드 깃허브 주소: https://github.com/tutorial-sejong/cr-backend -- Configure the top-level `parserOptions` property like this: +## 팀 소개 + + + + + + + + + + + + + + + +
Tutorial-Sejong
안정현
깃허브
오지현
깃허브
문지원
깃허브
황수빈
깃허브
+ +## 주요 기능 + +### 💡2025년 1학기 시간표 검색 + +- 해당 학기에 맞는 시간표로 업데이트 됩니다. + +### 💡관심 과목 담기 + +- 관심 과목을 기반으로 생성된 시간표를 확인할 수 있습니다. +- 어떤 강의를 많이 담았는지 인기 관심 과목 순위로 알 수 있습니다. + +### 💡수강신청 + +- 학과와 수강신청 날짜(본인학년/전학년)를 선택해 신청할 수 있는 학과를 제한할 수 있습니다. +- 신청 시 10% 확률로 수강 여석이 없을 수 있습니다. +- 시작 후 35초가 지나면 모든 과목의 수강 여석이 마감됩니다. (제한 시간은 변경할 수 있습니다.) + +## 기술스택 + +### 프론트엔드 + + + + +### 백엔드 + + + + + + + +### 협업 툴 + + + +## 화면 + +
+화면 접기/펼치기 +
+ +### 로그인 + + + +- 회원가입 없이 임의 학번으로 로그인 +- 동일한 학번과 비밀번호로 접속 시 관심과목 등 데이터 유지 + +### 시간표 검색 + + + +- 메뉴바 하단에서 인기 관심 과목 순위 확인 가능 + + + +- 과목 클릭시 상세 정보 모달 + +### 관심과목 담기 + + + + + +- 시간표 버튼 클릭했을 때 관심 과목이 없는 경우 + +![Untitledvideo-MadewithClipchamp2-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/867c1772-832b-4efc-9257-74c3c74f336b) + +- 관심 과목 기반 시간표 생성 +- 시간이 겹치는 경우: + - 시작 시간이 같은 경우 -> 끝나는 시간이 빠른 것이 앞으로 + - 시작 시간이 다른 경우 -> 시작 시간이 늦은 것이 앞으로 + +### 수강신청 + + + +- 학과, 수강신청 날짜 선택 가능 +- 선택 안 할 시 전학년으로 지정 + + + +- 헤더의 시간에 맞춰 시작 +- 시작 버튼을 눌러야 검색 가능 +- 시간 제한: + - 기본값 35초 + - 최소/최대값: 10초, 1분(3600초) + - 최소/최대값을 벗어난 값 입력시 최소/최대값으로 자동 조정 + + + +- 랜덤으로 매크로 방지 이미지 생성 + + + +- 시작 버튼을 누르고 지정한 제한 시간이 지났거나 10%의 확률로 실패 + + + +- 확인 버튼 누를 시 수강 신청 실패로 간주, 새로고침 + +### 404 + + + +- 잘못된 경로 또는 서버 오류시 보여지는 화면 + +
+
+
+ +## 디렉토리 구조 -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} ``` +cs-frontend +├─ .eslintrc.cjs : lint 규칙 +├─ .prettierrc : prettier 설정 +├─ index.html +├─ package-lock.json +├─ package.json +├─ README.md +├─ src +│ ├─ apis +│ │ ├─ api : api 요청/응답 코드 폴더 +│ │ │ ├─ auth.ts : 로그인 및 인증 관련 코드 +│ │ │ └─ course.ts : 강의 및 수강신청 관련 코드 +│ │ └─ utils : 인스턴스 / 공통 함수 폴더 +│ ├─ App.tsx +│ ├─ assets +│ │ ├─ data +│ │ │ ├─ constant.ts : 상수 데이터 +│ │ │ └─ filter.ts : 필터 옵션 데이터 +│ │ ├─ img : 아이콘이나 로고 등 필요한 이미지 폴더 +│ │ └─ types : 자주 쓰이는 타입 분리 폴더 +│ ├─ components +│ │ ├─ common : 여러 곳에서 쓰이는 컴포넌트 폴더 +│ │ │ ├─ FilterButton.tsx : 검색, 조회 등 필터 적용 버튼 +│ │ │ ├─ FilterInput.tsx +│ │ │ ├─ Modal +│ │ │ │ ├─ handlers +│ │ │ │ │ └─ handler.tsx +│ │ │ │ ├─ AntiMacroCodeModal.tsx : 매크로 모달 +│ │ │ │ ├─ EnrollmentInfoModal.tsx : 수강인원 등 강의 정보 모달 +│ │ │ │ ├─ ErrorModal.tsx +│ │ │ │ ├─ InfoModal.tsx : 수강신청 모달 +│ │ │ │ ├─ LoadingModal.tsx +│ │ │ │ ├─ RankInfoModal.tsx : 인기 관심 과목 상세 정보 모달 +│ │ │ │ ├─ TimetableModal : 시간표 모달 +│ │ │ │ └─ WaitingModal.tsx : 접속 대기 모달 +│ │ │ ├─ SelectBox.tsx : 필터 드롭다운 +│ │ │ └─ Table : 강의 목록 테이블 +│ │ ├─ CourseRegister : 수강신청 탭 +│ │ ├─ DeleteAccount +│ │ ├─ Header +│ │ │ ├─ ... +│ │ │ └─ TopNav.tsx : 타이틀 +│ │ ├─ LectureList : 시간표 검색 탭 +│ │ ├─ LoginForm +│ │ ├─ Menubar : 사이드 메뉴 +│ │ ├─ ProtectedRoute.tsx : 사용자 인증 여부에 따른 접근 제한 +│ │ ├─ TabMenu +│ │ ├─ Wishlist : 관심과목 탭 +│ │ └─ WishRank : 인기 관심 과목 +│ ├─ custom.d.ts : svg 관련 설정 파일 +│ ├─ main.tsx +│ ├─ pages +│ │ ├─ DeleteAccount.tsx +│ │ ├─ index +│ │ │ ├─ Home.tsx +│ │ │ ├─ Login.tsx +│ │ │ └─ NotFound.tsx : 에러 페이지 +│ │ └─ Maintenance.tsx : 리뉴얼 중 페이지 +│ ├─ store : 리덕스 툴킷 관련 폴더 +│ │ ├─ hooks +│ │ │ └─ index.ts +│ │ ├─ modules : 슬라이스 +│ │ └─ store.ts +│ ├─ styles : 공통 스타일 +│ ├─ utils +│ │ ├─ randomUtils.ts : 랜덤 학번 및 숫자 생성 +│ │ └─ scrollToTop.ts : 페이지 이동 시 스크롤 초기화 +│ └─ vite-env.d.ts +├─ tsconfig.app.json +├─ tsconfig.json +├─ tsconfig.node.json +└─ vite.config.ts -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +``` diff --git a/index.html b/index.html index b0d4a23..fc39472 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - 수강신청 + Tutorial Sejong
diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..0989bf6 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] + command = "npm run build" + publish = "dist" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package-lock.json b/package-lock.json index c897eae..671b17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,27 @@ "version": "0.0.0", "dependencies": { "@reduxjs/toolkit": "^2.2.6", + "@types/react-responsive": "^8.0.8", "axios": "^1.7.2", + "js-cookie": "^3.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-ga4": "^2.1.0", "react-redux": "^9.1.2", + "react-responsive": "^10.0.0", "react-router-dom": "^6.24.0", + "react-window": "^1.8.10", "redux-persist": "^6.0.0", "styled-components": "^6.1.11", "styled-reset": "^4.5.2", "vite-plugin-svgr": "^4.2.0" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/node": "^20.14.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-window": "^1.8.8", "@types/styled-components": "^5.1.34", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", @@ -32,6 +39,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "prettier": "^3.3.2", + "terser": "^5.31.3", "typescript": "^5.2.2", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2" @@ -330,6 +338,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", @@ -918,6 +937,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -1468,6 +1497,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.14.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", @@ -1480,14 +1515,12 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1502,6 +1535,23 @@ "@types/react": "*" } }, + "node_modules/@types/react-responsive": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@types/react-responsive/-/react-responsive-8.0.8.tgz", + "integrity": "sha512-HDUZtoeFRHrShCGaND23HmXAB9evOOTjkghd2wAasLkuorYYitm5A1XLeKkhXKZppcMBxqB/8V4Snl6hRUTA8g==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/styled-components": { "version": "5.1.34", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.34.tgz", @@ -1737,7 +1787,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", - "dev": true, + "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -1877,6 +1927,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1960,6 +2016,12 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "devOptional": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2018,6 +2080,11 @@ "node": ">=4" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" + }, "node_modules/css-to-react-native": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", @@ -2758,6 +2825,11 @@ "react-is": "^16.7.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2867,6 +2939,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3003,6 +3083,19 @@ "yallist": "^3.0.2" } }, + "node_modules/matchmediaquery": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", + "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==", + "dependencies": { + "css-mediaquery": "^0.1.2" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3101,6 +3194,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3292,6 +3393,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3349,11 +3460,15 @@ "react": "^18.3.1" } }, + "node_modules/react-ga4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", + "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-redux": { "version": "9.1.2", @@ -3386,6 +3501,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-responsive": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.0.tgz", + "integrity": "sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.4.2", + "prop-types": "^15.6.1", + "shallow-equal": "^3.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-router": { "version": "6.24.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.0.tgz", @@ -3416,6 +3548,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -3437,6 +3585,11 @@ "redux": "^5.0.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -3553,6 +3706,11 @@ "node": ">=10" } }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -3597,6 +3755,15 @@ "tslib": "^2.0.3" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -3605,6 +3772,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3720,6 +3897,24 @@ "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" }, + "node_modules/terser": { + "version": "5.31.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.3.tgz", + "integrity": "sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==", + "devOptional": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 5d24f9e..d73984b 100644 --- a/package.json +++ b/package.json @@ -5,27 +5,34 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc && vite build", "format": "prettier --write --cache .", "lint": "eslint src/**/*.{ts,tsx} --fix", "preview": "vite preview" }, "dependencies": { "@reduxjs/toolkit": "^2.2.6", + "@types/react-responsive": "^8.0.8", "axios": "^1.7.2", + "js-cookie": "^3.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-ga4": "^2.1.0", "react-redux": "^9.1.2", + "react-responsive": "^10.0.0", "react-router-dom": "^6.24.0", + "react-window": "^1.8.10", "redux-persist": "^6.0.0", "styled-components": "^6.1.11", "styled-reset": "^4.5.2", "vite-plugin-svgr": "^4.2.0" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/node": "^20.14.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-window": "^1.8.8", "@types/styled-components": "^5.1.34", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", @@ -35,6 +42,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "prettier": "^3.3.2", + "terser": "^5.31.3", "typescript": "^5.2.2", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2" diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..f754e1c Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/App.tsx b/src/App.tsx index 41be489..e1a1c05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,41 @@ +import {useEffect} from 'react'; import {Route, Routes} from 'react-router-dom'; import {ThemeProvider} from 'styled-components'; import GlobalStyle from './styles/GlobalStyle'; import {theme} from './styles/theme/Theme'; -import Home from '@pages/index/Home'; -import Login from '@pages/index/Login'; +import ReactGA from 'react-ga4'; +import ProtectedRoute from '@components/ProtectedRoute.tsx'; +import Home from '@pages/index/Home.tsx'; +import Login from '@pages/index/Login.tsx'; +import DeleteAccount from '@pages/DeleteAccount.tsx'; +import ScrollToTop from './utils/scrollToTop'; +import NotFound from './pages/index/NotFound'; + +function initializeAnalytics() { + ReactGA.initialize(import.meta.env.VITE_GTM_ID); + ReactGA.send({hitType: 'pageview', page: '/'}); +} function App() { + useEffect(() => { + initializeAnalytics(); + }, []); return ( + - } /> } /> + } /> + + + + } + /> + } /> ); diff --git a/src/apis/api/auth.ts b/src/apis/api/auth.ts new file mode 100644 index 0000000..086ac48 --- /dev/null +++ b/src/apis/api/auth.ts @@ -0,0 +1,48 @@ +import {baseAPI} from '../utils/instance'; + +interface LoginCredentials { + studentId: string; + password: string; +} +interface LoginResponse { + accessToken: string; + username: string; +} + +export const login = async ( + credentials: LoginCredentials, +): Promise => { + try { + const {data} = await baseAPI.post( + '/api/auth/login', + credentials, + ); + return data; + } catch (error) { + console.log('Login failed: ', error); + throw error; + } +}; + +export const refreshAccessToken = async (): Promise => { + try { + const {data} = await baseAPI.post<{accessToken: string}>( + '/api/auth/refresh', + ); + return data.accessToken; + } catch (error) { + console.log('Failed to refresh access token: ', error); + throw error; + } +}; + + +export const withdrawal = async (studentId: string)=> { + try { + const {data} = await baseAPI.delete(`/api/auth/withdrawal/${studentId}`); + return data; + } catch (error) { + console.log('Failed User withdrawal'); + throw error; + } +}; \ No newline at end of file diff --git a/src/apis/api/course.ts b/src/apis/api/course.ts index cc2eb87..1f207f3 100644 --- a/src/apis/api/course.ts +++ b/src/apis/api/course.ts @@ -1,10 +1,121 @@ +import {CourseTypes} from '@/assets/types/tableType'; import {baseAPI} from '../utils/instance'; -export const getCourseList = async (body: object) => { +export const getCourseList = async (filter: object) => { + const queryParams = new URLSearchParams(); + + Object.entries(filter).forEach(([key, value]) => { + if (value && value.length !== 0 && !value.includes('-')) { + queryParams.append(key, value.toString()); + } + }); + try { - const {data} = await baseAPI.get('/schedules/search', body); + const {data} = await baseAPI.get( + `/schedules/search?${queryParams.toString()}`, + ); return data; } catch (error) { console.log('get course list fail: ', error); } }; + +export const getWishRank = async () => { + try { + const {data} = await baseAPI.get('/schedules/popular?limit=5'); + + return data; + } catch (error) { + console.log('get wish rank fail: ', error); + } +}; + +export const saveWishlistItem = async ( + studentId: string, + scheduleId: number, +) => { + try { + const {data} = await baseAPI.post('/wishlist/save', { + studentId, + scheduleId, + }); + return data; + } catch (error) { + console.error('Save wishlist fail: ', error); + throw error; + } +}; + +export const getWishlist = async ( + studentId: string, +): Promise => { + try { + const {data} = await baseAPI.get(`/wishlist?studentId=${studentId}`); + return data; + } catch (error) { + console.error('Get wishlist fail: ', error); + throw error; + } +}; + +export const deleteWishlistItem = async ( + studentId: string, + scheduleId: number, +) => { + try { + const {data} = await baseAPI.delete( + `/wishlist?studentId=${studentId}&scheduleId=${scheduleId}`, + ); + return data; + } catch (error) { + console.error('Delete wishlist item fail: ', error); + throw error; + } +}; + +export const getRegisterdList = async () => { + try { + const {data} = await baseAPI.get('/registrations'); + return data; + } catch (error) { + console.error('get registerd List fail: ', error); + } +}; + +export const postCourse = async (id: number) => { + try { + const {data} = await baseAPI.post(`/registrations/${id}`); + return data; + } catch (error) { + console.error('post course fail: ', error); + return error; + } +}; + +export const deleteCourse = async (id: number) => { + try { + const {data} = await baseAPI.delete(`/registrations/${id}`); + return data; + } catch (error) { + console.error('delete course fail: ', error); + } +}; + +export const deleteAllRegistrations = async () => { + try { + const {data} = await baseAPI.delete('/registrations/all'); + return data; + } catch (error) { + console.error('모든 수강신청 내역 삭제 실패: ', error); + } +}; + +export const getMacroCode = async () => { + try { + const {data} = await baseAPI.get('/api/auth/macro'); + return data; + } catch (error) { + console.error('Get macro code fail', error); + throw error; + } +}; diff --git a/src/apis/utils/instance.ts b/src/apis/utils/instance.ts index 451ff9d..12c9344 100644 --- a/src/apis/utils/instance.ts +++ b/src/apis/utils/instance.ts @@ -1,4 +1,10 @@ -import axios from 'axios'; +import axios, {AxiosError, AxiosResponse} from 'axios'; +import Cookies from 'js-cookie'; +import {setModalName} from '@/store/modules/modalSlice'; +import {setType} from '@/store/modules/errorSlice'; +import {store} from '@/store/store'; +import {clearUserInfo} from '@/store/modules/userSlice'; +import {resetCourseRegistered} from '@/store/modules/courseRegisteredSlice'; const baseURL = import.meta.env.VITE_BASE_URL; @@ -10,11 +16,87 @@ export const baseAPI = axios.create({ withCredentials: true, }); -// 토큰 받아오는 작업 필요 -export const authAPI = axios.create({ - baseURL: baseURL, - headers: { - // Authorization: `Bearer ${token}`, +let isRefreshing = false; +let subscribers: ((token: string) => void)[] = []; + +function onAccessTokenFetched(accessToken: string) { + subscribers.forEach(callback => callback(accessToken)); + subscribers = []; +} + +function addSubscriber(callback: (token: string) => void) { + subscribers.push(callback); +} + +baseAPI.interceptors.request.use( + config => { + if (!config.headers['Authorization']) { + config.headers['Authorization'] = `Bearer ${Cookies.get('accessToken')}`; + } + + return config; }, - withCredentials: true, -}); + error => { + return Promise.reject(error); + }, +); + +baseAPI.interceptors.response.use( + response => { + return response; + }, + async error => { + const {config, response} = error; + const originalRequest = config; + const code = response.data.code; + + if (code === 'A002' && !originalRequest._retry) { + if (isRefreshing) { + return new Promise(resolve => { + addSubscriber((token: string) => { + originalRequest.headers['Authorization'] = `Bearer ${token}`; + resolve(baseAPI(originalRequest)); + }); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const {data} = await baseAPI.post('/api/auth/refresh'); + + baseAPI.defaults.headers.common['Authorization'] = + `Bearer ${data.accessToken}`; + Cookies.set('accessToken', data.accessToken, {expires: 1}); + onAccessTokenFetched(data.accessToken); + isRefreshing = false; + + originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`; + + return baseAPI(originalRequest); + } catch (err) { + isRefreshing = false; + return Promise.reject(err); + } + } else if (code === 'A001' || code === 'A003') { + store.dispatch(clearUserInfo()); + store.dispatch(resetCourseRegistered()); + delete baseAPI.defaults.headers.common['Authorization']; + location.href = '/'; + Cookies.remove('accessToken'); + } else if (code === 'S001' || code === 'W003') { + // 검색결과 없음 + return Promise.resolve({...error.response, data: []} as AxiosResponse); + } else if (code === 'C001' || code === 'W001' || code === 'W002') { + store.dispatch(setModalName('fail')); + store.dispatch(setType(409)); + + return Promise.reject({...error.response} as AxiosError); + } else if (code === 'G001' || code === 'G005') { + location.href = '/*'; + } + + return Promise.reject(error); + }, +); diff --git a/src/assets/css/default.css b/src/assets/css/default.css deleted file mode 100644 index 2989b8c..0000000 --- a/src/assets/css/default.css +++ /dev/null @@ -1,3 +0,0 @@ -html, body { - -} \ No newline at end of file diff --git a/src/assets/css/menubar/Menubar.module.css b/src/assets/css/menubar/Menubar.module.css deleted file mode 100644 index 4c40ac4..0000000 --- a/src/assets/css/menubar/Menubar.module.css +++ /dev/null @@ -1,3 +0,0 @@ -div { - -} \ No newline at end of file diff --git a/src/assets/data/constant.ts b/src/assets/data/constant.ts new file mode 100644 index 0000000..cfddf87 --- /dev/null +++ b/src/assets/data/constant.ts @@ -0,0 +1,3 @@ +export const defaultTime = 35; +export const maxTime = 3600; +export const minTime = 10; diff --git a/src/assets/data/filter.ts b/src/assets/data/filter.ts index ea06a02..7af637e 100644 --- a/src/assets/data/filter.ts +++ b/src/assets/data/filter.ts @@ -1,21 +1,20 @@ -export const term = [ - {id: 0, value: '2024/1학기'}, - {id: 1, value: '2024/2학기'}, -]; +export const term = [{id: 0, value: '2025/1학기'}]; export const completion = [ {id: 0, value: '-전체-'}, {id: 1, value: '교양필수'}, {id: 2, value: '공통교양필수'}, {id: 3, value: '교양선택(1영역)'}, - {id: 4, value: '학문기초교양필수'}, - {id: 5, value: '교양선택'}, - {id: 6, value: '학문기초교양'}, - {id: 7, value: '전공기초'}, - {id: 8, value: '전공필수'}, - {id: 9, value: '전공선택'}, - {id: 10, value: '교직'}, - {id: 11, value: '무관후보생교육'}, + {id: 4, value: '균형교양필수'}, + {id: 5, value: '교양선택(2영역)'}, + {id: 6, value: '학문기초교양필수'}, + {id: 7, value: '교양선택'}, + {id: 8, value: '학문기초교양'}, + {id: 9, value: '전공기초'}, + {id: 10, value: '전공필수'}, + {id: 11, value: '전공선택'}, + {id: 12, value: '교직'}, + {id: 13, value: '무관후보생교육'}, ]; export const optional = [ @@ -35,137 +34,133 @@ export const optional = [ {id: 13, value: '자연과과학기술'}, {id: 14, value: '세계와지구촌'}, {id: 15, value: '예술과체육'}, + {id: 16, value: '자기계발과진로'}, {id: 17, value: '역사와사상'}, {id: 18, value: '자연과과학'}, {id: 19, value: '경제와사회'}, {id: 20, value: '문화와예술'}, {id: 21, value: '사상과 역사'}, {id: 22, value: '대학위성강좌'}, + {id: 23, value: '생명과자연【폐기】'}, + {id: 24, value: '사회와 제도【폐기】'}, + {id: 25, value: '문화, 예술, 스포츠【폐기】'}, + {id: 26, value: '국제사회의 이해【폐기】'}, + {id: 27, value: '인문과학영역【폐기】'}, + {id: 28, value: '사회경영영역【폐기】'}, + {id: 29, value: '자연과학영역【폐기】'}, + {id: 30, value: '예체능영역【폐기】'}, ]; export const major = [ - {id: 0, value: '-선택-'}, + {id: 0, value: '-전체-'}, {id: 1, value: 'AI로봇학과【3517 학부】인공지능융합대학'}, {id: 2, value: 'AI연계융합전공【3317 학부】연계전공'}, - { - id: 3, - value: 'AI연계융합전공 소셜미디어매니지먼트소프트웨어【3328 학부】연계전공', - }, - { - id: 4, - value: - 'AI연계융합전공 스마트투어리즘매니지먼트소프트웨어【3331 학부】연계전공', - }, + {id: 3, value: 'AI연계융합전공 소셜미디어매니지먼트소프트웨어【3328 학부】연계전공'}, + {id: 4, value: 'AI연계융합전공 스마트투어리즘매니지먼트소프트웨어【3331 학부】연계전공'}, {id: 5, value: 'AI연계융합전공 시스템생명공학【3324 학부】연계전공'}, - { - id: 6, - value: 'AI연계융합전공 에듀테크콘텐츠애널리틱스【3326 학부】연계전공', - }, - {id: 7, value: '건설환경공학과【2733 학부】공과대학'}, - {id: 8, value: '건축공학과【2720 학부】공과대학'}, - {id: 9, value: '건축공학부 건축공학전공【2779 학부】공과대학'}, + {id: 6, value: 'AI융합전자공학과【3521 학부】인공지능융합대학'}, + {id: 7, value: 'IT계열【3617 학부】유형2'}, + {id: 8, value: '건설환경공학과【2733 학부】공과대학'}, + {id: 9, value: '건축공학과【2720 학부】공과대학'}, {id: 10, value: '건축공학부 건축학전공【2780 학부】공과대학'}, {id: 11, value: '건축학과【2739 학부】공과대학'}, - {id: 12, value: '경영학부【2274 학부】경영경제대학'}, - {id: 13, value: '경제학과【2273 학부】경영경제대학'}, - {id: 14, value: '교육학과【2114 학부】인문과학대학'}, - {id: 15, value: '국방시스템공학과【2784 학부】공과대학'}, - {id: 17, value: '국어국문학과【2111 학부】인문과학대학'}, - {id: 18, value: '국제학부【2130 학부】인문과학대학'}, - {id: 19, value: '국제학부 영어영문학전공【2131 학부】인문과학대학'}, - {id: 20, value: '국제학부 일어일문학전공【2132 학부】인문과학대학'}, - {id: 21, value: '국제학부 중국통상학전공【2133 학부】인문과학대학'}, - {id: 22, value: '글로벌미디어소프트웨어 융합전공【3330 학부】연계전공'}, - {id: 23, value: '글로벌인재학부【2122 학부】인문과학대학'}, - {id: 24, value: '글로벌조리학과【3037 학부】호텔관광대학'}, - {id: 25, value: '기계공학과【2725 학부】공과대학'}, - {id: 26, value: '기계항공우주공학부 기계공학전공【2723 학부】공과대학'}, - {id: 27, value: '기계항공우주공학부 항공우주공학전공【2724 학부】공과대학'}, - {id: 28, value: '나노신소재공학과【2786 학부】공과대학'}, - {id: 29, value: '뉴미디어퍼포먼스 융합전공【3376 학부】연계전공'}, - {id: 30, value: '대양휴머니티칼리지【9005 학부】대양휴머니티칼리지'}, - {id: 31, value: '데이터사이언스학과【3225 학부】소프트웨어융합대학'}, - {id: 32, value: '디지털역사문화자원큐레이션융합전공【3395 학부】연계전공'}, - {id: 33, value: '럭셔리 브랜드 디자인 융합전공【3370 학부】연계전공'}, - {id: 34, value: '무용과【2515 학부】예체능대학'}, - {id: 35, value: '문화산업경영 융합전공【3366 학부】연계전공'}, - {id: 36, value: '물리천문학과【2450 학부】자연과학대학'}, - {id: 37, value: '미디어커뮤니케이션학과【2233 학부】사회과학대학'}, - {id: 38, value: '반도체시스템공학과【2931 학부】전자정보공학대학'}, - {id: 39, value: '반도체시스템공학과【3512 학부】인공지능융합대학'}, - {id: 40, value: '법학과【2053 학부】사회과학대학'}, - {id: 41, value: '법학부 법학전공【2052 학부】대학'}, - {id: 42, value: '비즈니스 애널리틱스 융합전공【3350 학부】연계전공'}, - {id: 43, value: '생명시스템학부【3140 학부】생명과학대학'}, - { - id: 44, - value: '생명시스템학부 바이오산업자원공학전공【3144 학부】생명과학대학', - }, - {id: 45, value: '생명시스템학부 바이오융합공학전공【3142 학부】생명과학대학'}, - {id: 46, value: '생명시스템학부 식품생명공학전공【3145 학부】생명과학대학'}, - {id: 47, value: '소프트웨어학과【3220 학부】소프트웨어융합대학'}, - {id: 48, value: '소프트웨어학과【3515 학부】인공지능융합대학'}, - {id: 49, value: '수학통계학과【2658 학부】자연과학대학'}, - {id: 50, value: '수학통계학부 수학전공【2648 학부】자연과학대학'}, - {id: 51, value: '수학통계학부 응용통계학전공【2649 학부】자연과학대학'}, - {id: 52, value: '스마트생명산업융합학과【3146 학부】생명과학대학'}, - {id: 53, value: '양자원자력공학과【2789 학부】공과대학'}, - {id: 54, value: '엔터테인먼트 소프트웨어 융합전공【3320 학부】연계전공'}, - {id: 55, value: '역사학과【2115 학부】인문과학대학'}, - {id: 56, value: '영상디자인 융합전공【3360 학부】연계전공'}, - {id: 57, value: '영화예술학과【2525 학부】예체능대학'}, - {id: 58, value: '예술융합콘텐츠 융합전공【3386 학부】연계전공'}, - {id: 59, value: '우주항공드론공학부【2757 학부】공과대학'}, - {id: 60, value: '우주항공드론공학부 항공시스템공학전공【2761 학부】공과대학'}, - { - id: 61, - value: '우주항공시스템공학부 항공시스템공학전공【2793 학부】공과대학', - }, - {id: 62, value: '융합창업전공【3310 학부】연계전공'}, - {id: 63, value: '음악과【2513 학부】예체능대학'}, - {id: 64, value: '인공지능데이터사이언스학과【3516 학부】인공지능융합대학'}, - {id: 65, value: '인공지능학과【3238 학부】소프트웨어융합대학'}, - {id: 66, value: '전자정보통신공학과【2930 학부】전자정보공학대학'}, - {id: 67, value: '전자정보통신공학과【3511 학부】인공지능융합대학'}, - {id: 68, value: '정보보호학과【3215 학부】소프트웨어융합대학'}, - {id: 69, value: '정보보호학과【3514 학부】인공지능융합대학'}, - {id: 70, value: '지구자원시스템공학과【2788 학부】공과대학'}, - {id: 71, value: '지능기전공학과【3233 학부】소프트웨어융합대학'}, - { - id: 72, - value: '지능기전공학부 무인이동체공학전공【3231 학부】소프트웨어융합대학', - }, - { - id: 73, - value: '지능기전공학부 스마트기기공학전공【3232 학부】소프트웨어융합대학', - }, - { - id: 74, - value: '창의소프트학부 디자인이노베이션전공【3236 학부】소프트웨어융합대학', - }, - { - id: 75, - value: '창의소프트학부 만화애니메이션텍전공【3237 학부】소프트웨어융합대학', - }, - {id: 76, value: '창의소프트학부【3518 학부】인공지능융합대학'}, - {id: 77, value: '체육학과【2514 학부】예체능대학'}, - {id: 78, value: '컴퓨터공학과【3210 학부】소프트웨어융합대학'}, - {id: 79, value: '컴퓨터공학과【3513 학부】인공지능융합대학'}, - {id: 80, value: '패션디자인학과【2536 학부】예체능대학'}, - {id: 81, value: '항공시스템공학과【2787 학부】공과대학'}, - {id: 82, value: '행정학과【2223 학부】사회과학대학'}, - {id: 83, value: '호텔관광외식경영학부【3029 학부】호텔관광대학'}, - { - id: 84, - value: '호텔관광외식경영학부 외식경영학전공【3036 학부】호텔관광대학', - }, - { - id: 85, - value: '호텔관광외식경영학부 호텔관광경영학전공【3035 학부】호텔관광대학', - }, - {id: 86, value: '호텔외식관광프랜차이즈경영학과【3033 학부】호텔관광대학'}, - {id: 87, value: '호텔외식비즈니스학과【3034 학부】호텔관광대학'}, - {id: 88, value: '화학과【2433 학부】자연과학대학'}, - {id: 89, value: '환경에너지공간융합학과【2790 학부】공과대학'}, - {id: 90, value: '환경에너지공간융합학과【2790 학부】공과대학'}, + {id: 12, value: '경상호텔관광계열【3614 학부】유형2'}, + {id: 13, value: '경영학부【2274 학부】경영경제대학'}, + {id: 14, value: '경제학과【2273 학부】경영경제대학'}, + {id: 15, value: '공과계열【3619 학부】유형2'}, + {id: 16, value: '교육학과【2114 학부】인문과학대학'}, + {id: 17, value: '국방시스템공학과【2784 학부】공과대학'}, + {id: 18, value: '국어국문학과【2111 학부】인문과학대학'}, + {id: 19, value: '국제학부【2130 학부】인문과학대학'}, + {id: 20, value: '국제학부 영어영문학전공【2131 학부】인문과학대학'}, + {id: 21, value: '국제학부 일어일문학전공【2132 학부】인문과학대학'}, + {id: 22, value: '국제학부 중국통상학전공【2133 학부】인문과학대학'}, + {id: 23, value: '글로벌미디어소프트웨어 융합전공【3330 학부】연계전공'}, + {id: 24, value: '글로벌인재학부【2122 학부】인문과학대학'}, + {id: 25, value: '글로벌인재학부 국제통상전공【2124 학부】인문과학대학'}, + {id: 26, value: '글로벌인재학부 국제협력전공【2125 학부】인문과학대학'}, + {id: 27, value: '글로벌인재학부 한국언어문화전공【2123 학부】인문과학대학'}, + {id: 28, value: '글로벌조리학과【3037 학부】호텔관광대학'}, + {id: 29, value: '금융보험애널리틱스 융합전공【3394 학부】연계전공'}, + {id: 30, value: '기계공학과【2725 학부】공과대학'}, + {id: 31, value: '기계항공우주공학부 기계공학전공【2723 학부】공과대학'}, + {id: 32, value: '기계항공우주공학부 항공우주공학전공【2724 학부】공과대학'}, + {id: 33, value: '나노신소재공학과【2786 학부】공과대학'}, + {id: 34, value: '뉴미디어퍼포먼스 융합전공【3376 학부】연계전공'}, + {id: 35, value: '대양휴머니티칼리지【9005 학부】대양휴머니티칼리지'}, + {id: 36, value: '데이터사이언스학과【3225 학부】소프트웨어융합대학'}, + {id: 37, value: '럭셔리 브랜드 디자인 융합전공【3370 학부】연계전공'}, + {id: 38, value: '무용과【2515 학부】예체능대학'}, + {id: 39, value: '문화산업경영 융합전공【3366 학부】연계전공'}, + {id: 40, value: '물리천문학과【2450 학부】자연과학대학'}, + {id: 41, value: '미디어커뮤니케이션학과【2233 학부】사회과학대학'}, + {id: 42, value: '반도체시스템공학과【2931 학부】전자정보공학대학'}, + {id: 43, value: '반도체시스템공학과【3512 학부】인공지능융합대학'}, + {id: 44, value: '법학과【2053 학부】사회과학대학'}, + {id: 45, value: '법학부 법학전공【2052 학부】대학'}, + {id: 46, value: '비즈니스 애널리틱스 융합전공【3350 학부】연계전공'}, + {id: 47, value: '사이버국방학과【3524 학부】인공지능융합대학'}, + {id: 48, value: '생명시스템학부【3140 학부】생명과학대학'}, + {id: 49, value: '생명시스템학부 바이오산업자원공학전공【3144 학부】생명과학대학'}, + {id: 50, value: '생명시스템학부 바이오융합공학전공【3142 학부】생명과학대학'}, + {id: 51, value: '생명시스템학부 식품생명공학전공【3145 학부】생명과학대학'}, + {id: 52, value: '소프트웨어학과【3220 학부】소프트웨어융합대학'}, + {id: 53, value: '소프트웨어학과【3515 학부】인공지능융합대학'}, + {id: 54, value: '수학통계학과【2658 학부】자연과학대학'}, + {id: 55, value: '스마트생명산업융합학과【3146 학부】생명과학대학'}, + {id: 56, value: '양자원자력공학과【2789 학부】공과대학'}, + {id: 57, value: '엔터테인먼트 소프트웨어 융합전공【3320 학부】연계전공'}, + {id: 58, value: '역사학과【2115 학부】인문과학대학'}, + {id: 59, value: '영상디자인 융합전공【3360 학부】연계전공'}, + {id: 60, value: '영화예술학과【2525 학부】예체능대학'}, + {id: 61, value: '예술융합콘텐츠 융합전공【3386 학부】연계전공'}, + {id: 62, value: '우주항공시스템공학부【2791 학부】공과대학'}, + {id: 63, value: '우주항공시스템공학부 우주항공공학전공【2792 학부】공과대학'}, + {id: 64, value: '우주항공시스템공학부 지능형드론융합전공【2795 학부】공과대학'}, + {id: 65, value: '우주항공시스템공학부 항공시스템공학전공【2793 학부】공과대학'}, + {id: 66, value: '융합창업전공【3310 학부】연계전공'}, + {id: 67, value: '음악과【2513 학부】예체능대학'}, + {id: 68, value: '인공지능데이터사이언스학과【3516 학부】인공지능융합대학'}, + {id: 69, value: '인공지능학과【3238 학부】소프트웨어융합대학'}, + {id: 70, value: '인문사회계열【3611 학부】유형2'}, + {id: 71, value: '자연생명계열【3615 학부】유형2'}, + {id: 72, value: '자유전공학부【2010 학부】-'}, + {id: 73, value: '전자정보통신공학과【2930 학부】전자정보공학대학'}, + {id: 74, value: '전자정보통신공학과【3511 학부】인공지능융합대학'}, + {id: 75, value: '정보보호학과【3215 학부】소프트웨어융합대학'}, + {id: 76, value: '정보보호학과【3514 학부】인공지능융합대학'}, + {id: 77, value: '지구자원시스템공학과【2788 학부】공과대학'}, + {id: 78, value: '지능IoT학과【3525 학부】인공지능융합대학'}, + {id: 79, value: '지능기전공학과【3233 학부】소프트웨어융합대학'}, + {id: 80, value: '지능기전공학부 무인이동체공학전공【3231 학부】소프트웨어융합대학'}, + {id: 81, value: '지능기전공학부 스마트기기공학전공【3232 학부】소프트웨어융합대학'}, + {id: 82, value: '지능정보융합학과【3522 학부】인공지능융합대학'}, + {id: 83, value: '창의소프트학부 디자인이노베이션전공【3236 학부】소프트웨어융합대학'}, + {id: 84, value: '창의소프트학부 만화애니메이션텍전공【3237 학부】소프트웨어융합대학'}, + {id: 85, value: '창의소프트학부【3518 학부】인공지능융합대학'}, + {id: 86, value: '창의소프트학부 디자인이노베이션전공【3519 학부】인공지능융합대학'}, + {id: 87, value: '창의소프트학부 만화애니메이션텍전공【3520 학부】인공지능융합대학'}, + {id: 88, value: '체육학과【2514 학부】예체능대학'}, + {id: 89, value: '컴퓨터공학과【3210 학부】소프트웨어융합대학'}, + {id: 90, value: '컴퓨터공학과【3513 학부】인공지능융합대학'}, + {id: 91, value: '콘텐츠소프트웨어학과【3523 학부】인공지능융합대학'}, + {id: 92, value: '패션디자인학과【2536 학부】예체능대학'}, + {id: 93, value: '항공시스템공학과【2787 학부】공과대학'}, + {id: 94, value: '행정학과【2223 학부】사회과학대학'}, + {id: 95, value: '호텔관광외식경영학부【3029 학부】호텔관광대학'}, + {id: 96, value: '호텔관광외식경영학부 외식경영학전공【3036 학부】호텔관광대학'}, + {id: 97, value: '호텔관광외식경영학부 호텔관광경영학전공【3035 학부】호텔관광대학'}, + {id: 98, value: '호텔외식관광프랜차이즈경영학과【3033 학부】호텔관광대학'}, + {id: 99, value: '호텔외식비즈니스학과【3034 학부】호텔관광대학'}, + {id: 100, value: '화학과【2433 학부】자연과학대학'}, + {id: 101, value: '환경에너지공간융합학과【2790 학부】공과대학'}, + {id: 102, value: '환경융합공학과【2794 학부】공과대학'}, + {id: 103, value: '회화과【2511 학부】예체능대학'}, +]; + +export const searchOptions = [ + {id: 0, value: '관심과목 검색'}, + {id: 1, value: '학수번호 검색'}, + {id: 2, value: '교과목명 검색'}, + {id: 3, value: '강의교수 검색'}, ]; diff --git a/src/assets/fonts/fonts.css b/src/assets/fonts/fonts.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/assets/img/arrow-down-s-fill.png b/src/assets/img/arrow-down-s-fill.png new file mode 100644 index 0000000..e44f423 Binary files /dev/null and b/src/assets/img/arrow-down-s-fill.png differ diff --git a/src/assets/img/arrow_right.png b/src/assets/img/arrow_right.png deleted file mode 100644 index 7bf786a..0000000 Binary files a/src/assets/img/arrow_right.png and /dev/null differ diff --git a/src/assets/img/arrow_up.png b/src/assets/img/arrow_up.png deleted file mode 100644 index 72c2308..0000000 Binary files a/src/assets/img/arrow_up.png and /dev/null differ diff --git a/src/assets/img/bookmark-3-line.svg b/src/assets/img/bookmark-3-line.svg new file mode 100644 index 0000000..cbeb6a6 --- /dev/null +++ b/src/assets/img/bookmark-3-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/btn_gnb_cu.png b/src/assets/img/btn_gnb_cu.png deleted file mode 100644 index 9caea97..0000000 Binary files a/src/assets/img/btn_gnb_cu.png and /dev/null differ diff --git a/src/assets/img/btn_main_top_left.svg b/src/assets/img/btn_main_top_left.svg deleted file mode 100644 index 2f7a3dd..0000000 --- a/src/assets/img/btn_main_top_left.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - diff --git a/src/assets/img/btn_main_top_right.svg b/src/assets/img/btn_main_top_right.svg deleted file mode 100644 index da2e7a9..0000000 --- a/src/assets/img/btn_main_top_right.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - diff --git a/src/assets/img/check.png b/src/assets/img/check.png new file mode 100644 index 0000000..012d196 Binary files /dev/null and b/src/assets/img/check.png differ diff --git a/src/assets/img/close-line-red.png b/src/assets/img/close-line-red.png new file mode 100644 index 0000000..c5404a4 Binary files /dev/null and b/src/assets/img/close-line-red.png differ diff --git a/src/assets/img/close-line.png b/src/assets/img/close-line.png new file mode 100644 index 0000000..6f0b34b Binary files /dev/null and b/src/assets/img/close-line.png differ diff --git a/src/assets/img/close-sidebar.svg b/src/assets/img/close-sidebar.svg new file mode 100644 index 0000000..d00c116 --- /dev/null +++ b/src/assets/img/close-sidebar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/delete_bg.webp b/src/assets/img/delete_bg.webp new file mode 100644 index 0000000..3f11710 Binary files /dev/null and b/src/assets/img/delete_bg.webp differ diff --git a/src/assets/img/edit-line.png b/src/assets/img/edit-line.png new file mode 100644 index 0000000..8bf4c82 Binary files /dev/null and b/src/assets/img/edit-line.png differ diff --git a/src/assets/img/edit-line.svg b/src/assets/img/edit-line.svg new file mode 100644 index 0000000..f57a63e --- /dev/null +++ b/src/assets/img/edit-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/fav_gray.png b/src/assets/img/fav_gray.png deleted file mode 100644 index 06e191f..0000000 Binary files a/src/assets/img/fav_gray.png and /dev/null differ diff --git a/src/assets/img/fav_white.png b/src/assets/img/fav_white.png deleted file mode 100644 index 2ac1240..0000000 Binary files a/src/assets/img/fav_white.png and /dev/null differ diff --git a/src/assets/img/file-copy-line.png b/src/assets/img/file-copy-line.png new file mode 100644 index 0000000..2a916b8 Binary files /dev/null and b/src/assets/img/file-copy-line.png differ diff --git a/src/assets/img/github-fill.svg b/src/assets/img/github-fill.svg new file mode 100644 index 0000000..fdda08a --- /dev/null +++ b/src/assets/img/github-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/home.png b/src/assets/img/home.png deleted file mode 100644 index a1bbcb0..0000000 Binary files a/src/assets/img/home.png and /dev/null differ diff --git a/src/assets/img/info.svg b/src/assets/img/info.svg new file mode 100644 index 0000000..dc7e426 --- /dev/null +++ b/src/assets/img/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/input_dropdown.png b/src/assets/img/input_dropdown.png deleted file mode 100644 index 9032549..0000000 Binary files a/src/assets/img/input_dropdown.png and /dev/null differ diff --git a/src/assets/img/login_bg.png b/src/assets/img/login_bg.png deleted file mode 100644 index 24d0538..0000000 Binary files a/src/assets/img/login_bg.png and /dev/null differ diff --git a/src/assets/img/login_bg.webp b/src/assets/img/login_bg.webp new file mode 100644 index 0000000..34ebd85 Binary files /dev/null and b/src/assets/img/login_bg.webp differ diff --git a/src/assets/img/logo.png b/src/assets/img/logo.png deleted file mode 100644 index 455c810..0000000 Binary files a/src/assets/img/logo.png and /dev/null differ diff --git a/src/assets/img/logo.webp b/src/assets/img/logo.webp new file mode 100644 index 0000000..911e935 Binary files /dev/null and b/src/assets/img/logo.webp differ diff --git a/src/assets/img/logout.png b/src/assets/img/logout.png index 0432683..fc7276f 100644 Binary files a/src/assets/img/logout.png and b/src/assets/img/logout.png differ diff --git a/src/assets/img/main_logo.png b/src/assets/img/main_logo.png deleted file mode 100644 index 16c8225..0000000 Binary files a/src/assets/img/main_logo.png and /dev/null differ diff --git a/src/assets/img/menu.png b/src/assets/img/menu.png deleted file mode 100644 index 03a4e13..0000000 Binary files a/src/assets/img/menu.png and /dev/null differ diff --git a/src/assets/img/menu2_close.png b/src/assets/img/menu2_close.png deleted file mode 100644 index 18fe207..0000000 Binary files a/src/assets/img/menu2_close.png and /dev/null differ diff --git a/src/assets/img/menu_close.png b/src/assets/img/menu_close.png deleted file mode 100644 index f3fbbdf..0000000 Binary files a/src/assets/img/menu_close.png and /dev/null differ diff --git a/src/assets/img/notice.png b/src/assets/img/notice.png deleted file mode 100644 index 0dfb387..0000000 Binary files a/src/assets/img/notice.png and /dev/null differ diff --git a/src/assets/img/refresh-line.png b/src/assets/img/refresh-line.png new file mode 100644 index 0000000..c6227bd Binary files /dev/null and b/src/assets/img/refresh-line.png differ diff --git a/src/assets/img/search-line.svg b/src/assets/img/search-line.svg new file mode 100644 index 0000000..6da739b --- /dev/null +++ b/src/assets/img/search-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/search.png b/src/assets/img/search.png deleted file mode 100644 index ed1c404..0000000 Binary files a/src/assets/img/search.png and /dev/null differ diff --git a/src/assets/img/setitng.png b/src/assets/img/setitng.png deleted file mode 100644 index 757ae28..0000000 Binary files a/src/assets/img/setitng.png and /dev/null differ diff --git a/src/assets/img/study.svg b/src/assets/img/study.svg deleted file mode 100644 index dff3c4c..0000000 --- a/src/assets/img/study.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/src/assets/img/tab_close.png b/src/assets/img/tab_close.png deleted file mode 100644 index d63a65c..0000000 Binary files a/src/assets/img/tab_close.png and /dev/null differ diff --git a/src/assets/img/tab_close_all.png b/src/assets/img/tab_close_all.png deleted file mode 100644 index 8658dd6..0000000 Binary files a/src/assets/img/tab_close_all.png and /dev/null differ diff --git a/src/assets/img/table_drodown.gif b/src/assets/img/table_drodown.gif deleted file mode 100644 index 67de811..0000000 Binary files a/src/assets/img/table_drodown.gif and /dev/null differ diff --git a/src/assets/img/tag.png b/src/assets/img/tag.png deleted file mode 100644 index bee4c48..0000000 Binary files a/src/assets/img/tag.png and /dev/null differ diff --git a/src/assets/img/to_top.png b/src/assets/img/to_top.png deleted file mode 100644 index e19d388..0000000 Binary files a/src/assets/img/to_top.png and /dev/null differ diff --git a/src/assets/img/top_menu_down.png b/src/assets/img/top_menu_down.png deleted file mode 100644 index 60432f9..0000000 Binary files a/src/assets/img/top_menu_down.png and /dev/null differ diff --git a/src/assets/img/tutorial_sejong_logo.webp b/src/assets/img/tutorial_sejong_logo.webp new file mode 100644 index 0000000..911e935 Binary files /dev/null and b/src/assets/img/tutorial_sejong_logo.webp differ diff --git a/src/assets/img/view.svg b/src/assets/img/view.svg deleted file mode 100644 index 942915f..0000000 --- a/src/assets/img/view.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/src/assets/img/warning.png b/src/assets/img/warning.png new file mode 100644 index 0000000..d914b1e Binary files /dev/null and b/src/assets/img/warning.png differ diff --git a/src/assets/types/tableType.ts b/src/assets/types/tableType.ts index aa1e31f..2e0fb4e 100644 --- a/src/assets/types/tableType.ts +++ b/src/assets/types/tableType.ts @@ -7,3 +7,28 @@ export interface TableHeadTypes { value: string; initialWidth?: number; } + +export interface CourseTypes { + scheduleId?: number; + schDeptAlias?: string; + curiNo?: string; + classNo?: string; + schCollegeAlias?: string; + curiNm?: string; + curiLangNm?: string | null; + curiTypeCdNm?: string; + sltDomainCdNm?: string; + tmNum?: string; + studentYear?: string; + corsUnitGrpCdNm?: string; + manageDeptNm?: string; + lesnEmp?: string; + lesnTime?: string; + lesnRoom?: string; + cyberTypeCdNm?: string; + internshipTypeCdNm?: string | null; + inoutSubCdtExchangeYn?: string | null; + remark?: string; + rank?: number; + wishCount?: number; +} diff --git a/src/components/CourseRegister/InfoContent.tsx b/src/components/CourseRegister/InfoContent.tsx new file mode 100644 index 0000000..9d31d57 --- /dev/null +++ b/src/components/CourseRegister/InfoContent.tsx @@ -0,0 +1,163 @@ +import {styled} from 'styled-components'; +import {WarningWrap} from '../LectureList/Filters'; + +function InfoContent() { + return ( + <> + + 2025-1학기 수강신청 연습 안내 +

+ 1. 관심과목 담기: 1.24.(금) 16:00 ~ 1.31.(금) 16:00 +

+

+ 2. 수강신청 주요일정 +

+ + + + + + + + + + + + + + + + 소속학부(과)의 주·복수· +
+ 부전공과목과 교양과목만 +
수강신청 가능 +
+ + + + + + + + + + + + + + + + + 다른 학과 전공과목도 수강 +
+ 신청 가능 +
+ + + + + ※ 변경가능 + + + + + + + 다른 학과 전공과목도 수강 +
+ 신청 가능 +
+ + +
구분대상일정비고
수강신청 + 4학년(7~8학기 등록 예정자),
건축학 5학년, 수업연한초과자 +
2.11.(화) 10:00 ~ 17:00
3학년(5~6학기 등록 예정자)2.12.(수) 10:00 ~ 17:00
2학년(3~4학기 등록 예정자)2.13.(목) 10:00 ~ 17:00
1학년(1~2학기 등록 예정자)
전학년2.14.(금) 10:00 ~ 17:00
신입생, 편입생2.28.(금) 10:00 ~ 17:00
+ 수강신청과목
확인 및 변경 +
전학년3. 5.(수) ~ 3.10.(월) 10:00 ~ 17:00
+
+

+ 3. 수강신청 연습 방법   ※날짜 설정 모드※ +

+

본인의 학과를 선택하고, 수강신청 날짜를 지정합니다.

+

+ - 본인 학년 선택: 본인 소속학부(과)의 주·복수·부전공과목과 교양과목만 + 수강신청 가능 +

+

- 전학년 선택: 다른 학과 전공과목도 수강신청가능

+

+ 학과를 선택하지 않을 경우, 학과 제한이 없는 전학년 수강신청 날짜로 + 자동 설정됩니다. +

+
+ +

+ ※ 본 수강신청 연습 사이트는 학사정보시스템의 실제 수강신청과 다를 수 + 있습니다. 수강 대상 및 유의사항을 반드시 확인하시고, 수강편람을 + 숙지하여 주시기 바랍니다. +

+
+
+ + ); +} + +const Container = styled.div` + border: 0.1rem solid #714656; + border-radius: 2px; + padding: 1.5rem 1.5rem; + margin-bottom: 2rem; + + > p { + font-weight: normal; + font-size: 1.4rem; + margin-bottom: 0.8rem; + line-height: 1.6; + letter-spacing: 0.01em; + + > span { + font-weight: bold; + font-size: 1.4rem; + color: #333; + } + } +`; + +const SubTitle = styled.div` + ${props => props.theme.texts.subtitle}; + font-size: 1.6rem; + margin-bottom: 2rem; +`; + +const Table = styled.table` + width: 70%; + height: auto; + max-width: 60rem; + border-collapse: collapse; + border: 1.6px solid #000; + + th { + ${props => props.theme.texts.tableTitle}; + background-color: #e5e5e5; + border: 1px solid #c3c3c3; + padding: 0.8rem; + text-align: center; + } + + td { + ${props => props.theme.texts.content}; + border: 1px solid #c3c3c3; + padding: 0.8rem; + text-align: center; + vertical-align: middle; + line-height: 1.4; + letter-spacing: 0.01em; + word-break: break-all; + background-color: white; + } +`; + +const Note = styled.td` + text-align: start !important; +`; + +export default InfoContent; diff --git a/src/components/CourseRegister/RegisterFilters.tsx b/src/components/CourseRegister/RegisterFilters.tsx new file mode 100644 index 0000000..f962705 --- /dev/null +++ b/src/components/CourseRegister/RegisterFilters.tsx @@ -0,0 +1,169 @@ +import {useState} from 'react'; +import styled from 'styled-components'; +import {CourseTypes} from '@/assets/types/tableType'; +import FilterButton from '../common/FilterButton'; +import FilterInput from '../common/FilterInput'; +import SelectBox from '../common/SelectBox'; +import {term, searchOptions} from '@assets/data/filter'; +import {FilterBox, FilterContainer, FilterWrap} from '@/styles/FilterLayout'; + +interface FiltersProps { + isRegistrationStarted: boolean; + onSearch: ( + newList: CourseTypes[], + filter: CourseTypes, + searchOption: string, + ) => Promise; +} + +function RegisterFilters({onSearch, isRegistrationStarted}: FiltersProps) { + const [filter, setFilter] = useState({}); + const [searchOption, setSearchOption] = useState('관심과목'); + + const handleSelect = (name: keyof CourseTypes, value: string | undefined) => { + setFilter(prevState => ({ + ...prevState, + [name]: value, + })); + }; + + const handleSearchOptions = (name: string) => { + const label = name.split(' ')[0]; + setSearchOption(label); + setFilter({}); + }; + + const handleInput = (value: string | undefined) => { + switch (searchOption) { + case '관심과목': + setFilter({}); + break; + case '교과목명': + setFilter({curiNm: value}); + break; + case '강의교수': + setFilter({lesnEmp: value}); + break; + case '학수번호': + setFilter(prevState => ({...prevState, curiNo: value})); + break; + default: + break; + } + }; + + const renderSearchForm = () => { + if (searchOption === '학수번호') { + return ( + + + handleSelect('curiNo', value)} + /> + + + 분반 + handleSelect('classNo', value)} + /> + + + ); + } else { + return ( + handleInput(value)} + /> + ); + } + }; + + return ( + +
+ + + 조직분류 + handleInput(value)} + /> + + + 년도/학기 + handleInput(value)} + /> + + + + 검색 구분 + handleSearchOptions(value)} + /> + + + {searchOption} + handleSelect('curiTypeCdNm', value)} + /> + {renderSearchForm()} + + + +
+ +
+ ); +} + +const RegisterFilterContainer = styled(FilterContainer)` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 0.7rem 0; + + @media ${props => props.theme.device.mobile} { + flex-wrap: wrap; + } +`; + +const SearchBox = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.7rem 3rem; +`; + +const SearchWrap = styled(FilterWrap)` + > div { + margin-right: 0.7rem; + } + align-items: center; +`; + +const CuriNoWrap = styled(FilterBox)` + align-items: center; +`; + +export default RegisterFilters; diff --git a/src/components/CourseRegister/RegisterInfo.tsx b/src/components/CourseRegister/RegisterInfo.tsx new file mode 100644 index 0000000..43ade36 --- /dev/null +++ b/src/components/CourseRegister/RegisterInfo.tsx @@ -0,0 +1,130 @@ +import {TableTitle, TableTitleWrap} from '../LectureList'; +import styled from 'styled-components'; +import SelectBox from '../common/SelectBox'; +import {major} from '@/assets/data/filter'; +import {useAppSelector} from '@/store/hooks'; +import {useDispatch} from 'react-redux'; +import {setSelectedDate, setUserMajor} from '@/store/modules/dateModeSlice'; +import InfoContent from './InfoContent'; + +interface RegisterInfoProps { + onClickNext: () => void; +} + +function RegisterInfo({onClickNext}: RegisterInfoProps) { + const userMajor = useAppSelector(state => state.dateMode.userMajor); + const dispatch = useDispatch(); + + const handleSelectMajor = (value: string | undefined) => { + dispatch(setUserMajor(value!.split('【')[0])); + if (value === '-전체-') { + dispatch(setSelectedDate('전학년 (학과 제한 없음)')); + } + }; + + const handleSelectDate = (value: string | undefined) => { + dispatch(setSelectedDate(value!)); + }; + + return ( + <> + + 안내문 + + + + + + 학과전공 + handleSelectMajor(value)} + /> + + + 수강신청 날짜 + handleSelectDate(value)} + restricted={userMajor === '-전체-'} + /> + + + + + + + 저장/NEXT + + + ); +} + +const Container = styled.div` + border: 0.1rem solid #714656; + border-radius: 2px; + padding: 1.5rem 1rem; + margin: 1.5rem 0; +`; + +export const SelectArea = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 0.7rem 3rem; + + @media ${props => props.theme.device.mobile} { + flex-wrap: wrap; + } +`; + +export const SelectBoxWrap = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.7rem 3rem; +`; + +export const SelectWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + display: flex; + flex-wrap: wrap; + gap: 0.7rem 0; + align-items: center; + + > span { + margin-right: 1rem; + text-align: right; + min-width: 7rem; + flex-basis: 7rem; + } +`; + +const ButtonWrap = styled.button` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.primary}; + color: ${props => props.theme.colors.white}; + min-width: 8rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + + &:hover { + filter: grayscale(15%); + } +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 2rem; +`; + +export default RegisterInfo; diff --git a/src/components/CourseRegister/RegisteredList.tsx b/src/components/CourseRegister/RegisteredList.tsx new file mode 100644 index 0000000..16bb81c --- /dev/null +++ b/src/components/CourseRegister/RegisteredList.tsx @@ -0,0 +1,78 @@ +import styled from 'styled-components'; +import {deleteCourse} from '@/apis/api/course'; +import {CourseTypes} from '@/assets/types/tableType'; +import {TableTitle, TableTitleWrap} from '../LectureList'; +import Table from '../common/Table'; + +const colData = [ + {name: 'action', value: '삭제', initialWidth: 50, enableFilters: false}, + {name: 'curiNo', value: '학수번호', initialWidth: 92}, + {name: 'classNo', value: '분반', initialWidth: 58}, + {name: 'schDeptAlias', value: '개설학과', initialWidth: 177}, + {name: 'curiNm', value: '교과목명', initialWidth: 242}, + {name: 'curiLangNm', value: '강의언어', initialWidth: 73}, + {name: 'tmNum', value: '학점/이론/실습', initialWidth: 144}, + {name: 'curiTypeCdNm', value: '이수구분'}, + {name: 'studentYear', value: '학년 (학기)'}, + {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 183}, + {name: 'lesnEmp', value: '교수명', initialWidth: 238}, + {name: 'lesnRoom', value: '강의실', initialWidth: 114}, + {name: 'remark', value: '수강대상및유의사항', initialWidth: 610}, +]; + +interface RegisteredListProps { + list: CourseTypes[]; + refreshAll: () => Promise; +} + +function RegisteredList({list, refreshAll}: RegisteredListProps) { + const handleAction = async ( + _action: string, + scheduleId: number | undefined, + ) => { + if (scheduleId) { + await deleteCourse(scheduleId); + await refreshAll(); + } + }; + + return ( + + + 수강신청내역 + 재조회 + + + + ); +} + +const RegisteredTitleWrap = styled(TableTitleWrap)` + display: flex; + align-items: center; + gap: 0 1rem; +`; + +const ListContainer = styled.div` + margin-top: 2rem; +`; + +const ButtonWrap = styled.button` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.secondary}; + color: ${props => props.theme.colors.white}; + width: 6rem; + height: 2.4rem; + + &:hover { + filter: grayscale(15%); + } +`; + +export default RegisteredList; diff --git a/src/components/CourseRegister/StartButton.tsx b/src/components/CourseRegister/StartButton.tsx new file mode 100644 index 0000000..5503cd3 --- /dev/null +++ b/src/components/CourseRegister/StartButton.tsx @@ -0,0 +1,80 @@ +import {useEffect, useState} from 'react'; +import styled from 'styled-components'; +import {useDispatch} from 'react-redux'; +import {deleteAllRegistrations} from '@apis/api/course.ts'; +import {setEndCount} from '@/store/modules/courseRegisteredSlice'; +import {useAppSelector} from '@/store/hooks'; +import Timeout from './Timeout'; +import ReactGA from 'react-ga4'; + +interface StartBtnProps { + onClick: () => void; +} + +function StartButton({onClick}: StartBtnProps) { + const dispatch = useDispatch(); + const time = useAppSelector(state => state.courseRegistered.time); + const [timeLeft, setTimeLeft] = useState(time); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + if (!isRunning || timeLeft <= 0) return; + + const countdown = setInterval(() => { + setTimeLeft(prev => prev - 1); + }, 1000); + + return () => clearInterval(countdown); + }, [isRunning, timeLeft]); + + useEffect(() => { + if (timeLeft === 0) { + setIsRunning(false); + setTimeLeft(time); + dispatch(setEndCount(true)); + } + }, [timeLeft, dispatch]); + + const handleClick = async () => { + if (!confirm('수강신청 연습 시작하시겠습니까?')) return; + + ReactGA.event({ + category: 'Course Registration', + action: isRunning ? 'Restart Practice Button' : 'Start Practice Button', + label: isRunning ? 'PracticeButton_Restart' : 'PracticeButton_Start', + }); + + //카운트다운 중에 재시작 + if (isRunning) { + setTimeLeft(time); + } + + setIsRunning(true); + await deleteAllRegistrations(); + onClick(); + }; + + return ( + +

+ 시작 버튼을 누르면 수강 신청이 시작됩니다. 다시 연습하고 싶다면 한번 더 + 버튼을 눌러주세요. +

+

현재 상태를 확인하고 싶다면, 재조회 버튼을 눌러주세요.

+ +
+ ); +} + +const Container = styled.div` + font-weight: normal; + > p { + font-size: 1.6rem; + margin-bottom: 1.5rem; + } +`; +export default StartButton; diff --git a/src/components/CourseRegister/Timeout.tsx b/src/components/CourseRegister/Timeout.tsx new file mode 100644 index 0000000..ea3d571 --- /dev/null +++ b/src/components/CourseRegister/Timeout.tsx @@ -0,0 +1,160 @@ +import {useState} from 'react'; +import {useDispatch} from 'react-redux'; +import {useAppSelector} from '@/store/hooks'; +import styled from 'styled-components'; +import {FilterWrap} from '@/styles/FilterLayout'; +import InfoIcon from '@assets/img/info.svg?react'; +import {setTime} from '@/store/modules/courseRegisteredSlice'; +import {defaultTime, maxTime, minTime} from '@/assets/data/constant'; + +interface TimeoutProps { + timeLeft: number; + setTimeLeft: React.Dispatch>; + handleClick: () => void; +} + +function Timeout({timeLeft, setTimeLeft, handleClick}: TimeoutProps) { + const dispatch = useDispatch(); + const time = useAppSelector(state => state.courseRegistered.time); + const [timeout, setTimeout] = useState(time); + const [popup, setPopup] = useState(false); + + const handleInput = (e: React.ChangeEvent) => { + const timeInput = parseInt(e.target.value); + + if (e.target.value) { + if (timeInput <= minTime) { + setTimeLeft(minTime); + dispatch(setTime(minTime)); + } else if (timeInput >= maxTime) { + setTimeLeft(maxTime); + dispatch(setTime(maxTime)); + } else { + setTimeLeft(timeInput); + dispatch(setTime(timeInput)); + } + + setTimeout(timeInput); + } else { + setTimeout(''); + setTimeLeft(defaultTime); + dispatch(setTime(defaultTime)); + } + }; + + const formatTime = (time: number) => { + return time.toString().padStart(2, '0'); + }; + + return ( + <> + + +

제한 시간

+ setPopup(true)} + onMouseOut={() => setPopup(false)} + > + + + {popup && ( + +

- 기본 설정: 35초

+

- 최소 시간: 10초 (10초 이하 입력 시 자동으로 10초로 조정)

+

+ - 최대 시간: 1시간(3600초) (1시간 이상 입력 시 자동으로 + 1시간으로 조정) +

+

+ - 설정한 제한 시간이 지나면 모든 과목의 수강여석이 없음으로 + 변경됩니다. +

+
+ )} +
+
※ 초단위로 입력해주세요!
+
+ + {formatTime(Math.floor(timeLeft / 60))}: + {formatTime(timeLeft % 60)} + + + + 시작 / 초기화 + + + ); +} + +const TimeoutTitleBox = styled.div` + margin-bottom: 1rem; + position: relative; + > h5 { + font-size: 1.4rem; + color: ${props => props.theme.colors.primary}; + font-weight: bold; + } +`; + +const TimeoutTitleWrap = styled.div` + display: flex; + align-items: center; + gap: 0.7rem; + font-size: 1.6rem; + font-weight: bold; + margin-bottom: 0.5rem; +`; + +const InfoButton = styled.button` + display: flex; +`; + +const PopupWrap = styled.div` + ${props => props.theme.texts.tabTitle}; + position: absolute; + top: 0.6rem; + left: 9.5rem; + background-color: ${props => props.theme.colors.white}; + border: 1px solid ${props => props.theme.colors.neutral4}; + padding: 0.7rem; + + > p { + font-size: 1.4rem; + padding: 0.5rem; + } +`; + +const TimeoutWrap = styled.div<{$isAlmostDone: boolean}>` + ${props => props.theme.texts.title}; + margin: 1rem 0; + font-size: 2.5rem; + letter-spacing: 0.5rem; + color: ${props => + props.$isAlmostDone ? props.theme.colors.primary : 'inherit'}; +`; + +const InputWrap = styled(FilterWrap)` + margin: 1.5rem 0; +`; + +const InputBox = styled.input` + height: 2.4rem; + border: 1px solid ${props => props.theme.colors.neutral4}; + padding-left: 0.5rem; + width: 21.5rem; +`; + +const ButtonWrap = styled.button` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.primary}; + color: ${props => props.theme.colors.white}; + width: 8rem; + height: 2.4rem; + margin-left: 1rem; + + &:hover { + filter: grayscale(15%); + } +`; + +export default Timeout; diff --git a/src/components/CourseRegister/index.tsx b/src/components/CourseRegister/index.tsx new file mode 100644 index 0000000..826bf38 --- /dev/null +++ b/src/components/CourseRegister/index.tsx @@ -0,0 +1,141 @@ +import {useCallback, useEffect, useState} from 'react'; +import {CourseTypes} from '@/assets/types/tableType'; +import RegisterFilters from './RegisterFilters'; +import Table from '../common/Table'; +import {TableTitle, TableTitleWrap} from '../LectureList'; +import RegisteredList from './RegisteredList'; +import {useDispatch} from 'react-redux'; +import {setCourseData, setModalName} from '@/store/modules/modalSlice'; +import StartButton from '@components/CourseRegister/StartButton.tsx'; +import {getCourseList, getRegisterdList, getWishlist} from '@/apis/api/course'; +import {useAppSelector} from '@/store/hooks'; +import {openModalHandler} from '../common/Modal/handlers/handler'; +import {setEndCount} from '@/store/modules/courseRegisteredSlice'; +import RegisterInfo from './RegisterInfo'; +import {setIsConfirm} from '@/store/modules/dateModeSlice'; + +const colData = [ + {name: 'action', value: '신청', initialWidth: 50, enableFilters: false}, + {name: 'curiNo', value: '학수번호', initialWidth: 92}, + {name: 'classNo', value: '분반', initialWidth: 58}, + {name: 'schDeptAlias', value: '개설학과', initialWidth: 177}, + {name: 'curiNm', value: '교과목명', initialWidth: 242}, + {name: 'curiLangNm', value: '강의언어', initialWidth: 73}, + {name: 'tmNum', value: '학점/이론/실습', initialWidth: 144}, + {name: 'curiTypeCdNm', value: '이수구분'}, + {name: 'studentYear', value: '학년 (학기)'}, + {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 183}, + {name: 'lesnEmp', value: '교수명', initialWidth: 238}, + {name: 'lesnRoom', value: '강의실', initialWidth: 114}, + {name: 'remark', value: '수강대상및유의사항', initialWidth: 610}, +]; + +function CourseRegister() { + const [list, setList] = useState([]); + const [registeredList, setRegisteredList] = useState([]); + const [currentFilter, setCurrentFilter] = useState({}); + const [currentSearchOption, setCurrentSearchOption] = + useState('관심과목'); + const [isRegistrationStarted, setIsRegistrationStarted] = + useState(false); + const [isFirstSearch, setIsFirstSearch] = useState(true); + + const dispatch = useDispatch(); + const studentId = useAppSelector(state => state.userInfo.username); + const isConfirm = useAppSelector(state => state.dateMode.isConfirm); + + useEffect(() => { + dispatch(setEndCount(false)); + }, [dispatch]); + + const handleNextButtonClick = () => { + dispatch(setIsConfirm()); + window.scrollTo(0, 0); + }; + + const refreshAll = useCallback(async () => { + const registeredCourses = await getRegisterdList(); + setRegisteredList(registeredCourses || []); + + let searchResult: CourseTypes[] = []; + if (currentSearchOption === '관심과목') { + searchResult = await getWishlist(studentId); + } else { + searchResult = await getCourseList(currentFilter); + } + setList(searchResult); + }, [currentFilter, currentSearchOption, studentId]); + + const handleSearch = async ( + newList: CourseTypes[], + filter: CourseTypes, + searchOption: string, + ) => { + if (isRegistrationStarted && isFirstSearch) { + openModalHandler(dispatch, 'waiting'); + setIsFirstSearch(false); + } + setList(newList); + setCurrentFilter(filter); + setCurrentSearchOption(searchOption); + const registeredCourses = await getRegisterdList(); + setRegisteredList(registeredCourses || []); + }; + + const handleStartButtonClick = () => { + setList([]); + setRegisteredList([]); + setIsRegistrationStarted(true); + setIsFirstSearch(true); + dispatch(setEndCount(false)); + }; + + const handleAction = async ( + _action: string, + scheduleId: number | undefined, + curiNm: string | undefined, + schDeptAlias: string | undefined, + curiTypeCdNm: string | undefined, + ) => { + if (scheduleId && curiNm && schDeptAlias && curiTypeCdNm) { + dispatch( + setCourseData({ + scheduleId: scheduleId, + curiNm: curiNm, + schDeptAlias: schDeptAlias, + curiTypeCdNm: curiTypeCdNm, + }), + ); + dispatch(setModalName('macro')); + } + }; + + return ( + <> + {!isConfirm ? ( + + ) : ( + <> + + + + 수강대상교과목 + +
+ + + )} + + ); +} + +export default CourseRegister; diff --git a/src/components/DeleteAccount/DeleteAccountForm.tsx b/src/components/DeleteAccount/DeleteAccountForm.tsx new file mode 100644 index 0000000..430fbe2 --- /dev/null +++ b/src/components/DeleteAccount/DeleteAccountForm.tsx @@ -0,0 +1,105 @@ +import {useState} from 'react'; +import styled from 'styled-components'; +import {useNavigate} from 'react-router-dom'; +import {withdrawal} from '@/apis/api/auth'; +import FormInput from '@components/LoginForm/FormInput.tsx'; + +export type setType = string | number | undefined; + +function DeleteAccountForm() { + const [id, setId] = useState(''); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + + const handleDelete = async () => { + if (typeof id === 'undefined') { + return; + } + + if (id.toString().length < 11) { + setError('11자리 이상의 임의 학번을 입력해주세요!'); + return; + } + + try { + const response = await withdrawal(id.toString()); + console.log(response); + console.log('Withdrawal successful'); + + navigate('/'); + } catch (error) { + console.error('Withdrawal failed', error); + setError('제거 실패'); + } + }; + + return ( + + + + 학번 + + + navigate('/login')}>로그인 페이지 + + {error && {error}} + + 제거 + + + ); +} + +const FormContainer = styled.div` + padding: 1.5rem 4rem; + background-color: ${props => props.theme.colors.white}; + border-radius: 0.3rem; + margin-bottom: 2rem; +`; + +const InputContainer = styled.div` + margin-bottom: 2.5rem; +`; + +const InputBox = styled.div` + margin-top: 2rem; +`; + +const LabelWrap = styled.div` + ${props => props.theme.texts.loginContent}; + margin-bottom: 0.7rem; +`; + +const LoginBtnWrap = styled.button` + width: 100%; + height: 5rem; + background-color: ${props => props.theme.colors.secondary}; + border: none; + border-radius: 5rem; + color: ${props => props.theme.colors.white}; + font-size: 1.7rem; + font-weight: 700; + box-shadow: 0px 4px 5px lightgray; + margin-bottom: 2rem; + + &:hover { + background-color: #c3002fc7; + } +`; + +const FindWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + color: ${props => props.theme.colors.neutral4}; + float: inline-end; + margin-top: 2rem; + margin-bottom: 2rem; +`; + +const ErrorMessage = styled.div` + color: red; + margin-bottom: 1rem; + text-align: center; +`; + +export default DeleteAccountForm; diff --git a/src/components/Header/Clock.tsx b/src/components/Header/Clock.tsx new file mode 100644 index 0000000..20e6512 --- /dev/null +++ b/src/components/Header/Clock.tsx @@ -0,0 +1,53 @@ +import {useEffect, useState} from 'react'; +import styled from 'styled-components'; + +interface ClockProps { + name: string; +} + +function Clock({name}: ClockProps) { + const [time, setTime] = useState(35985000); + + const formatTime = (time: number) => { + return time.toString().padStart(2, '0'); + }; + + const resetTime = () => { + setTime(35985000); + }; + + useEffect(() => { + const clock = setInterval(() => { + setTime(prev => prev + 1000); + }, 1000); + + if (time === 36000000) { + clearInterval(clock); + } + + return () => clearInterval(clock); + }, [time]); + + return ( + + {name} + + 님 [{formatTime(Math.floor(time / 1000 / 3600))}: + {formatTime(Math.floor(((time / 1000) % 3600) / 60))}: + {formatTime(Math.floor(((time / 1000) % 3600) % 60))}] + + + ); +} + +const ClockWrap = styled.button` + ${props => props.theme.texts.tableTitle}; + font-size: 1.3rem; + + > span { + color: ${props => props.theme.colors.neutral4}; + font-weight: 400; + } +`; + +export default Clock; diff --git a/src/components/Header/TImer.tsx b/src/components/Header/TImer.tsx deleted file mode 100644 index 03137fb..0000000 --- a/src/components/Header/TImer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; -import styled from 'styled-components'; - -interface TimerProps { - name: string; -} - -function Timer({name}: TimerProps) { - const navigate = useNavigate(); - const [time, setTime] = useState(1800); - - const formatTime = (time: number) => { - return time.toString().padStart(2, '0'); - }; - - const resetTime = () => { - setTime(1800); - }; - - useEffect(() => { - const timer = setInterval(() => { - setTime(prev => prev - 1); - }, 1000); - - if (time === 0) { - navigate('/login'); - } - - return () => clearInterval(timer); - }, [time]); - - return ( - - {name} - - 님 [00:{formatTime(Math.floor(time / 60))}:{formatTime(time % 60)}] - - - ); -} - -const TimerWrap = styled.button` - ${props => props.theme.texts.tableTitle}; - font-size: 1.3rem; - - > span { - color: ${props => props.theme.colors.neutral4}; - font-weight: 400; - } -`; - -export default Timer; diff --git a/src/components/Header/TopMenu.tsx b/src/components/Header/TopMenu.tsx index 53f34c9..480919f 100644 --- a/src/components/Header/TopMenu.tsx +++ b/src/components/Header/TopMenu.tsx @@ -1,43 +1,31 @@ import styled from 'styled-components'; import {useNavigate} from 'react-router-dom'; -import Left from '@assets/img/btn_main_top_left.svg?react'; -import Right from '@assets/img/btn_main_top_right.svg?react'; +import {useDispatch} from 'react-redux'; +import Cookies from 'js-cookie'; +import {baseAPI} from '@/apis/utils/instance'; +import {useAppSelector} from '@/store/hooks'; +import {clearUserInfo} from '@/store/modules/userSlice'; import logout from '@assets/img/logout.png'; -import down from '@assets/img/top_menu_down.png'; -import notice from '@assets/img/notice.png'; -import setting from '@assets/img/setitng.png'; -import menu from '@assets/img/menu.png'; -import Timer from './TImer'; +import Clock from './Clock'; +import {resetCourseRegistered} from '@/store/modules/courseRegisteredSlice'; function TopMenu() { const navigate = useNavigate(); - const name = '세종대'; + const dispatch = useDispatch(); + const {username} = useAppSelector(state => state.userInfo); const handleLogout = () => { + dispatch(clearUserInfo()); + dispatch(resetCourseRegistered()); + delete baseAPI.defaults.headers.common['Authorization']; + Cookies.remove('accessToken'); navigate('/login'); }; return ( - - - - - + - - PC - - - - KOR - - - - - - - ); } @@ -48,60 +36,15 @@ const TopMenuContainer = styled.div` column-gap: 1rem; `; -const ArrowWrap = styled.div` - display: flex; - align-items: center; - column-gap: 1.2rem; - margin-right: 1.5rem; -`; - -const StyledLeft = styled(Left)` - fill: ${props => props.theme.colors.neutral4}; - &:hover { - fill: ${props => props.theme.colors.primary}; - } -`; - -const StyledRight = styled(Right)` - fill: ${props => props.theme.colors.neutral4}; - &:hover { - fill: ${props => props.theme.colors.primary}; - } -`; - const LogoutBtn = styled.button` background-image: url(${logout}); - width: 1.4rem; - height: 1.4rem; - + background-size: 1.8rem; + width: 1.8rem; + height: 1.8rem; + margin-right: 1rem; &:hover { filter: brightness(20%); } `; -const DropdownWrap = styled.div` - ${props => props.theme.texts.tableTitle}; - font-size: 1.3rem; - display: flex; - align-items: center; - column-gap: 1.5rem; - - > img { - &:hover { - filter: brightness(20%); - } - } -`; - -const GroupWrap = styled.div` - display: flex; - column-gap: 1rem; - - > img { - &:hover { - filter: brightness(20%); - } - } -`; - export default TopMenu; diff --git a/src/components/Header/TopNav.tsx b/src/components/Header/TopNav.tsx index 66293f8..a251110 100644 --- a/src/components/Header/TopNav.tsx +++ b/src/components/Header/TopNav.tsx @@ -3,8 +3,7 @@ import styled from 'styled-components'; function TopNav() { return ( - 학부생학사정보 - 학생서비스 + :  세종대학교 수강신청 연습 사이트 ); } @@ -12,16 +11,16 @@ function TopNav() { const TopNavContatiner = styled.div` display: flex; flex-shrink: 0; -`; - -const TopNavWrap = styled.button` - ${props => props.theme.texts.title}; - font-size: 1.6rem; - margin-right: 4rem; + align-items: center; - &:focus { - color: ${props => props.theme.colors.primary}; + @media ${props => props.theme.device.mobile} { + display: none; } `; +const TopNavWrap = styled.div` + ${props => props.theme.texts.loginContent}; + font-size: 1.5rem; +`; + export default TopNav; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 1e99463..adf1f40 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -1,16 +1,16 @@ import styled from 'styled-components'; -import logo from '@assets/img/main_logo.png'; +import logo from '@assets/img/tutorial_sejong_logo.webp'; import TopNav from './TopNav'; import TopMenu from './TopMenu'; function Header() { return ( -
+ -
- + +
@@ -20,18 +20,27 @@ function Header() { const HeaderContainer = styled.div` border-top: 0.5rem solid ${props => props.theme.colors.primary}; max-width: 100%; - height: 6rem; + height: 7rem; display: flex; align-items: center; justify-content: space-between; - padding: 0 2rem; +`; + +const LogoBox = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 0 1rem; + > img { + height: 6rem; + display: block; + } `; const HeaderBox = styled.div` display: flex; - flex-grow: 1; + height: 100%; justify-content: space-between; - margin-left: 7.5rem; `; export default Header; diff --git a/src/components/LectureList/Filters.tsx b/src/components/LectureList/Filters.tsx index 40f22f1..d180956 100644 --- a/src/components/LectureList/Filters.tsx +++ b/src/components/LectureList/Filters.tsx @@ -4,22 +4,22 @@ import FilterButton from '@components/common/FilterButton'; import FilterInput from '@components/common/FilterInput'; import SelectBox from '@components/common/SelectBox'; import {completion, major, optional, term} from '@assets/data/filter'; +import {CourseTypes} from '@/assets/types/tableType'; +import { + FilterArea, + FilterBox, + FilterContainer, + FilterWrap, +} from '@/styles/FilterLayout'; -export interface LectureProps { - schCollegeAlias?: string | undefined; - schDeptAlias?: string | undefined; - curiTypeCdNm?: string | undefined; - sltDomainCdNm?: string | undefined; - curiNm?: string | undefined; - lesnEmp?: string | undefined; +interface FiltersProps { + onSearch: (newList: CourseTypes[]) => Promise; } -function Filters() { - const [lecture, setLecture] = useState(); - const handleSelect = ( - name: keyof LectureProps, - value: string | undefined, - ) => { +function Filters({onSearch}: FiltersProps) { + const [filter, setFilter] = useState({}); + + const handleSelect = (name: keyof CourseTypes, value: string | undefined) => { let dept = ''; let colledge = ''; @@ -27,13 +27,13 @@ function Filters() { dept = value!.substring(0, value!.indexOf('【')); colledge = value!.substring(value!.indexOf('】') + 1); - setLecture(prevState => ({ + setFilter(prevState => ({ ...prevState, schDeptAlias: dept, schCollegeAlias: colledge, })); } else { - setLecture(prevState => ({ + setFilter(prevState => ({ ...prevState, [name]: value, })); @@ -48,7 +48,6 @@ function Filters() { 조직분류 handleSelect('curiTypeCdNm', value)} @@ -58,7 +57,6 @@ function Filters() { 년도/학기 handleSelect('curiTypeCdNm', value)} @@ -68,7 +66,6 @@ function Filters() { 이수구분 handleSelect('curiTypeCdNm', value)} /> @@ -77,7 +74,6 @@ function Filters() { 선택영역 handleSelect('sltDomainCdNm', value)} /> @@ -86,7 +82,6 @@ function Filters() { 학과전공 handleSelect('schDeptAlias', value)} /> @@ -106,7 +101,12 @@ function Filters() { /> - +

@@ -114,45 +114,12 @@ function Filters() { ROTC과목은 개설학과전공을 대양휴머니티칼리지(또는 교양학부)로 하여 조회하시기 바랍니다.

-

- ※ 과목에 대한 문의는 개설학과가 아닌 주관학과에 문의하시기 바라며, - 영어과목에 대한 문의는 교양영어실로 문의하시기 바랍니다. -

); } -const FilterContainer = styled.div` - border: 0.1rem solid #714656; - border-radius: 2px; - padding: 0.5rem 1.5rem; - margin-bottom: 2rem; -`; - -const FilterBox = styled.div` - display: flex; - flex-wrap: wrap; - gap: 0.7rem 3rem; -`; - -const FilterArea = styled.div` - display: flex; - align-items: flex-end; - margin-bottom: 1rem; -`; - -const FilterWrap = styled.div` - ${props => props.theme.texts.tableTitle}; - > span { - display: inline-block; - margin-right: 1rem; - text-align: right; - min-width: 4.5rem; - } -`; - -const WarningWrap = styled.div` +export const WarningWrap = styled.div` ${props => props.theme.texts.warning}; color: #c30e2e; margin-bottom: -1.5rem; diff --git a/src/components/LectureList/index.tsx b/src/components/LectureList/index.tsx index 688a79a..173866b 100644 --- a/src/components/LectureList/index.tsx +++ b/src/components/LectureList/index.tsx @@ -1,108 +1,70 @@ import styled from 'styled-components'; import Filters from './Filters'; import Table from '@components/common/Table'; - -const data = [ - { - schDeptAlias: '대양휴머니티칼리지', - curiNo: '011312', - class_: '001', - schCollegeAlias: '대양휴머니티칼리지', - curiNm: '경영학', - curiLangNm: null, - curiTypeCdNm: '균형교양필수', - sltDomainCdNm: '경제와사회', - tmNum: '3.0 / 3 / 0', - studentYear: '2', - corsUnitGrpCdNm: '학사', - manageDeptNm: '대양휴머니티칼리지', - lesnEmp: '이지훈', - lesnTime: '목 18:00~19:00', - lesnRoom: '집301', - cyberTypeCdNm: '본교 e-러닝강의', - internshipTypeCdNm: null, - inoutSubCdtExchangeYn: null, - remark: '사회과학,경영경제,호텔관광대학2 제외', - }, - { - schDeptAlias: '대양휴머니티칼리지', - curiNo: '011312', - class_: '001', - schCollegeAlias: '대양휴머니티칼리지', - curiNm: '경영학', - curiLangNm: null, - curiTypeCdNm: '균형교양필수', - sltDomainCdNm: '경제와사회', - tmNum: '3.0 / 3 / 0', - studentYear: '2', - corsUnitGrpCdNm: '학사', - manageDeptNm: '대양휴머니티칼리지', - lesnEmp: '이지훈', - lesnTime: '목 18:00~19:00', - lesnRoom: '집301', - cyberTypeCdNm: '본교 e-러닝강의', - internshipTypeCdNm: null, - inoutSubCdtExchangeYn: null, - remark: '사회과학,경영경제,호텔관광대학2 제외', - }, - { - schDeptAlias: '대양휴머니티칼리지', - curiNo: '011312', - class_: '001', - schCollegeAlias: '대양휴머니티칼리지', - curiNm: '경제학', - curiLangNm: null, - curiTypeCdNm: '균형교양필수', - sltDomainCdNm: '경제와사회', - tmNum: '3.0 / 3 / 0', - studentYear: '2', - corsUnitGrpCdNm: '학사', - manageDeptNm: '대양휴머니티칼리지', - lesnEmp: '이지훈', - lesnTime: '목 18:00~19:00', - lesnRoom: '집301', - cyberTypeCdNm: '본교 e-러닝강의', - internshipTypeCdNm: null, - inoutSubCdtExchangeYn: null, - remark: '사회과학,경영경제,호텔관광대학2 제외', - }, -]; +import {useCallback, useEffect, useState} from 'react'; +import {CourseTypes} from '@assets/types/tableType'; +import {getCourseList} from '@/apis/api/course'; const colData = [ - {name: 'schDeptAlias', value: '개설학과전공', initialWidth: 167}, + {name: 'schDeptAlias', value: '개설학과전공', initialWidth: 276}, {name: 'curiNo', value: '학수번호', initialWidth: 92}, - {name: 'class_', value: '분반', initialWidth: 58}, - {name: 'curiNm', value: '교과목명', initialWidth: 232}, + {name: 'classNo', value: '분반', initialWidth: 58}, + {name: 'curiNm', value: '교과목명', initialWidth: 242}, {name: 'curiLangNm', value: '강의언어', initialWidth: 73}, {name: 'curiTypeCdNm', value: '이수구분'}, {name: 'sltDomainCdNm', value: '선택영역', initialWidth: 136}, - {name: 'tmNum', value: '학점/이론/실습', initialWidth: 134}, + {name: 'tmNum', value: '학점/이론/실습', initialWidth: 144}, {name: 'studentYear', value: '학년 (학기)'}, {name: 'corsUnitGrpCdNm', value: '대상과정'}, - {name: 'manageDeptNm', value: '주관학과', initialWidth: 135}, - {name: 'lesnEmp', value: '교수명'}, - {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 130}, + {name: 'manageDeptNm', value: '주관학과', initialWidth: 276}, + {name: 'lesnEmp', value: '교수명', initialWidth: 238}, + {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 183}, {name: 'lesnRoom', value: '강의실', initialWidth: 114}, - {name: 'cyberTypeCdNm', value: '사이버강좌', initialWidth: 104}, - {name: 'internshipTypeCdNm', value: '강좌유형', initialWidth: 126}, - {name: 'inoutSubCdtExchangeYn', value: '학점교류수강가능', initialWidth: 130}, - {name: 'remark', value: '수강대상및유의사항', initialWidth: 230}, + {name: 'cyberTypeCdNm', value: '사이버강좌', initialWidth: 171}, + {name: 'internshipTypeCdNm', value: '강좌유형', initialWidth: 136}, + {name: 'inoutSubCdtExchangeYn', value: '학점교류수강가능', initialWidth: 140}, + {name: 'remark', value: '수강대상및유의사항', initialWidth: 610}, ]; function LectureList() { + const [list, setList] = useState([]); + + useEffect(() => { + const getList = async () => { + const res = await getCourseList({}); + if (res) { + setList(res); + } + }; + + getList(); + }, []); + + const handleSearch = useCallback(async (newList: CourseTypes[]) => { + setList(newList); + }, []); + return ( - -
+ + + 개설강좌 + +
); } const ListContainer = styled.div``; +export const TableTitleWrap = styled.div` + margin-bottom: 1rem; +`; + +export const TableTitle = styled.div` + ${props => props.theme.texts.subtitle}; + border-left: 4px solid ${props => props.theme.colors.primary}; + padding-left: 0.5rem; +`; + export default LectureList; diff --git a/src/components/LoginForm/index.tsx b/src/components/LoginForm/index.tsx index 8ce83f1..cfd012e 100644 --- a/src/components/LoginForm/index.tsx +++ b/src/components/LoginForm/index.tsx @@ -1,37 +1,149 @@ +import {useState} from 'react'; import styled from 'styled-components'; import FormInput from './FormInput'; -import {useState} from 'react'; +import {login} from '@/apis/api/auth'; +import {useDispatch} from 'react-redux'; +import {useNavigate} from 'react-router-dom'; +import {setUserInfo} from '@/store/modules/userSlice'; +import {baseAPI} from '@/apis/utils/instance'; +import Cookies from 'js-cookie'; +import {generateRandomStudentId} from '@/utils/randomUtils.ts'; + +import copyIcon from '@/assets/img/file-copy-line.png'; +import reloadIcon from '@/assets/img/refresh-line.png'; +import {resetTab} from '@/store/modules/tabSlice'; +import ReactGA from 'react-ga4'; export type setType = string | number | undefined; -function LoginForm() { - const [id, setId] = useState(undefined); - const [name, setName] = useState(''); +function LoginForm({isTermsCheck}: {isTermsCheck: boolean}) { + const [id, setId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const [randomStudentId, setRandomStudentId] = useState( + generateRandomStudentId, + ); + + const handleRandomStudentId = () => { + setRandomStudentId(generateRandomStudentId); + }; + const handleCopyStudentId = () => { + navigator.clipboard + .writeText(randomStudentId.toString()) + .then(() => { + alert('복사 완료!'); + }) + .catch(err => { + console.error('Failed to copy text: ', err); + }); + }; + const handleLogin = async () => { + ReactGA.event({ + category: 'User', + action: 'Login Attempt', + label: 'Login Page', + }); + + if (!id || !password) { + setError('학번과 비밀번호를 모두 입력해주세요.'); + return; + } + + if (typeof id === 'string' && id.length < 11) { + setError('11자리 이상의 임의 학번을 입력해주세요!'); + return; + } + + if (!isTermsCheck) { + setError('이용약관에 동의하지 않으셨습니다.'); + + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + return; + } + + try { + const response = await login({ + studentId: id.toString(), + password: password.toString(), + }); + + console.log('Login successful'); + + ReactGA.event({ + category: 'User', + action: 'Login Success', + label: 'Login Page', + }); + + Cookies.set('accessToken', response.accessToken, {expires: 0.5 / 24}); + baseAPI.defaults.headers.common['Authorization'] = + `Bearer ${response.accessToken}`; + + dispatch( + setUserInfo({ + username: response.username, + }), + ); + + dispatch(resetTab()); + + alert('※ 본 사이트는 실제 수강신청 사이트가 아닙니다. ※'); + navigate('/'); + } catch (error) { + console.error('Login failed', error); + + ReactGA.event({ + category: 'User', + action: 'Login Failed', + label: 'Login Page', + }); + + setError('로그인에 실패했습니다. 다시 시도해주세요.'); + } + }; return ( + + 임의 학번, 비밀번호 생성 + + {randomStudentId} + + reload + + + copy + + + 학번 - 이름 - + 비밀번호 + - - - - - 아이디 찾기 | 비밀번호 찾기 - 로그인 + {error && {error}} + + 로그인 + ); } const FormContainer = styled.div` - padding: 1.5rem 4rem; + display: flex; + flex-direction: column; + align-items: center; background-color: ${props => props.theme.colors.white}; border-radius: 0.3rem; margin-bottom: 2rem; @@ -45,27 +157,40 @@ const InputBox = styled.div` margin-top: 2rem; `; -const LabelWrap = styled.div` - ${props => props.theme.texts.loginContent}; - margin-bottom: 0.7rem; +const RandomStudentIdContainer = styled.div` + display: flex; + justify-content: flex-start; + align-items: center; `; -const CheckboxWrap = styled.div` - margin-left: -0.5rem; - margin-top: 1rem; +const RandomStudentIdInput = styled.p` + border-bottom: 1px solid #000000; + padding: 0.5rem 1rem; + font-size: 1.7rem; + font-weight: 700; + flex: 1 1 0; `; -const FindWrap = styled.div` - ${props => props.theme.texts.tableTitle}; - color: ${props => props.theme.colors.neutral4}; - float: inline-end; - margin-bottom: 2.5rem; +const GenerateButton = styled.button` + display: block; + padding: 1rem; + font-size: 1.7rem; + font-weight: 700; + + > img { + width: 2rem; + } +`; + +const LabelWrap = styled.div` + ${props => props.theme.texts.loginContent}; + margin-bottom: 0.7rem; `; const LoginBtnWrap = styled.button` - width: 100%; + width: 90%; height: 5rem; - background-color: #c3002f; + background-color: ${props => props.theme.colors.secondary}; border: none; border-radius: 5rem; color: ${props => props.theme.colors.white}; @@ -79,4 +204,10 @@ const LoginBtnWrap = styled.button` } `; +const ErrorMessage = styled.div` + color: red; + margin-bottom: 1rem; + text-align: center; +`; + export default LoginForm; diff --git a/src/components/Menubar/BarTitle.tsx b/src/components/Menubar/BarTitle.tsx index 9b6d6f7..0c96b06 100644 --- a/src/components/Menubar/BarTitle.tsx +++ b/src/components/Menubar/BarTitle.tsx @@ -1,37 +1,28 @@ import styled from 'styled-components'; -import Star from '@assets/img/fav_white.png'; -import Search from '@assets/img/search.png'; -import Close from '@assets/img/menu_close.png'; +import Close from '@assets/img/close-sidebar.svg?react'; -interface OpenProps { - setIsOpen: React.Dispatch>; +interface TitleProps { + setOpen: React.Dispatch>; } -function BarTitle({setIsOpen}: OpenProps) { +function BarTitle({setOpen}: TitleProps) { return ( 학부생수강시스템 - - - - setIsOpen(false)}> - - - + setOpen(!prev)}> + + ); } const BarTitleContainer = styled.div` - background: linear-gradient( - 90deg, - rgba(163, 20, 50, 1) 0%, - rgba(51, 77, 97, 1) 100% - ); + background: ${props => props.theme.colors.secondary}; height: 4rem; display: flex; align-items: center; - justify-content: space-around; + justify-content: space-between; + padding: 0 1.5rem; `; const BarTitleWrap = styled.div` @@ -40,15 +31,11 @@ const BarTitleWrap = styled.div` color: ${props => props.theme.colors.white}; `; -const IconBox = styled.div` - display: flex; - align-items: center; - column-gap: 0.5rem; -`; - const CloseBtn = styled.button` display: flex; align-items: center; + height: 100%; + width: 1.5rem; `; export default BarTitle; diff --git a/src/components/Menubar/Menu.tsx b/src/components/Menubar/Menu.tsx index d29fe77..04be724 100644 --- a/src/components/Menubar/Menu.tsx +++ b/src/components/Menubar/Menu.tsx @@ -1,8 +1,7 @@ import styled from 'styled-components'; -import arrow from '@assets/img/arrow_up.png'; -import hyphen from '@assets/img/menu2_close.png'; +import {useAppDispatch, useAppSelector} from '@/store/hooks'; import MenuItem from './MenuItem'; -import {useState} from 'react'; +import {addTab, setFocused} from '@/store/modules/tabSlice'; interface ItemProps { id: number; @@ -11,28 +10,23 @@ interface ItemProps { } const menuItems: ItemProps[] = [ - {id: 0, name: '강의시간표 조회', type: 'view'}, - {id: 1, name: '관심과목 담기', type: 'study'}, + {id: 0, name: '강의시간표/수업계획서조회', type: 'search'}, + {id: 1, name: '관심과목 담기', type: 'bookmark'}, {id: 2, name: '수강신청', type: 'study'}, ]; function Menu() { - const [focused, setFocused] = useState(null); + const dispatch = useAppDispatch(); + const focused = useAppSelector(state => state.tabs.focused); const handleClick = (id: number) => { - setFocused(id); + dispatch(addTab({id: id, name: menuItems[id].name})); + dispatch(setFocused(id)); }; return ( - - 수강 및 변동신청 - - - - - 수강신청 및 기타 - + 수강신청 및 기타 {menuItems.map(item => ( props.theme.colors.neutral5}; `; -const MenuTitleWrap = styled.div` - ${props => props.theme.texts.menuTitle}; -`; const MenuSubtitleBox = styled(MenuTitleBox)` ${props => props.theme.texts.menuTitle}; @@ -73,6 +64,8 @@ const DetailBox = styled.div` display: flex; flex-direction: column; align-items: center; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; `; export default Menu; diff --git a/src/components/Menubar/MenuItem.tsx b/src/components/Menubar/MenuItem.tsx index 2b6f1bb..eeba817 100644 --- a/src/components/Menubar/MenuItem.tsx +++ b/src/components/Menubar/MenuItem.tsx @@ -1,6 +1,7 @@ import styled from 'styled-components'; -import View from '@assets/img/view.svg?react'; -import Study from '@assets/img/study.svg?react'; +import Search from '@assets/img/search-line.svg?react'; +import BookMark from '@assets/img/bookmark-3-line.svg?react'; +import Study from '@assets/img/edit-line.svg?react'; interface DetailProps { id: number; @@ -13,7 +14,13 @@ interface DetailProps { function MenuItem({id, type, name, isActive, onClick}: DetailProps) { return ( onClick(id)}> - {type === 'view' ? : } + {type === 'search' ? ( + + ) : type === 'bookmark' ? ( + + ) : ( + + )} {name} ); @@ -21,13 +28,14 @@ function MenuItem({id, type, name, isActive, onClick}: DetailProps) { const DetailWrap = styled.button<{$isactive: boolean}>` ${props => props.theme.texts.tableTitle}; - width: 17.5rem; + width: 90%; height: 2.8rem; display: flex; align-items: center; column-gap: 1rem; - padding-left: 10px; - + padding-left: 1rem; + border-radius: 0.5rem; + margin-top: 0.5rem; background-color: ${props => props.$isactive ? props.theme.colors.primary : 'transparent'}; color: ${props => props.$isactive && props.theme.colors.white}; diff --git a/src/components/Menubar/index.tsx b/src/components/Menubar/index.tsx index e08516d..9a76f2a 100644 --- a/src/components/Menubar/index.tsx +++ b/src/components/Menubar/index.tsx @@ -1,51 +1,46 @@ import styled from 'styled-components'; -import {useState} from 'react'; import BarTitle from './BarTitle'; import Menu from './Menu'; -import close from '@assets/img/menu_close.png'; +import Open from '@assets/img/close-sidebar.svg?react'; -function Menubar() { - const [isOpen, setIsOpen] = useState(true); +interface BarProps { + open: boolean; + setOpen: React.Dispatch>; +} +function Menubar({open, setOpen}: BarProps) { return ( - - {isOpen ? ( - - + + {open ? ( + + - + ) : ( - - setIsOpen(true)}> - - - + setOpen(true)}> + + )} ); } -const BarContainer = styled.div` - height: 100vh; -`; - -const OpendBar = styled.div` - width: 23rem; -`; - -const ClosedBar = styled.div` - width: 2rem; +const BarContainer = styled.div<{$open: boolean}>` + min-width: ${props => (props.$open ? '23rem' : '2rem')}; + background-color: ${props => props.theme.colors.white}; height: 100%; - background-color: ${props => props.theme.colors.neutral4}; `; -const OpenBtn = styled.button` +const BarBox = styled.div``; + +const OpenBtnWrap = styled.button` + background-color: ${props => props.theme.colors.secondary}; width: 100%; height: 4rem; - background-color: ${props => props.theme.colors.primary}; - > img { - transform: rotate(180deg); - } +`; + +const OpenBtn = styled(Open)` + transform: rotate(180deg); `; export default Menubar; diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ff90439 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import {ReactNode} from 'react'; +import {Navigate} from 'react-router-dom'; +import Cookies from 'js-cookie'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +function ProtectedRoute({children}: ProtectedRouteProps) { + const tokenExists = Cookies.get('accessToken'); + + return tokenExists ? <>{children} : ; +} + +export default ProtectedRoute; diff --git a/src/components/TabMenu/Tab.tsx b/src/components/TabMenu/Tab.tsx index 8a34af7..3f03285 100644 --- a/src/components/TabMenu/Tab.tsx +++ b/src/components/TabMenu/Tab.tsx @@ -1,6 +1,8 @@ -import {useState} from 'react'; import styled, {css} from 'styled-components'; -import close from '@assets/img/tab_close.png'; +import close from '@assets/img/close-line-red.png'; +import {useAppDispatch, useAppSelector} from '@/store/hooks'; +import {delTab, setFocused} from '@/store/modules/tabSlice'; +import {resetDateMode} from '@/store/modules/dateModeSlice'; interface TabProps { id: number; @@ -10,29 +12,28 @@ interface TabProps { } function Tab({id, label, isActive, onClick}: TabProps) { - const [isOpen, setIsOpen] = useState(true); + const dispatch = useAppDispatch(); + const tabs = useAppSelector(state => state.tabs.tab); + const idx = tabs.findIndex(item => item.id === id); const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); - - if (id > 0) { - onClick(id - 1); + if (idx === 0) { + dispatch(setFocused(tabs[idx + 1].id)); } else { - onClick(1); + dispatch(setFocused(tabs[idx - 1].id)); + } + dispatch(delTab(id)); + if (label === '수강신청') { + dispatch(resetDateMode()); } - - setIsOpen(false); }; return ( - <> - {isOpen && ( - onClick(id)} $isactive={isActive}> -

{label}

- -
- )} - + onClick(id)} $isactive={isActive}> +

{label}

+ +
); } @@ -43,18 +44,18 @@ const TabContainer = styled.a<{$isactive: boolean}>` : props.theme.texts.tabTitle}; background-color: ${props => props.$isactive ? props.theme.colors.white : 'transparent'}; - width: calc(99% / 8); - height: 100%; - border-top: 0.3rem solid - ${props => (props.$isactive ? props.theme.colors.primary : 'none')}; + width: calc(99% / 7); + height: 102%; + border-bottom: none; border-right: 1px solid #ccc; - border-radius: 0; padding: 0 1rem; display: flex; align-items: center; text-align: center; cursor: pointer; filter: ${props => (props.$isactive ? 'grayscale(0)' : 'grayscale(100%)')}; + position: relative; + overflow: hidden; > p { width: 100%; @@ -62,14 +63,14 @@ const TabContainer = styled.a<{$isactive: boolean}>` text-overflow: ellipsis; white-space: nowrap; word-break: break-all; - margin-right: 1.5rem; + margin-right: 1rem; } ${props => !props.$isactive && css` &:hover { - background-color: white; + color: ${props => props.theme.colors.primary}; filter: grayscale(0); } `} @@ -77,9 +78,10 @@ const TabContainer = styled.a<{$isactive: boolean}>` const CloseBtn = styled.button` z-index: 5; - width: 1rem; + width: 1.8rem; height: 100%; background-image: url(${close}); + background-size: 1.8rem; background-repeat: no-repeat; background-position-y: center; `; diff --git a/src/components/TabMenu/index.tsx b/src/components/TabMenu/index.tsx index ea0f137..7c883a4 100644 --- a/src/components/TabMenu/index.tsx +++ b/src/components/TabMenu/index.tsx @@ -1,41 +1,30 @@ -import {useState} from 'react'; +import {useAppDispatch, useAppSelector} from '@/store/hooks'; import styled from 'styled-components'; import Tab from './Tab'; -import closeAll from '@assets/img/tab_close_all.png'; -import up from '@assets/img/btn_gnb_cu.png'; +import {setFocused} from '@/store/modules/tabSlice'; function TabMenu() { - const [focused, setFocused] = useState(0); - const [close, setClose] = useState(false); + const dispatch = useAppDispatch(); + const tabs = useAppSelector(state => state.tabs.tab); + const focused = useAppSelector(state => state.tabs.focused); const handleClick = (id: number) => { - setFocused(id); + dispatch(setFocused(id)); }; return ( - {!close && ( - <> - - - - )} + {tabs.map(tab => ( + + ))} - - setClose(true)} /> - - ); } @@ -55,19 +44,4 @@ const TabWrap = styled.div` display: flex; `; -const ButtonWrap = styled.div` - display: flex; - align-items: center; - margin-right: 1rem; - column-gap: 0.8rem; -`; - -const CloseAllBtn = styled.button` - width: 1.2rem; - height: 100%; - background-image: url(${closeAll}); - background-repeat: no-repeat; - background-position-y: center; -`; - export default TabMenu; diff --git a/src/components/WishRank/RankItem.tsx b/src/components/WishRank/RankItem.tsx new file mode 100644 index 0000000..36d7069 --- /dev/null +++ b/src/components/WishRank/RankItem.tsx @@ -0,0 +1,54 @@ +import styled from 'styled-components'; +import {CourseTypes} from '@/assets/types/tableType'; +import {useAppDispatch} from '@/store/hooks'; +import {setCourseData} from '@/store/modules/modalSlice'; +import {openModalHandler} from '../common/Modal/handlers/handler'; + +interface RankItemProps { + courseData: CourseTypes; +} + +function RankItem({courseData}: RankItemProps) { + const dispatch = useAppDispatch(); + + const handleClick = () => { + openModalHandler(dispatch, 'wishrank'); + dispatch(setCourseData(courseData)); + }; + + return ( + + {courseData.rank} +

{courseData.curiNm}

+

{courseData.lesnEmp}

+
+ ); +} + +const RankWrap = styled.button` + ${props => props.theme.texts.tabTitle}; + width: 100%; + display: grid; + grid-template-columns: 1fr 6fr 3fr; + align-items: center; + column-gap: 0.5rem; + padding: 0.7rem 0; + border-bottom: 1px solid ${props => props.theme.colors.neutral5}; + + > p { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + } + + &:hover { + background-color: ${props => props.theme.colors.neutral6}; + } +`; + +const RankNo = styled.p` + color: ${props => props.theme.colors.primary}; +`; + +export default RankItem; diff --git a/src/components/WishRank/index.tsx b/src/components/WishRank/index.tsx new file mode 100644 index 0000000..863e89c --- /dev/null +++ b/src/components/WishRank/index.tsx @@ -0,0 +1,50 @@ +import {getWishRank} from '@/apis/api/course'; +import {CourseTypes} from '@/assets/types/tableType'; +import {useEffect, useState} from 'react'; +import styled from 'styled-components'; +import RankItem from './RankItem'; + +function WishRank() { + const [rank, setRank] = useState([]); + + useEffect(() => { + const getRank = async () => { + const res = await getWishRank(); + if (res) { + setRank(res); + } + }; + + getRank(); + }, []); + + return ( + + 인기 관심 과목 순위 + + {rank.map(lect => ( + + ))} + + + ); +} + +const RankContainer = styled.div` + position: fixed; + bottom: 0; + left: 0; + width: 23rem; +`; + +const RankTitle = styled.div` + ${props => props.theme.texts.subtitle}; + margin-bottom: 1.5rem; + padding-left: 1rem; +`; + +const RankBox = styled.div` + margin-bottom: 0.5rem; +`; + +export default WishRank; diff --git a/src/components/Wishlist/Filters.tsx b/src/components/Wishlist/Filters.tsx new file mode 100644 index 0000000..22c9340 --- /dev/null +++ b/src/components/Wishlist/Filters.tsx @@ -0,0 +1,223 @@ +import {useState} from 'react'; +import styled from 'styled-components'; +import SelectBox from '@components/common/SelectBox'; +import FilterInput from '@components/common/FilterInput'; +import {getCourseList} from '@/apis/api/course'; +import {CourseTypes} from '@/assets/types/tableType'; +import {openModalHandler} from '../common/Modal/handlers/handler'; +import {useDispatch} from 'react-redux'; +import {setField, setType} from '@/store/modules/errorSlice'; +import { + FilterArea, + FilterBox, + FilterContainer, + FilterWrap, +} from '@/styles/FilterLayout'; + +const searchOptions = [ + {id: 0, value: '학수번호 검색'}, + {id: 1, value: '교과목명 검색'}, + {id: 2, value: '강의교수 검색'}, +]; + +interface SearchParams { + searchType: string; + curiNo: string; + classNo: string; + curiNm: string; + lesnEmp: string; +} + +interface FilterParams { + curiNo?: string; + classNo?: string; + curiNm?: string; + lesnEmp?: string; +} + +interface FiltersProps { + setSearchResults: React.Dispatch>; +} + +function Filters({setSearchResults}: FiltersProps) { + const dispatch = useDispatch(); + const [searchParams, setSearchParams] = useState({ + searchType: '학수번호 검색', + curiNo: '', + classNo: '', + curiNm: '', + lesnEmp: '', + }); + + const setError = (option: string) => { + openModalHandler(dispatch, 'fail'); + dispatch(setType(422)); + dispatch(setField(option)); + }; + + const handleSearch = async () => { + const filter: FilterParams = {}; + switch (searchParams.searchType) { + case '학수번호 검색': + if (!searchParams.curiNo || searchParams.curiNo.length < 2) { + setError('학수번호'); + return; + } else { + // filter.curiNm = searchParams.curiNm; + filter.curiNo = searchParams.curiNo; + } + if (!searchParams.classNo || searchParams.classNo.length < 2) { + setError('분반'); + return; + } else { + filter.classNo = searchParams.classNo; + } + break; + case '교과목명 검색': + if (!searchParams.curiNm || searchParams.curiNm.length < 2) { + setError('교과목명'); + return; + } else { + filter.curiNm = searchParams.curiNm; + } + break; + case '강의교수 검색': + if (!searchParams.lesnEmp || searchParams.lesnEmp.length < 2) { + setError('강의교수'); + return; + } else { + filter.lesnEmp = searchParams.lesnEmp; + } + break; + } + + try { + const data = await getCourseList(filter); + setSearchResults(Array.isArray(data) ? data : []); + } catch (error) { + console.error('Failed to fetch courses:', error); + setSearchResults([]); + } + }; + + const handleInputChange = (name: keyof SearchParams, value: string) => { + setSearchParams(prev => ({...prev, [name]: value})); + }; + + const renderSearchForm = () => { + switch (searchParams.searchType) { + case '학수번호 검색': + return ( + <> + + 학수번호 + handleInputChange('curiNo', value)} + /> + + + 분반 + handleInputChange('classNo', value)} + /> + + + ); + case '교과목명 검색': + return ( + + 교과목명 + handleInputChange('curiNm', value)} + /> + + ); + case '강의교수 검색': + return ( + + 교수명 + handleInputChange('lesnEmp', value)} + /> + + ); + default: + return null; + } + }; + + return ( + + + + + 조직분류 + {}} + /> + + + 년도/학기 + {}} + /> + + + + 검색구분 + handleInputChange('searchType', value || '')} + /> + + {renderSearchForm()} + + 검색 + + + ); +} + +const FilterBreak = styled.div` + flex-basis: 100%; + height: 0; +`; + +const WishFilterWrap = styled(FilterWrap)` + ${props => props.theme.texts.tableTitle}; + display: flex; + align-items: center; + > span { + display: inline-block; + margin-right: 1rem; + text-align: right; + min-width: 5rem; + } +`; + +const SearchButton = styled.button` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.secondary}; + color: ${props => props.theme.colors.white}; + min-width: 6.5rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + border: none; + cursor: pointer; +`; + +export default Filters; diff --git a/src/components/Wishlist/index.tsx b/src/components/Wishlist/index.tsx new file mode 100644 index 0000000..6bf4fff --- /dev/null +++ b/src/components/Wishlist/index.tsx @@ -0,0 +1,233 @@ +import styled from 'styled-components'; +import Table from '@components/common/Table'; +import Filters from './Filters'; +import {CourseTypes} from '@/assets/types/tableType'; +import {useCallback, useEffect, useState} from 'react'; +import { + deleteWishlistItem, + getWishlist, + saveWishlistItem, +} from '@/apis/api/course'; +import {RootState} from '@/store/store'; +import {useSelector} from 'react-redux'; +import {TableTitle, TableTitleWrap} from '../LectureList'; +import {openModalHandler} from '../common/Modal/handlers/handler'; +import {useAppDispatch} from '@/store/hooks'; +import ReactGA from 'react-ga4'; + +const searchResultColData = [ + {name: 'action', value: '신청', initialWidth: 50, enableFilters: false}, + {name: 'schDeptAlias', value: '개설학과전공', initialWidth: 276}, + {name: 'curiNo', value: '학수번호', initialWidth: 92}, + {name: 'classNo', value: '분반', initialWidth: 58}, + {name: 'curiNm', value: '교과목명', initialWidth: 242}, + {name: 'curiTypeCdNm', value: '이수구분'}, + {name: 'tmNum', value: '학점/이론/실습', initialWidth: 134}, + {name: 'lesnEmp', value: '교수명', initialWidth: 238}, + {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 183}, + {name: 'lesnRoom', value: '강의실', initialWidth: 114}, +]; + +const wishlistColData = [ + {name: 'action', value: '삭제', initialWidth: 50, enableFilters: false}, + {name: 'schDeptAlias', value: '개설학과전공', initialWidth: 276}, + {name: 'curiNo', value: '학수번호', initialWidth: 92}, + {name: 'classNo', value: '분반', initialWidth: 58}, + {name: 'curiNm', value: '교과목명', initialWidth: 242}, + {name: 'curiTypeCdNm', value: '이수구분'}, + {name: 'tmNum', value: '학점/이론/실습', initialWidth: 134}, + {name: 'lesnEmp', value: '교수명', initialWidth: 238}, + {name: 'lesnTime', value: '요일 및 강의시간', initialWidth: 183}, + {name: 'lesnRoom', value: '강의실', initialWidth: 114}, +]; + +function Wishlist() { + const [searchResultsData, setSearchResultsData] = useState([]); + const [wishlistData, setWishlistData] = useState([]); + const [registeredCourseCount, setRegisteredCourseCount] = useState(0); + const [registeredCredits, setRegisteredCredits] = useState(0); + const {username} = useSelector((state: RootState) => state.userInfo); + const dispatch = useAppDispatch(); + + const fetchWishlist = useCallback(async () => { + try { + const data = await getWishlist(username); + setWishlistData(data); + updateWishlistStats(data); + } catch (error) { + console.error('Failed to fetch wishlist:', error); + } + }, [username]); + + const updateWishlistStats = (data: CourseTypes[]) => { + setRegisteredCourseCount(data.length); + const totalCredits = data.reduce((sum, course) => { + if (course.tmNum) { + const creditMatch = course.tmNum.match(/^(\d+)/); + const credit = creditMatch ? parseInt(creditMatch[1], 10) : 0; + return sum + (isNaN(credit) ? 0 : credit); + } + return sum; + }, 0); + setRegisteredCredits(totalCredits); + }; + + useEffect(() => { + fetchWishlist(); + }, [fetchWishlist]); + + const handleAction = async ( + action: string, + scheduleId: number | undefined, + ) => { + if (action === '신청' && scheduleId) { + try { + ReactGA.event({ + category: 'Wishlist', + action: 'Add to Wishlist', + label: 'Click_WishlistButton', + }); + await saveWishlistItem(username, scheduleId); + console.log('관심과목 담기 성공'); + fetchWishlist(); + } catch (error) { + console.error('관심과목 담기 실패:', error); + } + } else if (action === '삭제' && scheduleId) { + try { + await deleteWishlistItem(username, scheduleId); + console.log('관심과목 삭제 성공'); + fetchWishlist(); + } catch (error) { + console.error('관심과목 삭제 실패:', error); + } + } + }; + + const handleClickTimetable = () => { + ReactGA.event({ + category: 'Timetable', + action: 'View Timetable', + label: 'Click_ViewTimetableButton', + }); + + openModalHandler(dispatch, 'timetable'); + if (wishlistData.length !== 0) { + document.body.style.overflow = 'hidden'; + } + }; + + return ( + + + + + 등록가능학점(임시): + 24 + + + 등록과목수: + {registeredCourseCount} + + + 등록학점: + {registeredCredits} + + + + + + 관심과목 대상교과목 + +
+ + + + 관심과목내역 + 예상시간표 + +
+ + + + ); +} + +const WishlistContainer = styled.div` + width: 100%; +`; + +const TableWrapper = styled.div` + display: flex; + gap: 2rem; +`; + +const TableSection = styled.div` + flex: 1; + width: 50%; + min-width: 0; +`; + +const WishlistInfo = styled.div` + display: flex; + justify-content: flex-end; + margin-bottom: 1.4rem; + + span { + margin-left: 2rem; + } +`; + +const WishTitleWrap = styled(TableTitleWrap)` + display: flex; + align-items: center; + gap: 0 1rem; + height: 2.4rem; + margin-bottom: 0.8rem; +`; + +const InfoBox = styled.div` + background-color: #f0f0f0; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.5rem 1rem; + margin: 0.2rem; + display: flex; + align-items: center; +`; + +const InfoLabel = styled.span` + font-size: 1.2rem; + margin-right: 0.5rem; +`; + +const InfoValue = styled.span` + font-size: 1.2rem; + font-weight: bold; +`; + +const ButtonWrap = styled.button` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.secondary}; + color: ${props => props.theme.colors.white}; + min-width: 8.5rem; + height: 2.4rem; + + &:hover { + filter: grayscale(15%); + } +`; + +export default Wishlist; diff --git a/src/components/common/FilterButton.tsx b/src/components/common/FilterButton.tsx index 35846f4..8aca4eb 100644 --- a/src/components/common/FilterButton.tsx +++ b/src/components/common/FilterButton.tsx @@ -1,32 +1,79 @@ import styled from 'styled-components'; -import search from '@assets/img/search.png'; -import {LectureProps} from '@components/LectureList/Filters'; +import {CourseTypes} from '@/assets/types/tableType'; +import {getCourseList, getWishlist} from '@/apis/api/course'; +import {useAppDispatch, useAppSelector} from '@/store/hooks'; +import {setField, setType} from '@/store/modules/errorSlice'; +import {openModalHandler} from './Modal/handlers/handler'; interface ButtonProps { label: string; - lecture: LectureProps | undefined; + filter: CourseTypes; + isRegister?: boolean; + onSearch: ( + newList: CourseTypes[], + filter: CourseTypes, + searchOption: string, + ) => Promise; + searchOption: string; + isRegistrationStarted?: boolean; } -function FilterButton({label, lecture}: ButtonProps) { - const handleClick = () => { - console.log(lecture); +function FilterButton({ + label, + filter, + onSearch, + searchOption, + isRegister = false, + isRegistrationStarted, +}: ButtonProps) { + const dispatch = useAppDispatch(); + const studentId = useAppSelector(state => state.userInfo.username); + + const setError = () => { + openModalHandler(dispatch, 'fail'); + dispatch(setType(422)); + dispatch(setField(searchOption)); + }; + + const searchLecture = async () => { + let result: CourseTypes[] = []; + + if (searchOption === '관심과목') { + result = await getWishlist(studentId); + } else { + if (isRegister) { + if (Object.keys(filter).length == 0 && filter.constructor === Object) { + setError(); + return; + } else { + const checked = Object.values(filter).filter(item => item.length < 2); + if (checked.length !== 0) { + setError(); + return; + } + } + } + result = await getCourseList(filter); + } + onSearch(result, filter, searchOption); + }; + + const handleClick = async () => { + if (label === '조회') { + searchLecture(); + return; + } + + if (label === '검색' && isRegistrationStarted) searchLecture(); + return; }; - return ( - - - {label} - - ); + return {label}; } const ButtonWrap = styled.button` ${props => props.theme.texts.content}; - background: linear-gradient( - 90deg, - rgba(163, 20, 50, 1) 0%, - rgba(51, 77, 97, 1) 100% - ); + background-color: ${props => props.theme.colors.secondary}; color: ${props => props.theme.colors.white}; min-width: 6.5rem; height: 2.4rem; diff --git a/src/components/common/FilterInput.tsx b/src/components/common/FilterInput.tsx index 68a36d5..5cf0917 100644 --- a/src/components/common/FilterInput.tsx +++ b/src/components/common/FilterInput.tsx @@ -1,14 +1,16 @@ import styled, {css} from 'styled-components'; interface InputInterface { + disabled?: boolean; sizes: string; onChange: (value: string) => void; } -function FilterInput({sizes, onChange}: InputInterface) { +function FilterInput({disabled, sizes, onChange}: InputInterface) { return ( <> ) => onChange(e.target.value) @@ -24,22 +26,38 @@ const InputWrap = styled.input<{sizes: string}>` ${props => props.sizes === 's' && css` - width: 14rem; + max-width: 14rem; + + @media ${props => props.theme.device.mobile} { + max-width: 14rem; + } `}; ${props => props.sizes === 'm' && css` - width: 19.5rem; + max-width: 19.5rem; + + @media ${props => props.theme.device.mobile} { + max-width: 19.5rem; + } `}; ${props => - props.sizes === 'xl' && + props.sizes === 'l' && css` - width: 48.5rem; + max-width: 25rem; + + @media ${props => props.theme.device.mobile} { + max-width: 25rem; + } `}; height: 2.4rem; border: 1px solid ${props => props.theme.colors.neutral5}; padding-left: 0.8rem; + + &:disabled { + background: ${props => props.theme.colors.neutral5}; + } `; export default FilterInput; diff --git a/src/components/common/Modal/AntiMacroCodeModal.tsx b/src/components/common/Modal/AntiMacroCodeModal.tsx new file mode 100644 index 0000000..3652f30 --- /dev/null +++ b/src/components/common/Modal/AntiMacroCodeModal.tsx @@ -0,0 +1,198 @@ +import styled from 'styled-components'; +import {useEffect, useState} from 'react'; +import {getMacroCode} from '@apis/api/course.ts'; +import {useDispatch} from 'react-redux'; +import { + openModalHandler, + closeHandler, +} from '@components/common/Modal/handlers/handler.tsx'; +import { + CloseImage, + Modal, + ModalContainer, + ModalFooter, + ModalHeader, + Title, +} from '@/styles/ModalLayout'; + +interface MacroTypes { + url: string; + answer: number; +} + +function AntiMacroCodeModal() { + const [macroCode, setMacroCode] = useState({ + url: '', + answer: 0, + }); + const [inputCode, setInputCode] = useState(); + const [imageSrc, setImageSrc] = useState(''); + + const dispatch = useDispatch(); + + const fetchMacroCode = async () => { + try { + const {data} = await getMacroCode(); + setMacroCode(prev => ({ + ...prev, + url: data.url, + answer: data.answer, + })); + setImageSrc(data.url); + } catch (error) { + console.error('매크로 코드 불러오기 실패: ', error); + } + }; + + const checkCode = () => { + if (inputCode === macroCode.answer) { + setInputCode(''); + openModalHandler(dispatch, 'check'); + return; + } + + alert('코드가 일치하지 않습니다.'); + fetchMacroCode(); + setInputCode(''); + }; + + const closeButton = () => { + closeHandler(dispatch); + }; + + useEffect(() => { + fetchMacroCode(); + }, []); + + return ( + + + + 매크로방지 코드입력 (Anti-macro code input) + + + + + + 생성된 코드 + + 재생성 + + + {imageSrc && } + + + + 생성된 코드 입력 + + ) => { + setInputCode(e.target.value); + }} + /> + + + + ※ 코드가 표시되지 않는 경우 잠시 기다리거나 매크로방지 코드 입력 창을 + 닫고 새로 열어주세요. + + + + 코드입력 + + + 닫기 + + + + + ); +} + +const MacroModal = styled(Modal)` + width: 50rem; + height: 31.25rem; +`; + +const ModalBody = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; +`; + +const MacroCodeBox = styled.div` + margin-left: 1rem; +`; + +const BoxTitle = styled.p` + border-left: 4px solid ${props => props.theme.colors.primary}; + font-size: 1.4rem; + font-weight: 600; + color: #333; + padding-left: 0.5rem; +`; + +const MacroCodeInputBox = styled.div` + margin-right: 1rem; +`; + +const MacroCodHeader = styled.div` + height: 3rem; + display: flex; + justify-content: left; + align-items: center; + margin-top: 1rem; +`; + +const RegenerateCodeButton = styled.div` + background: ${props => props.theme.colors.primary}; + color: #ffffff; + border-radius: 3px; + font-size: 1.4rem; + padding: 0.8rem 1.5rem; + text-align: center; + margin-left: 2rem; + cursor: pointer; + + &:hover { + background: #a4223d; + } +`; + +const MacroCodeImage = styled.img` + margin-top: 1rem; + width: 12rem; +`; + +const MacroCodeInput = styled.input` + width: 20rem; + border: 1px solid #858181; + border-radius: 0; + font-size: 1.3rem; + padding: 0.6rem 0.8rem; + margin-top: 1rem; +`; + +const InfoMessage = styled.p` + font-size: 1.3rem; + font-weight: 600; + position: absolute; + bottom: 6rem; + margin: 0 1rem; +`; + +const FooterBtn = styled.div` + font-size: 1.4rem; + border: 1px solid #000000; + background: #ffffff; + padding: 0.6rem 1.5rem; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +export default AntiMacroCodeModal; diff --git a/src/components/common/Modal/EnrollmentInfoModal.tsx b/src/components/common/Modal/EnrollmentInfoModal.tsx new file mode 100644 index 0000000..75b71e9 --- /dev/null +++ b/src/components/common/Modal/EnrollmentInfoModal.tsx @@ -0,0 +1,218 @@ +import styled, {css} from 'styled-components'; +import {closeHandler} from './handlers/handler'; +import {useAppDispatch, useAppSelector} from '@/store/hooks'; +import { + CloseImage, + Modal, + ModalContainer, + ModalFooter, + ModalHeader, + Title, +} from '@/styles/ModalLayout'; + +function EnrollmentInfoModal() { + const dispatch = useAppDispatch(); + const courseData = useAppSelector(state => state.modalInfo.courseData); + + const closeButton = () => { + closeHandler(dispatch); + }; + + return ( + + + + 수강인원 + + + + + + + + 조직분류 + +

학부

+
+
+ + 년도 + +

2024

+
+
+ + 학기 + +

2학기

+
+
+ + 개설학과전공 + +

{courseData.schDeptAlias}

+
+
+ + 학년(학기) + +

+
+
+ + 교과목번호-분반 + +

+ {courseData.curiNo}-{courseData.classNo} +

+
+ +

{courseData.curiNm}

+
+
+
+
+
+ + + + + 총 수강인원 + +

+ 9 +

+
+ +
+ + 남은 자리 + +

+ 0 +

+
+ +
+
+
+
+
+ + + 닫기 + + +
+
+ ); +} + +const ModalBody = styled.div``; + +const LectureInfoContainer = styled.div` + width: 72rem; + border: 0.1rem solid #714656; + border-radius: 2px; + padding: 0.5rem 1.5rem; + margin: 3rem auto; +`; + +const LectureInfoArea = styled.div``; + +const LectureInfoBox = styled.div` + width: 71rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.7rem 2.7rem; +`; + +const LectureInfoWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + display: flex; + align-items: center; + + > span { + display: inline-block; + text-align: right; + min-width: 3rem; + } +`; + +const FooterBtn = styled.div` + font-size: 1.4rem; + border: 1px solid #000000; + background: #ffffff; + padding: 0.6rem 1.5rem; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +const InputWrap = styled.div<{sizes: string}>` + ${props => props.theme.texts.content}; + + ${props => + props.sizes === 's' && + css` + width: 14rem; + `}; + ${props => + props.sizes === 'm' && + css` + width: 19.5rem; + `}; + ${props => + props.sizes === 'l' && + css` + width: 37.3rem; + `}; + ${props => + props.sizes === 'xl' && + css` + width: 45.8rem; + `}; + height: 2.4rem; + border: 1px solid ${props => props.theme.colors.neutral5}; + margin-left: 0.8rem; + background: ${props => props.theme.colors.neutral5}; + display: flex; + align-items: center; + + > p { + width: 100%; + padding-left: 0.8rem; + } +`; + +const LectureEnrollmentBox = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 7rem; +`; + +export default EnrollmentInfoModal; diff --git a/src/components/common/Modal/ErrorModal.tsx b/src/components/common/Modal/ErrorModal.tsx new file mode 100644 index 0000000..1992de9 --- /dev/null +++ b/src/components/common/Modal/ErrorModal.tsx @@ -0,0 +1,111 @@ +import styled from 'styled-components'; +import warning from '@assets/img/warning.png'; +import {useDispatch} from 'react-redux'; +import {closeHandler} from '@components/common/Modal/handlers/handler.tsx'; +import {useAppSelector} from '@/store/hooks'; +import { + CloseImage, + Modal, + ModalBody, + ModalContainer, + ModalFooter, + ModalHeader, +} from '@/styles/ModalLayout'; + +function ErrorModal() { + const dispatch = useDispatch(); + const {type, field} = useAppSelector(state => state.error); + + const closeButton = () => { + closeHandler(dispatch); + }; + + const errorMessage = () => { + switch (type) { + case 404: + return '조회된 내역이 없습니다.'; + case 409: + return '이미 신청된 정보가 존재하므로 신청할 수 없습니다.'; + case 410: + return '수강여석이 없습니다!'; + case 422: + switch (field) { + case '교수명': + return '강의 교수명은 2자 이상 반드시 입력하십시오!'; + case '교과목명': + return '교과목명은 2자 이상 반드시 입력하십시오!'; + case '학수번호': + return '학수번호를 정확하게 입력하십시오!'; + case '분반': + return '분반을 정확하게 입력하십시오!'; + default: + return '422 알 수 없는 오류'; + } + case 500: + return '대상학년 또는 대상학과가 아니므로 수강신청할 수 없습니다!'; + default: + return '알 수 없는 오류'; + } + }; + + return ( + + + + + + + + {errorMessage()} + + + <> + + 확인 + + + + + + ); +} + +const WarningModal = styled(Modal)` + width: 50rem; + height: 40rem; +`; + +const WarningImage = styled.img.attrs({ + src: `${warning}`, +})` + display: block; + width: 5rem; + margin: 0 auto 1rem; +`; + +const InfoMessage = styled.p` + font-size: 1.5rem; + margin-bottom: 25px; + line-height: 2.7rem; + padding: 0 3.4rem; +`; + +const FooterBtn = styled.div<{type: string}>` + font-size: 1.4rem; + border: 1px solid #000000; + background: ${props => + props.type === 'check' ? props.theme.colors.primary : '#ffffff'}; + color: ${props => (props.type === 'cancel' ? '#000000' : '#ffffff')}; + padding: 0.6rem 1.5rem; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +export default ErrorModal; diff --git a/src/components/common/Modal/InfoModal.tsx b/src/components/common/Modal/InfoModal.tsx new file mode 100644 index 0000000..64438aa --- /dev/null +++ b/src/components/common/Modal/InfoModal.tsx @@ -0,0 +1,128 @@ +import styled from 'styled-components'; +import check from '@assets/img/check.png'; +import {useDispatch} from 'react-redux'; +import { + closeHandler, + openModalHandler, +} from '@components/common/Modal/handlers/handler.tsx'; +import { + CloseImage, + Modal, + ModalBody, + ModalContainer, + ModalFooter, +} from '@/styles/ModalLayout'; + +function InfoModal({curiNm, type}: {curiNm?: string; type: string}) { + const dispatch = useDispatch(); + + const eventHandler = async () => { + // 수강신청 확인 모달 + if (type === 'check') { + openModalHandler(dispatch, 'loading'); + + return; + } + + // 수강신청 완료 후 모달 + closeHandler(dispatch); + alert('새로고침으로 수강신청 실패!'); + location.reload(); + }; + + const closeButton = () => { + closeHandler(dispatch); + }; + + return ( + + + + + + + + {type === 'check' ? ( + <> + 선택한 과목을 수강신청 하시겠습니까? + 교과목명(Course Title) : {curiNm} + + ) : ( + <> + + 과목이 신청 되었습니다. 수강신청내역을 재 조회 하시겠습니까? + + + ※ 취소를 선택하실 경우 [수강신청내역]이 갱신되지 않습니다. + + + 취소를 선택하실 경우 수강신청 최종 완료 후 반드시 [수강신청내역] + 재조회를 눌러 신청내역을 확인하세요. [수강신청내역]에 조회된 + 과목만이 정상적으로 수강신청된 과목입니다. + + + )} + + + + 취소 + + + 확인 + + + + + ); +} + +const InfoModalBox = styled(Modal)` + width: 50rem; + height: 40rem; +`; + +const ModalHeader = styled.div` + height: 50px; + display: flex; + justify-content: flex-end; + border-bottom: 1px solid #ababab; +`; + +const CheckImage = styled.img.attrs({ + src: `${check}`, +})` + display: block; + width: 50px; + margin: 0 auto 10px; +`; + +const InfoMessage = styled.p` + font-size: 1.5rem; + margin-bottom: 25px; + line-height: 2.7rem; + padding: 0 34px; +`; + +const FooterBtn = styled.div<{type: string}>` + font-size: 1.4rem; + border: 1px solid #000000; + background: ${props => + props.type === 'check' ? props.theme.colors.primary : '#ffffff'}; + color: ${props => (props.type === 'cancel' ? '#000000' : '#ffffff')}; + padding: 6px 15px; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +export default InfoModal; diff --git a/src/components/common/Modal/LoadingModal.tsx b/src/components/common/Modal/LoadingModal.tsx new file mode 100644 index 0000000..d0e0c78 --- /dev/null +++ b/src/components/common/Modal/LoadingModal.tsx @@ -0,0 +1,148 @@ +import styled, {keyframes} from 'styled-components'; +import {useEffect, useRef} from 'react'; +import {getRandomInt} from '@/utils/randomUtils.ts'; +import {openModalHandler} from '@components/common/Modal/handlers/handler.tsx'; +import {useDispatch} from 'react-redux'; +import {useAppSelector} from '@store/hooks'; +import {postCourse} from '@apis/api/course.ts'; +import {setType} from '@/store/modules/errorSlice'; +import {ModalContainer} from '@/styles/ModalLayout'; + +interface LoadingModalProps { + scheduleId?: number; + schDeptAlias?: string; + curiTypeCdNm?: string; +} + +function LoadingModal({ + scheduleId = 0, + schDeptAlias, + curiTypeCdNm, +}: LoadingModalProps) { + const dispatch = useDispatch(); + const userMajor = useAppSelector(state => state.dateMode.userMajor); + const selectedDate = useAppSelector(state => state.dateMode.selectedDate); + const endCount = useAppSelector(state => state.courseRegistered.endCount); + const isRequesting = useRef(false); + + useEffect(() => { + const endRandomCount = getRandomInt(0.5, 1) * 1000; + + // 시간 이내여도 10%의 확률로 실패 + const randomFailNumber = getRandomInt(1, 10); + + const timer = setTimeout(async () => { + if (isRequesting.current) return; //이미 서버에 요청 중이면 취소 + isRequesting.current = true; + + try { + if (endCount || randomFailNumber === 1) { + dispatch(setType(410)); + openModalHandler(dispatch, 'fail'); + return; + } + + // 본인학년 (학과 제한 있음) 선택 시 학과 제한 + if ( + selectedDate === '본인학년 (학과 제한 있음)' && + schDeptAlias !== '대양휴머니티칼리지' && + schDeptAlias !== userMajor + ) { + dispatch(setType(500)); + openModalHandler(dispatch, 'fail'); + return; + } + + //교직은 교육학과만 수강가능 + if (curiTypeCdNm === '교직') { + if (schDeptAlias !== '교육학과') { + dispatch(setType(500)); + openModalHandler(dispatch, 'fail'); + return; + } + } + // 수강신청 요청 + const res = await postCourse(scheduleId); + + if (res.status === 409) { + return; + } + } catch (error) { + console.error(error); + return; + } + + openModalHandler(dispatch, 'reload'); + return () => clearTimeout(timer); + }, endRandomCount); + }, [ + curiTypeCdNm, + dispatch, + endCount, + schDeptAlias, + scheduleId, + selectedDate, + userMajor, + ]); + + return ( + + + 데이터 처리중 입니다. + + + + + + ); +} + +const LoadingContainer = styled.div` + background: #ffffff; + width: 30rem; + padding: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + text-align: center; +`; + +const LoadingText = styled.div` + margin-bottom: 1rem; + font-size: 1.6rem; +`; + +const move = keyframes` + 0% { + background-position: 0 0; + } + 100% { + background-position: 5rem 0; + } +`; + +const LoadingBar = styled.div` + width: 100%; + height: 2rem; + border-radius: 4px; + background-color: #e0e0e0; + overflow: hidden; + position: relative; +`; + +const LoadingProgress = styled.div` + width: 150%; + height: 100%; + background: repeating-linear-gradient( + 45deg, + #6a91d7, + #6a91d7 10px, + #87aaeb 10px, + #87aaeb 20px + ); + position: absolute; + top: 0; + left: -50%; + animation: ${move} 1s linear infinite; +`; + +export default LoadingModal; diff --git a/src/components/common/Modal/RankInfoModal.tsx b/src/components/common/Modal/RankInfoModal.tsx new file mode 100644 index 0000000..7740d70 --- /dev/null +++ b/src/components/common/Modal/RankInfoModal.tsx @@ -0,0 +1,123 @@ +import {useAppDispatch, useAppSelector} from '@/store/hooks'; +import { + CloseImage, + Modal, + ModalBody, + ModalContainer, + ModalFooter, + ModalHeader, + Title, +} from '@/styles/ModalLayout'; +import {closeHandler} from './handlers/handler'; +import styled from 'styled-components'; + +function RankInfoModal() { + const dispatch = useAppDispatch(); + const courseData = useAppSelector(state => state.modalInfo.courseData); + + const closeButton = () => { + closeHandler(dispatch); + }; + + return ( + + + + 강의 정보 + + + + + + 과목명 + {courseData.curiNm} + + + 분반 + {courseData.classNo} + + + 관심 과목 담은 수 + {courseData.wishCount} + + + 개설학과 + {courseData.manageDeptNm} + + + 담당 교수명 + {courseData.lesnEmp} + + + 강의시간 + {courseData.lesnTime} + + + 강의실 + {courseData.lesnRoom} + + + + + + 닫기 + + + + + ); +} + +const WishModal = styled(Modal)` + min-height: 26rem; + height: fit-content; +`; + +const WishModalBody = styled(ModalBody)` + margin: 1.5rem 3rem; +`; + +const InfoBox = styled.div` + border: 0.1rem solid #714656; + display: flex; + flex-wrap: wrap; + width: 73rem; + padding: 1.5rem 1rem; + gap: 1.2rem 1rem; +`; + +const InfoWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + display: flex; + align-items: center; +`; + +const LabelWrap = styled.div` + min-width: 6rem; +`; + +const ContentWrap = styled.div` + ${props => props.theme.texts.content}; + display: flex; + align-items: center; + border: 1px solid ${props => props.theme.colors.neutral5}; + border-radius: 2px; + background: ${props => props.theme.colors.neutral5}; + height: 2.4rem; + margin-left: 1rem; + padding: 1rem 5rem; +`; + +const FooterBtn = styled.div` + font-size: 1.4rem; + border: 1px solid #000000; + background: #ffffff; + padding: 0.6rem 1.5rem; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +export default RankInfoModal; diff --git a/src/components/common/Modal/TimetableModal/TimetableBody.tsx b/src/components/common/Modal/TimetableModal/TimetableBody.tsx new file mode 100644 index 0000000..9151545 --- /dev/null +++ b/src/components/common/Modal/TimetableModal/TimetableBody.tsx @@ -0,0 +1,209 @@ +import React, {useCallback, useRef, useState} from 'react'; +import {styled} from 'styled-components'; +import {Schedule} from './WishTimetable'; + +interface TimetableBodyProps { + days: string[]; + timetable: Schedule[][]; + setHoveredSubjects: React.Dispatch>; + setHoveredPosition: React.Dispatch< + React.SetStateAction<{ + x: number; + y: number; + } | null> + >; +} + +function TimetableBody({ + days, + timetable, + setHoveredSubjects, + setHoveredPosition, +}: TimetableBodyProps) { + const timeSlots = Array.from({length: 28}, (_, i) => { + const hourStart = 8 + Math.floor(i / 2); + const minuteStart = i % 2 === 0 ? '00' : '30'; + const hourEnd = 8 + Math.floor((i + 1) / 2); + const minuteEnd = (i + 1) % 2 === 0 ? '00' : '30'; + + return { + start: `${hourStart}:${minuteStart}`, + end: `${hourEnd}:${minuteEnd}`, + label: `${hourStart}:${minuteStart} ~ ${hourEnd}:${minuteEnd}`, + }; + }); + + const subjectColorsRef = useRef>({}); + const [hoveredCell, setHoveredCell] = useState(null); + const [hoveredSubject, setHoveredSubject] = useState(null); + + const handleMouseEnter = useCallback( + (subjects: string[], event: React.MouseEvent) => { + setHoveredSubjects(subjects); + setHoveredPosition({x: event.clientX, y: event.clientY}); + }, + [setHoveredPosition, setHoveredSubjects], + ); + + const handleMouseLeave = useCallback(() => { + setHoveredSubjects([]); + setHoveredPosition(null); + }, [setHoveredPosition, setHoveredSubjects]); + + const getCellColor = (subjects: string[]) => { + const cellKey = subjects.join(','); + const getRandomColor = () => { + const hue = Math.floor(Math.random() * 360); + const saturation = 70; + const lightness = 80; + + return `${hue}, ${saturation}%, ${lightness}%`; + }; + + if (!subjectColorsRef.current[cellKey]) { + subjectColorsRef.current[cellKey] = getRandomColor(); + } + + return subjectColorsRef.current[cellKey]; + }; + + const isSlotOccupied = (dayIndex: number, rowIndex: number) => { + return timetable[dayIndex].some( + schedule => rowIndex >= schedule.start && rowIndex < schedule.end, + ); + }; + + return ( + <> + {timeSlots.map((slot, rowIndex) => ( + + {slot.label} + {days.map( + (_, colIndex) => + !isSlotOccupied(colIndex, rowIndex) && ( + + ), + )} + + ))} + {timetable.map((daySchedule, dayIndex) => + daySchedule.map((schedule, scheduleIndex) => { + const cellId = `${dayIndex}-${scheduleIndex}`; + const isHovered = hoveredCell === cellId; + const isRelated = hoveredSubject === schedule.subject; + + return ( + { + setHoveredCell(cellId); + setHoveredSubject(schedule.subject); + handleMouseEnter(schedule.overlaps || [schedule.subject], e); + }} + onMouseLeave={() => { + setHoveredCell(null); + setHoveredSubject(null); + handleMouseLeave(); + }} + > +

+ {(schedule.showSubject || + !schedule.hasPartialOverlap || + isHovered || + isRelated) && + (schedule.overlaps && schedule.overlaps.length > 1 + ? `${schedule.overlaps[0]} 외 ${schedule.overlaps.length - 1}*` + : schedule.subject)} +

+
+ ); + }), + )} + + ); +} + +const GridRow = styled.div` + display: contents; +`; + +const GridTimeSlot = styled.div` + ${props => props.theme.texts.tableTitle}; + vertical-align: middle; + text-align: center; + padding: 1rem; + background-color: #f0f0f0; + border: 1px solid #e5e5e5; + grid-column: 1; +`; + +const GridCell = styled.div<{ + $backgroundColor: string; + $isEarlierOverlap?: boolean; + $isHovered: boolean; + $isRelated: boolean; +}>` + ${props => props.theme.texts.content}; + text-align: center; + vertical-align: middle; + position: relative; + padding: 0.8rem; + text-align: center; + border: 1px solid #f0f0f0; + cursor: pointer; + transition: + transform 0.3s, + opacity 0.3s; + background-color: ${props => `hsla(${props.$backgroundColor}, 0.6)`}; + color: #000; + + p { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + } + + ${props => + props.$isEarlierOverlap && + ` + width: 88%; + border-radius: 0 1.4rem 1.4rem 0; + box-shadow: 3px 3px 6px rgba(0,0,0,0.2); + `} + + ${props => + (props.$isHovered || props.$isRelated) && + ` + opacity: 1; + transform: scale(1.05); + border-radius: 0; + z-index: 1000 !important; + background-color: ${`hsla(${props.$backgroundColor}, 1)`}; + `} +`; + +const EmptyCell = styled.div` + border: 1px solid #f0f0f0; +`; + +export default TimetableBody; diff --git a/src/components/common/Modal/TimetableModal/WishTimetable.tsx b/src/components/common/Modal/TimetableModal/WishTimetable.tsx new file mode 100644 index 0000000..d096304 --- /dev/null +++ b/src/components/common/Modal/TimetableModal/WishTimetable.tsx @@ -0,0 +1,188 @@ +import {useState} from 'react'; +import {styled} from 'styled-components'; +import TimetableBody from './TimetableBody'; + +interface WishlistData { + curiNm: string | undefined; + lesnTime: string | undefined; +} + +export interface Schedule { + subject: string; + start: number; + end: number; + overlaps?: string[]; + showSubject?: boolean; + hasPartialOverlap?: boolean; + isEarlierOverlap?: boolean; +} + +function WishTimetable({wishlistData}: {wishlistData: WishlistData[]}) { + const days = ['월', '화', '수', '목', '금']; + const [hoveredSubjects, setHoveredSubjects] = useState([]); + const [hoveredPosition, setHoveredPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + const timetable: Schedule[][] = Array.from({length: days.length}, () => []); + + wishlistData.forEach(data => { + const timeInfo = data.lesnTime?.split(' '); + if (!timeInfo) return; + + const times = timeInfo.filter(segment => segment.includes('~')); + const daysList = timeInfo.filter(segment => !segment.includes('~')); + + daysList.forEach(day => { + times.forEach(timeRange => { + const [start, end] = timeRange.split('~'); + const dayIndex = days.indexOf(day); + + const [startHour, startMinute] = start.split(':').map(Number); + const [endHour, endMinute] = end.split(':').map(Number); + + const startIndex = (startHour - 8) * 2 + (startMinute === 30 ? 1 : 0); + const endIndex = (endHour - 8) * 2 + (endMinute === 30 ? 1 : 0); + + timetable[dayIndex].push({ + subject: data.curiNm || '', + start: startIndex, + end: endIndex, + }); + }); + }); + }); + + const sortSchedules = ( + schedules: {subject: string; start: number; end: number}[], + ) => { + return schedules.sort((a, b) => { + if (a.start !== b.start) { + return a.start - b.start; + } else { + return b.end - b.start - (a.end - a.start); + } + }); + }; + + const groupOverlappingSchedules = (schedules: Schedule[]): Schedule[] => { + const groups: Schedule[] = []; + + schedules.forEach(schedule => { + const existingGroup = groups.find( + group => group.start === schedule.start && group.end === schedule.end, + ); + + if (existingGroup) { + existingGroup.overlaps = existingGroup.overlaps || [ + existingGroup.subject, + ]; + existingGroup.overlaps.push(schedule.subject); + } else { + const partialOverlaps = groups.filter( + group => group.start === schedule.start && group.end !== schedule.end, + ); + + if (partialOverlaps.length > 0) { + const allOverlaps = [...partialOverlaps, schedule].sort( + (a, b) => a.end - b.end, + ); + + allOverlaps.forEach((overlap, index) => { + overlap.showSubject = index === 0; + overlap.hasPartialOverlap = true; + overlap.isEarlierOverlap = index < allOverlaps.length - 1; + }); + } + + groups.push({...schedule, overlaps: [schedule.subject]}); + } + }); + + return groups; + }; + + timetable.forEach((daySchedule, index) => { + const sortedSchedules = sortSchedules(daySchedule); + timetable[index] = groupOverlappingSchedules(sortedSchedules); + }); + + return ( + + +

구분

+ {days.map(day => ( +

{day}

+ ))} +
+ + + + {hoveredSubjects.length > 0 && hoveredPosition && ( + + {hoveredSubjects.map((subject, index) => ( +

{subject}

+ ))} +
+ )} +
+ ); +} + +const GridContainer = styled.div` + width: 100%; + height: 100%; + min-height: 90rem; + max-width: 80rem; + border-collapse: collapse; + border: 1.6px solid #000; + display: grid; + grid-template-columns: 10rem repeat(5, 1fr); + grid-template-rows: repeat(28, 1fr); + border: 1px solid #000; +`; + +const GridHeader = styled.div` + display: contents; + + p { + ${props => props.theme.texts.tableTitle}; + text-align: center; + font-weight: bold; + padding: 1rem; + background-color: #f0f0f0; + border: 1px solid #e5e5e5; + } +`; + +const GridBody = styled.div` + display: contents; +`; + +const Tooltip = styled.div` + position: fixed; + background: #fff; + border: 1px solid #ccc; + padding: 0.5rem; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + z-index: 1000; + white-space: nowrap; + pointer-events: none; + transition: opacity 0.3s ease-in-out; + max-width: 30rem; + word-wrap: break-word; + opacity: 1; + + p { + ${props => props.theme.texts.content}; + padding: 0.4rem; + } +`; + +export default WishTimetable; diff --git a/src/components/common/Modal/TimetableModal/index.tsx b/src/components/common/Modal/TimetableModal/index.tsx new file mode 100644 index 0000000..c4c1985 --- /dev/null +++ b/src/components/common/Modal/TimetableModal/index.tsx @@ -0,0 +1,146 @@ +import styled from 'styled-components'; +import {useDispatch} from 'react-redux'; +import {closeHandler} from '@components/common/Modal/handlers/handler.tsx'; +import {useCallback, useEffect, useMemo, useState} from 'react'; +import {getWishlist} from '@/apis/api/course'; +import {useAppSelector} from '@/store/hooks'; +import WishTimetable from './WishTimetable'; +import { + CloseImage, + Modal, + ModalContainer, + ModalHeader, + Title, +} from '@/styles/ModalLayout'; + +interface WishlistData { + curiNm: string | undefined; + lesnTime: string | undefined; +} + +function TimetableModal() { + const studentId = useAppSelector(state => state.userInfo.username); + const [wishlistData, setWishlistData] = useState([]); + const dispatch = useDispatch(); + + const fetchWishlist = useCallback(async () => { + try { + const data = await getWishlist(studentId); + const processedData = data.map(({curiNm, lesnTime}) => ({ + curiNm, + lesnTime, + })); + setWishlistData(processedData); + } catch (error) { + console.error('Failed to fetch wishlist:', error); + } + }, [studentId]); + + useEffect(() => { + fetchWishlist(); + }, [fetchWishlist]); + + const isEmpty = useMemo( + () => (wishlistData.length === 0 ? true : false), + [wishlistData], + ); + + const closeButton = () => { + closeHandler(dispatch); + document.body.style.overflow = 'auto'; + }; + + return ( + + + + 관심과목 강의 시간표 + + + + {isEmpty ? ( + 관심과목이 없습니다. + ) : ( + + )} + + + + ※ 시간표가 표시되지 않는 경우 잠시 기다리거나 관심과목 강의 시간표 + 창을 닫고 새로 열어주세요. + + + + 닫기 + + + + + + ); +} + +const TimetableModalContainer = styled(ModalContainer)<{$isEmpty: boolean}>` + overflow: auto; + position: ${({$isEmpty}) => ($isEmpty ? 'absolute' : 'fixed')}; + align-items: ${({$isEmpty}) => ($isEmpty ? 'center' : 'flex-start')}; +`; + +const ModalWrap = styled(Modal)<{$isEmpty: boolean}>` + overflow: auto; + display: flex; + flex-direction: column; + justify-content: space-between; + margin: 3rem; + width: ${({$isEmpty}) => ($isEmpty ? '60rem' : '82rem')}; + height: ${({$isEmpty}) => ($isEmpty ? '24rem' : '108rem')}; +`; + +const ModalBody = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +`; + +const EmptyContent = styled.div` + font-size: 1.8rem; +`; + +const FooterWrap = styled.div` + width: 100%; + height: auto; + display: flex; + flex-direction: column; +`; + +const InfoMessage = styled.p` + font-size: 1.3rem; + font-weight: 600; + margin: 1rem; +`; + +const ModalFooter = styled.div` + background: ${props => props.theme.colors.neutral5}; + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + height: 5rem; +`; + +const FooterBtn = styled.div` + font-size: 1.4rem; + border: 1px solid #000000; + background: #ffffff; + padding: 0.6rem 1.5rem; + cursor: pointer; + + &:hover { + border: 1px solid ${props => props.theme.colors.primary}; + } +`; + +export default TimetableModal; diff --git a/src/components/common/Modal/WaitingModal.tsx b/src/components/common/Modal/WaitingModal.tsx new file mode 100644 index 0000000..b00f54f --- /dev/null +++ b/src/components/common/Modal/WaitingModal.tsx @@ -0,0 +1,201 @@ +import styled from 'styled-components'; +import logo from '@/assets/img/logo.webp'; +import close from '@/assets/img/close-line.png'; +import {useEffect, useState} from 'react'; +import {useDispatch} from 'react-redux'; +import {clearModalInfo} from '@/store/modules/modalSlice'; +import {getRandomInt} from '@/utils/randomUtils.ts'; +import {Modal, ModalContainer} from '@/styles/ModalLayout'; + +function WaitingModal() { + const dispatch = useDispatch(); + const initialWaitingNumber = getRandomInt(100, 800); + const [waitingNumber, setWaitingNumber] = + useState(initialWaitingNumber); + const initialEstimatedTime = getRandomInt(1, 3); + const [estimatedTime, setEstimatedTime] = + useState(initialEstimatedTime); + const [progressBarValue, setProgressBarValue] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setEstimatedTime(prev => { + if (prev - 1 <= 0) { + clearInterval(interval); + dispatch(clearModalInfo()); + return 0; + } + return prev - 1; + }); + + setWaitingNumber(prev => { + const decrementValue = getRandomInt(20, 90); + const newWaitingNumber = prev - decrementValue; + if (newWaitingNumber <= 0) { + return 0; + } + return newWaitingNumber; + }); + }, 1000); + + return () => clearInterval(interval); + }, [dispatch]); + + const stopButton = () => { + alert('수강신청에 실패하셨습니다. :('); + dispatch(clearModalInfo()); + location.reload(); + }; + + const ProgressBar = ({value}: {value: number}) => { + return ( + + + + ); + }; + + useEffect(() => { + setProgressBarValue( + ((initialEstimatedTime - estimatedTime) / initialEstimatedTime) * 100, + ); + }, [estimatedTime]); + + return ( + + + + + 서비스{' '} + <TextStrong color='#838fe2' fontSize={2.5}> + 접속대기 중 + </TextStrong>{' '} + 입니다. + + + 예상대기시간 :{' '} + + {estimatedTime} + + + 초 + {' '} + + + + 고객님 앞에{' '} + + {waitingNumber} + + 의 대기자가 있습니다. + + + 현재 접속 사용자가 많아 대기 중이며, 잠시만 기다리시면 서비스로 자동 + 접속 됩니다. + + + 중지 + + 재 접속하시면 대기시간이 더 길어집니다. + + + ); +} + +const WaitingModalBox = styled(Modal)` + position: static; + width: 39rem; + height: 45rem; + min-width: 39rem; + min-height: 43rem; + font-weight: lighter; + padding: 1rem 4rem; + word-break: unset; + display: flex; + flex-direction: column; + align-items: center; +`; + +const Logo = styled.img.attrs({ + src: `${logo}`, +})` + width: 5.5rem; + margin-right: auto; + display: block; + margin-top: 1rem; + margin-bottom: 1rem; +`; + +const Title = styled.p` + font-size: 2.5rem; + font-weight: bold; + color: #676763; + margin-bottom: 2rem; +`; + +const SubTitle = styled.p` + font-size: 2.2rem; + font-weight: bold; + color: #676763; +`; + +const ProgressBarContainer = styled.div` + width: 100%; + background-color: #e0e0e0; + border-radius: 1rem; + overflow: hidden; + margin-top: 1rem; + margin-bottom: 2rem; +`; + +const ProgressBarFill = styled.div<{$progress: number}>` + height: 1rem; + background-color: #a0a0a0; + width: ${props => `${props.$progress}%`}; + border-radius: 1rem 0 0 1rem; + transition: width 0.3s ease; +`; + +const Contents = styled.p` + font-size: 1.7rem; + color: #676763; + line-height: 3rem; +`; + +const TextStrong = styled.span<{ + color?: string; + fontSize?: number; + fontWeight?: string; +}>` + color: ${props => props.color}; + font-size: ${props => `${props.fontSize}rem`}; + font-weight: ${props => props.fontWeight}; +`; + +const StopButton = styled.div` + text-align: center; + font-size: 1.5rem; + border: 1px solid #8d8d87; + padding: 0.8rem 1.3rem; + color: #676763; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + margin-top: 2.5rem; + margin-bottom: 1rem; + + &:hover { + border: 1px solid #a6a69e; + color: #a6a69e; + } +`; + +const CloseImage = styled.img.attrs({ + src: `${close}`, +})` + width: 1.5rem; + margin-right: 1rem; +`; + +export default WaitingModal; diff --git a/src/components/common/Modal/handlers/handler.tsx b/src/components/common/Modal/handlers/handler.tsx new file mode 100644 index 0000000..822671d --- /dev/null +++ b/src/components/common/Modal/handlers/handler.tsx @@ -0,0 +1,10 @@ +import {Dispatch} from 'redux'; +import {clearModalInfo, setModalName} from '@/store/modules/modalSlice'; + +export const closeHandler = (dispatch: Dispatch) => { + dispatch(clearModalInfo()); +}; + +export const openModalHandler = (dispatch: Dispatch, name: string) => { + dispatch(setModalName(name)); +}; diff --git a/src/components/common/SelectBox.tsx b/src/components/common/SelectBox.tsx index ed1a4f2..e37ad96 100644 --- a/src/components/common/SelectBox.tsx +++ b/src/components/common/SelectBox.tsx @@ -1,7 +1,6 @@ import {useEffect, useRef, useState} from 'react'; import styled, {css} from 'styled-components'; -import arrow from '@assets/img/input_dropdown.png'; -import tag from '@assets/img/tag.png'; +import arrow from '@assets/img/arrow-down-s-fill.png'; interface OptionsInterface { id: number; @@ -10,24 +9,25 @@ interface OptionsInterface { interface SelectProps { options: OptionsInterface[]; - tagged: boolean; disabled?: boolean; sizes: string; onSelect: (value: string) => void; + restricted?: boolean; } function SelectBox({ options, - tagged, disabled = false, sizes, onSelect, + restricted = false, }: SelectProps) { const [open, setOpen] = useState(false); const [input, setInput] = useState(options[0].value); const [selected, setSelected] = useState(options[0].value); const [filtered, setFiltered] = useState(options); const dropdownRef = useRef(null); + const debounceTimeout = useRef(null); const handleBtnClick = () => { if (!disabled) { @@ -36,12 +36,25 @@ function SelectBox({ } }; + const handleInputFocus = () => { + setInput(''); // 입력값 초기화 + setFiltered(options); // 전체 목록 표시 + setOpen(true); + }; + const handleInput = (e: React.ChangeEvent) => { setInput(e.target.value); - setFiltered( - options.filter(option => option.value.includes(e.target.value)), - ); setOpen(true); + + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); // 이전 타이머 취소 + } + + debounceTimeout.current = setTimeout(() => { + setFiltered( + options.filter(option => option.value.includes(e.target.value)), + ); + }, 500); }; const handleOptionClick = (value: string) => { @@ -73,12 +86,16 @@ function SelectBox({ return ( - {tagged && } - + {open && ( - + {filtered.map(option => ( ` props.sizes === 's' && css` width: 15rem; + + @media ${props => props.theme.device.mobile} { + max-width: 15rem; + } `}; ${props => props.sizes === 'm' && css` width: 20.5rem; + + @media ${props => props.theme.device.mobile} { + max-width: 20.5rem; + } `}; ${props => props.sizes === 'xl' && css` width: 50rem; + + @media ${props => props.theme.device.mobile} { + max-width: 50rem; + } `}; + min-width: 7rem; height: 2.4rem; position: relative; display: inline-block; @@ -132,16 +162,10 @@ const InputContainer = styled.div<{disabled: boolean}>` } `; -const TagWrap = styled.img<{disabled: boolean}>` - position: absolute; - z-index: 2; - filter: ${props => (props.disabled ? 'grayscale(100%)' : 'none')}; -`; - const InputWrap = styled.input` ${props => props.theme.texts.content}; - width: calc(100% - 1rem); - height: inherit; + width: 100%; + height: 100%; padding: 0 0 0 1rem; &:hover { @@ -156,18 +180,11 @@ const InputWrap = styled.input` const ArrowWrap = styled.img` position: absolute; right: 0.3rem; - top: 10%; - border: 1px solid transparent; - border-radius: 5px; - - &:hover { - border: 1px solid ${props => props.theme.colors.neutral5}; - } `; const SelectWrap = styled.ul` - width: inherit; - max-height: 12rem; + min-width: 15rem; + max-height: 14.5rem; position: absolute; top: 100%; z-index: 5; diff --git a/src/components/common/Table/TableHead.tsx b/src/components/common/Table/TableHead.tsx index 7871db5..57b7f15 100644 --- a/src/components/common/Table/TableHead.tsx +++ b/src/components/common/Table/TableHead.tsx @@ -1,13 +1,14 @@ import styled from 'styled-components'; -import dropdown from '@assets/img/table_drodown.gif'; +import dropdown from '@assets/img/arrow-down-s-fill.png'; import {useEffect, useRef, useState} from 'react'; interface HeadProps { label: string; index: number; width: number; + tableHeight: string; options: string[]; - handleMouseDown: (index: number) => (event: React.MouseEvent) => void; + type?: string; selectedOptions: string[]; onFilterChange: (index: number, selectedOptions: string[]) => void; } @@ -16,10 +17,11 @@ function TableHead({ label, index, width, + tableHeight, options, + type, selectedOptions, onFilterChange, - handleMouseDown, }: HeadProps) { const [open, setOpen] = useState(false); const dropdownRef = useRef(null); @@ -67,38 +69,44 @@ function TableHead({
{label} - setOpen(prev => !prev)} /> - {open && ( - - - - - - {options?.map((option, index) => ( - - handleCheckboxChange(option)} - /> - - - ))} - + {type !== 'action' && ( + <> + setOpen(prev => !prev)} + /> + {open && ( + + + + + + {options?.map((option, index) => ( + + handleCheckboxChange(option)} + /> + + + ))} + + )} + )} -
); } -const Wrap = styled.th<{width: number}>` +const Wrap = styled.div<{width: number}>` min-width: ${props => props.width}px; text-align: ${props => (props.width > 100 ? 'center' : 'left')}; @@ -114,31 +122,22 @@ const Wrap = styled.th<{width: number}>` } `; -const Resizer = styled.div` - width: 5px; - height: 100%; - position: absolute; - right: 0; - top: 0; - bottom: 0; - cursor: col-resize; - background-color: transparent; - z-index: 1; -`; - const DropdownBtn = styled.button` - width: 1.5rem; - height: 1.5rem; + width: 1.8rem; + height: 1.8rem; background: url(${dropdown}) no-repeat center; + background-size: 1.8rem; `; -const OptionBox = styled.ul` +const OptionBox = styled.ul<{$height: string}>` width: 100%; + max-height: calc(${props => props.$height} - 7rem); overflow: scroll; background: white; position: absolute; top: 3rem; left: 0; + z-index: 1; `; const OptionWrap = styled.li` diff --git a/src/components/common/Table/index.tsx b/src/components/common/Table/index.tsx index 9440238..e1a2cc7 100644 --- a/src/components/common/Table/index.tsx +++ b/src/components/common/Table/index.tsx @@ -1,60 +1,59 @@ -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import styled from 'styled-components'; +import {VariableSizeGrid as Grid} from 'react-window'; import TableHead from './TableHead'; -import {TableHeadTypes, TableTypes} from '@assets/types/tableType'; +import {CourseTypes, TableHeadTypes} from '@assets/types/tableType'; interface TableProps { colData: TableHeadTypes[]; - data: TableTypes[]; - initialWidth: string; + data: CourseTypes[]; + width: string; height: string; + onAction?: ( + action: string, + scheduleId: number | undefined, + curiNm: string | undefined, + schDeptAlias: string | undefined, + curiTypeCdNm: string | undefined, + ) => void; } -function Table({data, colData, initialWidth, height}: TableProps) { - const tableRef = useRef(null); - const [columnWidths, setColumnWidths] = useState([]); +function Table({data, colData, width, height, onAction}: TableProps) { + const tableRef = useRef(null); + const [tableWidth, setTableWidth] = useState( + tableRef.current?.offsetWidth || 1000, + ); + let uniqueOptions: string[] = []; const [filters, setFilters] = useState( colData.map(col => { - const uniqueOptions = Array.from( - new Set(data.map(row => row[col.name])), - ).filter(option => option !== null) as string[]; + if (col.name !== 'action') { + uniqueOptions = Array.from( + new Set(data?.map(row => row[col.name as keyof CourseTypes])), + ).filter(option => option !== null) as string[]; + } + return uniqueOptions.length === 0 ? ['빈값'] : uniqueOptions.sort(); }), ); useEffect(() => { - if (tableRef.current) { - const initialWidths = Array.from( - tableRef.current.querySelectorAll('th'), - ).map(th => th.getBoundingClientRect().width); - setColumnWidths(initialWidths); - } - }, [tableRef]); - - const handleMouseDown = (index: number) => (event: React.MouseEvent) => { - const startX = event.clientX; - const startWidth = columnWidths[index]; + if (!tableRef.current) return; - const handleMouseMove = (moveEvent: MouseEvent) => { - const newWidth = startWidth + (moveEvent.clientX - startX); - setColumnWidths(prevWidths => - prevWidths.map((width, i) => (i === index ? newWidth : width)), - ); - }; + const observer = new ResizeObserver(entries => { + setTableWidth(entries[0].contentRect.width); + }); - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; + observer.observe(tableRef.current); - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; + return () => observer.disconnect(); + }, []); const getOptions = colData.map(col => { - const uniqueOptions = Array.from( - new Set(data.map(row => row[col.name])), - ).filter(option => option !== null) as string[]; + if (col.name !== 'action') { + uniqueOptions = Array.from( + new Set(data?.map(row => row[col.name as keyof CourseTypes])), + ).filter(option => option !== null) as string[]; + } return uniqueOptions.length === 0 ? ['빈값'] : uniqueOptions.sort(); }); @@ -66,119 +65,201 @@ function Table({data, colData, initialWidth, height}: TableProps) { }); }; - const filteredData = data.filter(row => - colData.every( - (col, index) => - filters[index].includes('빈값') || - filters[index].includes(row[col.name] ?? ''), - ), + const filteredData = useMemo( + () => + Array.isArray(data) + ? data?.filter(row => + colData.every( + (col, index) => + filters[index].includes('빈값') || + filters[index].includes( + String( + col.name !== 'action' && + (row[col.name as keyof CourseTypes] ?? ''), + ), + ), + ), + ) + : [], + [data, filters, colData], ); - return ( - - - 개설강좌 - - - -
- - {colData.map((item, index) => ( - - ))} - - - - - {colData.map((item, index) => ( - - ))} - - - - {filteredData.map((row, rowIdx) => ( - - {rowIdx + 1} - {colData.map((col, colIdx) => ( - - ))} - - ))} - - - - - ); -} + const handleActionClick = (row: CourseTypes, action: string) => { + if (onAction) { + onAction( + action, + row.scheduleId, + row.curiNm, + row.schDeptAlias, + row.curiTypeCdNm, + ); + } else { + console.log(`${action} action for scheduleId: ${row.scheduleId}`); + } + }; + + const renderCell = (row: CourseTypes, col: TableHeadTypes) => { + if (col.name === 'action') { + return ( + handleActionClick(row, col.value)}> + {col.value} + + ); + } + return row[col.name as keyof CourseTypes]; + }; -const TableContainer = styled.div``; + const getColumnWidth = (index: number) => { + if (index === 0) return 40; + else return colData[index - 1]?.initialWidth || 80; + }; -const TableTitleWrap = styled.div` - margin-bottom: 1rem; -`; + const CellContent = ({ + content, + }: { + content: React.ReactNode | string | number | boolean; + }) => { + const contentRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); -const TableTitle = styled.div` - ${props => props.theme.texts.subtitle}; - border-left: 4px solid ${props => props.theme.colors.primary}; - padding-left: 0.5rem; -`; + useEffect(() => { + const checkTruncation = () => { + if (contentRef.current) { + const {offsetWidth, scrollWidth} = contentRef.current; + setIsTruncated(scrollWidth > offsetWidth); + } + }; + + checkTruncation(); + window.addEventListener('resize', checkTruncation); + return () => window.removeEventListener('resize', checkTruncation); + }, [content]); + + return ( + + {content} + + ); + }; + + const Cell = ({ + columnIndex, + rowIndex, + style, + }: { + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + }) => { + if (rowIndex === 0) { + return ( + + {columnIndex === 0 ? ( +
순번
+ ) : ( + + )} +
+ ); + } + if (filteredData.length === 0) { + if (rowIndex === 1 && columnIndex === 0) { + return ( + + 조회된 내역이 없습니다. + + ); + } + } else { + const row = filteredData[rowIndex - 1]; + const column = colData[columnIndex - 1]; + + return ( + + {columnIndex === 0 ? ( + {rowIndex} + ) : ( + + )} + + ); + } + }; + + return ( + + 30} + height={tableRef.current?.offsetHeight || 500} + width={tableWidth} + > + {Cell} + + + ); +} const TableBox = styled.div<{width: string; height: string}>` + ${props => props.theme.texts.content}; width: ${props => props.width}; height: ${props => props.height}; - overflow: scroll; border-left: 1px solid #c3c3c3; border-bottom: 1px solid #c3c3c3; -`; - -const TableWrap = styled.table` - ${props => props.theme.texts.content}; border-top: 1px solid ${props => props.theme.colors.black}; white-space: nowrap; - border-collapse: collapse; - - > thead > tr > th { - ${props => props.theme.texts.tableTitle}; - position: relative; - border-top: 1px solid black; - background-color: ${props => props.theme.colors.neutral5}; - } `; -const RowWrap = styled.tr` - height: 3rem; - > th, - td { - border: 1px solid #c3c3c3; - border-left: none; - padding: 0 0.5rem; - vertical-align: middle; - } +const RowWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + background-color: ${props => props.theme.colors.neutral5}; + text-align: center; + box-shadow: 0 0 0 1px #c3c3c3; + border-top: none; + display: flex; + align-items: center; `; -const IndexWrap = styled.td` +const IndexWrap = styled.div` background-color: ${props => props.theme.colors.blue}; - text-align: center; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; `; -const ContentWrap = styled(RowWrap)<{$isEven: boolean}>` +const ContentWrap = styled.div<{$isEven: boolean}>` background-color: ${props => props.$isEven ? 'rgb(252, 252, 252)' : props.theme.colors.white}; text-align: center; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 0 0 0.5px #c3c3c3; + &:hover { background-color: rgb(250, 235, 238); } @@ -188,4 +269,66 @@ const ContentWrap = styled(RowWrap)<{$isEven: boolean}>` } `; +const ContentText = styled.div<{ + $isTruncated: boolean; +}>` + padding: 0 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ${props => + props.$isTruncated && + ` + &:hover::after { + content: attr(data-full-content); + position: absolute; + left: 0; + top: 0; + width: auto; + height: 2.8rem; + padding: 0 0.5rem; + background-color: white; + border: 1px solid #ddd; + z-index: 1000; + display: flex; + align-items: center; + } + `} +`; + +const ActionButton = styled.button` + ${props => props.theme.texts.content}; + width: 4rem; + height: 2.4rem; + background-color: ${props => props.theme.colors.primary}; + color: ${props => props.theme.colors.white}; + cursor: pointer; + + &:hover { + background-color: ${props => props.theme.colors.primary}; + } +`; + +const NoresultWrap = styled.div<{width: number; height: string}>` + width: ${props => props.width}px; + height: ${props => props.height}; + display: flex; + align-items: center; + justify-content: center; + + @media ${props => props.theme.device.mobile} { + max-width: auto; + } +`; + +const Noresult = styled.div` + ${props => props.theme.texts.content}; + background-color: ${props => props.theme.colors.neutral6}; + border: 1px solid #c3c3c3; + text-align: center; + padding: 0.7rem 0; + width: 30rem; +`; + export default Table; diff --git a/src/main.tsx b/src/main.tsx index 3e0d1d7..0225e7a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,26 +1,22 @@ -// css -import '@assets/css/default.css'; -import '@assets/fonts/fonts.css'; - import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; -import { BrowserRouter } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import store from '@store/store.ts'; -import { PersistGate } from 'redux-persist/integration/react'; -import { persistStore } from 'redux-persist'; +import {BrowserRouter} from 'react-router-dom'; +import {Provider} from 'react-redux'; +import {store} from '@store/store.ts'; +import {PersistGate} from 'redux-persist/integration/react'; +import {persistStore} from 'redux-persist'; export const persist = persistStore(store); ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - -); \ No newline at end of file + + + + + + + + + , +); diff --git a/src/pages/DeleteAccount.tsx b/src/pages/DeleteAccount.tsx new file mode 100644 index 0000000..450313f --- /dev/null +++ b/src/pages/DeleteAccount.tsx @@ -0,0 +1,102 @@ +import styled from 'styled-components'; +import Bg from '@assets/img/delete_bg.webp'; +import Logo from '@assets/img/tutorial_sejong_logo.webp'; + +import githubIcon from '@assets/img/github-fill.svg'; +import DeleteAccountForm from '@components/DeleteAccount/DeleteAccountForm.tsx'; + +function DeleteAccount() { + return ( + + + + + + + 유저정보 제거 + 가입하신 학번을 입력하면, 정보가 제거됩니다. + + + + [ 장애 문의 ]: tutorialsejong@gmail.com + + github window.open('https://github.com/tutorial-sejong')} + /> + + + + + ); +} + +const Container = styled.div` + background: url(${Bg}) 50% 50% no-repeat; + background-size: cover; + height: 70rem; + background-color: #fafafa; + width: 100%; +`; + +const Box = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const LogoWrap = styled.div` + margin: 3rem 0; + + > img { + width: 15rem; + } +`; + +const TitleWrap = styled.div` + color: ${props => props.theme.colors.white}; + text-align: center; + margin-bottom: 2.5rem; + + > p { + line-height: 2.5rem; + font-weight: 600; + font-size: 1.35rem; + } + + > p > em { + color: #ffea9b; + } +`; + +const Title = styled.h1` + font-size: 3.5rem; + font-weight: 700; + margin-bottom: 2rem; +`; + +const SubTitle = styled.h2` + font-size: 2rem; + font-weight: 700; + margin-bottom: 2rem; +`; + +const FormWrap = styled.div` + margin-bottom: 2.5rem; +`; + +const FaqWrap = styled.div` + ${props => props.theme.texts.loginContent}; + text-align: center; + color: #fff; + > img { + width: 3rem; + cursor: pointer; + display: block; + text-align: center; + margin: 2rem auto; + } +`; + +export default DeleteAccount; diff --git a/src/pages/Maintenance.tsx b/src/pages/Maintenance.tsx new file mode 100644 index 0000000..0e9850f --- /dev/null +++ b/src/pages/Maintenance.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import styled from 'styled-components'; +import logo from '@assets/img/tutorial_sejong_logo.webp'; +const MaintenancePage: React.FC = () => { + return ( + + + 사이트 리뉴얼 중입니다! + 2024년 8월 7일 16:25 ~ 2024년 8월 8일 23:59 + 이용에 불편을 드려 죄송합니다. + + 문의사항이 있으시면{' '} + + tutorialsejong@gmail.com + + 으로 연락 주세요. + + + ); +}; + +const Container = styled.div` + text-align: center; + padding: 5rem; + font-family: 'Arial', sans-serif; +`; + +const Logo = styled.img` + width: 30rem; +`; + +const Title = styled.h1` + font-size: 3rem; + color: #333; +`; + +const Message = styled.p` + color: #666; + margin: 2rem auto; + font-size: 1.8rem; +`; + +const ContactLink = styled.a` + color: #0077cc; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +export default MaintenancePage; diff --git a/src/pages/index/Home.tsx b/src/pages/index/Home.tsx index e56aa9c..18d5b9e 100644 --- a/src/pages/index/Home.tsx +++ b/src/pages/index/Home.tsx @@ -1,20 +1,101 @@ +import {useEffect, useState} from 'react'; import styled from 'styled-components'; import Menubar from '@components/Menubar'; import Header from '@components/Header'; import LectureList from '@components/LectureList'; import TabMenu from '@components/TabMenu'; +import {useAppSelector} from '@/store/hooks'; +import CourseRegister from '@/components/CourseRegister'; +import Wishlist from '@/components/Wishlist'; +import AntiMacroCodeModal from '@components/common/Modal/AntiMacroCodeModal.tsx'; +import InfoModal from '@components/common/Modal/InfoModal.tsx'; +import EnrollmentInfoModal from '@components/common/Modal/EnrollmentInfoModal.tsx'; +import LoadingModal from '@components/common/Modal/LoadingModal.tsx'; +import WaitingModal from '@components/common/Modal/WaitingModal.tsx'; +import {useDispatch} from 'react-redux'; +import {clearModalInfo} from '@/store/modules/modalSlice'; +import ErrorModal from '@components/common/Modal/ErrorModal.tsx'; +import {useMediaQuery} from 'react-responsive'; +import TimetableModal from '@/components/common/Modal/TimetableModal'; +import WishRank from '@/components/WishRank'; +import RankInfoModal from '@/components/common/Modal/RankInfoModal'; function Home() { + const isPc = useMediaQuery({query: '(min-width: 1024px)'}); + const {tab, focused} = useAppSelector(state => state.tabs); + const [barOpen, setBarOpen] = useState(isPc); + + useEffect(() => { + setBarOpen(isPc); + }, [isPc]); + + const {modalName, courseData} = useAppSelector(state => state.modalInfo); + + const focusedTab = tab.find(tab => tab.id === focused); + const focusedTabName = focusedTab ? focusedTab.name : '선택된 탭이 없습니다.'; + + const dispatch = useDispatch(); + + window.addEventListener('beforeunload', () => { + dispatch(clearModalInfo()); + }); + + const renderContent = () => { + switch (focused) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return
선택된 탭이 없습니다.
; + } + }; + + const renderModal = () => { + switch (modalName) { + case 'waiting': + return ; + case 'macro': + return ; + case 'check': + return ; + case 'loading': + return ( + + ); + case 'reload': + return ; + case 'fail': + return ; + case 'timetable': + return ; + case 'enrollment': + return ; + case 'wishrank': + return ; + default: + return <>; + } + }; + return ( + {renderModal()}
- -
+ + {barOpen && } +
-

강의시간표/수업계획서조회

- +

{focusedTabName}

+ {renderContent()}
@@ -28,10 +109,12 @@ const Container = styled.div` const Box = styled.div` display: flex; + max-width: 100vw; `; -const Main = styled.div` - width: calc(100% - 23rem); +const Main = styled.div<{$isOpen: boolean}>` + width: ${props => + props.$isOpen ? 'calc(100% - 23rem)' : 'calc(100% - 2rem)'}; `; const Article = styled.div` diff --git a/src/pages/index/Login.tsx b/src/pages/index/Login.tsx index 25892a3..920c5ed 100644 --- a/src/pages/index/Login.tsx +++ b/src/pages/index/Login.tsx @@ -1,9 +1,21 @@ import styled from 'styled-components'; -import Bg from '@assets/img/login_bg.png'; -import Logo from '@assets/img/logo.png'; +import Bg from '@assets/img/login_bg.webp'; +import Logo from '@assets/img/tutorial_sejong_logo.webp'; + import LoginForm from '@components/LoginForm/index'; +import githubIcon from '@assets/img/github-fill.svg'; +import {useState} from 'react'; +import {useNavigate} from 'react-router-dom'; function Login() { + const [isTermsCheck, setTermsCheck] = useState(false); + + const navigate = useNavigate(); + + const handleTermsCheck = (e: React.ChangeEvent) => { + setTermsCheck(e.target.checked); + }; + return ( @@ -11,37 +23,180 @@ function Login() { - 통합 로그인 + 로그인 + + 본 서비스는 실제 세종대학교 수강신청 시스템이 아닙니다. +

- 서비스 이용을 끝낸 후에는 개인정보보호를 위하여 꼭 로그아웃 - 을 해주시기 바랍니다. -
아이디는 학생은 학번, 교수/직원은{' '} - 포털 아이디(이메일아이디) - 또는 직번입니다. + 수강신청 연습사이트 tutorial-sejong 입니다. +
+ 임의학번을 입력해주시면 됩니다. +
+ 동일한 학번으로 로그인하면 이전관심과목을 + 불러옵니다.

- - - 동일한 학번과 이름으로 로그인하면 이전의 데이터를 불러옵니다. - - [장애 문의] + + [ 장애 문의 ]: tutorialsejong@gmail.com + + github window.open('https://github.com/tutorial-sejong')} + /> + - - 세종대학교 05006 서울특별시 광진구 능동로 209 (군자동) |{' '} - TEL - 02.3408.3114 | E-MAIL itservice@sejong.ac.kr -
-

COPYRIGHT 2012 SEJONG UNIVERSITY. ALL RIGHTS RESVERED.

-
+ + 서비스 이용약관 동의 + + + 1. 서비스 목적 + + + 본 서비스는 실제 수강신청 사이트가 아니며 + , 학습 목적으로 제공되는 모의 수강신청 시스템입니다. 본 + 서비스에서 사용하는 학번은{' '} + + 실제 학번이 아닌, 11자리 이상의 임의로 생성된 학번 + + 입니다. + + + + + + 2. 개인정보의 수집 및 이용 + + + 수집하는 개인정보 항목: 본 서비스에서는 사용자가 입력한 임의의{' '} + + 학번, 비밀번호, 관심과목, 수강신청 목록을 수집 + + 합니다. + + + 개인정보 수집 목적: 수집된 정보는{' '} + + 사용자가 저장한 과목 목록을 불러오기 위한 용도 + + 로만 사용됩니다.{' '} + + 그 외 다른 목적으로는 절대 사용되지 않습니다. + + + + 개인정보의 보유 및 이용 기간: 수집된 정보는{' '} + 매일 자정에 자동으로 삭제되며, 추가적인 + 보관 기간은 없습니다. + + + 수집된 정보의 저장 위치: 수집된 정보는{' '} + 안전한 서버에 저장되며,{' '} + 외부로 유출되지 않도록 보호됩니다. + + + + + + 3. 비밀번호 및 보안 + + + 본 서비스에 입력된 비밀번호는{' '} + 암호화 알고리즘을 사용하여 안전하게 보호 + 됩니다. 비밀번호는 복호화가 불가능하며,{' '} + + 실제 사용하는 비밀번호가 아닌 임의의 비밀번호를 사용하는 것을 + 권장 + + 합니다. + + + + + + 4. 개인정보의 파기 + + + 수집된 개인정보는{' '} + 매일 자정에 자동으로 서버에서 삭제됩니다. + 만약 자정 전에 정보를 삭제하고 싶으신 경우,{' '} + navigate('/delete')}> + https://tutorial-sejong.com/delete + {' '} + 페이지에서 로그인 시 입력한{' '} + 학번을 입력하여 직접 삭제할 수 있습니다. + + + 학번을 기억하지 못할 경우,{' '} + tutorialsejong@gmail.com으로 메일을 + 보내주시면 관심과목 목록 및 로그인 시간을 기준으로 삭제를 + 도와드리겠습니다. 만약 확인이 불가능한 경우,{' '} + 모든 정보를 일괄적으로 삭제 처리 + 하겠습니다. + + + + + + 5. 개인정보의 파기 + + + 사용자는 언제든지 본 서비스에 제공된 개인정보의 삭제를 요청할 수 + 있으며, 삭제 요청은 위의 방법을 통해 처리됩니다. + + + 개인정보와 관련된 문의는{' '} + tutorialsejong@gmail.com으로 문의하시면 + 신속히 대응해드리겠습니다. + + + + + + 6. 개인정보의 보호 + + + 본 서비스는 개인정보 보호법과 정보통신망법에 따라 사용자의 + 개인정보를 보호하기 위해 최선을 다하고 있습니다. 수집된 + 개인정보는 불법적인 접근, 유출, 사용을 방지하기 위해 방화벽, + 암호화 통신, 접근 통제 등 적절한 보안 조치를 취하고 있습니다. + + + + + + 7. 개인정보 처리방침 변경 + + + 본 서비스의 개인정보 처리방침은 법률 개정이나 서비스 변경에 따라 + 수정될 수 있으며, 수정된 내용은 이 페이지에서 확인 가능합니다. + + + + + + + + +
); } const Container = styled.div` - background-image: url(${Bg}); - background-repeat: no-repeat; + background: url(${Bg}) 50% 50% no-repeat; + background-size: cover; + height: 70rem; background-color: #fafafa; width: 100%; `; @@ -54,17 +209,24 @@ const Box = styled.div` const LogoWrap = styled.div` margin: 3rem 0; + + > img { + width: 15rem; + } `; const TitleWrap = styled.div` color: ${props => props.theme.colors.white}; text-align: center; + padding: 0 1rem; margin-bottom: 2.5rem; + > p { line-height: 2.5rem; font-weight: 600; font-size: 1.35rem; } + > p > em { color: #ffea9b; } @@ -76,32 +238,89 @@ const Title = styled.h1` margin-bottom: 2rem; `; -const FormWrap = styled.div` - margin-bottom: 2.5rem; +const SubTitle = styled.h2` + font-size: 2rem; + font-weight: 700; + margin-bottom: 2rem; `; -const WarningWrap = styled.p` - ${props => props.theme.texts.loginContent}; +const FormWrap = styled.div` + width: 46rem; margin-bottom: 2.5rem; + + @media ${props => props.theme.device.mobile} { + width: 100%; + } `; const FaqWrap = styled.div` ${props => props.theme.texts.loginContent}; -`; -const FooterWrap = styled.div` - ${props => props.theme.texts.loginContent}; - letter-spacing: 0; - > em { - color: ${props => props.theme.colors.black}; - } - > p { - color: gray; + > img { + width: 3rem; + cursor: pointer; + display: block; text-align: center; - font-weight: 500; - font-size: 1.2rem; - margin: 0.7rem 0 3rem 0; + margin: 2rem auto; } `; +const TermsContainer = styled.div` + max-width: 89rem; + margin: 0 auto 2rem; + padding: 2rem; + background-color: #f9f9f9; + border-radius: 1rem; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +`; + +const CheckboxWrap = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-left: -0.5rem; + margin-top: 1rem; +`; + +const TermsTitle = styled.h1` + font-size: 2.4rem; + font-weight: bold; + color: #333; + margin-bottom: 2rem; +`; + +const List = styled.ul` + list-style: none; + padding: 0; + margin: 0; +`; + +const ListTitle = styled.li` + font-size: 1.8rem; + font-weight: bold; + margin-top: 1.5rem; + margin-bottom: 1.5rem; +`; + +const ListItem = styled.li` + margin-bottom: 0.5rem; + font-size: 1.5rem; + color: #555; + line-height: 2.5rem; + + &::before { + content: '•'; + color: #007bff; + font-weight: bold; + display: inline-block; + width: 1em; + margin-left: 0.5em; + } +`; + +const Highlight = styled.span` + cursor: pointer; + font-weight: bold; + color: #007bff; +`; export default Login; diff --git a/src/pages/index/NotFound.tsx b/src/pages/index/NotFound.tsx new file mode 100644 index 0000000..101f62d --- /dev/null +++ b/src/pages/index/NotFound.tsx @@ -0,0 +1,80 @@ +import styled from 'styled-components'; +import {useNavigate} from 'react-router-dom'; +import Bg from '@assets/img/login_bg.webp'; +import Logo from '@assets/img/tutorial_sejong_logo.webp'; + +function NotFound() { + const navigate = useNavigate(); + + const handleClick = () => { + navigate('/'); + }; + return ( + + + + + + + 해당 페이지를 찾을 수 없습니다. +

+ 찾으시는 페이지의 주소를 잘못 입력하였거나, +
해당 페이지의 주소가 변경 또는 삭제되었을 수 있습니다. +

+
+ 홈으로 이동 +
+
+ ); +} + +const Container = styled.div` + background: url(${Bg}) 50% 50% no-repeat; + background-size: cover; + height: 100vh; + width: 100%; + display: flex; + justify-content: center; + align-items: center; +`; + +const Box = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const LogoWrap = styled.div` + margin-bottom: 1.7rem; + + > img { + width: 15rem; + } +`; + +const TextWrap = styled.div` + font-size: 3.5rem; + font-weight: 700; + color: ${props => props.theme.colors.neutral6}; + text-align: center; + + > p { + ${props => props.theme.texts.tabTitle}; + color: ${props => props.theme.colors.neutral6}; + line-height: 2rem; + margin-top: 1.5rem; + } +`; + +const HomeBtn = styled.button` + ${props => props.theme.texts.subtitle}; + color: ${props => props.theme.colors.neutral6}; + text-decoration: underline; + margin-top: 2.5rem; + + &:hover { + color: ${props => props.theme.colors.primary}; + } +`; + +export default NotFound; diff --git a/src/store/hooks/index.ts b/src/store/hooks/index.ts new file mode 100644 index 0000000..07a6f1a --- /dev/null +++ b/src/store/hooks/index.ts @@ -0,0 +1,5 @@ +import {useDispatch, useSelector, TypedUseSelectorHook} from 'react-redux'; +import type {RootState, AppDispatch} from '../store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/modules/courseRegisteredSlice.ts b/src/store/modules/courseRegisteredSlice.ts new file mode 100644 index 0000000..12193c4 --- /dev/null +++ b/src/store/modules/courseRegisteredSlice.ts @@ -0,0 +1,43 @@ +import {defaultTime} from '@/assets/data/constant'; +import {createSlice} from '@reduxjs/toolkit'; + +export interface CourseRegistered { + endCount: boolean; + time: number; +} + +const courseRegistered = createSlice({ + name: 'courseRegistered', + initialState: { + endCount: false, + time: defaultTime, + }, + reducers: { + setEndCount(state: CourseRegistered, {payload}: {payload: boolean}) { + state.endCount = payload; + }, + setTime(state: CourseRegistered, {payload}: {payload: number}) { + state.time = payload; + }, + clearCount(state: CourseRegistered) { + state.endCount = false; + }, + cleatTime(state: CourseRegistered) { + state.time = defaultTime; + }, + resetCourseRegistered(state: CourseRegistered) { + state.endCount = false; + state.time = defaultTime; + }, + }, +}); + +export const { + setEndCount, + setTime, + clearCount, + cleatTime, + resetCourseRegistered, +} = courseRegistered.actions; + +export default courseRegistered.reducer; diff --git a/src/store/modules/dateModeSlice.ts b/src/store/modules/dateModeSlice.ts new file mode 100644 index 0000000..3ac75c4 --- /dev/null +++ b/src/store/modules/dateModeSlice.ts @@ -0,0 +1,40 @@ +import {createSlice} from '@reduxjs/toolkit'; + +export interface dateMode { + isConfirm: boolean; + userMajor: string; + selectedDate: string; +} + +const dateMode = createSlice({ + name: 'dateMode', + initialState: { + isConfirm: false, + userMajor: '-전체-', + selectedDate: '전학년 (학과 제한 없음)', + }, + reducers: { + setIsConfirm(state: dateMode) { + state.isConfirm = true; + }, + + setUserMajor(state: dateMode, {payload}: {payload: string}) { + state.userMajor = payload; + }, + + setSelectedDate(state: dateMode, {payload}: {payload: string}) { + state.selectedDate = payload; + }, + + resetDateMode(state: dateMode) { + state.isConfirm = false; + state.userMajor = '-전체-'; + state.selectedDate = '전학년 (학과 제한 없음)'; + }, + }, +}); + +export const {setIsConfirm, setUserMajor, setSelectedDate, resetDateMode} = + dateMode.actions; + +export default dateMode.reducer; diff --git a/src/store/modules/errorSlice.ts b/src/store/modules/errorSlice.ts new file mode 100644 index 0000000..4c9c317 --- /dev/null +++ b/src/store/modules/errorSlice.ts @@ -0,0 +1,25 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +const initialState = { + type: 0, + field: '', +}; + +const errorSlice = createSlice({ + name: 'errorSlice', + initialState: initialState, + reducers: { + setType(state, action: PayloadAction) { + state.type = action.payload; + }, + setField(state, action: PayloadAction) { + state.field = action.payload; + }, + resetError: () => { + return initialState; + }, + }, +}); + +export const {setType, setField, resetError} = errorSlice.actions; +export default errorSlice.reducer; diff --git a/src/store/modules/modalSlice.ts b/src/store/modules/modalSlice.ts new file mode 100644 index 0000000..956dff9 --- /dev/null +++ b/src/store/modules/modalSlice.ts @@ -0,0 +1,45 @@ +import {CourseTypes} from '@/assets/types/tableType'; +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export interface ModalInfo { + modalName: string; + courseData: CourseTypes; +} + +const initialState: ModalInfo = { + modalName: '', + courseData: { + scheduleId: 0, + schDeptAlias: '', + curiTypeCdNm: '', + curiNo: '', + classNo: '', + curiNm: '', + manageDeptNm: '', + lesnEmp: '', + lesnTime: '', + lesnRoom: '', + rank: 1, + wishCount: 0, + }, +}; + +const modalInfo = createSlice({ + name: 'modalInfo', + initialState: initialState, + reducers: { + setModalName(state: ModalInfo, {payload}: {payload: string}) { + state.modalName = payload; + }, + setCourseData: (state, action: PayloadAction>) => { + state.courseData = {...state.courseData, ...action.payload}; + }, + clearModalInfo: () => { + return initialState; + }, + }, +}); + +export const {setModalName, setCourseData, clearModalInfo} = modalInfo.actions; + +export default modalInfo.reducer; diff --git a/src/store/modules/tabSlice.ts b/src/store/modules/tabSlice.ts new file mode 100644 index 0000000..dc1323e --- /dev/null +++ b/src/store/modules/tabSlice.ts @@ -0,0 +1,48 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +interface TabsInterface { + id: number; + name: string; +} + +export interface TabSliceInterface { + tab: TabsInterface[]; + focused: number; +} + +const initialState: TabSliceInterface = { + tab: [ + { + id: 0, + name: '강의시간표/수업계획서조회', + }, + ], + focused: 0, +}; + +const tabSlice = createSlice({ + name: 'tabSlice', + initialState: initialState, + reducers: { + addTab(state, action: PayloadAction) { + if (!state.tab.find(item => item.id === action.payload.id)) { + state.tab.push({ + id: action.payload.id, + name: action.payload.name, + }); + } + }, + delTab(state, action: PayloadAction) { + state.tab = state.tab.filter(item => item.id !== action.payload); + }, + setFocused(state, action: PayloadAction) { + state.focused = action.payload; + }, + resetTab: () => { + return initialState; + }, + }, +}); + +export const {addTab, delTab, setFocused, resetTab} = tabSlice.actions; +export default tabSlice.reducer; diff --git a/src/store/modules/userSlice.ts b/src/store/modules/userSlice.ts new file mode 100644 index 0000000..96ed4ac --- /dev/null +++ b/src/store/modules/userSlice.ts @@ -0,0 +1,29 @@ +import {createSlice} from '@reduxjs/toolkit'; + +export interface UserInfo { + username: string; +} + +const userInfo = createSlice({ + name: 'userInfo', + initialState: { + username: '', + }, + reducers: { + setUsername(state: UserInfo, {payload}: {payload: string}) { + state.username = payload; + }, + + setUserInfo(state: UserInfo, {payload}: {payload: UserInfo}) { + state.username = payload.username; + }, + + clearUserInfo(state: UserInfo) { + state.username = ''; + }, + }, +}); + +export const {setUsername, setUserInfo, clearUserInfo} = userInfo.actions; + +export default userInfo.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index 2f5a661..7892493 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,26 +1,37 @@ -import { combineReducers, configureStore } from '@reduxjs/toolkit'; -import { persistReducer } from 'redux-persist'; +import {combineReducers, configureStore} from '@reduxjs/toolkit'; +import {persistReducer} from 'redux-persist'; import storage from 'redux-persist/lib/storage'; -import userSlice, { UserInfo } from '@store/userSlice.ts'; - -export interface RootState { - userInfo: UserInfo -} +import userSlice from '@/store/modules/userSlice'; +import modalSlice from '@/store/modules/modalSlice'; +import courseRegisteredSlice from '@/store/modules/courseRegisteredSlice'; +import tabSlice from './modules/tabSlice'; +import errorSlice from './modules/errorSlice'; +import dateModeSlice from './modules/dateModeSlice'; const reducers = combineReducers({ - userInfo: userSlice.reducer + userInfo: userSlice, + modalInfo: modalSlice, + courseRegistered: courseRegisteredSlice, + tabs: tabSlice, + error: errorSlice, + dateMode: dateModeSlice, }); const persistConfig = { - key: 'root', // localStorage key - storage, // localStorage - whitelist: ['userInfo'] // target (reducer name) + key: 'root', // localStorage key + storage, // localStorage + whitelist: ['userInfo', 'modalInfo', 'courseRegistered'], + blacklist: ['tabs', 'error'], }; const persistStore = persistReducer(persistConfig, reducers); -export default configureStore({ - reducer: persistStore, - middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }) -}); \ No newline at end of file +export const store = configureStore({ + reducer: persistStore, + middleware: getDefaultMiddleware => + getDefaultMiddleware({serializableCheck: false}), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts deleted file mode 100644 index e3bb8d4..0000000 --- a/src/store/userSlice.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -export interface UserInfo { - userName: string, - accessToken: string -} - -const userInfo = createSlice({ - name: 'userInfo', - initialState: { - userName: '', - accessToken: '' - }, - reducers: { - setUserName(state: UserInfo, { payload }: { payload: string }) { - state.userName = payload; - }, - setAccessToken(state: UserInfo, { payload }: { payload: string }) { - state.accessToken = payload; - } - } -}); - -export const { - setUserName, - setAccessToken -} = userInfo.actions; - -export default userInfo; \ No newline at end of file diff --git a/src/styles/FilterLayout.tsx b/src/styles/FilterLayout.tsx new file mode 100644 index 0000000..7bedbe3 --- /dev/null +++ b/src/styles/FilterLayout.tsx @@ -0,0 +1,40 @@ +import styled from 'styled-components'; + +export const FilterContainer = styled.div` + border: 0.1rem solid #714656; + border-radius: 2px; + padding: 0.5rem 1.5rem; + margin-bottom: 2rem; +`; + +export const FilterArea = styled.div` + display: flex; + align-items: flex-end; + margin-bottom: 1rem; + gap: 0.7rem 3rem; + + @media ${props => props.theme.device.mobile} { + flex-wrap: wrap; + } +`; + +export const FilterBox = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.7rem 3rem; +`; + +export const FilterWrap = styled.div` + ${props => props.theme.texts.tableTitle}; + display: flex; + flex-wrap: wrap; + gap: 0.7rem 0; + align-items: center; + + > span { + margin-right: 1rem; + text-align: right; + min-width: 5rem; + flex-basis: 5rem; + } +`; diff --git a/src/styles/GlobalStyle.tsx b/src/styles/GlobalStyle.tsx index 1ca4b7e..f2fa310 100644 --- a/src/styles/GlobalStyle.tsx +++ b/src/styles/GlobalStyle.tsx @@ -15,6 +15,10 @@ const GlobalStyle = createGlobalStyle` -moz-appearance: textfield; } + *{ + box-sizing: border-box; + } + html { font-size: 62.5%; } diff --git a/src/styles/ModalLayout.tsx b/src/styles/ModalLayout.tsx new file mode 100644 index 0000000..d21671e --- /dev/null +++ b/src/styles/ModalLayout.tsx @@ -0,0 +1,63 @@ +import styled from 'styled-components'; +import close from '@assets/img/close-line.png'; + +export const ModalContainer = styled.div` + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0); + position: absolute; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +`; + +export const Modal = styled.div` + position: relative; + width: 79rem; + height: 35.5rem; + border: 1px solid ${props => props.theme.colors.black}; + background: ${props => props.theme.colors.white}; + font-weight: lighter; +`; + +export const ModalHeader = styled.div` + display: flex; + justify-content: space-between; + border-bottom: 1px solid #ababab; + height: 5rem; +`; + +export const Title = styled.div` + font-size: 2rem; + font-weight: bolder; + padding: 1.5rem 3rem; +`; + +export const CloseImage = styled.img.attrs({ + src: `${close}`, +})` + display: block; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; + margin-top: 1rem; + margin-right: 1rem; + margin-left: auto; +`; + +export const ModalBody = styled.div` + text-align: center; + margin-top: 1.5rem; +`; + +export const ModalFooter = styled.div` + background: ${props => props.theme.colors.neutral5}; + position: absolute; + width: 100%; + bottom: 0; + display: flex; + justify-content: flex-end; + align-items: center; + height: 5rem; +`; diff --git a/src/styles/theme/Theme.ts b/src/styles/theme/Theme.ts index 072c622..213d6b7 100644 --- a/src/styles/theme/Theme.ts +++ b/src/styles/theme/Theme.ts @@ -2,7 +2,7 @@ import {DefaultTheme} from 'styled-components'; const colors = { primary: '#a31432', - secondary: '#334D61', + secondary: '#46515b', neutral1: '#222', neutral2: '#333', neutral3: '#444', @@ -62,10 +62,18 @@ const texts = { }, }; +const device = { + mobile: 'screen and (max-width: 767px)', + tablet: 'screen and (min-width: 768px) and (max-width: 1023px)', + pc: 'screen and (min-width: 1024px)', +}; + export type ColorsType = typeof colors; export type TextsType = typeof texts; +export type DeviceType = typeof device; export const theme: DefaultTheme = { colors, texts, + device, }; diff --git a/src/styles/theme/style.d.ts b/src/styles/theme/style.d.ts index 8dffb0f..389a5b8 100644 --- a/src/styles/theme/style.d.ts +++ b/src/styles/theme/style.d.ts @@ -1,9 +1,10 @@ import 'styled-components'; -import { ColorsType, TextsType } from './Theme'; +import {ColorsType, DeviceType, TextsType} from './Theme'; declare module 'styled-components' { export interface DefaultTheme { colors: ColorsType; texts: TextsType; + device: DeviceType; } -} \ No newline at end of file +} diff --git a/src/utils/api-setting.ts b/src/utils/api-setting.ts deleted file mode 100644 index 2db0dfb..0000000 --- a/src/utils/api-setting.ts +++ /dev/null @@ -1,7 +0,0 @@ -import axios from 'axios'; - -console.log(import.meta.env.VITE_BASE_URL); -export const requestApi = axios.create({ - baseURL: import.meta.env.VITE_BASE_URL, - withCredentials: true, -}); \ No newline at end of file diff --git a/src/utils/randomUtils.ts b/src/utils/randomUtils.ts new file mode 100644 index 0000000..93a22ff --- /dev/null +++ b/src/utils/randomUtils.ts @@ -0,0 +1,18 @@ +export const getRandomInt = (min: number, max: number) => { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +export const generateRandomStudentId = () => { + const minDigits = 11; + const maxDigits = 15; + + const numDigits = + Math.floor(Math.random() * (maxDigits - minDigits + 1)) + minDigits; + + const min = Math.pow(10, numDigits - 1); + const max = Math.pow(10, numDigits) - 1; + + return Math.floor(Math.random() * (max - min + 1)) + min; +}; diff --git a/src/utils/scrollToTop.ts b/src/utils/scrollToTop.ts new file mode 100644 index 0000000..49303d4 --- /dev/null +++ b/src/utils/scrollToTop.ts @@ -0,0 +1,12 @@ +import {useEffect} from 'react'; +import {useLocation} from 'react-router-dom'; + +export default function ScrollToTop() { + const {pathname} = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 8eb668a..fe5e469 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -33,6 +33,7 @@ "@plugins/*": ["plugins/*"], "@apis/*": ["apis/*"] }, + "esModuleInterop": true, "allowSyntheticDefaultImports": true }, "include": ["src", "src/custom.d.ts"]
순번
{row[col.name]}