diff --git a/Frontend/.env.example b/Frontend/.env.example index 4b439814..19839264 100644 --- a/Frontend/.env.example +++ b/Frontend/.env.example @@ -1,4 +1,4 @@ -NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -NUXT_CLERK_SECRET_KEY= +NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY= #Reach out to admin for the Secret Key +NUXT_CLERK_SECRET_KEY= #Reach out to admin for the Secret Key NEST_API_URL=http://localhost:8080/ -ALLOWED_EMAIL_DOMAINS=example.com,another-domain.edu \ No newline at end of file +ALLOWED_EMAIL_DOMAINS=fhstp.ac.at \ No newline at end of file diff --git a/Frontend/README.md b/Frontend/README.md index f030688f..dba74f0d 100644 --- a/Frontend/README.md +++ b/Frontend/README.md @@ -1,35 +1,64 @@ +# Superwise Frontend + +This is the frontend application for the Superwise project, built with Nuxt 3, Vue 3, Pinia, and TailwindCSS. It provides a modern, responsive interface for students and supervisors to manage supervision requests, profiles, and onboarding. + + ## Tech stack -- **Nuxt** framwork for Vue +- **Nuxt** framework for Vue - **Vitest** for testing -- **I8ln** for localisation -- **Pinia** for state managment +- **I18n** for localisation +- **Pinia** for state management - **DaisyUI** for css designs +## Features + +- **Authentication:** Secure sign-in and sign-out using Clerk. +- **Role-based Dashboards:** Separate dashboards for students and supervisors. +- **Supervision Requests:** Students can send, withdraw, and track supervision requests; supervisors can accept, reject, and manage them. +- **Profile Management:** Users can edit their profiles and manage tags/interests. +- **Onboarding:** Guided onboarding flows for both students and supervisors. +- **Responsive Design:** Optimized for both desktop and mobile devices. +- **Localization:** Multi-language support using Vue I18n. +- **State Management:** Uses Pinia for global state and store management. +- **API Integration:** Communicates with the backend via RESTful API endpoints. + +## Project Structure + +- `pages/` — Main application pages (student, supervisor, onboarding, etc.) +- `components/` — Reusable Vue components (cards, modals, navigation, etc.) +- `stores/` — Pinia stores for state management +- `plugins/` — Nuxt plugins (e.g., Clerk, FontAwesome) +- `middleware/` — Route guards and global middleware +- `server/` — Nuxt backend API routes and server logic +- `assets/` — Static assets (images, styles) +- `locales/` — Localization files + +## Getting Started + ## Installation and running -1. Clone the repository -2. Change into the frontend directory +1. **Clone the repository to local directory** +2. **ensure you are in the Frontend directory** ```bash -cd FeMatchMaker +cd Frontend ``` -3. Install dependencies: +3. **Install dependencies**: + ```bash npm install ``` -4. Run the application -```bash -npm run dev -``` -## Testing with PWA -The PWA module is not fully compataibile with hot module replacement due to caching and other "middle man" functionalities. As a result whenever testing with PWA run the following command to boot the applicaiton +4. **Environment Variables:** + +Copy the `.env.example` to an `.env` file in the root directory and ensure all the environment variables are set correctly. You can reach out to the admin for the secret keys. + +5. **Run the application** ```bash -npm run generate-preview +npm run dev ``` - ## Testing To Run all the test in the application, run the command ```bash diff --git a/Frontend/components/ActionCard/ActionCard.vue b/Frontend/components/ActionCard/ActionCard.vue index 7f09d371..d00925c3 100644 --- a/Frontend/components/ActionCard/ActionCard.vue +++ b/Frontend/components/ActionCard/ActionCard.vue @@ -2,52 +2,53 @@ import CustomButton from "../CustomButton/CustomButton.vue"; const props = defineProps({ - buttonText: { - type: String, - default: "Click Me", - }, - cardType: { - type: String, - default: 'ghost', - validator: (value) => !value || ["ghost", "primary"].includes(value), - }, - headerText: { - type: String, - default: '' - }, + buttonText: { + type: String, + default: "Click Me", + }, + cardType: { + type: String, + default: 'ghost', + validator: (value) => !value || ["ghost", "primary"].includes(value), + }, + headerText: { + type: String, + default: '' + }, }); const emit = defineEmits(['actionButtonClicked']); + function emitActionEvent() { emit('actionButtonClicked'); } \ No newline at end of file diff --git a/Frontend/components/AdminHeader/AdminHeader.vue b/Frontend/components/AdminHeader/AdminHeader.vue index 883e8532..2e1de32c 100644 --- a/Frontend/components/AdminHeader/AdminHeader.vue +++ b/Frontend/components/AdminHeader/AdminHeader.vue @@ -7,7 +7,7 @@ const router = useRouter(); const colorMode = useColorMode(); interface Props { - variant?: "default" | "upload" | "download" | "delete" | "text"; + variant?: "default" | "upload" | "download" | "delete" | "text" | "warning"; headerText: string; rightButton?: string; rightIcon?: string; @@ -25,6 +25,7 @@ const headerBG = computed(() => ({ 'bg-info': props.variant === 'upload', 'bg-success': props.variant === 'download', 'bg-error': props.variant === 'delete', + 'bg-warning': props.variant === 'warning', })); const colorText = computed(() => ({ @@ -32,6 +33,7 @@ const colorText = computed(() => ({ 'text-info-content': props.variant === 'upload', 'text-success-content': props.variant === 'download', 'text-error-content': props.variant === 'delete', + 'text-warning-content': props.variant === 'warning', })); const goBack = () => { diff --git a/Frontend/components/ConfirmationModal/ConfirmationModal.vue b/Frontend/components/ConfirmationModal/ConfirmationModal.vue index 758e69d6..8dc7cd6e 100644 --- a/Frontend/components/ConfirmationModal/ConfirmationModal.vue +++ b/Frontend/components/ConfirmationModal/ConfirmationModal.vue @@ -86,7 +86,7 @@ const handleConfirm = () => { {{ props.headline }}
- +
diff --git a/Frontend/components/Progress/Progress.vue b/Frontend/components/Progress/Progress.vue new file mode 100644 index 00000000..ec171091 --- /dev/null +++ b/Frontend/components/Progress/Progress.vue @@ -0,0 +1,31 @@ + + + + + \ No newline at end of file diff --git a/Frontend/components/SideDrawer/SideDrawer.vue b/Frontend/components/SideDrawer/SideDrawer.vue index 6ae31da2..3dafd3ca 100644 --- a/Frontend/components/SideDrawer/SideDrawer.vue +++ b/Frontend/components/SideDrawer/SideDrawer.vue @@ -1,12 +1,13 @@ - @@ -175,24 +192,72 @@ import { ref } from "vue"; import CustomButton from "~/components/CustomButton/CustomButton.vue"; import ProfileDescription from "~/components/ProfileViewComponents/ProfileDescription.vue"; +import { + HttpMethods, + supervisionRequestStatus, +} from "~/shared/enums/enums"; import SupervisorStatistics from "~/components/ProfileViewComponents/SupervisorStatistics.vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { UserRoles } from "#shared/enums/enums"; const { t } = useI18n(); const userStore = useUserStore(); +const supervisorStore = useSupervisorStore(); if (!userStore.user) { await userStore.refetchCurrentUser(); } const currentUser = userStore.user; const route = useRoute(); const routeParamUserId = route.params.id as string; + const isOwnProfile = computed(() => currentUser?.id === routeParamUserId); +const isAdmin = computed(() => { + return routeParamUser.value.role === UserRoles.ADMIN; +}); +const isSupervisor = computed(() => { + return routeParamUser.value.role === UserRoles.SUPERVISOR; +}); +const isStudent = computed(() => { + return routeParamUser.value.role === UserRoles.STUDENT; +}); +//hacky solution, begin with true to not show the button. IF no supervisor, turn it false +const hasSupervisor = ref(true) +const studentGotDismissed = ref(false) + +onMounted(async() => { + if (isStudent.value) { + await supervisorStore.getSupervisionRequests(); + const data = await $fetch( + `/api/supervision-requests/count/${routeParamUserId}`, + { + method: HttpMethods.GET, + query: { + request_state: supervisionRequestStatus.ACCEPTED, + }, + } + ); + if (data.request_count === 0) { + hasSupervisor.value = false; + } + console.log("requests", supervisorStore.supervisionRequests); + } +}); + + const { data, error, pending } = await useFetch( `/api/users/${ routeParamUserId }/with-relations` ); const routeParamUser = data; +const studentPendingRequest = computed(() => { + return supervisorStore.supervisionRequests?.find( + request => + request.student.user_id === routeParamUser.value.id && + request.request_state === supervisionRequestStatus.PENDING + + ); +}); + const toastData = ref({ visible: false, type: "success", @@ -202,6 +267,8 @@ const toastData = ref({ const modalInformation = ref({ visible: false, confirmButtonText: t("modal.confirm"), + cancelButtonText: t("generic.cancel"), + icon: '', headline: "", description: t("modal.supervisionInfo"), image: routeParamUser.value.profile_image || "", @@ -238,6 +305,36 @@ const askForConfirmation = () => { openModal(); }; +const manageAddAsSupervisee = () => { + modalInformation.value = { + visible: true, + confirmButtonText: t("modal.supervisee.accept"), + cancelButtonText: t("generic.cancel"), + headline: t("modal.supervisee.headline"), + icon: 'envelope', + description: t("modal.supervisee.description"), + image: routeParamUser.value.profile_image || "", + linkedComponentId: `profilesConfirmationModal-${ routeParamUserId }`, + }; + openModal(); +}; + +const addStudentAsSupervisee = async() => { + await $fetch("/api/supervision-requests", { + method: "POST", + body: { + supervisor_id: currentUser?.id, + student_email: routeParamUser.value.email, + }, + }); + toastData.value = { + visible: true, + type: "success", + message: t("toast.addedStudentAsSupervisee"), + }; + hasSupervisor.value = true; +} + const sendSupervisionRequest = async () => { closeModal(); try { @@ -290,6 +387,96 @@ const sendSupervisionRequest = async () => { } }; +const managePendingRequest = (() =>{ + console.log("Managing supervision request for user:", routeParamUser.value); + modalInformation.value = { + visible: true, + confirmButtonText: t("modal.handleRequest.accept"), + cancelButtonText: t("modal.handleRequest.dismiss"), + headline: t("modal.handleRequest.headline"), + icon: 'xmark', + description: t("modal.handleRequest.description", { + firstName: routeParamUser.value.first_name, + lastName: routeParamUser.value.last_name, + }), + image: routeParamUser.value.profile_image || "", + linkedComponentId: `profilesConfirmationModal-${ routeParamUserId }`, + }; + openModal(); +}); + +const acceptPendingRequest = async () => { + if (!studentPendingRequest.value) { + console.error("No pending request found for the student."); + return; + } + try { + await $fetch(`/api/supervision-requests/${ studentPendingRequest.value.id }`, { + method: HttpMethods.PATCH, + body: { + request_state: supervisionRequestStatus.ACCEPTED, + }, + }); + toastData.value = { + visible: true, + type: "success", + message: t("toast.acceptedStudentRequest"), + }; + hasSupervisor.value = true; + } catch (error) { + console.error("Error accepting pending request:", error); + } +} + +const dismissPendingRequest = async () => { + if (!studentPendingRequest.value) { + console.error("No pending request found for the student."); + return; + } + try { + await $fetch(`/api/supervision-requests/${ studentPendingRequest.value.id }`, { + method: HttpMethods.PATCH, + body: { + request_state: supervisionRequestStatus.REJECTED, + }, + }); + toastData.value = { + visible: true, + type: "error", + message: t("toast.withdrawnStudentRequest"), + }; + studentGotDismissed.value = true; + } catch (error) { + console.error("Error dismissing pending request:", error); + toastData.value = { + visible: true, + type: "error", + message: t("toast.somethingWentWrong"), + }; + } +} + +const handleModalAbort = () => { + if (isSupervisor.value){ + closeModal(); + } else if (isStudent.value) { + dismissPendingRequest(); + } +} + +const handleModalConfirm = () => { + if (isSupervisor.value) { + sendSupervisionRequest(); + } else if (isStudent.value) { + if (studentPendingRequest.value) { + acceptPendingRequest(); + } else { + addStudentAsSupervisee(); + } + } + closeModal(); +}; + const closeModal = () => { const modal = document.getElementById( modalInformation.value.linkedComponentId @@ -317,17 +504,6 @@ const emoji = computed(() => { } }); -const isAdmin = computed(() => { - return routeParamUser.value.role === UserRoles.ADMIN; -}); - -const isSupervisor = computed(() => { - return routeParamUser.value.role === UserRoles.SUPERVISOR; -}); - -const isStudent = computed(() => { - return routeParamUser.value.role === UserRoles.STUDENT; -}); const currentUserTags = ref([]); if (currentUser?.id) { diff --git a/Frontend/pages/student/dashboard.vue b/Frontend/pages/student/dashboard.vue index 66c58551..6627e1d3 100644 --- a/Frontend/pages/student/dashboard.vue +++ b/Frontend/pages/student/dashboard.vue @@ -10,6 +10,7 @@ +
@@ -18,6 +19,17 @@ class="w-full px-4 py-8 flex flex-col gap-8" > + + + + + +
+ + + { + console.log(userStore.user); + console.log(studentStore.studentProfile?.thesis_description); + + const elements = [ + userStore.user.first_name, + userStore.user.last_name, + userStore.user.email, + userStore.user.profile_image, + // TODO: this sucks super hard. Make sure this is loaded on the dashboard + studentStore.studentProfile?.thesis_description, + // userStore.user.thesis_description + ]; + + let result: number = 0; + const step: number = 100 / elements.length; + + elements.forEach((element) => { + if (element && element !== '') { + result += step; + } + }); + + return Math.round(result); +} + const isLoading = computed(() => { return supervisorStore.supervisors.length === 0 && acceptedSupervisionRequests.value.length === 0; }); @@ -195,9 +259,9 @@ const matches = computed(() => { const dashboardState = computed(() => studentStore.dashboardState); const supervisionRequestsSentByCurrentStudent = computed(() => - studentStore.supervisionRequestsSentByCurrentStudent.filter( - (request) => request.request_state === supervisionRequestStatus.PENDING - ) + studentStore.supervisionRequestsSentByCurrentStudent.filter( + (request) => request.request_state === supervisionRequestStatus.PENDING + ) ); const acceptedSupervisionRequests = computed(() => studentStore.acceptedSupervisionRequests); @@ -224,33 +288,33 @@ const warningModalId = computed(() => { }); onMounted(async () => { - if (!userStore.user) { - await userStore.refetchCurrentUser(); + if (!userStore.user) { + await userStore.refetchCurrentUser(); + } + try { + await Promise.all([ + studentStore.fetchSupervisionRequests(), + userStore.user ? Promise.resolve() : userStore.refetchCurrentUser() + ]); + if (acceptedSupervisionRequests.value.length > 1) { + const modal = document.getElementById( + warningModalId.value + ) as HTMLDialogElement; + if (modal) { + modal.showModal(); + } else { + console.error("Warning modal element not found:", warningModalId.value); + } } - try { - await Promise.all([ - studentStore.fetchSupervisionRequests(), - userStore.user ? Promise.resolve() : userStore.refetchCurrentUser() - ]); - if (acceptedSupervisionRequests.value.length > 1) { - const modal = document.getElementById( - warningModalId.value - ) as HTMLDialogElement; - if (modal) { - modal.showModal(); - } else { - console.error("Warning modal element not found:", warningModalId.value); - } - } - if (userStore.user) { - const res = (await getRecommendedSupervisors( - userStore.user.id - )) as SupervisorData[]; - supervisorStore.setSupervisors(res); - } - } catch (error) { - console.error("Error fetching supervision requests or user data:", error); + if (userStore.user) { + const res = (await getRecommendedSupervisors( + userStore.user.id + )) as SupervisorData[]; + supervisorStore.setSupervisors(res); } + } catch (error) { + console.error("Error fetching supervision requests or user data:", error); + } }); watch( diff --git a/Frontend/pages/supervisor/dashboard.vue b/Frontend/pages/supervisor/dashboard.vue index 9e73ec02..df3ac712 100644 --- a/Frontend/pages/supervisor/dashboard.vue +++ b/Frontend/pages/supervisor/dashboard.vue @@ -2,10 +2,11 @@ import { ref, watch } from "vue"; import { useUserStore } from "~/stores/useUserStore"; import type { UserData } from "#shared/types/userInterfaces"; -import type { SupervisorData, SupervisionRequestsData } from "#shared/types/supervisorInterfaces"; +import type { SupervisionRequestsData, SupervisorData } from "#shared/types/supervisorInterfaces"; import { useSupervisionRequests } from "~/composables/useSupervisionRequests"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import EmptyPagePlaceholder from "~/components/Placeholder/EmptyPagePlaceholder.vue"; +import Progress from "~/components/Progress/Progress.vue"; const authStore = useAuthStore() const { user } = storeToRefs(authStore) @@ -47,24 +48,48 @@ watch( const data = (await getSupervisorByUserId( current_user.value.id )) as SupervisorData; - supervisorStore.setSupervisors([data]); + supervisorStore.setSupervisors([ data ]); } }, { immediate: true } ); watch( - pendingRequests, - (newVal) => { - if (newVal) { - supervisorStore.setSupervisionRequests(newVal as SupervisionRequestsData[]); - } - }, - { immediate: true } + pendingRequests, + (newVal) => { + if (newVal) { + supervisorStore.setSupervisionRequests(newVal as SupervisionRequestsData[]); + } + }, + { immediate: true } ); const { t } = useI18n(); + +const getProgress: number = () => { + console.log(userStore.user); + + const elements = [ + userStore.user.first_name, + userStore.user.last_name, + userStore.user.email, + userStore.supervisorProfile.bio, + userStore.user.profile_image, + ]; + + let result: number = 0; + const step: number = 100 / elements.length; + + elements.forEach((element) => { + if (element && element !== '') { + result += step; + } + }); + + return Math.round(result); +} + definePageMeta({ layout: "supervisor-base-layout", }); @@ -72,6 +97,17 @@ definePageMeta({