diff --git a/.env.example b/.env.example index a2a581ef..f07fc909 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ VITE_APP_PROD=0 +VITE_APP_STAGING=0 VITE_APP_RELEASE_DATE=2023-01-27 PORT=3100 VITE_APP_PLAUSIBLE_DOMAIN= diff --git a/docker-compose.yaml b/docker-compose.yaml index f7304b1a..ec2c22f8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - ./src:/usr/src/tts-fe/src - ./public:/usr/src/tts-fe/public ports: - - 3100:3100 + - 3101:3100 env_file: - .env @@ -25,3 +25,7 @@ services: - ./public:/usr/src/tts-fe/public ports: - 3100:80 + +networks: + default: + name: tts-docker-network diff --git a/package-lock.json b/package-lock.json index ad89c0cd..764ae29a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,17 @@ "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.1.1", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", @@ -41,6 +44,7 @@ "cmdk": "^0.2.1", "emoji-picker-react": "^4.6.4", "html-to-image": "^1.11.11", + "js-cookie": "^3.0.5", "lucide-react": "^0.307.0", "node-fetch": "^3.2.8", "plausible-tracker": "^0.3.9", @@ -48,6 +52,7 @@ "react-dom": "^18.1.0", "react-router-dom": "^6.3.0", "react-sortablejs": "^6.1.4", + "react-spinners": "^0.14.1", "react-toastify": "^9.1.1", "socket.io-client": "^4.8.0", "sortablejs": "^1.15.2", @@ -822,6 +827,45 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", @@ -1318,6 +1362,149 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", + "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", @@ -1357,6 +1544,48 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", + "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", @@ -4456,19 +4685,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5286,6 +5502,14 @@ "jiti": "bin/jiti.js" } }, + "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", @@ -6304,6 +6528,15 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" }, + "node_modules/react-spinners": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.14.1.tgz", + "integrity": "sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", diff --git a/package.json b/package.json index b956abec..9e018e20 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,17 @@ "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.1.1", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", @@ -37,6 +40,7 @@ "cmdk": "^0.2.1", "emoji-picker-react": "^4.6.4", "html-to-image": "^1.11.11", + "js-cookie": "^3.0.5", "lucide-react": "^0.307.0", "node-fetch": "^3.2.8", "plausible-tracker": "^0.3.9", @@ -44,6 +48,7 @@ "react-dom": "^18.1.0", "react-router-dom": "^6.3.0", "react-sortablejs": "^6.1.4", + "react-spinners": "^0.14.1", "react-toastify": "^9.1.1", "socket.io-client": "^4.8.0", "sortablejs": "^1.15.2", diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts index 08275950..f19e0c40 100644 --- a/src/@types/index.d.ts +++ b/src/@types/index.d.ts @@ -1,3 +1,5 @@ +import { DirectExchangePendingMotive } from "../utils/exchange" + enum lesson_type { T = "T", // Ensino teórico TP = "TP", // Ensino teórico-prático @@ -94,12 +96,77 @@ export type ImportedCourses = { [key: string]: string } +/* Exchange data types */ + +export type ExchangeOption = { + course_info: CourseInfo, + course_unit_id: number, + class_issuer_goes_from: ClassInfo, + class_issuer_goes_to: ClassInfo, + other_student?: number // The current student will be determined by the backend depending on session data +} + +export type CreateRequestCardMetadata = { + courseUnitName: string, + courseUnitAcronym: string, + requesterClassName: string, + availableClasses: Array // Classes from the course unit +} + +export type CreateRequestData = { + courseUnitId: number, + courseUnitName: string, + classNameRequesterGoesFrom: string, + classNameRequesterGoesTo: string, + other_student?: Student +} + +export type MarketplaceRequest = { + id: number, + type: string, + issuer_name: string, + issuer_nmec: string, + date: string, + options?: Array, + classes?: Array, + accepted: boolean, +} + +export type DirectExchangeRequest = { + id: number, + type: string, + issuer_name: string, + issuer_nmec: string, + accepted: boolean, + pending_motive: DirectExchangePendingMotive, + options: DirectExchangeParticipant[], + date: string +} + +export type DirectExchangeParticipant = { + id: number, + course_info: CourseInfo, + participant_name: string, + participant_nmec: string, + class_participant_goes_from: ClassInfo, + class_participant_goes_to: ClassInfo, + course_unit: string, + course_unit_id: string, + accepted: boolean + date: string +} + +export type Student = { + name: string, + mecNumber: number +} + export type CollabSession = { id: number name: string lastEdited: string lifeSpan: number currentUser: string - link : string + link: string participants: Array -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index afd77e2b..3d35c63b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,12 @@ import { Toaster } from './components/ui/toaster' import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from 'react-router-dom' import './app.css' import CombinedProvider from './contexts/CombinedProvider' -import { AboutPage, TimeTableSelectorPage, FaqsPage, NotFoundPage } from './pages' +import { AboutPage, TimeTableSelectorPage, FaqsPage, NotFoundPage, AdminPage } from './pages' import { getPath, config, dev_config, plausible } from './utils' import Layout from './components/layout' +import Exchange from './pages/Exchange' +import { useEffect } from 'react' +import api from './api/backend' import * as Sentry from "@sentry/react"; const configToUse = Number(import.meta.env.VITE_APP_PROD) ? config : dev_config @@ -16,6 +19,7 @@ const pages = [ { path: getPath(configToUse.paths.planner), location: 'Horários', element: TimeTableSelectorPage, liquid: true }, { path: getPath(configToUse.paths.faqs), location: 'FAQs', element: FaqsPage, liquid: true }, { path: getPath(configToUse.paths.notfound), location: 'NotFound', element: NotFoundPage, liquid: true }, + { path: getPath(config.paths.exchange), location: 'Trocas', element: Exchange, liquid: true }, ] const redirects = [ @@ -33,6 +37,11 @@ const App = () => { const { enableAutoPageviews } = plausible enableAutoPageviews() + useEffect(() => { + fetch(`${api.BACKEND_URL}/csrf/`, { credentials: "include" }).then(() => { + }).catch((e) => console.error(e)); + }); + // Enable Error Tracking, Performance Monitoring and Session Replay Sentry.init({ environment: Number(import.meta.env.VITE_APP_PROD) ? "production" : "development", @@ -84,6 +93,20 @@ const App = () => { element={} /> ))} + + } + /> + + } + /> diff --git a/src/api/backend.ts b/src/api/backend.ts index 99eb4210..9feeeaa9 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -1,9 +1,12 @@ import { Major, CourseInfo } from "../@types" import { dev_config, getSemester, config } from "../utils" +import Cookies from 'js-cookie' const prod_val = import.meta.env.VITE_APP_PROD const BE_CONFIG = Number(prod_val) ? config : dev_config const BACKEND_URL = import.meta.env.VITE_APP_BACKEND_URL || `${BE_CONFIG.api.protocol}://${BE_CONFIG.api.host}:${BE_CONFIG.api.port}${BE_CONFIG.api.pathPrefix}` +const OIDC_LOGIN_URL = `${BACKEND_URL}/oidc-auth/authenticate` +const OIDC_LOGOUT_URL = `${BACKEND_URL}/oidc-auth/logout` const SEMESTER = import.meta.env.VITE_APP_SEMESTER || getSemester() // If we are in september 2024 we use 2024, if we are january 2025 we use 2024 because the first year of the academic year (2024/2025) @@ -120,7 +123,13 @@ const getCourseUnitHashes = async (ids: number[]) => { } }; +const getCSRFToken = () => { + return Cookies.get('csrftoken'); +} +const csrfTokenName = (): string => { + return "X-CSRFToken"; +} const api = { getMajors, @@ -131,7 +140,11 @@ const api = { getCourseUnit, getInfo, getCourseUnitHashes, - BACKEND_URL + getCSRFToken, + csrfTokenName, + BACKEND_URL, + OIDC_LOGIN_URL, + OIDC_LOGOUT_URL } export default api diff --git a/src/api/services/authService.ts b/src/api/services/authService.ts new file mode 100644 index 00000000..8392d76d --- /dev/null +++ b/src/api/services/authService.ts @@ -0,0 +1,21 @@ +import api from "../backend"; + +const logout = async (token, setSignedIn, setLoggingOut) => { + fetch(`${api.OIDC_LOGOUT_URL}/`, { + method: "POST", credentials: "include", headers: { + "X-CSRFToken": api.getCSRFToken() + } + }).then(() => { + setSignedIn(false); + setLoggingOut(false); + }).catch((e) => { + console.error(e); + }); + +} + +const authService = { + logout +}; + +export default authService; diff --git a/src/api/services/exchangeRequestService.ts b/src/api/services/exchangeRequestService.ts new file mode 100644 index 00000000..bd69188c --- /dev/null +++ b/src/api/services/exchangeRequestService.ts @@ -0,0 +1,64 @@ +import { Key } from "swr"; +import { CreateRequestData, MarketplaceRequest } from "../../@types"; +import api from "../backend"; + +const isDirectExchange = (requests: IterableIterator) => { + for (const request of requests) { + if (!request.other_student) return false; + } + + return true; +} + +const submitExchangeRequest = async (requests: Map) => { + const formData = new FormData(); + + for (const request of requests.values()) { + formData.append("exchangeChoices[]", JSON.stringify(request)); + } + + return fetch( + `${api.BACKEND_URL}/exchange/${isDirectExchange(requests.values()) ? "direct/" : "marketplace/"}`, + { + method: "POST", + credentials: "include", + headers: { + "X-CSRFToken": api.getCSRFToken(), + }, + body: formData + }, + ).then(async (res) => { + const json = await res.json(); + return json; + }).catch((e) => { + console.error(e); + }); +} + +const retrieveMarketplaceRequest = async (url: string): Promise => { + return fetch(url).then(async (res) => { + const json = await res.json(); + return json.data; + }).catch((e) => { + console.error(e); + return []; + }) +} + +const retrieveRequestCardMetadata = async (courseUnitId: Key) => { + return fetch(`${api.BACKEND_URL}/course_unit/${courseUnitId}/exchange/metadata`).then(async (res) => { + if (!res.ok) return []; + return await res.json(); + }).catch((e) => { + console.error(e); + return []; + }); +} + +const exchangeRequestService = { + submitExchangeRequest, + retrieveMarketplaceRequest, + retrieveRequestCardMetadata +} + +export default exchangeRequestService; diff --git a/src/api/services/studentInfo.ts b/src/api/services/studentInfo.ts new file mode 100644 index 00000000..f4db7d74 --- /dev/null +++ b/src/api/services/studentInfo.ts @@ -0,0 +1,11 @@ +import api from "../backend" + +const getStudentPictureUrl = (username: string) => { + return `${api.BACKEND_URL}/student/${username}/photo`; +} + +const studentInfoService = { + getStudentPictureUrl +} + +export default studentInfoService; \ No newline at end of file diff --git a/src/app.css b/src/app.css index 102306fe..7754020d 100644 --- a/src/app.css +++ b/src/app.css @@ -121,4 +121,8 @@ button { #option-name { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ -} \ No newline at end of file +} + +.success-button { + @apply bg-green-200 text-green-600 border border-green-600 hover:bg-white; +} diff --git a/src/components/admin/AcceptedExchangeCard.tsx b/src/components/admin/AcceptedExchangeCard.tsx new file mode 100644 index 00000000..02019244 --- /dev/null +++ b/src/components/admin/AcceptedExchangeCard.tsx @@ -0,0 +1,91 @@ +import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline" +import { Button } from "../ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" +import { useState } from "react" +import { DirectExchangeParticipant } from "../../@types" +import ExchangeSchedule from "../exchange/schedule/ExchangeSchedule" +import ScheduleContext from "../../contexts/ScheduleContext" + +type Props = { + participant_nmec: number + participant_name: string + date: string + exchanges: Array + schedule: any +} + +export const AcceptedExchangeCard = ({ + participant_name, + participant_nmec, + date, + exchanges, + schedule +}: Props) => { + const [open, setOpen] = useState(false); + + return ( + + +
+
+ + {participant_name} + + + {participant_nmec} + +
+

{new Date(date).toLocaleDateString()}

+
+
+ +
+
+ + + {open && +
+
+

Trocas feitas

+ { + exchanges.map((exchange) => ( +
+

{exchange.course_unit}

+
+

{exchange.class_participant_goes_from.name}

+ +

{exchange.class_participant_goes_to.name}

+
+
+ )) + } +
+
+

Horário

+ { }, + "enrolledCourseUnits": [] + }}> + + +
+
+ } +
+
+ ) +} \ No newline at end of file diff --git a/src/components/admin/AdminMainContent.tsx b/src/components/admin/AdminMainContent.tsx new file mode 100644 index 00000000..079e1fe9 --- /dev/null +++ b/src/components/admin/AdminMainContent.tsx @@ -0,0 +1,30 @@ +import useAdminExchanges from "../../hooks/useAdminExchanges"; +import { AcceptedExchangeCard } from "./AcceptedExchangeCard"; + +export const AdminMainContent = () => { + const { exchanges } = useAdminExchanges(); + + console.log("Exchanges: ", exchanges); + + return ( +
+

Pedidos

+
+ {(!exchanges || exchanges.length === 0) && ( +

Nenhum pedido encontrado de momento

+ )} + {exchanges?.map((exchange) => ( +
+ +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/admin/AdminSettings.tsx b/src/components/admin/AdminSettings.tsx new file mode 100644 index 00000000..55f18dd8 --- /dev/null +++ b/src/components/admin/AdminSettings.tsx @@ -0,0 +1,5 @@ +export const AdminSettings = () => { + return ( +

Settings

+ ) +} \ No newline at end of file diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx new file mode 100644 index 00000000..668e5e6b --- /dev/null +++ b/src/components/admin/AdminSidebar.tsx @@ -0,0 +1,24 @@ +import { LayoutDashboard, SendHorizonal, Settings } from "lucide-react" +import { Separator } from "../ui/separator" + +export const AdminSidebar = () => { + return ( +
+
+ +

Admin

+
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/auth/HeaderProfileDropdown.tsx b/src/components/auth/HeaderProfileDropdown.tsx new file mode 100644 index 00000000..85337f36 --- /dev/null +++ b/src/components/auth/HeaderProfileDropdown.tsx @@ -0,0 +1,58 @@ +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator } from "../ui/dropdown-menu" +import { Button } from "../ui/button"; +import { ChevronRightIcon } from "@heroicons/react/24/solid"; +import { useContext, useState } from "react"; +import { ClipLoader } from "react-spinners"; +import SessionContext from "../../contexts/SessionContext"; +import authService from "../../api/services/authService"; +import studentInfoService from "../../api/services/studentInfo"; + +export const HeaderProfileDropdown = () => { + const [loggingOut, setLoggingOut] = useState(false); + + const { user, setSignedIn } = useContext(SessionContext); + + const logout = async () => { + setLoggingOut(true); + await authService.logout(user.token, setSignedIn, setLoggingOut); + } + + return + + + + {user ? user.name.charAt(0) : ""} + + + +
+
+

{user?.name}

+

{user?.username}

+
+ + +
+
+
+} + + diff --git a/src/components/auth/LoginButton.tsx b/src/components/auth/LoginButton.tsx new file mode 100644 index 00000000..0989542a --- /dev/null +++ b/src/components/auth/LoginButton.tsx @@ -0,0 +1,21 @@ +import { ArrowLeftEndOnRectangleIcon } from "@heroicons/react/24/outline" +import api from "../../api/backend" +import { Button } from "../ui/button" + +type Props = { + expanded: boolean +} + +export const LoginButton = ({ expanded = false }: Props) => { + return + +} diff --git a/src/components/config/StagingMode.tsx b/src/components/config/StagingMode.tsx new file mode 100644 index 00000000..c419d14d --- /dev/null +++ b/src/components/config/StagingMode.tsx @@ -0,0 +1,12 @@ +type Props = { + children: JSX.Element; + }; + + const StagingMode = ({ children }: Props) => { + return import.meta.env.VITE_APP_PROD === '0' || import.meta.env.VITE_APP_STAGING === '1' ? ( + <>{children} + ) : null; + }; + + export default StagingMode; + \ No newline at end of file diff --git a/src/components/exchange/ExchangeSidebar.tsx b/src/components/exchange/ExchangeSidebar.tsx new file mode 100644 index 00000000..502bfcac --- /dev/null +++ b/src/components/exchange/ExchangeSidebar.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; +import { CreateRequest } from "./requests/issue/CreateRequest"; +import { ViewRequests } from "./requests/view/ViewRequests"; + +export const ExchangeSidebar = () => { + const [creatingRequest, setCreatingRequest] = useState(false); + + return
+ { + creatingRequest + ? + : + } +
+} diff --git a/src/components/exchange/requests/issue/ChooseIncludedCourseUnits.tsx b/src/components/exchange/requests/issue/ChooseIncludedCourseUnits.tsx new file mode 100644 index 00000000..8ed64fd3 --- /dev/null +++ b/src/components/exchange/requests/issue/ChooseIncludedCourseUnits.tsx @@ -0,0 +1,54 @@ +import { CheckBadgeIcon } from "@heroicons/react/24/outline"; +import { CourseInfo } from "../../../../@types"; +import { Button } from "../../../ui/button"; +import { Checkbox } from "../../../ui/checkbox"; +import { IncludeCourseUnitCard } from "./cards/IncludeCourseUnitCard"; + +type Props = { + setSelectedCourseUnits: React.Dispatch> + enrolledCourseUnits: CourseInfo[] + selectedCourseUnits: CourseInfo[] + setSelectingCourseUnits: React.Dispatch> +} + +export const ChooseIncludedCourseUnits = ({ + selectedCourseUnits, + setSelectedCourseUnits, + enrolledCourseUnits, + setSelectingCourseUnits +}: Props) => { + return
+
+ { + if (!checked) { + setSelectedCourseUnits([]); + } else { + setSelectedCourseUnits(enrolledCourseUnits); + } + }} + checked={selectedCourseUnits.length === enrolledCourseUnits.length} + /> + +
+ {enrolledCourseUnits?.map((courseInfo: CourseInfo) => ( + + ))} + + + +
+} diff --git a/src/components/exchange/requests/issue/CreateRequest.tsx b/src/components/exchange/requests/issue/CreateRequest.tsx new file mode 100644 index 00000000..616e03af --- /dev/null +++ b/src/components/exchange/requests/issue/CreateRequest.tsx @@ -0,0 +1,51 @@ +import { Dispatch, SetStateAction, useState } from "react" +import { CourseInfo, CreateRequestData } from "../../../../@types" +import useStudentCourseUnits from "../../../../hooks/useStudentCourseUnits" +import { Button } from "../../../ui/button" +import { ChooseIncludedCourseUnits } from "./ChooseIncludedCourseUnits" +import { CustomizeRequest } from "./CustomizeRequest" + +type Props = { + setCreatingRequest: Dispatch> +} + +export const CreateRequest = ({ + setCreatingRequest +}: Props) => { + const [requests, setRequests] = useState>(new Map()); + const [selectedCourseUnits, setSelectedCourseUnits] = useState([]); + const [selectingCourseUnits, setSelectingCourseUnits] = useState(false); + const { enrolledCourseUnits } = useStudentCourseUnits(); + + return
+
+
+

Criar pedido

+ {!selectingCourseUnits && + + } +
+ +
+ { + selectingCourseUnits + ? + : + } +
+
+
+} diff --git a/src/components/exchange/requests/issue/CustomizeRequest.tsx b/src/components/exchange/requests/issue/CustomizeRequest.tsx new file mode 100644 index 00000000..386fe000 --- /dev/null +++ b/src/components/exchange/requests/issue/CustomizeRequest.tsx @@ -0,0 +1,116 @@ +import { ArrowLeftIcon } from "@heroicons/react/24/outline" +import { Dispatch, SetStateAction, useState } from "react" +import { CourseInfo } from "../../../../@types" +import exchangeRequestService from "../../../../api/services/exchangeRequestService" +import { Desert } from "../../../svgs" +import { Button } from "../../../ui/button" +import { Switch } from "../../../ui/switch" +import { toast } from "../../../ui/use-toast" +import { CreateRequestCard } from "./cards/CreateRequestCard" +import PreviewRequestForm from "./PreviewRequestForm" + +type Props = { + selectedCourseUnits: CourseInfo[] + setCreatingRequest: Dispatch> + requests: any + setRequests: Dispatch> + setSelectedCourseUnits: Dispatch> +} + +export const CustomizeRequest = ({ + selectedCourseUnits, + setCreatingRequest, + requests, + setRequests, + setSelectedCourseUnits +}: Props) => { + const [hasStudentToExchange, setHasStudentToExchange] = useState(false); + const [submittingRequest, setSubmittingRequest] = useState(false); + const [previewingForm, setPreviewingForm] = useState(false); + + const submitRequest = async () => { + setSubmittingRequest(true); + const json = await exchangeRequestService.submitExchangeRequest(requests); + + if (json.success) { + setPreviewingForm(false); + toast({ + title: 'Pedido submetido com sucesso!', + }); + } + + setSubmittingRequest(false); + } + + + return
+ {selectedCourseUnits.length === 0 + ?
+ +

Ainda não adicionaste nenhuma disciplina para fazer uma troca.

+ +
+ : <> +
+
+ {selectedCourseUnits.length > 0 && + <> + + + + } +
+
+ +
+ { + setHasStudentToExchange(checked) + if (!checked) { + requests.forEach((request) => { + request.other_student = null; + }) + setRequests(new Map(requests)); + } + }} /> + +
+ + { + Array.from(selectedCourseUnits).map((courseInfo: CourseInfo) => ( + + )) + } + + } + +
+} diff --git a/src/components/exchange/requests/issue/PreviewRequestForm.tsx b/src/components/exchange/requests/issue/PreviewRequestForm.tsx new file mode 100644 index 00000000..2d31f8c9 --- /dev/null +++ b/src/components/exchange/requests/issue/PreviewRequestForm.tsx @@ -0,0 +1,82 @@ +import { CheckBadgeIcon } from "@heroicons/react/24/outline"; +import { BeatLoader } from "react-spinners"; +import { Dispatch, SetStateAction } from "react"; +import { CreateRequestData } from "../../../../@types"; +import exchangeUtils from "../../../../utils/exchange"; +import { Desert } from "../../../svgs"; +import { Button } from "../../../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog"; +import PreviewRequestCard from "./cards/PreviewRequestCard"; + +type Props = { + requests: Map + requestSubmitHandler: () => void + previewingFormHook: [boolean, Dispatch>] + submittingRequest: boolean +} + +const PreviewRequestForm = ({ requests, requestSubmitHandler, previewingFormHook, submittingRequest }: Props) => { + const [previewingForm, setPreviewingForm] = previewingFormHook; + + return setPreviewingForm(open)}> + + + + + + Prever visualização do pedido + + { + exchangeUtils.isDirectExchange(Array.from(requests.values())) + ? "Após submeteres o pedido, irá ser enviado e-mails de confirmação para todos os estudantes de destino que selecionaste." + : "Após submeteres o pedido, irá ser mostrado na página de visualização de pedidos." + } + + +
+ {requests.size === 0 ? +
+ +

Ainda não escolheste nenhuma turma em nenhuma das disciplinas selecionadas.

+
+ :
+ { + Array.from(requests.values()).map((request) => ( + + )) + } +
+ } +
+ + {requests.size > 0 && +
+ + {submittingRequest && } + + } +
+
+}; + +export default PreviewRequestForm; diff --git a/src/components/exchange/requests/issue/cards/CreateRequestCard.tsx b/src/components/exchange/requests/issue/cards/CreateRequestCard.tsx new file mode 100644 index 00000000..70ca53e4 --- /dev/null +++ b/src/components/exchange/requests/issue/cards/CreateRequestCard.tsx @@ -0,0 +1,179 @@ +import { ArrowRightIcon, TrashIcon } from "@heroicons/react/24/outline" +import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react" +import { ClassDescriptor, CourseInfo, SlotInfo, ClassInfo, CreateRequestData, Student } from "../../../../../@types" +import { ScrollArea } from '../../../../ui/scroll-area' +import ScheduleContext from "../../../../../contexts/ScheduleContext" +import useRequestCardCourseMetadata from "../../../../../hooks/useRequestCardCourseMetadata" +import { Button } from "../../../../ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "../../../../ui/card" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../../ui/dropdown-menu" + +type Props = { + courseInfo: CourseInfo + hasStudentToExchange: boolean + setSelectedCourseUnits: Dispatch> + requestsHook: [Map, Dispatch>>] +} + +export const CreateRequestCard = ({ + courseInfo, + hasStudentToExchange, + setSelectedCourseUnits, + requestsHook +}: Props) => { + const { data: requestMetadata } = useRequestCardCourseMetadata(courseInfo); + const [requests, setRequests] = requestsHook; + const [issuerOriginClass, setIssuerOriginClass] = useState(null); + const [issuerOriginClassName, setIssuerOriginClassName] = useState(null); + const [selectedDestinationClass, setSelectedDestinationClass] = useState(null); + const [selectedDestinationStudent, setSelectedDestinationStudent] = useState(null); + const { exchangeSchedule, originalExchangeSchedule, setExchangeSchedule } = useContext(ScheduleContext); + + useEffect(() => { + if (!originalExchangeSchedule || !courseInfo) return; + const originClassName = findIssuerOriginClassName(); + + setIssuerOriginClassName(originClassName); + setIssuerOriginClass(findClassInfoByName(originClassName)); + + const request = requests.get(courseInfo.id); + if (request) { + const destinationClass = findClassInfoByName(request.classNameRequesterGoesTo); + setSelectedDestinationClass(destinationClass); + } + }, [originalExchangeSchedule, courseInfo, requestMetadata]); + + const findIssuerOriginClassName = (): string | null => { + const scheduleItem = originalExchangeSchedule + .filter((item) => item.classInfo?.slots?.[0]?.lesson_type !== "T") + .find((item: ClassDescriptor) => item.courseInfo.id === courseInfo.id); + + return scheduleItem?.classInfo.name || null; + }; + + const findClassInfoByName = (className: string | null): ClassInfo | null => { + return requestMetadata?.classes?.find((classInfo: ClassInfo) => classInfo.name === className) || null; + }; + + const excludeClass = () => { + if (requests.get(courseInfo.id)) { + const newRequests = new Map(requests); + newRequests.delete(courseInfo.id) + setRequests(newRequests); + } + + setSelectedCourseUnits((prev) => prev.filter((currentCourseInfo) => currentCourseInfo.id !== courseInfo.id)); + } + + const addRequest = (destinationClassName: string) => { + const currentRequest: CreateRequestData = { + courseUnitId: courseInfo.id, + courseUnitName: courseInfo.name, + classNameRequesterGoesFrom: issuerOriginClassName, + classNameRequesterGoesTo: destinationClassName + } + + requests.set(courseInfo.id, currentRequest); + setRequests(new Map(requests)); + } + + const togglePreview = (destinationClass: ClassInfo, slots: SlotInfo[]) => { + const newExchangeSchedule = exchangeSchedule.filter((scheduleItem) => scheduleItem.courseInfo.id !== courseInfo.id); + + for (const slot of slots) { + newExchangeSchedule.push({ + courseInfo: courseInfo, + classInfo: { + id: destinationClass.id, + name: destinationClass.name, + slots: [slot], + filteredTeachers: [] + } + }) + } + + setExchangeSchedule(newExchangeSchedule); + } + + return + + {courseInfo.name} +
+ +
+
+ +
+

{issuerOriginClassName}

+ +
+ + + + + + + {requestMetadata?.classes?.filter((currentClass) => currentClass.name !== issuerOriginClassName) + .map((currentClass) => ( + togglePreview(currentClass, currentClass.slots)} + onMouseLeave={() => { + const persistentClass = selectedDestinationClass || issuerOriginClass; + togglePreview(persistentClass, persistentClass?.slots); + }} + onSelect={() => { + setSelectedDestinationClass(currentClass); + togglePreview(currentClass, currentClass.slots); + addRequest(currentClass.name); + } + }> +

{currentClass.name}

+
+ ))} +
+
+
+
+
+
+
+ + +
+

Estudante

+ +
+
+ + + {requestMetadata?.students?.map((student) => ( + { + if (requests.get(courseInfo.id)) { + requests.get(courseInfo.id).other_student = { name: student.nome, mecNumber: Number(student.codigo) } + setRequests(new Map(requests)); + } + + setSelectedDestinationStudent(student); + }}> +

{student.nome}

+
+ ))} +
+
+
+
+
+
+
+} diff --git a/src/components/exchange/requests/issue/cards/IncludeCourseUnitCard.tsx b/src/components/exchange/requests/issue/cards/IncludeCourseUnitCard.tsx new file mode 100644 index 00000000..ac206c3a --- /dev/null +++ b/src/components/exchange/requests/issue/cards/IncludeCourseUnitCard.tsx @@ -0,0 +1,29 @@ +import { CourseInfo } from "../../../../../@types" +import { Card, CardHeader, CardTitle } from "../../../../ui/card" +import { Checkbox } from "../../../../ui/checkbox" + +type Props = { + courseInfo: CourseInfo + selectedCourseUnitsHook: [CourseInfo[], React.Dispatch>] +} + +export const IncludeCourseUnitCard = ({ courseInfo, selectedCourseUnitsHook }: Props) => { + const [selectedCourseUnits, setSelectedCourseUnits] = selectedCourseUnitsHook; + + return + + {courseInfo.name} + { + if (checked) { + setSelectedCourseUnits([...selectedCourseUnits, courseInfo]); + } else { + setSelectedCourseUnits(selectedCourseUnits.filter((currentCourseInfo) => currentCourseInfo.id !== courseInfo.id)); + } + }} + checked={selectedCourseUnits.some((currentCourseInfo) => currentCourseInfo.id === courseInfo.id)} + /> + + +} diff --git a/src/components/exchange/requests/issue/cards/PreviewRequestCard.tsx b/src/components/exchange/requests/issue/cards/PreviewRequestCard.tsx new file mode 100644 index 00000000..c4dfaf63 --- /dev/null +++ b/src/components/exchange/requests/issue/cards/PreviewRequestCard.tsx @@ -0,0 +1,37 @@ +import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { CreateRequestData } from "../../../../../@types"; +import { Card, CardContent } from "../../../../ui/card"; + +type Props = { + request: CreateRequestData +} + +const PreviewRequestCard = ({ request }: Props) => { + return + +
+
+

{request.courseUnitName}

+
+ {/* The new class of the other student is our old class */} +

{request.classNameRequesterGoesFrom}

+ + + +

{request.classNameRequesterGoesTo}

+
+
+ + {request.other_student && +
+

Estudante

+

{request.other_student.mecNumber}

+
+ } +
+
+
+ +}; + +export default PreviewRequestCard; diff --git a/src/components/exchange/requests/view/ViewReceivedRequests.tsx b/src/components/exchange/requests/view/ViewReceivedRequests.tsx new file mode 100644 index 00000000..ba2dca03 --- /dev/null +++ b/src/components/exchange/requests/view/ViewReceivedRequests.tsx @@ -0,0 +1,18 @@ +import useReceivedRequests from "../../../../hooks/useReceivedRequests"; +import { ReceivedRequestCard } from "./cards/ReceivedRequestCard"; + +export const ViewReceivedRequests = () => { + const { data } = useReceivedRequests(); + + const requests = data ? [].concat(...data) : []; + + return <> + {requests.map((request) => ( + + ))} + + +} diff --git a/src/components/exchange/requests/view/ViewRequestBadgeFilter.tsx b/src/components/exchange/requests/view/ViewRequestBadgeFilter.tsx new file mode 100644 index 00000000..e7838481 --- /dev/null +++ b/src/components/exchange/requests/view/ViewRequestBadgeFilter.tsx @@ -0,0 +1,90 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline" +import { Dispatch, SetStateAction } from "react" +import { ClassInfo, CourseInfo } from "../../../../@types" +import useCourseUnitClasses from "../../../../hooks/useCourseUnitClasses" +import { Badge } from "../../../ui/badge" +import { Button } from "../../../ui/button" +import { Checkbox } from "../../../ui/checkbox" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../ui/dropdown-menu" + +type Props = { + courseUnit: CourseInfo + filterCourseUnitsHook: [Set, Dispatch>>] + classesFilterHook: [Map>, Dispatch>>>] +} + +export const ViewRequestBadgeFilter = ({ + courseUnit, + filterCourseUnitsHook, + classesFilterHook +}: Props) => { + const [classesFilter, setClassesFilter] = classesFilterHook; + const [filterCourseUnits, setFilterCourseUnits] = filterCourseUnitsHook + const { classes } = useCourseUnitClasses(courseUnit.id); + + const handleClassFilterChange = (className: string, checked: boolean) => { + const classFilterItem = classesFilter.get(courseUnit.acronym); + + if (checked) { + if (classFilterItem) classFilterItem.add(className); + else classesFilter.set(courseUnit.acronym, new Set([className])); + } else { + classFilterItem?.delete(className); + if (classFilterItem?.size === 0) classesFilter?.delete(courseUnit.acronym); + } + + setClassesFilter(new Map(classesFilter)); + } + + return
+ { + const newFilterCourseUnits = new Set(filterCourseUnits); + + if (newFilterCourseUnits.has(courseUnit.id)) newFilterCourseUnits.delete(courseUnit.id); + else newFilterCourseUnits.add(courseUnit.id); + + setFilterCourseUnits(newFilterCourseUnits); + }} + > + {courseUnit.acronym} + + + + + + + +

Turma de destino

+ {classes?.map((currentClass: ClassInfo) => ( + +
+ { + handleClassFilterChange(currentClass.name, checked); + }} + onClick={(e) => e.stopPropagation()} + /> + +
+
+ ))} +
+
+ +
+} diff --git a/src/components/exchange/requests/view/ViewRequests.tsx b/src/components/exchange/requests/view/ViewRequests.tsx new file mode 100644 index 00000000..c5109cdd --- /dev/null +++ b/src/components/exchange/requests/view/ViewRequests.tsx @@ -0,0 +1,200 @@ +import { PlusIcon } from "@heroicons/react/24/outline"; +import { Dispatch, SetStateAction, useContext, useEffect, useRef, useState } from "react"; +import { DirectExchangeRequest, MarketplaceRequest } from "../../../../@types"; +import ScheduleContext from "../../../../contexts/ScheduleContext"; +import useMarketplaceRequests from "../../../../hooks/useMarketplaceRequests"; +import { Desert } from "../../../svgs"; +import { Button } from "../../../ui/button"; +import { Skeleton } from "../../../ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs"; +import { CommonRequestCard } from "./cards/CommonRequestCard"; +import { MineRequestCard } from "./cards/MineRequestCard"; +import { ReceivedRequestCard } from "./cards/ReceivedRequestCard"; +import { RequestCard } from "./cards/RequestCard"; +import { ViewRequestsFilters } from "./ViewRequestsFilters"; + +type Props = { + setCreatingRequest: Dispatch> +} + +const requestTypeFilters = ["all", "mine", "received"]; + +const EmptyRequestGuard = ({ requests, children }: { requests: Array, children: React.ReactNode }) => { + return <> + {requests.length === 0 ? +
+ +

Não existem pedidos.

+
+ : <> + {children} + + } + +} + +const RequestCardSkeletons = () => { + const skeletons = Array.from({ length: 3 }, (_, i) => ( +
+ +
+ +
+ + +
+
+
+ )) + + return <> + {skeletons} + +} + +export const ViewRequests = ({ + setCreatingRequest +}: Props) => { + const { originalExchangeSchedule, setExchangeSchedule } = useContext(ScheduleContext); + const requestCardsContainerRef = useRef(null); + const [hiddenRequests, setHiddenRequests] = useState>(new Set()); + const [currentRequestTypeFilter, setCurrentRequestTypeFilter] = useState(0); + const [filterCourseUnitNames, setFilterCourseUnitNames] = useState>(new Set()); + const [classesFilter, setClassesFilter] = useState>>(new Map()); + + // This is to keep track of the request of the request card that is currently open + const [chosenRequest, setChosenRequest] = useState(null); + + const { data, size, setSize, isLoading } = useMarketplaceRequests( + filterCourseUnitNames, requestTypeFilters[currentRequestTypeFilter], classesFilter + ); + + const requests = data ? [].concat(...data) : []; + + const onScroll = () => { + if (!requestCardsContainerRef.current) return; + + if ((requestCardsContainerRef.current.scrollHeight - requestCardsContainerRef.current.scrollTop) + <= requestCardsContainerRef.current.clientHeight + 100 + ) { + setSize(size + 1); + } + } + + useEffect(() => { + if (!requestCardsContainerRef.current) return; + + requestCardsContainerRef.current.addEventListener('scroll', onScroll); + return () => { + if (requestCardsContainerRef.current) requestCardsContainerRef.current.removeEventListener('scroll', onScroll); + } + }, []); + + return
+
+

Pedidos

+ +
+ + + + setCurrentRequestTypeFilter(0)} value="todos">Todos + setCurrentRequestTypeFilter(1)} value="meus-pedidos">Enviados + setCurrentRequestTypeFilter(2)} value="recebidos">Recebidos + + + +
+ { + isLoading + ? : <> + { + + {requests?.filter((request) => request !== undefined).map((request: MarketplaceRequest) => ( + + + + ))} + + } + + } +
+
+ + + {/* */} +
+ {isLoading + ? + : + {requests?.filter((request) => request !== undefined).map((request: MarketplaceRequest) => ( + + + + + ))} + + } +
+
+ + +
+ {requests.map((request) => ( + + + + ))} +
+
+
+
+ ; +} diff --git a/src/components/exchange/requests/view/ViewRequestsFilters.tsx b/src/components/exchange/requests/view/ViewRequestsFilters.tsx new file mode 100644 index 00000000..3de71ab3 --- /dev/null +++ b/src/components/exchange/requests/view/ViewRequestsFilters.tsx @@ -0,0 +1,46 @@ +import { Dispatch, SetStateAction, useContext } from "react" +import { CourseInfo } from "../../../../@types" +import ScheduleContext from "../../../../contexts/ScheduleContext" +import { Skeleton } from "../../../ui/skeleton" +import { ViewRequestBadgeFilter } from "./ViewRequestBadgeFilter" + +type Props = { + filterCourseUnitsHook: [Set, Dispatch>>] + classesFilterHook: [Map>, Dispatch>>>] +} + +const ViewRequestsFiltersSkeletons = () => { + return
+ { + Array.from({ length: 5 }).map((_, i) => ( + + )) + } +
+} + +export const ViewRequestsFilters = ({ + filterCourseUnitsHook, + classesFilterHook +}: Props) => { + const { enrolledCourseUnits, loadingSchedule } = useContext(ScheduleContext); + + return
+ {/* Course unit filters */} + {loadingSchedule ? + :
+
+ { + enrolledCourseUnits?.map((courseUnit: CourseInfo) => ( + + )) + } +
+
} +
+} diff --git a/src/components/exchange/requests/view/cards/CommonCardHeader.tsx b/src/components/exchange/requests/view/cards/CommonCardHeader.tsx new file mode 100644 index 00000000..031b36d8 --- /dev/null +++ b/src/components/exchange/requests/view/cards/CommonCardHeader.tsx @@ -0,0 +1,100 @@ +import { ArchiveBoxIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline" +import { Hourglass } from "lucide-react" +import { DirectExchangeRequest, MarketplaceRequest } from "../../../../../@types" +import { Button } from "../../../../ui/button" +import { CardDescription, CardHeader, CardTitle } from "../../../../ui/card" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../../ui/tooltip" +import RequestCardClassBadge from "./RequestCardClassBadge" + +type Props = { + name: string + username: string + hovered: boolean + request: MarketplaceRequest | DirectExchangeRequest + openHook: [boolean, React.Dispatch>] + showRequestStatus?: boolean + hideAbility?: boolean + hideHandler: () => void + classUserGoesToName: string +} + +export const CommonCardHeader = ({ + name, username, hovered, request, openHook, hideAbility = true, showRequestStatus = false, hideHandler, + classUserGoesToName +}: Props) => { + const [open, setOpen] = openHook; + + return + +
+
+
+ {name} + {showRequestStatus && +
+ {!request.accepted ? ( +
+ +
+ ) + : ( +
+ +
+ )} +
+ } +
+ {hideAbility && + + + + + + +

Esconder

+
+
+
+ } + + {open + ? + + : + } +
+
+ + {open + ?

{username}

+ : +
+ {request.options?.map((option) => { + return () + })} +
+ } +
+
+
+
+ +} diff --git a/src/components/exchange/requests/view/cards/CommonRequestCard.tsx b/src/components/exchange/requests/view/cards/CommonRequestCard.tsx new file mode 100644 index 00000000..1233c19f --- /dev/null +++ b/src/components/exchange/requests/view/cards/CommonRequestCard.tsx @@ -0,0 +1,125 @@ +import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react"; +import { ClassDescriptor, DirectExchangeRequest, MarketplaceRequest } from "../../../../../@types"; +import ExchangeRequestCommonContext from "../../../../../contexts/ExchangeRequestCommonContext"; +import ScheduleContext from "../../../../../contexts/ScheduleContext"; +import useSchedule from "../../../../../hooks/useSchedule"; +import { toast } from "../../../../ui/use-toast"; + +type Props = { + children: React.ReactNode; + request: MarketplaceRequest | DirectExchangeRequest; + hiddenRequests: Set; + setHiddenRequests: Dispatch>>; + chosenRequest: MarketplaceRequest | DirectExchangeRequest | null; + setChosenRequest: Dispatch>; + type: string +} + +export const CommonRequestCard = ({ + children, + request, + hiddenRequests, + setHiddenRequests, + chosenRequest, + setChosenRequest, + type +}: Props) => { + const { exchangeSchedule, setExchangeSchedule } = useContext(ScheduleContext); + const [open, setOpen] = useState(false); + const [selectedOptions, setSelectedOptions] = useState>(new Map()); + const [selectAll, setSelectAll] = useState(true); + const originalSchedule = useSchedule(); + + const hide = () => { + const newHidden = new Set(hiddenRequests); + newHidden.add(request.id); + setHiddenRequests(newHidden); + toast({ + title: "Pedido escondido", + description: "O pedido foi escondido da lista de pedidos.", + position: "top-right", + }); + } + + useEffect(() => { + if (open) { + setChosenRequest(request); + togglePreview(selectedOptions); + } else { + setExchangeSchedule(originalSchedule.schedule); + } + }, [open]); + + useEffect(() => { + const ucs = new Set(request.options?.map((option) => option.course_info.acronym)); + + const newSelectedOptions = new Map(); + ucs.forEach((acronym) => { + newSelectedOptions.set(acronym, true); + }); + + setSelectedOptions(newSelectedOptions); + }, [request]); + + const handleSelectAll = () => { + const allSelected = !selectAll; + setSelectAll(allSelected); + + for (const key of selectedOptions.keys()) { + selectedOptions.set(key, allSelected); + } + + const newSelectedOptions = new Map(selectedOptions); + setSelectedOptions(newSelectedOptions); + togglePreview(newSelectedOptions); + }; + + const togglePreview = (updatedOptions: Map) => { + const anySelected = Array.from(updatedOptions.values()).some((value) => value); + + // Schedule with courses that are not selected + const newExchangeSchedule = originalSchedule?.schedule?.filter( + (classDescriptor: ClassDescriptor) => { + return !updatedOptions.get(classDescriptor.courseInfo.acronym); + } + ); + + if (anySelected) { + request.options.forEach((option) => { + if (updatedOptions.get(option.course_info.acronym) === true) { + const matchingClass = (type === "directexchange" ? option.class_participant_goes_to : option.class_issuer_goes_from); + matchingClass.slots.forEach((slot) => { + newExchangeSchedule.push({ + courseInfo: option.course_info, + classInfo: { id: matchingClass.id, name: matchingClass.name, slots: [slot] }, + }); + }); + } + }); + + if (open) setExchangeSchedule(newExchangeSchedule); + } else { + if (open) setExchangeSchedule(originalSchedule.schedule); + } + }; + + return + {children} + +} diff --git a/src/components/exchange/requests/view/cards/ListRequestChanges.tsx b/src/components/exchange/requests/view/cards/ListRequestChanges.tsx new file mode 100644 index 00000000..1d5eb8ad --- /dev/null +++ b/src/components/exchange/requests/view/cards/ListRequestChanges.tsx @@ -0,0 +1,64 @@ +import { ArrowRightIcon } from "@heroicons/react/24/outline" +import { Dispatch, SetStateAction } from "react" +import { DirectExchangeParticipant, ExchangeOption } from "../../../../../@types" +import { Checkbox } from "../../../../ui/checkbox" +import { Separator } from "../../../../ui/separator" + +type Props = { + option: ExchangeOption | DirectExchangeParticipant + selectedOptionsHook: [Map, Dispatch>>] + setSelectAll: Dispatch> + togglePreview: (selectedOptions: Map) => void + type: string + showChooseCheckbox?: boolean +} + +export const ListRequestChanges = ({ + option, + selectedOptionsHook, + setSelectAll, + togglePreview, + type, + showChooseCheckbox = true +}: Props) => { + const [selectedOptions, setSelectedOptions] = selectedOptionsHook; + + const handleOptionChange = (acronym: string) => { + selectedOptions.set(acronym, !selectedOptions.get(acronym)); + setSelectedOptions(new Map(selectedOptions)); + + const allSelected = Array.from(selectedOptions.values()).every((value) => value); + setSelectAll(allSelected); + + togglePreview(selectedOptions); + }; + + return <> +
+ +
+ {showChooseCheckbox && + handleOptionChange(option.course_info.acronym)} + /> + } + +
+
+ + +} diff --git a/src/components/exchange/requests/view/cards/MineRequestCard.tsx b/src/components/exchange/requests/view/cards/MineRequestCard.tsx new file mode 100644 index 00000000..21e6fb70 --- /dev/null +++ b/src/components/exchange/requests/view/cards/MineRequestCard.tsx @@ -0,0 +1,68 @@ +import { useContext, useState } from "react"; +import { MarketplaceRequest } from "../../../../../@types"; +import ExchangeRequestCommonContext from "../../../../../contexts/ExchangeRequestCommonContext"; +import SessionContext from "../../../../../contexts/SessionContext"; +import { Card, CardContent, CardFooter } from "../../../../ui/card" +import { CommonCardHeader } from "./CommonCardHeader"; +import { ListRequestChanges } from "./ListRequestChanges"; + +type Props = { + request: MarketplaceRequest +} + +export const MineRequestCard = ({ request }: Props) => { + const { open, setOpen, selectedOptions, setSelectedOptions, setSelectAll, togglePreview } = useContext(ExchangeRequestCommonContext); + const [hovered, setHovered] = useState(false); + + const { user } = useContext(SessionContext); + + return { setHovered(true) }} + onMouseLeave={() => { setHovered(false) }} + > + { }} + /> + + {open && ( + <> + {request.options.map((option) => ( + + ))} + + )} + + + {/*
*/} + {/*
*/} + {/* {!request.accepted && request.pending_motive === DirectExchangePendingMotive.USER_DID_NOT_ACCEPT && */} + {/* */} + {/* } */} + {/*
*/} + {/*
*/} + +
+ +
+} diff --git a/src/components/exchange/requests/view/cards/ReceivedRequestCard.tsx b/src/components/exchange/requests/view/cards/ReceivedRequestCard.tsx new file mode 100644 index 00000000..49c1459f --- /dev/null +++ b/src/components/exchange/requests/view/cards/ReceivedRequestCard.tsx @@ -0,0 +1,81 @@ +import { useContext, useEffect, useState } from "react"; +import { DirectExchangeRequest } from "../../../../../@types" +import ExchangeRequestCommonContext from "../../../../../contexts/ExchangeRequestCommonContext"; +import SessionContext from "../../../../../contexts/SessionContext"; +import { DirectExchangePendingMotive } from "../../../../../utils/exchange"; +import { Button } from "../../../../ui/button"; +import { Card, CardContent, CardFooter } from "../../../../ui/card"; +import { CommonCardHeader } from "./CommonCardHeader"; +import { ListRequestChanges } from "./ListRequestChanges"; + +type Props = { + request: DirectExchangeRequest +} + +export const ReceivedRequestCard = ({ + request +}: Props) => { + const { open, setOpen, selectedOptions, setSelectedOptions, setSelectAll, togglePreview } = useContext(ExchangeRequestCommonContext); + const [hovered, setHovered] = useState(false); + + const { user } = useContext(SessionContext); + + useEffect(() => { + if (request.type === "directexchange") request.options = request.options.filter((option) => option.participant_nmec === user?.username); + }, [request]); + + return <> + {request.type === "directexchange" && + { setHovered(true) }} + onMouseLeave={() => { setHovered(false) }} + > + { }} + classUserGoesToName="class_participant_goes_to" + /> + + {open && ( + <> + {request.options.map((option) => ( + + ))} + + )} + + +
+
+ {!request.accepted && request.pending_motive === DirectExchangePendingMotive.USER_DID_NOT_ACCEPT && + + } +
+
+ +
+
} + + + +} diff --git a/src/components/exchange/requests/view/cards/RequestCard.tsx b/src/components/exchange/requests/view/cards/RequestCard.tsx new file mode 100644 index 00000000..22def84d --- /dev/null +++ b/src/components/exchange/requests/view/cards/RequestCard.tsx @@ -0,0 +1,123 @@ +import { useContext, useState, useEffect } from "react"; +import { Button } from "../../../../ui/button"; +import { Card, CardContent, CardFooter } from "../../../../ui/card"; +import { Checkbox } from "../../../../ui/checkbox"; +import { Separator } from "../../../../ui/separator"; +import exchangeRequestService from "../../../../../api/services/exchangeRequestService"; +import { ListRequestChanges } from "./ListRequestChanges"; +import ExchangeRequestCommonContext from "../../../../../contexts/ExchangeRequestCommonContext"; +import { CommonCardHeader } from "./CommonCardHeader"; +import ConflictsContext from "../../../../../contexts/ConflictsContext"; +import { ExchangeOption } from "../../../../../@types"; + +export const RequestCard = () => { + const { + chosenRequest, hiddenRequests, request, open, setOpen, selectedOptions, setSelectedOptions, + selectAll, setSelectAll, hide, togglePreview + } = useContext(ExchangeRequestCommonContext); + const [hovered, setHovered] = useState(false); + + const { isConflictSevere } = useContext(ConflictsContext); + + useEffect(() => { + if (chosenRequest?.id !== request.id) { + setOpen(false); + } + }, [chosenRequest]); + + const handleSelectAll = () => { + setSelectAll(!selectAll); + + for (const key of selectedOptions.keys()) { + selectedOptions.set(key, !selectAll); + } + + const newSelectedOptions = new Map(selectedOptions); + setSelectedOptions(newSelectedOptions); + togglePreview(newSelectedOptions); + }; + + const submitExchange = async (e) => { + e.preventDefault(); + + const exchangeRequests = new Map(); + for (const option of request.options) { + if (selectedOptions.get(option.course_info.acronym)) { + exchangeRequests.set( + option.course_info.id, + { + courseUnitId: option.course_info.id, + courseUnitName: option.course_info.name, + classNameRequesterGoesFrom: (option as ExchangeOption).class_issuer_goes_from.name, + classNameRequesterGoesTo: (option as ExchangeOption).class_issuer_goes_to.name, + other_student: { + name: request.issuer_name, + mecNumber: request.issuer_nmec + } + } + ); + } + } + + await exchangeRequestService.submitExchangeRequest(exchangeRequests); + }; + + return <> + {request.type === "marketplaceexchange" && + setHovered(true)} + onMouseLeave={() => setHovered(false)} + key={request.id} + className={`shadow-md exchange-request-card ${hiddenRequests.has(request.id) ? "hidden" : ""}`} + > + + + + {request.options?.map((option) => ( + + ))} + + {open && } + +
+
+ + +
+
+ +
+
+
+
+ } + ; +} diff --git a/src/components/exchange/requests/view/cards/RequestCardClassBadge.tsx b/src/components/exchange/requests/view/cards/RequestCardClassBadge.tsx new file mode 100644 index 00000000..773afec4 --- /dev/null +++ b/src/components/exchange/requests/view/cards/RequestCardClassBadge.tsx @@ -0,0 +1,25 @@ +import { ExchangeOption } from "../../../../../@types"; +import { Badge } from "../../../../ui/badge"; + +type Props = { + option: ExchangeOption + requestCardHovered: boolean + classUserGoesToName: string +} + +const RequestCardClassBadge = ({ option, requestCardHovered, classUserGoesToName }: Props) => { + return
+ + {option.course_info?.acronym} + + + {classUserGoesToName} + +
+} + +export default RequestCardClassBadge; diff --git a/src/components/exchange/schedule/ExchangeSchedule.tsx b/src/components/exchange/schedule/ExchangeSchedule.tsx new file mode 100644 index 00000000..91e07299 --- /dev/null +++ b/src/components/exchange/schedule/ExchangeSchedule.tsx @@ -0,0 +1,62 @@ +import { useContext, useEffect, useState } from "react"; +import { ClassDescriptor, SlotInfo } from "../../../@types"; +import ScheduleContext from "../../../contexts/ScheduleContext"; +import { Schedule } from "../../planner"; + +const ExchangeSchedule = () => { + const { exchangeSchedule } = useContext(ScheduleContext); + const [slots, setSlots] = useState([]); + const [classes, setClasses] = useState([]); + + console.log("Exchange schedule: ", exchangeSchedule); + console.log("SLOTS: ", slots); + console.log("Classes: ", classes); + + useEffect(() => { + if (!exchangeSchedule) return; + + const groupedClasses: Record = {}; + exchangeSchedule.forEach((currentClass: ClassDescriptor) => { + const courseUnitId = currentClass.courseInfo.id; + if (!groupedClasses[courseUnitId]) { + groupedClasses[courseUnitId] = []; + } + + groupedClasses[courseUnitId].push(currentClass); + }); + + const combinedClasses: ClassDescriptor[] = Object.values(groupedClasses).map((group) => { + const combinedClass: ClassDescriptor = { + ...group[0], + classInfo: { + ...group[0].classInfo, + name: getCombinedClassName(group), + slots: group.flatMap((cls) => cls.classInfo.slots), + }, + }; + return combinedClass; + }); + + setClasses(combinedClasses); + setSlots(combinedClasses.map((newClass) => newClass.classInfo.slots).flat()); + }, [exchangeSchedule]); + + + const getCombinedClassName = (classes: ClassDescriptor[]): string => { + + const praticalClass = classes.find((cls) => + cls.classInfo.slots.some(slot => slot.lesson_type === "TP" || slot.lesson_type === "PL") + ); + + + if (praticalClass) return praticalClass.classInfo.name; + return classes[0].classInfo.name; + }; + + return ; +} + +export default ExchangeSchedule; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 98702132..1fd12d78 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,16 +1,21 @@ import { Link } from 'react-router-dom' import { Disclosure } from '@headlessui/react' import { DarkModeSwitch } from './DarkModeSwitch' - import { Bars3Icon, XMarkIcon, AtSymbolIcon, RectangleStackIcon, QuestionMarkCircleIcon, + ArrowsRightLeftIcon, + WrenchScrewdriverIcon } from '@heroicons/react/24/outline' import { LogoNIAEFEUPImage } from '../../images' import { getPath, config } from '../../utils' +import SessionContext from '../../contexts/SessionContext' +import { useContext } from 'react' +import { LoginButton } from '../auth/LoginButton' +import { HeaderProfileDropdown } from '../auth/HeaderProfileDropdown' const navigation = [ { @@ -18,6 +23,11 @@ const navigation = [ location: getPath(config.paths.planner), icon: , wip: false, + },{ + title: 'Trocas', + location: getPath(config.paths.exchange), + icon: , + wip: true, }, { title: 'Sobre', location: getPath(config.paths.about), icon: , wip: false }, { @@ -26,6 +36,12 @@ const navigation = [ icon: , wip: false, }, + { + title: 'Admin', + location: getPath(config.paths.admin), + icon: , + wip: false, + }, ] type Props = { @@ -34,6 +50,8 @@ type Props = { } const Header = ({ siteTitle, location }: Props) => { + const { signedIn, user } = useContext(SessionContext); + return ( {
{navigation - .filter((link) => !link.wip) + .filter((link) => (!link.wip || (link.wip && (import.meta.env.VITE_APP_PROD === '0' || import.meta.env.VITE_APP_STAGING === '1')))) + .filter((link) => link.title !== 'Admin' || (signedIn && user?.is_admin)) .map((link, index) => (
-
- +
+ {signedIn ? + + : + }
- + ) }} -
+ ) } @@ -150,28 +172,35 @@ type MobileProps = { location: string } -const Mobile = ({ location }: MobileProps) => ( - - {navigation - .filter((link) => !link.wip) - .map((link, index) => ( - - - - ))} - -) + > + + {link.icon} + {link.title} + + {location === link.title && ( + + )} + + + ))} + + ); +}; + diff --git a/src/components/planner/Schedule.tsx b/src/components/planner/Schedule.tsx index df925089..f36df2df 100644 --- a/src/components/planner/Schedule.tsx +++ b/src/components/planner/Schedule.tsx @@ -1,14 +1,11 @@ import '../../styles/schedule.css' import classNames from 'classnames' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { ScheduleGrid, } from './schedules' import ToggleScheduleGrid from './schedule/ToggleScheduleGrid' import PrintSchedule from './schedule/PrintSchedule' -import { useContext } from 'react' import ScheduleTypes from './ScheduleType' import { ClassDescriptor, SlotInfo } from '../../@types' -import CourseContext from '../../contexts/CourseContext' -import MultipleOptionsContext from '../../contexts/MultipleOptionsContext' import { useShowGrid } from '../../hooks' import { maxHour, minHour, convertWeekdayLong, convertHour } from '../../utils' import SlotBoxes from './schedules/SlotBoxes' @@ -16,36 +13,16 @@ import SlotBoxes from './schedules/SlotBoxes' const dayValues = Array.from({ length: 6 }, (_, i) => i) const hourValues = Array.from({ length: maxHour - minHour + 1 }, (_, i) => minHour + i) -const Schedule = () => { - const { pickedCourses } = useContext(CourseContext) - const { multipleOptions, selectedOption } = useContext(MultipleOptionsContext) - - const [classes, setClasses] = useState([]) - const [slots, setSlots] = useState([]) - const scheduleRef = useRef(null) - - useEffect(() => { - //TODO: Improvements by functional programming - const newClasses = [] - const option = multipleOptions[selectedOption] - - for (let i = 0; i < option.course_options.length; i++) { - const course_info = pickedCourses.find((course) => course.id === option.course_options[i].course_id) - if (!course_info) continue; - const class_info = course_info.classes?.find( - (class_info) => class_info.id === option.course_options[i].picked_class_id - ) - - if (course_info === undefined || class_info === undefined) continue - newClasses.push({ - courseInfo: course_info, - classInfo: class_info, - }) - } +type Props = { + classes: Array, + slots: Array +} - setClasses(newClasses) - setSlots(newClasses.map((newClass) => newClass.classInfo.slots).flat()) - }, [multipleOptions, pickedCourses, selectedOption]) +const Schedule = ({ + classes, + slots +}: Props) => { + const scheduleRef = useRef(null); // TODO: Improvements by functional programming const slotTypes: string[] = useMemo(() => { diff --git a/src/components/planner/schedule/PlannerSchedule.tsx b/src/components/planner/schedule/PlannerSchedule.tsx new file mode 100644 index 00000000..20fe72e0 --- /dev/null +++ b/src/components/planner/schedule/PlannerSchedule.tsx @@ -0,0 +1,43 @@ +import { useContext, useEffect, useState } from "react"; +import { ClassDescriptor, SlotInfo } from "../../../@types"; +import CourseContext from "../../../contexts/CourseContext"; +import MultipleOptionsContext from "../../../contexts/MultipleOptionsContext"; +import Schedule from "../Schedule"; + +const PlannerSchedule = () => { + const { pickedCourses } = useContext(CourseContext); + const { multipleOptions, selectedOption } = useContext(MultipleOptionsContext); + const [classes, setClasses] = useState([]); + const [slots, setSlots] = useState([]); + + useEffect(() => { + //TODO: Improvements by functional programming + const newClasses = [] + const option = multipleOptions[selectedOption] + + for (let i = 0; i < option.course_options.length; i++) { + const course_info = pickedCourses.find((course) => course.id === option.course_options[i].course_id) + if (!course_info) continue; + const class_info = course_info.classes?.find( + (class_info) => class_info.id === option.course_options[i].picked_class_id + ) + + if (course_info === undefined || class_info === undefined) continue + newClasses.push({ + courseInfo: course_info, + classInfo: class_info, + }) + } + + setClasses(newClasses) + setSlots(newClasses.map((newClass) => newClass.classInfo.slots).flat()) + }, [multipleOptions, pickedCourses, selectedOption]) + + + return ; +} + +export default PlannerSchedule; diff --git a/src/components/planner/schedules/LessonBox.tsx b/src/components/planner/schedules/LessonBox.tsx index b447b9af..fdafefcb 100644 --- a/src/components/planner/schedules/LessonBox.tsx +++ b/src/components/planner/schedules/LessonBox.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames' -import { useState, useEffect } from 'react' +import { useContext, useState, useEffect } from 'react' +import ConflictsContext from '../../../contexts/ConflictsContext' import LessonPopover from './LessonPopover' import ConflictsPopover from './ConflictsPopover' import { CourseInfo, ClassInfo, SlotInfo, ClassDescriptor, ConflictInfo } from '../../../@types' @@ -40,6 +41,7 @@ const LessonBox = ({ const [isHovered, setIsHovered] = useState(false) const [conflict, setConflict] = useState(conflicts[slotInfo.id]); const hasConflict = conflict?.conflictingClasses?.length > 1; + const { setConflictSeverity } = useContext(ConflictsContext); // Needs to change the entry with the id of this lesson to contain the correct ConflictInfo when the classes change useEffect(() => { @@ -72,6 +74,10 @@ const LessonBox = ({ setConflict(newConflictInfo); }, [classInfo, classes]); + useEffect(() => { + setConflictSeverity(conflict?.severe); + }, [hasConflict]); + const showConflicts = () => { setConflictsShown(true) } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 00000000..0a6328d1 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "../../utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 00000000..3ae014b0 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../../utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border border-slate-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 dark:border-slate-800 dark:focus:ring-slate-300", + { + variants: { + variant: { + default: + "border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/80", + secondary: + "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80", + destructive: + "border-transparent bg-red-500 text-slate-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/80", + outline: "text-slate-950 dark:text-slate-50", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { } + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 00000000..fd7b0edf --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "../../utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 00000000..8d00b3e0 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "../../utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 00000000..f88e58da --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "../../utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/config/local.json b/src/config/local.json index 91deae56..40b8969e 100644 --- a/src/config/local.json +++ b/src/config/local.json @@ -8,7 +8,7 @@ "faqs": "faqs", "notfound": "*", "home": "home", - "test": "test" + "admin": "admin" }, "api": { "port": 8100, diff --git a/src/config/prod.json b/src/config/prod.json index 6e373f9a..3bec49d1 100644 --- a/src/config/prod.json +++ b/src/config/prod.json @@ -7,7 +7,8 @@ "exchange": "exchange", "faqs": "faqs", "notfound": "*", - "home": "home" + "home": "home", + "admin": "admin" }, "api": { "port": 443, diff --git a/src/contexts/CombinedProvider.tsx b/src/contexts/CombinedProvider.tsx index 2624daa9..8e85df2c 100644 --- a/src/contexts/CombinedProvider.tsx +++ b/src/contexts/CombinedProvider.tsx @@ -1,19 +1,40 @@ -import { useState } from "react"; -import { MultipleOptions } from "../@types"; +import { useEffect, useState } from "react"; +import { CourseInfo, Major, MultipleOptions } from "../@types"; import StorageAPI from "../api/storage"; import MultipleOptionsContext from "./MultipleOptionsContext"; import { ThemeContext } from "./ThemeContext"; import { useDarkMode } from "../hooks"; +import useSession from "../hooks/useSession"; +import SessionContext from "./SessionContext"; +import MajorContext from "./MajorContext"; +import CourseContext from "./CourseContext"; +import ConflictsContext from "./ConflictsContext"; type Props = { children: React.JSX.Element } const CombinedProvider = ({ children }: Props) => { + const [majors, setMajors] = useState([]) + const [coursesInfo, setCoursesInfo] = useState([]); + const [pickedCourses, setPickedCourses] = useState(StorageAPI.getPickedCoursesStorage()); + const [checkboxedCourses, setCheckboxedCourses] = useState(StorageAPI.getPickedCoursesStorage()); + const [ucsModalOpen, setUcsModalOpen] = useState(false); + + //TODO: Looks suspicious + const [choosingNewCourse, setChoosingNewCourse] = useState(false); + const [enabled, setEnabled] = useDarkMode() // TODO (Process-ing): Stop using a hook (who smoked here?) const [multipleOptions, setMultipleOptionsState] = useState(StorageAPI.getMultipleOptionsStorage()); const [selectedOption, setSelectedOptionState] = useState(StorageAPI.getSelectedOptionStorage()); + const { signedIn: userSignedIn, user, isLoading: isSessionLoading } = useSession(); + const [signedIn, setSignedIn] = useState(Boolean(localStorage.getItem("signedIn") ?? false)); + useEffect(() => { + setSignedIn(userSignedIn); + }, [userSignedIn]); + + const [isConflictSevere, setConflictSeverity] = useState(false); const setMultipleOptions = (newMultipleOptions: MultipleOptions | ((prevMultipleOptions: MultipleOptions) => MultipleOptions)) => { if (newMultipleOptions instanceof Function) @@ -32,11 +53,27 @@ const CombinedProvider = ({ children }: Props) => { } return ( - - - {children} - - + + + + + + + {children} + + + + + + ); }; diff --git a/src/contexts/ConflictsContext.tsx b/src/contexts/ConflictsContext.tsx new file mode 100644 index 00000000..297c0b03 --- /dev/null +++ b/src/contexts/ConflictsContext.tsx @@ -0,0 +1,13 @@ +import { Context, Dispatch, createContext, SetStateAction } from "react"; + +interface ConflictsContextType { + isConflictSevere: boolean; + setConflictSeverity: Dispatch>; +} + +const ConflictsContext: Context = createContext({ + isConflictSevere: false, + setConflictSeverity: () => { }, +}) + +export default ConflictsContext \ No newline at end of file diff --git a/src/contexts/ExchangeRequestCommonContext.tsx b/src/contexts/ExchangeRequestCommonContext.tsx new file mode 100644 index 00000000..0b38234d --- /dev/null +++ b/src/contexts/ExchangeRequestCommonContext.tsx @@ -0,0 +1,42 @@ +import { Context, Dispatch, SetStateAction } from 'react' +import { createContext } from 'react' +import { ClassDescriptor, DirectExchangeRequest, MarketplaceRequest } from '../@types' + +interface ExchangeRequestCommonContext { + request: MarketplaceRequest | DirectExchangeRequest + hiddenRequests: Set + setHiddenRequests: Dispatch>> + chosenRequest: MarketplaceRequest | DirectExchangeRequest | null + setChosenRequest: Dispatch> + exchangeSchedule: Array + selectAll: boolean + setSelectAll: Dispatch> + selectedOptions: Map + setSelectedOptions: Dispatch>> + open: boolean + setOpen: Dispatch> + togglePreview: (updatedOptions: Map) => void + hide: () => void + handleSelectAll: () => void +} + +const ExchangeRequestCommonContext: Context = createContext({ + request: null, + hiddenRequests: new Set(), + setHiddenRequests: () => { }, + chosenRequest: null, + setChosenRequest: () => { }, + exchangeSchedule: [], + selectAll: true, + setSelectAll: () => { }, + selectedOptions: new Map(), + setSelectedOptions: () => { }, + open: false, + setOpen: () => { }, + togglePreview: () => { }, + hide: () => { }, + handleSelectAll: () => { } +}); + +export default ExchangeRequestCommonContext; + diff --git a/src/contexts/PreviewContext.tsx b/src/contexts/PreviewContext.tsx new file mode 100644 index 00000000..bc941aa1 --- /dev/null +++ b/src/contexts/PreviewContext.tsx @@ -0,0 +1,9 @@ +export const previewMap: Record = {}; + +export const setPreview = (id: number, cur: string, next: string) => { + previewMap[id] = { cur, next }; +}; + +export const getPreview = (id: number) => previewMap[id]; + +export const getAllPreviews = () => previewMap; diff --git a/src/contexts/ScheduleContext.tsx b/src/contexts/ScheduleContext.tsx new file mode 100644 index 00000000..7b476136 --- /dev/null +++ b/src/contexts/ScheduleContext.tsx @@ -0,0 +1,22 @@ +import { Context, Dispatch, SetStateAction } from 'react' +import { createContext } from 'react' +import { ClassDescriptor, CourseInfo } from '../@types' + +interface ScheduleContent { + originalExchangeSchedule: Array + exchangeSchedule: Array + setExchangeSchedule: Dispatch>> + enrolledCourseUnits: Array + loadingSchedule: boolean +} + +const ScheduleContext: Context = createContext({ + originalExchangeSchedule: [], + exchangeSchedule: [], + loadingSchedule: false, + setExchangeSchedule: () => { }, + enrolledCourseUnits: [] +}); + +export default ScheduleContext; + diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx new file mode 100644 index 00000000..b8da9142 --- /dev/null +++ b/src/contexts/SessionContext.tsx @@ -0,0 +1,17 @@ +import { Context, createContext, Dispatch, SetStateAction } from 'react' + +interface SessionContextContent { + signedIn: boolean + user: any + isSessionLoading: boolean + setSignedIn: Dispatch> +} + +const SessionContext: Context = createContext({ + signedIn: false, + user: null, + isSessionLoading: false, + setSignedIn: () => { } +}); + +export default SessionContext diff --git a/src/hooks/index.ts b/src/hooks/index.ts index cc82d9b3..b2954263 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ import useDarkMode from './useDarkMode' import useShowGrid from './useShowGrid' +import useSession from './useSession' -export { useDarkMode, useShowGrid } +export { useDarkMode, useShowGrid, useSession } diff --git a/src/hooks/useAdminExchanges.tsx b/src/hooks/useAdminExchanges.tsx new file mode 100644 index 00000000..ba1dd3f1 --- /dev/null +++ b/src/hooks/useAdminExchanges.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import api from "../api/backend"; +import useSWR from "swr"; + +export default () => { + const getExchanges = async () => { + try { + const res = await fetch(`${api.BACKEND_URL}/exchange/direct/`, { + credentials: "include" + }); + + if(res.ok) { + return await res.json(); + } + } catch (error) { + console.error(error); + } + }; + + const { data, error, mutate } = useSWR("admin-exchanges", getExchanges); + const exchanges = useMemo(() => data ? data : null, [data]); + + return { + exchanges, + error, + loading: !data, + mutate, + }; +}; + + diff --git a/src/hooks/useCourseUnitClasses.tsx b/src/hooks/useCourseUnitClasses.tsx new file mode 100644 index 00000000..d5a3fb43 --- /dev/null +++ b/src/hooks/useCourseUnitClasses.tsx @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import api from "../api/backend"; +import useSWR from "swr"; + +export default (courseId: number) => { + const getClasses = async () => { + try { + const res = await fetch(`${api.BACKEND_URL}/class/${courseId}/`, { + credentials: "include" + }); + + if (!res.ok) return []; + return await res.json(); + } catch (e) { + console.error(e); + return []; + } + + } + + const { data, error, mutate, isValidating } = useSWR(`classes-of-${courseId}`, getClasses, {}); + + const classes = useMemo(() => data ? data : null, [data]); + + return { + classes, + error, + loading: !data, + isValidating, + mutate, + }; +}; + diff --git a/src/hooks/useCourseUnits.ts b/src/hooks/useCourseUnits.tsx similarity index 64% rename from src/hooks/useCourseUnits.ts rename to src/hooks/useCourseUnits.tsx index 517d2594..b02e059b 100644 --- a/src/hooks/useCourseUnits.ts +++ b/src/hooks/useCourseUnits.tsx @@ -3,18 +3,18 @@ import api from "../api/backend"; import useSWR from "swr"; export default (courseId: number | null) => { - const getCourseUnit = async (id) => { + const getCourseUnit = async () => { try { - if (courseId) return await api.getCoursesByMajorId(Number(id)); + if (courseId) return await api.getCoursesByMajorId(Number(courseId)); } catch (error) { console.error(error); } }; - const { data, error, mutate } = useSWR(String(courseId), getCourseUnit, { - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false + const { data, error, mutate } = useSWR(`course-units-of-${String(courseId)}`, getCourseUnit, { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false }); const courseUnits = useMemo(() => data ? data : null, [data]); diff --git a/src/hooks/useEligibleExchange.tsx b/src/hooks/useEligibleExchange.tsx new file mode 100644 index 00000000..734d322a --- /dev/null +++ b/src/hooks/useEligibleExchange.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import api from "../api/backend"; +import useSWR from "swr"; + +export default () => { + const isEligible = async () => { + try { + const res = await fetch(`${api.BACKEND_URL}/student/exchange/eligible`, { + credentials: "include", + }); + + return await res.json(); + } catch (error) { + console.error(error); + } + }; + + const { data, error, mutate, isLoading } = useSWR("", isEligible); + const courseUnits = useMemo(() => data ? data : null, [data]); + + return { + courseUnits, + error, + loading: isLoading, + mutate, + }; +}; + diff --git a/src/hooks/useMarketplaceRequests.tsx b/src/hooks/useMarketplaceRequests.tsx new file mode 100644 index 00000000..e20eef2b --- /dev/null +++ b/src/hooks/useMarketplaceRequests.tsx @@ -0,0 +1,48 @@ +import useSWRInfinite from "swr/infinite"; +import { MarketplaceRequest } from "../@types"; +import api from "../api/backend"; +import exchangeRequestService from "../api/services/exchangeRequestService"; + +const getUrl = (requestType: string) => { + switch (requestType) { + case "all": + return `exchange/marketplace`; + case "mine": + return `student/exchange/sent`; + case "received": + return `student/exchange/received`; + } +} + +/** + * Hook to get the marketplace requests + * @param courseUnitNameFilter - Set of course unit names to filter the requests by + * @param typeFilter - Type of request + * @param classesFilter - Set of dest classes to filter (hash of class-id,class_name_1,...,class_name_n) + * +*/ +export default (courseUnitNameFilter: Set, requestType: string, classesFilter: Map>) => { + const classesFilterArray = Array.from(classesFilter, ([key, value]) => [key, Array.from(value)]); + const classesFilterBase64 = btoa(JSON.stringify(classesFilterArray)); + const filters = `courseUnitNameFilter=${Array.from(courseUnitNameFilter).join(",")}&classesFilter=${classesFilterBase64}`; + + const getKey = (pageIndex: number) => { + if (pageIndex === 0) return `${api.BACKEND_URL}/${getUrl(requestType)}/?limit=10&${filters}`; + + return `${api.BACKEND_URL}/exchange/marketplace/?page=${pageIndex + 1}&limit=10&${filters}`; + } + + const { data, size, setSize, isLoading, isValidating } = useSWRInfinite>( + getKey, + exchangeRequestService.retrieveMarketplaceRequest + ); + + return { + data, + isLoading, + size, + isValidating, + setSize + } +}; + diff --git a/src/hooks/useReceivedRequests.tsx b/src/hooks/useReceivedRequests.tsx new file mode 100644 index 00000000..54d837c2 --- /dev/null +++ b/src/hooks/useReceivedRequests.tsx @@ -0,0 +1,26 @@ +import useSWRInfinite from "swr/infinite"; +import { MarketplaceRequest } from "../@types"; +import api from "../api/backend"; +import exchangeRequestService from "../api/services/exchangeRequestService"; + +export default () => { + const getKey = (pageIndex: number) => { + if (pageIndex === 0) return `${api.BACKEND_URL}/student/exchange/received/?limit=10`; + + return `${api.BACKEND_URL}/student/exchange/received/?page=${pageIndex + 1}&limit=10`; + } + + const { data, size, setSize, isLoading, isValidating } = useSWRInfinite>( + getKey, + exchangeRequestService.retrieveMarketplaceRequest + ); + + return { + data, + isLoading, + size, + isValidating, + setSize + } +}; + diff --git a/src/hooks/useRequestCardCourseMetadata.tsx b/src/hooks/useRequestCardCourseMetadata.tsx new file mode 100644 index 00000000..c5371b9b --- /dev/null +++ b/src/hooks/useRequestCardCourseMetadata.tsx @@ -0,0 +1,20 @@ +import useSWR from "swr"; +import { CourseInfo } from "../@types"; +import exchangeRequestService from "../api/services/exchangeRequestService"; + +/* + * This hook contains logic to retrieve the students and classes of a course info to be used to populate the information + * on the request cards +*/ +export default (courseInfo: CourseInfo) => { + const { data, isLoading } = useSWR( + `${courseInfo.id}`, + exchangeRequestService.retrieveRequestCardMetadata + ); + + return { + data, + isLoading, + } +}; + diff --git a/src/hooks/useSchedule.tsx b/src/hooks/useSchedule.tsx new file mode 100644 index 00000000..136f83cc --- /dev/null +++ b/src/hooks/useSchedule.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import api from "../api/backend"; +import useSWR from "swr"; + +export default () => { + const getSchedule = async () => { + try { + const res = await fetch(`${api.BACKEND_URL}/student/schedule`, { + credentials: "include" + }); + + if (res.ok) { + return await res.json(); + } + + return []; + } catch (e) { + console.error(e); + return []; + } + + } + + const { data, error, mutate, isValidating } = useSWR("schedule", getSchedule, {}); + const schedule = useMemo(() => data ? data.schedule : null, [data]); + const sigarraSynced = data ? data.noChanges : null; + + return { + schedule, + sigarraSynced, + error, + loading: !data, + isValidating, + mutate, + }; +}; + diff --git a/src/hooks/useSentRequests.tsx b/src/hooks/useSentRequests.tsx new file mode 100644 index 00000000..36189d9a --- /dev/null +++ b/src/hooks/useSentRequests.tsx @@ -0,0 +1,26 @@ +import useSWRInfinite from "swr/infinite"; +import { MarketplaceRequest } from "../@types"; +import api from "../api/backend"; +import exchangeRequestService from "../api/services/exchangeRequestService"; + +export default () => { + const getKey = (pageIndex: number) => { + if (pageIndex === 0) return `${api.BACKEND_URL}/student/exchange/sent/?limit=10`; + + return `${api.BACKEND_URL}/student/exchange/sent/?page=${pageIndex + 1}&limit=10`; + } + + const { data, size, setSize, isLoading, isValidating } = useSWRInfinite>( + getKey, + exchangeRequestService.retrieveMarketplaceRequest + ); + + return { + data, + isLoading, + size, + isValidating, + setSize + } +}; + diff --git a/src/hooks/useSession.tsx b/src/hooks/useSession.tsx new file mode 100644 index 00000000..fa4fc43d --- /dev/null +++ b/src/hooks/useSession.tsx @@ -0,0 +1,33 @@ +import useSwr from "swr"; +import api from "../api/backend"; + +const useSession = () => { + + const trySession = async (key) => { + try { + const res = await fetch(`${api.BACKEND_URL}/${key}`, { + method: "GET", + }); + + return await res.json(); + } catch (e) { + console.error(e); + } + } + + const { data, isLoading } = useSwr("auth/info/", trySession, { + refreshInterval: 3600000000, + }); + + if (data) { + localStorage.setItem("signedIn", (true).toString()); + } + + return { + signedIn: data ? data.signed : false, + user: data ? data : null, + isLoading + } +} + +export default useSession; diff --git a/src/hooks/useStudentCourseUnits.tsx b/src/hooks/useStudentCourseUnits.tsx new file mode 100644 index 00000000..78ddd1e1 --- /dev/null +++ b/src/hooks/useStudentCourseUnits.tsx @@ -0,0 +1,34 @@ +import { useMemo } from "react"; +import api from "../api/backend"; +import useSWR from "swr"; + +export default () => { + const getEligibileCourseUnits = async () => { + try { + const res = await fetch(`${api.BACKEND_URL}/student/course_units/eligible`, { + credentials: "include" + }); + + if (res.ok) { + return await res.json(); + } + + return []; + } catch (e) { + console.error(e); + return []; + } + + } + + const { data, error, mutate, isValidating } = useSWR("eligibleCourseUnits", getEligibileCourseUnits, {}); + const enrolledCourseUnits = useMemo(() => data ? data : null, [data]); + + return { + enrolledCourseUnits, + error, + loading: !data, + isValidating, + mutate, + }; +}; diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 00000000..eab09575 --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,33 @@ +import { BrowserRouter, Route, Router } from "react-router-dom"; +import { AdminMainContent } from "../components/admin/AdminMainContent"; +import { AdminSidebar } from "../components/admin/AdminSidebar"; +import { AdminSettings } from "../components/admin/AdminSettings"; +import SessionContext from '../contexts/SessionContext' +import { useContext } from "react"; + + +type Props = { + page: string; +} + +const AdminPage = ({ page }: Props) => { + const {signedIn, user} = useContext(SessionContext); + + if (!signedIn || user?.role !== 'admin') { + return
Access Denied. You do not have admin credentials.
; + } + + return ( +
+
+ +
+
+ {page === "pedidos" && } + {page === "settings" && } +
+
+ ) +} + +export default AdminPage; \ No newline at end of file diff --git a/src/pages/Exchange.tsx b/src/pages/Exchange.tsx new file mode 100644 index 00000000..20db69bf --- /dev/null +++ b/src/pages/Exchange.tsx @@ -0,0 +1,75 @@ +import { useContext, useEffect, useState } from "react"; +import { ClassDescriptor } from "../@types"; +import { LoginButton } from "../components/auth/LoginButton"; +import { ExchangeSidebar } from "../components/exchange/ExchangeSidebar"; +import ExchangeSchedule from "../components/exchange/schedule/ExchangeSchedule"; +import ScheduleContext from "../contexts/ScheduleContext"; +import SessionContext from "../contexts/SessionContext"; +import useSchedule from "../hooks/useSchedule"; +import useStudentCourseUnits from "../hooks/useStudentCourseUnits"; +import '../styles/exchange.css'; + +const ExchangeGuard = ({ children }: { children: React.ReactNode }) => { + return ( +
+

+ Trocas de Turmas +

+ {children} +
+ ); +} + +const ExchangePage = () => { + const [loads, setLoads] = useState(-1); + const { schedule, loading: loadingSchedule } = useSchedule(); + const [originalExchangeSchedule, setOriginalExchangeSchedule] = useState>([]); + const [exchangeSchedule, setExchangeSchedule] = useState>([]); + const { signedIn, user } = useContext(SessionContext); + const { enrolledCourseUnits } = useStudentCourseUnits(); + + useEffect(() => { + setLoads(prev => prev + 1); + }, [setLoads]); + + useEffect(() => { + setExchangeSchedule(schedule ? schedule : []); + + if (loads <= 0) { + setOriginalExchangeSchedule(schedule ? schedule : []); + } + }, [schedule]); + + return <> + {signedIn ? + + { + user?.eligible_exchange ? +
+ {/* Schedule Preview */} +
+
+ +
+
+ + +
+ : +

Não tens nenhuma inscrição numa disciplina com um período de trocas ativo.

+
+ } +
+ : +

+ Tens de iniciar sessão para acederes a esta funcionalidade. +

+
+ +
+ +
} + +} + +export default ExchangePage; diff --git a/src/pages/TimeTableSelector.tsx b/src/pages/TimeTableSelector.tsx index 9eb6f343..b50f81b2 100644 --- a/src/pages/TimeTableSelector.tsx +++ b/src/pages/TimeTableSelector.tsx @@ -1,20 +1,14 @@ import BackendAPI from '../api/backend' -import StorageAPI from '../api/storage' -import { useState, useEffect } from 'react' -import { Schedule, Sidebar } from '../components/planner' -import { CourseInfo, Major } from '../@types' +import { useEffect, useContext } from 'react' +import { Sidebar } from '../components/planner' +import { Major } from '../@types' import MajorContext from '../contexts/MajorContext' -import CourseContext from '../contexts/CourseContext' +import PlannerSchedule from '../components/planner/schedule/PlannerSchedule' const TimeTableSelectorPage = () => { - const [majors, setMajors] = useState([]) - const [coursesInfo, setCoursesInfo] = useState([]); - const [pickedCourses, setPickedCourses] = useState(StorageAPI.getPickedCoursesStorage()); - const [checkboxedCourses, setCheckboxedCourses] = useState(StorageAPI.getPickedCoursesStorage()); - const [ucsModalOpen, setUcsModalOpen] = useState(false); + const {setMajors} = useContext(MajorContext); - //TODO: Looks suspicious - const [choosingNewCourse, setChoosingNewCourse] = useState(false); + // fetch majors when component is ready useEffect(() => { document.getElementById('layout').scrollIntoView() BackendAPI.getMajors().then((majors: Major[]) => { @@ -22,28 +16,16 @@ const TimeTableSelectorPage = () => { }) }, []) return ( - - -
- {/* Schedule Preview */} -
-
- -
-
- - +
+ {/* Schedule Preview */} +
+
+
- - +
+ + +
) } diff --git a/src/pages/index.ts b/src/pages/index.ts index 18c5896c..570705ea 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -2,5 +2,7 @@ import AboutPage from './About' import TimeTableSelectorPage from './TimeTableSelector' import NotFoundPage from './NotFound' import FaqsPage from './Faqs' +import ExchangePage from './Exchange' +import AdminPage from './Admin' -export { AboutPage, TimeTableSelectorPage, NotFoundPage, FaqsPage } +export { AboutPage, TimeTableSelectorPage, NotFoundPage, FaqsPage, ExchangePage, AdminPage } diff --git a/src/styles/exchange.css b/src/styles/exchange.css new file mode 100644 index 00000000..c9533b0f --- /dev/null +++ b/src/styles/exchange.css @@ -0,0 +1,3 @@ +.add-item-button { + @apply bg-white text-black border shadow-md mr-0 hover:bg-gray-50 flex flex-row gap-x-1; +} diff --git a/src/styles/schedule.css b/src/styles/schedule.css index 317d0c12..a6d7e9be 100644 --- a/src/styles/schedule.css +++ b/src/styles/schedule.css @@ -37,7 +37,7 @@ } .schedule-main-right { - @apply w-full h-full pt-0 xl:pt-1; + @apply w-full pt-0 xl:pt-1; } /* Schedule Grid */ @@ -53,6 +53,11 @@ @apply absolute top-0 w-full h-full; } +.exchange-request-card { + @apply dark:border dark:border-white dark:bg-gray-800; +} + + /* Columns */ .schedule-column { @apply absolute top-0 w-1/6 h-full; diff --git a/src/utils/exchange.ts b/src/utils/exchange.ts new file mode 100644 index 00000000..5019a824 --- /dev/null +++ b/src/utils/exchange.ts @@ -0,0 +1,27 @@ +import { CreateRequestData } from "../@types"; + +const exchangeIsValid = (exchange: Array) => { + for (const request of exchange) { + if (!request.classNameRequesterGoesFrom || !request.classNameRequesterGoesTo) return false; + } + + return true; +} + +const isDirectExchange = (exchanges: Array) => { + return exchanges.every((exchange) => exchange.other_student); +} + +export enum DirectExchangePendingMotive { + USER_DID_NOT_ACCEPT = 1, + OTHERS_DID_NOT_ACCEPT = 2 +} + +const exchangeUtils = { + exchangeIsValid, + isDirectExchange, +} + +export default exchangeUtils; + + diff --git a/vite.config.ts b/vite.config.ts index 99942203..931d51a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,10 +14,14 @@ export default defineConfig({ project: "tts" })], server: { + host: '0.0.0.0', + hmr: { + host: 'tts-dev.niaefeup.pt', + }, port: 3100, }, build: { outDir: 'build', sourcemap: true } -}) \ No newline at end of file +})