diff --git a/.eslintignore b/.eslintignore index 281a06ce..426365bd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ -node_modules +node_module +node_modules/fraction.js __generated__ \ No newline at end of file diff --git a/APIClients/AuthAPIClient.ts b/APIClients/AuthAPIClient.ts new file mode 100644 index 00000000..fd4cfb9e --- /dev/null +++ b/APIClients/AuthAPIClient.ts @@ -0,0 +1,28 @@ +import { fetchGraphql } from "@utils/makegqlrequest"; +import { mutations, queries } from "graphql/queries"; +import BaseAPIClient from "./BaseAPIClient"; + +export type TokenInfo = { + accessToken: string; + refreshToken: string; +}; + +export type Role = "Admin" | "User"; + +const isAuthorizedByRole = (allowedRoles: Role[]): Promise => { + BaseAPIClient.handleAuthRefresh(); + const accessToken = localStorage.getItem("accessToken"); + + return fetchGraphql(queries.isAuthorizedByRole, { + accessToken, + roles: allowedRoles, + }) + .then((result) => result.data.isAuthorizedByRole) + .catch(() => { + throw new Error("Auth Validation Error"); + }); +}; + +export default { + isAuthorizedByRole, +}; diff --git a/APIClients/BaseAPIClient.ts b/APIClients/BaseAPIClient.ts new file mode 100644 index 00000000..e5f964a1 --- /dev/null +++ b/APIClients/BaseAPIClient.ts @@ -0,0 +1,42 @@ +import { fetchGraphql } from "@utils/makegqlrequest"; +import { mutations } from "graphql/queries"; +import jwt_decode from "jwt-decode"; + +type AccessToken = { + readonly exp: number; +}; + +class BaseAPIClient { + static handleAuthRefresh(): void { + const accessToken = localStorage.getItem("accessToken"); + const refreshToken = localStorage.getItem("refreshToken"); + if (accessToken && refreshToken) { + const decodedToken = jwt_decode(accessToken); + if ( + decodedToken && + decodedToken.exp <= Math.round(new Date().getTime() / 1000) + ) { + fetchGraphql(mutations.refresh, { + refreshToken: localStorage.getItem("refreshToken"), + }) + .then((result) => { + if (typeof result.data.refresh === "string") { + localStorage.setItem("accessToken", result.data.refresh); + } + }) + .catch((e: Error) => { + // fail to refresh and de-auth user + localStorage.clear(); + window.location.reload(); // refresh current page + throw new Error( + `Failed to refresh accessToken token. Cause: ${e.message}`, + ); + }); + } + } else { + throw new Error("No access or refresh token provided"); + } + } +} + +export default BaseAPIClient; diff --git a/components/admin/ApplicationDashboardTable.tsx b/components/admin/ApplicationDashboardTable.tsx deleted file mode 100644 index 07257f31..00000000 --- a/components/admin/ApplicationDashboardTable.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { FC } from "react"; -import TableTitle from "./ApplicationDashboardTableTitle"; -import ApplicationsTable from "./ApplicationsTable"; - -const Table: FC = () => { - return ( -
- - -
- ); -}; - -export default Table; diff --git a/components/admin/ApplicationDashboardTableTitle.tsx b/components/admin/ApplicationDashboardTableTitle.tsx deleted file mode 100644 index 862250b4..00000000 --- a/components/admin/ApplicationDashboardTableTitle.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { FC } from "react"; - -const TableTitle: FC = () => { - const tabStyle = - "border-2 border-blue-300 rounded-full text-blue-300 text-center px-4 m-2 inline-block capitalize"; - - const editButton = - "border-2 border-blue rounded-full text-blue text-center px-8 py-1 m-2 inline-block capitalize bg-white"; - - return ( -
-
-

Applicant Entry

-

200 Entries

-
- -
- ); -}; - -export default TableTitle; diff --git a/components/admin/ApplicationsTable.tsx b/components/admin/ApplicationsTable.tsx index 50f18de9..292ea85e 100644 --- a/components/admin/ApplicationsTable.tsx +++ b/components/admin/ApplicationsTable.tsx @@ -1,196 +1,120 @@ -import { createTheme, MuiThemeProvider } from "@material-ui/core/styles"; +import { MuiThemeProvider } from "@material-ui/core/styles"; +import { fetchGraphql } from "@utils/makegqlrequest"; import MUIDataTable from "mui-datatables"; import React, { useEffect, useState } from "react"; -import { getTableColumns } from "./ApplicationsTableColumn"; +import { getApplicationTableColumns } from "./ApplicationsTableColumn"; +import ApplicantRole from "entities/applicationRole"; +import { ResumeIcon } from "@components/icons/resume.icon"; +import { applicationTableQueries } from "graphql/queries"; +import { getMuiTheme } from "utils/muidatatable"; -export type Student = { - id: string; - firstName: string; - lastName: string; - email: string; - academicYear: string; - program: string; - resumeLink: string; - firstChoiceRole: string; - secondChoiceRole: string; -}; - -type StudentRow = { - name: string; - application: string; - term: string; - program: string; - reviewerOne: string; - reviewerTwo: string; - score: number; - status: string; - skill: string; -}; +interface TableProps { + activeRole?: ApplicantRole; + whichChoiceTab?: number; + setNumFirstChoiceEntries: (tab: number) => void; + numFirstChoiceEntries?: number; + setNumSecondChoiceEntries: (tab: number) => void; + numSecondChoiceEntries?: number; +} -const queries = { - applicationsByRole: ` - query applicationsByRole($firstChoice: String!) { - applicationsByRole(firstChoice: $firstChoice) { - id - firstName - lastName - academicYear - resumeUrl - program - status - } - } - `, - dashboardsByApplicationId: ` - query dashboardsByApplicationId($applicationId: Int!) { - dashboardsByApplicationId(applicationId: $applicationId) { - reviewerEmail - passionFSG - teamPlayer - desireToLearn - skillCategory - } - } - `, - userByEmail: ` - query userByEmail($email: String!) { - userByEmail(email: $email) { - firstName - lastName - } - } - `, -}; - -const ApplicationsTable: React.FC = () => { - const [applications, setApplications] = useState([]); +const ApplicationsTable: React.FC = ({ + activeRole, + whichChoiceTab, + setNumFirstChoiceEntries, + setNumSecondChoiceEntries, +}) => { + const [firstChoiceApplications, setFirstChoiceApplications] = useState( + [], + ); + const [secondChoiceApplications, setSecondChoiceApplications] = useState< + any[] + >([]); useEffect(() => { - applications.map(async (app) => { - const dashboards: any[] = await dashboardsByApplicationId(app.id); - app.dashboards = dashboards; - const reviewerNames = await Promise.all( - dashboards.map(async (dash) => { - const res = await nameByEmail(dash.reviewerEmail); - return res; - }), + fetchApplicationsByRole(); + }, [activeRole, whichChoiceTab]); + + const fetchApplicationsByRole = async () => { + const currentRole = activeRole || ApplicantRole.vpe; + try { + const firstChoiceResult = await fetchGraphql( + applicationTableQueries.applicationsByRole, + { + role: currentRole, + }, ); - app.reviewerNames = reviewerNames; - }); - }, [applications]); - useEffect(() => { - applicationsByRole(); - }, []); + const secondChoiceResult = await fetchGraphql( + applicationTableQueries.applicationsBySecondChoiceRole, + { + role: currentRole, + }, + ); + setFirstChoiceApplications(firstChoiceResult.data.applicationTable); + setNumFirstChoiceEntries(firstChoiceResult.data.applicationTable.length); - const getSkillCategory = (application: any) => { - if (application.dashboards?.length >= 2) { - const reviewer1Skill = application.dashboards[0].skillCategory; - const reviewer2Skill = application.dashboards[1].skillCategory; - if (reviewer1Skill == "junior" || reviewer2Skill == "junior") { - return "Junior"; - } else if ( - reviewer1Skill == "intermediate" || - reviewer2Skill == "intermediate" - ) { - return "Intermediate"; - } else { - return "Senior"; - } + setSecondChoiceApplications( + secondChoiceResult.data.secondChoiceRoleApplicationTable, + ); + setNumSecondChoiceEntries( + secondChoiceResult.data.secondChoiceRoleApplicationTable.length, + ); + } catch (error) { + console.error("Error fetching applications:", error); } - return ""; }; - const nameByEmail = async (email: string) => { - const response = await fetch("http://localhost:5000/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: queries.userByEmail, - variables: { - email: email, - }, - }), - }); - const users = await response.json(); - return users.data.userByEmail; - }; + const createStudentRow = (application: any) => { + const app = application.application; + const reviewers = application.reviewers; - const applicationsByRole = () => { - fetch("http://localhost:5000/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: queries.applicationsByRole, - variables: { - firstChoice: "project developer", - }, - }), - }).then( - async (res) => - await res - .json() - .then((result) => setApplications(result.data.applicationsByRole)), - ); - }; - const dashboardsByApplicationId = async (id: number) => { - const response = await fetch("http://localhost:5000/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: queries.dashboardsByApplicationId, - variables: { - applicationId: id, - }, - }), - }); - const dashboards = await response.json(); - return dashboards.data.dashboardsByApplicationId; + return { + id: app.id, + name: app.firstName + " " + app.lastName, + resume: ( + + + View Resume + + ), + term: app.academicYear, + program: app.program, + reviewerOne: + reviewers?.length >= 1 + ? `${reviewers[0].firstName} ${reviewers[0].lastName}` + : "", + reviewerTwo: + reviewers?.length >= 2 + ? `${reviewers[1].firstName} ${reviewers[1].lastName}` + : "", + status: app.status, + secondChoice: app.secondChoiceRole, + secondChoiceStatus: app.secondChoiceStatus, + }; }; - const getMuiTheme = () => - createTheme({ - overrides: { - MUIDataTableHeadCell: { - data: { color: "blue" }, // @todo use real colour - }, - }, - }); - - const getTableRows = (): StudentRow[] => { - const rows: StudentRow[] = applications.map((application) => { - return { - name: application.firstName + " " + application.lastName, - application: application.resumeUrl, - term: application.academicYear, - program: application.program, - reviewerOne: - application.reviewerNames?.length >= 1 - ? application.reviewerNames[0].firstName + " " - : "", - reviewerTwo: - application.reviewerNames?.length >= 2 - ? application.reviewerNames[1].firstName - : "", - score: 100, - status: application.status, - skill: getSkillCategory(application), - }; - }); - return rows; + const getTableRows = () => { + if (!whichChoiceTab) { + return firstChoiceApplications.map(createStudentRow); + } + return secondChoiceApplications.map(createStudentRow); }; return ( ); diff --git a/components/admin/ApplicationsTableColumn.tsx b/components/admin/ApplicationsTableColumn.tsx index 5038e4c9..da642c93 100644 --- a/components/admin/ApplicationsTableColumn.tsx +++ b/components/admin/ApplicationsTableColumn.tsx @@ -1,46 +1,127 @@ import { MUIDataTableColumn } from "mui-datatables"; +import { LinkIcon } from "@components/icons/link.icon"; +import { Status, SecondChoiceStatus } from "@utils/muidatatable"; +import router from "next/router"; + +export const getApplicationTableColumns = (): MUIDataTableColumn[] => { + const handleNameClick = (appId: string) => { + router.push(`/review?reviewId=${appId}`); + }; -export const getTableColumns = (): MUIDataTableColumn[] => { const columns: MUIDataTableColumn[] = [ { - name: "name", - label: "Name", - options: {}, + name: "id", + options: { + display: "excluded", + filter: false, + searchable: false, + sort: false, + }, }, { - name: "application", + name: "name", label: "Application", - options: {}, - }, - { - name: "resume", - label: "Resume", - options: {}, + options: { + filter: false, + // sortCompare: (order: "asc" | "desc") => createSortFunction(order), + searchable: true, + customBodyRender(value, tableMeta) { + const appId = tableMeta.rowData[0]; + return ( +
handleNameClick(appId)} + className="flex items-center cursor-pointer" + > + + {value} +
+ ); + }, + }, }, { name: "reviewerOne", label: "Reviewer #1", - options: {}, + options: { + filter: false, + sort: true, + searchable: true, + }, }, { name: "reviewerTwo", label: "Reviewer #2", - options: {}, + options: { + filter: false, + sort: true, + searchable: true, + }, }, { - name: "score", - label: "Score", - options: {}, + name: "status", + label: "Status", + options: { + filter: true, + sort: true, + searchable: true, + filterOptions: { + names: [ + "accepted", + "applied", + "interviewed", + "in review", + "pending", + "rejected", + ], + }, + filterType: "multiselect", + customBodyRender(value) { + return ; + }, + }, }, { - name: "status", - label: "Application Status", - options: {}, + name: "secondChoice", + label: "2nd Choice", + options: { + filter: true, + sort: true, + searchable: true, + filterType: "multiselect", + }, + }, + { + name: "secondChoiceStatus", + label: "2nd Choice Status", + options: { + filter: true, + sort: true, + searchable: true, + filterOptions: { + names: [ + "considered", + "not considered", + "n/a", + "recommended", + "in review", + "interview", + "no interview", + ], + }, + filterType: "multiselect", + customBodyRender(value) { + return ; + }, + }, }, { - name: "skill", - label: "Skill Category", - options: {}, + name: "resume", + label: "Resume", + options: { + filter: false, + sort: true, + searchable: true, + }, }, ]; return columns; diff --git a/components/admin/DashboardTableTitle.tsx b/components/admin/DashboardTableTitle.tsx new file mode 100644 index 00000000..c5d2ae87 --- /dev/null +++ b/components/admin/DashboardTableTitle.tsx @@ -0,0 +1,93 @@ +import React, { FC } from "react"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; + +interface TitleProps { + numFirstChoiceEntries?: number; + numSecondChoiceEntries?: number; + setWhichChoiceTab: (tab: number) => void; + whichChoiceTab?: number; +} +interface TabDescriptionProps { + title: string; + numEntries: number | undefined; + pillStyle: string; +} + +const TabDescription: FC = ({ + title, + numEntries, + pillStyle, +}) => ( +
+ {title}

{numEntries} Entries

+
+); + +const TableTitle: FC = ({ + numFirstChoiceEntries, + numSecondChoiceEntries, + setWhichChoiceTab, + whichChoiceTab, +}) => { + const pillStyle = + "border-2 border-blue-100 text-blue rounded-full px-4 py-2 m-2 font-large inline-block"; + + // const editButton = + // "border-2 border-blue rounded-full text-blue text-center px-8 py-1 m-2 inline-block capitalize bg-white"; + + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setWhichChoiceTab(newValue); + }; + + return ( +
+
+ + + } + /> + + } + /> + +
+ {/* */} +
+ ); +}; + +export default TableTitle; diff --git a/components/admin/DropdownMenu.tsx b/components/admin/DropdownMenu.tsx index 1919e1f1..cf5aa4ad 100644 --- a/components/admin/DropdownMenu.tsx +++ b/components/admin/DropdownMenu.tsx @@ -1,19 +1,27 @@ import React, { useState } from "react"; -const DropdownMenu: React.FC = () => { +const DropdownMenu: React.FC<{ onChange?: (value: string) => void }> = ({ + onChange, +}) => { const [isOpen, setIsOpen] = useState(false); const toggleDropdown = () => setIsOpen(!isOpen); + const handleOptionChange = (event: React.ChangeEvent) => { + if (onChange) { + onChange(event.target.value); + } + }; + return ( -
+
); diff --git a/components/admin/Header.tsx b/components/admin/Header.tsx deleted file mode 100644 index 38968f85..00000000 --- a/components/admin/Header.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { FC } from "react"; -import DropdownMenu from "./DropdownMenu"; -import { Avatar } from "./Profile"; -import Tabs from "./Tabs"; -import Permissions from "entities/permissions"; -import Users from "entities/users"; -import RoleHeader from "./RoleHeader"; - -export enum OrganizationalArea { - Engineering = "Engineering", - Design = "Design", - Product = "Product", - Community = "Community", -} - -const Header: FC = () => { - const [activeTab, setActiveTab] = React.useState< - OrganizationalArea | undefined - >(); - const user: Users = { - id: "1", - name: "Chris Abey", - email: "chrisabey@uwblueprint.org", - role: Permissions.admin, - profile_picture: - "https://firebasestorage.googleapis.com/v0/b/uw-blueprint.appspot.com/o/img%2Fw23headshots%2FDev_Chris_Abey.jpg?alt=media&token=97630484-8ce5-49da-b77c-4190028a9abb", - }; - return ( -
-
-
-
- - UW Blueprint Logo - - -
-
- -
-
- -
- -
- ); -}; - -export default Header; diff --git a/components/admin/ReviewTable.tsx b/components/admin/ReviewTable.tsx new file mode 100644 index 00000000..cbd184c7 --- /dev/null +++ b/components/admin/ReviewTable.tsx @@ -0,0 +1,115 @@ +import { MuiThemeProvider } from "@material-ui/core/styles"; +import { fetchGraphql } from "@utils/makegqlrequest"; +import MUIDataTable from "mui-datatables"; +import React, { useEffect, useState } from "react"; +import { getReviewTableColumns } from "./ReviewTableColumn"; +import ApplicantRole from "entities/applicationRole"; +import { ResumeIcon } from "@components/icons/resume.icon"; +import { applicationTableQueries } from "graphql/queries"; +import { getMuiTheme } from "utils/muidatatable"; + +interface TableProps { + activeRole?: ApplicantRole; + whichChoiceTab?: number; + setNumFirstChoiceEntries: (tab: number) => void; + numFirstChoiceEntries?: number; + setNumSecondChoiceEntries: (tab: number) => void; + numSecondChoiceEntries?: number; +} + +const ApplicationsTable: React.FC = ({ + activeRole, + whichChoiceTab, + setNumFirstChoiceEntries, + setNumSecondChoiceEntries, +}) => { + const [firstChoiceApplications, setFirstChoiceApplications] = useState( + [], + ); + const [secondChoiceApplications, setSecondChoiceApplications] = useState< + any[] + >([]); + useEffect(() => { + fetchApplicationsByRole(); + }, [activeRole, whichChoiceTab]); + + const fetchApplicationsByRole = async () => { + const currentRole = activeRole || ApplicantRole.vpe; + try { + const firstChoiceResult = await fetchGraphql( + applicationTableQueries.applicationsByRole, + { + role: currentRole, + }, + ); + + const secondChoiceResult = await fetchGraphql( + applicationTableQueries.applicationsBySecondChoiceRole, + { + role: currentRole, + }, + ); + setFirstChoiceApplications(firstChoiceResult.data.applicationTable); + setNumFirstChoiceEntries(firstChoiceResult.data.applicationTable.length); + + setSecondChoiceApplications( + secondChoiceResult.data.secondChoiceRoleApplicationTable, + ); + setNumSecondChoiceEntries( + secondChoiceResult.data.secondChoiceRoleApplicationTable.length, + ); + } catch (error) { + console.error("Error fetching applications:", error); + } + }; + + const createStudentRow = (application: any) => { + const app = application.application; + const reviewers = application.reviewers; + + return { + id: app.id, + name: app.firstName + " " + app.lastName, + resume: ( + + + View Resume + + ), + term: app.academicYear, + program: app.program, + status: app.status, + secondChoice: app.secondChoiceRole, + secondChoiceStatus: app.secondChoiceStatus, + }; + }; + + const getTableRows = () => { + if (!whichChoiceTab) { + return firstChoiceApplications.map(createStudentRow); + } + return secondChoiceApplications.map(createStudentRow); + }; + + return ( + + + + ); +}; + +export default ApplicationsTable; diff --git a/components/admin/ReviewTableColumn.tsx b/components/admin/ReviewTableColumn.tsx new file mode 100644 index 00000000..e44cddb6 --- /dev/null +++ b/components/admin/ReviewTableColumn.tsx @@ -0,0 +1,153 @@ +import { MUIDataTableColumn } from "mui-datatables"; +import { LinkIcon } from "@components/icons/link.icon"; +import { Status, SecondChoiceStatus, SkillCategory } from "@utils/muidatatable"; +import { router } from "next/router"; + +export const getReviewTableColumns = (): MUIDataTableColumn[] => { + const handleNameClick = (appId: string) => { + router.push(`/review?reviewId=${appId}`); + }; + + const columns: MUIDataTableColumn[] = [ + { + name: "id", + options: { + display: "excluded", + filter: false, + searchable: false, + sort: false, + }, + }, + { + name: "name", + label: "Application", + options: { + filter: false, + // sortCompare: (order: "asc" | "desc") => createSortFunction(order), + searchable: true, + customBodyRender(value, tableMeta, updateValue) { + const appId = tableMeta.rowData[0]; + return ( +
handleNameClick(appId)} + className="flex items-center cursor-pointer" + > + + {value} +
+ ); + }, + }, + }, + { + name: "term", + label: "Term", + options: { + filter: false, + sort: true, + searchable: true, + }, + }, + { + name: "program", + label: "Program", + options: { + filter: false, + sort: true, + searchable: true, + }, + }, + { + name: "score", + label: "Score", + options: { + filter: false, + sort: true, + searchable: true, + }, + }, + { + name: "status", + label: "Status", + options: { + filter: true, + sort: true, + searchable: true, + filterOptions: { + names: [ + "accepted", + "applied", + "interviewed", + "in review", + "pending", + "rejected", + ], + }, + filterType: "multiselect", + customBodyRender(value, tableMeta, updateValue) { + return ; + }, + }, + }, + { + name: "skillCategory", + label: "Skill Category", + options: { + filter: true, + sort: true, + searchable: true, + filterOptions: { + names: ["intermediate", "junior", "senior"], + }, + filterType: "multiselect", + customBodyRender(value, tableMeta, updateValue) { + return ; + }, + }, + }, + { + name: "secondChoice", + label: "2nd Choice", + options: { + filter: true, + sort: true, + searchable: true, + filterType: "multiselect", + }, + }, + { + name: "secondChoiceStatus", + label: "2nd Choice Status", + options: { + filter: true, + sort: true, + searchable: true, + filterOptions: { + names: [ + "considered", + "not considered", + "n/a", + "recommended", + "in review", + "interview", + "no interview", + ], + }, + filterType: "multiselect", + customBodyRender(value, tableMeta, updateValue) { + return ; + }, + }, + }, + { + name: "resume", + label: "Resume", + options: { + filter: false, + sort: true, + searchable: true, + }, + }, + ]; + return columns; +}; diff --git a/components/admin/RoleHeader.tsx b/components/admin/RoleHeader.tsx index cafe093f..fdaaa3de 100644 --- a/components/admin/RoleHeader.tsx +++ b/components/admin/RoleHeader.tsx @@ -1,9 +1,11 @@ import React, { FC, useState, useEffect, useCallback } from "react"; import ApplicantRole from "entities/applicationRole"; -import { OrganizationalArea } from "./Header"; +import { OrganizationalArea } from "./Table"; interface RoleHeaderProps { activeTab?: OrganizationalArea; + setActiveRole: (tab: ApplicantRole) => void; + activeRole?: ApplicantRole; } interface ColourMap { @@ -13,8 +15,11 @@ interface ColourMap { text: string; } -const RoleHeader: FC = ({ activeTab }) => { - const [activeRole, setActiveRole] = useState(null); +const RoleHeader: FC = ({ + activeTab, + setActiveRole, + activeRole, +}) => { const [colour, setColour] = useState>({}); const [roles, setRoles] = useState([]); @@ -98,7 +103,7 @@ const RoleHeader: FC = ({ activeTab }) => { return (

Roles

diff --git a/components/admin/Table.tsx b/components/admin/Table.tsx new file mode 100644 index 00000000..fc00543b --- /dev/null +++ b/components/admin/Table.tsx @@ -0,0 +1,119 @@ +import React, { FC } from "react"; +import DropdownMenu from "./DropdownMenu"; +import { Avatar } from "./Profile"; +import Tabs from "./Tabs"; +import Permissions from "entities/permissions"; +import Users from "entities/users"; +import RoleHeader from "./RoleHeader"; +import TableTitle from "./DashboardTableTitle"; +import ApplicationsTable from "./ApplicationsTable"; +import ApplicantRole from "entities/applicationRole"; +import ReviewTable from "./ReviewTable"; + +export enum OrganizationalArea { + Engineering = "Engineering", + Design = "Design", + Product = "Product", + Community = "Community", +} + +const Table: FC = () => { + const [activeTab, setActiveTab] = React.useState< + OrganizationalArea | undefined + >(); + const [activeRole, setActiveRole] = React.useState< + ApplicantRole | undefined + >(); + const [numFirstChoiceEntries, setNumFirstChoiceEntries] = React.useState< + number | undefined + >(); + const [numSecondChoiceEntries, setNumSecondChoiceEntries] = React.useState< + number | undefined + >(); + const [whichChoiceTab, setWhichChoiceTab] = React.useState(0); + + const [selectedDropdownItem, setSelectedDropdownItem] = + React.useState(""); + + const handleDropdownChange = (selectedItem: string) => { + setSelectedDropdownItem(selectedItem); + }; + + const user: Users = { + id: "1", + name: "Chris Abey", + email: "chrisabey@uwblueprint.org", + role: Permissions.admin, + profile_picture: + "https://firebasestorage.googleapis.com/v0/b/uw-blueprint.appspot.com/o/img%2Fw23headshots%2FDev_Chris_Abey.jpg?alt=media&token=97630484-8ce5-49da-b77c-4190028a9abb", + }; + return ( +
+
+
+
+ + UW Blueprint Logo + +
+
+ +
+
+ +
+
+ + +
+
+ + + {selectedDropdownItem === "Review Dashboard" && ( + + )} + {selectedDropdownItem === "Delegation Dashboard" && ( + + )} +
+
+ ); +}; + +export default Table; diff --git a/components/admin/Tabs.tsx b/components/admin/Tabs.tsx index 853045b6..48f0f8ff 100644 --- a/components/admin/Tabs.tsx +++ b/components/admin/Tabs.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useState } from "react"; import Users from "../../entities/users"; import Permissions from "../../entities/permissions"; -import { OrganizationalArea } from "./Header"; +import { OrganizationalArea } from "./Table"; interface NavbarProps { user: Users; diff --git a/components/common/Login.tsx b/components/common/Login.tsx index f3b648e5..42d591e7 100644 --- a/components/common/Login.tsx +++ b/components/common/Login.tsx @@ -4,47 +4,31 @@ import { mutations } from "graphql/queries"; import { ReactElement } from "react"; import { useRouter } from "next/router"; import Button from "./Button"; +import { fetchGraphql } from "@utils/makegqlrequest"; const Login = (): ReactElement => { const router = useRouter(); - const signInWithGoogle = async () => { const provider = new GoogleAuthProvider(); + provider.setCustomParameters({ hd: "uwblueprint.org" }); // only allow uwblueprint.org emails to sign in const res = await signInWithPopup(auth, provider); if (res) { const oauthIdToken = (res as any)._tokenResponse.oauthIdToken; - fetch("http://localhost:5000/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", + fetchGraphql(mutations.loginWithGoogle, { idToken: oauthIdToken }).then( + (result) => { + if (result.data) { + localStorage.setItem( + "accessToken", + result.data.loginWithGoogle.accessToken, + ); + localStorage.setItem( + "refreshToken", + result.data.loginWithGoogle.refreshToken, + ); + router.back(); + } }, - body: JSON.stringify({ - query: mutations.loginWithGoogle, - variables: { - idToken: oauthIdToken, - }, - }), - }) - .then( - async (res) => - await res.json().then((result) => { - if (result.data) { - localStorage.setItem( - "accessToken", - result.data.loginWithGoogle.accessToken, - ); - localStorage.setItem( - "refreshToken", - result.data.loginWithGoogle.refreshToken, - ); - router.back(); - } - }), - ) - .catch((e) => { - console.error("Oauth login failed"); - console.log(e); - }); + ); } }; diff --git a/components/context/ProtectedRoute.tsx b/components/context/ProtectedRoute.tsx index f0f4794a..781c626c 100644 --- a/components/context/ProtectedRoute.tsx +++ b/components/context/ProtectedRoute.tsx @@ -1,7 +1,8 @@ import { ReactChild, ReactElement, useEffect, useState } from "react"; import Loading from "@components/common/Loading"; -import { queries } from "graphql/queries"; import { useRouter } from "next/router"; +import AuthAPIClient from "APIClients/AuthAPIClient"; +import { AuthStatus } from "types"; type Props = { children: ReactChild; @@ -10,11 +11,6 @@ type Props = { type Role = "Admin" | "User"; -interface AuthStatus { - loading: boolean; - isAuthorized: boolean; -} - const ProtectedRoute = ({ children, allowedRoles }: Props): ReactElement => { const router = useRouter(); const [authStatus, setAuthStatus] = useState({ @@ -22,44 +18,28 @@ const ProtectedRoute = ({ children, allowedRoles }: Props): ReactElement => { isAuthorized: false, }); useEffect(() => { - const accessToken = localStorage.getItem("accessToken"); - if (accessToken == null) { + // check if we have an accessToken cached + if (localStorage.getItem("accessToken") == null) { setAuthStatus({ loading: false, isAuthorized: false, }); return; } - - fetch("http://localhost:5000/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: queries.isAuthorizedByRole, - variables: { - accessToken, - roles: allowedRoles, - }, - }), - }) - .then( - async (res) => - await res.json().then((result) => { - if (result.data.isAuthorizedByRole) { - setAuthStatus({ - loading: false, - isAuthorized: true, - }); - } else { - setAuthStatus({ - loading: false, - isAuthorized: false, - }); - } - }), - ) + AuthAPIClient.isAuthorizedByRole(allowedRoles) + .then(async (isAuthorized) => { + if (isAuthorized) { + setAuthStatus({ + loading: false, + isAuthorized: true, + }); + } else { + setAuthStatus({ + loading: false, + isAuthorized: false, + }); + } + }) .catch((e) => { console.error("Auth Validation Error"); console.error(e); diff --git a/components/icons/copy.icon.tsx b/components/icons/copy.icon.tsx new file mode 100644 index 00000000..7ba4a131 --- /dev/null +++ b/components/icons/copy.icon.tsx @@ -0,0 +1,17 @@ +export const CopyIcon: React.FC = () => ( + + + + + +); diff --git a/components/icons/link.icon.tsx b/components/icons/link.icon.tsx new file mode 100644 index 00000000..5ab30587 --- /dev/null +++ b/components/icons/link.icon.tsx @@ -0,0 +1,17 @@ +export const LinkIcon: React.FC = () => ( + + + +); diff --git a/components/icons/resume.icon.tsx b/components/icons/resume.icon.tsx new file mode 100644 index 00000000..8d85ea29 --- /dev/null +++ b/components/icons/resume.icon.tsx @@ -0,0 +1,18 @@ +export const ResumeIcon: React.FC = () => ( + + + + +); diff --git a/components/icons/warning.icon.tsx b/components/icons/warning.icon.tsx new file mode 100644 index 00000000..b1e7b619 --- /dev/null +++ b/components/icons/warning.icon.tsx @@ -0,0 +1,21 @@ +import { ReactElement } from "react"; + +const WarningIcon = (): ReactElement => { + return ( + + + + ); +}; + +export default WarningIcon; diff --git a/components/review/shared/reviewContext.tsx b/components/review/shared/reviewContext.tsx index dcbb5821..f439e363 100644 --- a/components/review/shared/reviewContext.tsx +++ b/components/review/shared/reviewContext.tsx @@ -4,3 +4,7 @@ import { createContext } from "react"; export const ReviewSetStageContext = createContext< null | ((newValue: ReviewStage) => void) >(null); + +export const ReviewSetScoresContext = createContext< + null | ((newKey: ReviewStage, newValue: number) => void) +>(null); diff --git a/components/review/shared/reviewRatingPage.tsx b/components/review/shared/reviewRatingPage.tsx index 2cf0745c..18859f3d 100644 --- a/components/review/shared/reviewRatingPage.tsx +++ b/components/review/shared/reviewRatingPage.tsx @@ -1,23 +1,65 @@ import { ReviewStage } from "pages/review"; import { ReviewSplitPanelPage } from "./reviewSplitPanelPage"; +import Button from "@components/common/Button"; interface Props { studentName: string; currentStage: ReviewStage; + currentStageRubric: JSX.Element; + currentStageAnswers: JSX.Element; title: string; + resumeLink?: string; + scores: Map; } +interface resumeProps { + resumeLink: string; +} + +const ResumeLink: React.FC = ({ resumeLink }) => { + return ( +
+ +
+ ); +}; + export const ReviewRatingPage: React.FC = ({ studentName, currentStage, + currentStageRubric, + currentStageAnswers, title, + resumeLink, + scores, }) => { return ( + {resumeLink ? : null} +
{currentStageAnswers}
+
+ } + scores={scores} /> ); }; diff --git a/components/review/shared/reviewSplitPanelPage.tsx b/components/review/shared/reviewSplitPanelPage.tsx index 38247134..39a2d659 100644 --- a/components/review/shared/reviewSplitPanelPage.tsx +++ b/components/review/shared/reviewSplitPanelPage.tsx @@ -2,39 +2,119 @@ import { ReviewStage } from "pages/review/index.jsx"; import React from "react"; import { ReviewStepper } from "./reviewStepper"; + interface Props { studentName: string; leftTitle?: string; rightTitle?: string; + rightTitleButton?: JSX.Element; leftContent?: JSX.Element; rightContent?: JSX.Element; currentStage: ReviewStage; + scores: Map; + tallyLeftTitle?: string; + tallyRightTitle?: string; + totalTally?: JSX.Element; + comment?: JSX.Element; } export const ReviewSplitPanelPage: React.FC = ({ studentName, leftTitle, rightTitle, + rightTitleButton, leftContent, rightContent, currentStage, -}) => { + scores, + tallyLeftTitle, + tallyRightTitle, + totalTally, + comment, +}) => { return ( -
-
-
-

{studentName}

-
-
- {leftTitle ?

{leftTitle}

: null} - {leftContent} +
+
+ {leftTitle ? ( +

+ {leftTitle} +

+ ) : null} + + {tallyLeftTitle && tallyRightTitle && ( +
+

+ {tallyLeftTitle} +

+

+ {tallyRightTitle} +

+
+ )} + +
+
{leftContent}
+ {totalTally && ( + <> +
+
{totalTally}
+ + )} +
-
-
- {rightTitle ?

{rightTitle}

: null} - {rightContent} +
); diff --git a/components/review/shared/reviewStepper.tsx b/components/review/shared/reviewStepper.tsx index d4b788b0..1a77a545 100644 --- a/components/review/shared/reviewStepper.tsx +++ b/components/review/shared/reviewStepper.tsx @@ -5,11 +5,12 @@ import { ReviewSetStageContext } from "./reviewContext"; interface Props { currentStage: ReviewStage; + scores: Map; } type NavigationItemState = "current" | "past" | "future"; -export const ReviewStepper: React.FC = ({ currentStage }) => { +export const ReviewStepper: React.FC = ({ currentStage, scores }) => { const buttons = useMemo( () => [ { title: "INFO", index: 1, stage: ReviewStage.INFO }, @@ -57,15 +58,36 @@ export const ReviewStepper: React.FC = ({ currentStage }) => { return buttons[Math.max(currentButtonIndex - 1, 0)].stage; }; + const isButtonDisabled = () => { + if ( + currentStage == ReviewStage.INFO || + currentStage == ReviewStage.END_SUCCESS + ) { + return false; + } else if (scores == undefined) { + return false; + } else { + const currScore = scores.get(currentStage); + if (currScore == undefined) { + return false; + } else if (currScore > 0 && currScore <= 5) { + return false; + } else { + return true; + } + } + }; + return ( -
+
- {buttons.map((buttonProps) => ( + {buttons.map((buttonProps, idx) => ( ))}
@@ -94,7 +116,10 @@ export const ReviewStepper: React.FC = ({ currentStage }) => { diff --git a/components/review/stages/reviewAnswers.tsx b/components/review/stages/reviewAnswers.tsx new file mode 100644 index 00000000..e0a633a4 --- /dev/null +++ b/components/review/stages/reviewAnswers.tsx @@ -0,0 +1,29 @@ +interface Props { + questions: string[]; + answers: string[]; +} + +export const ReviewAnswers: React.FC = ({ questions, answers }) => { + return ( +
+ {questions.map((question, idx) => { + return ( +
+
{question}
+
+
+
{answers[idx]}
+
+
+
+ ); + })} +
+ ); +}; diff --git a/components/review/stages/reviewDriveToLearnStage.tsx b/components/review/stages/reviewDriveToLearnStage.tsx index eae7a5ee..de4d643c 100644 --- a/components/review/stages/reviewDriveToLearnStage.tsx +++ b/components/review/stages/reviewDriveToLearnStage.tsx @@ -1,12 +1,47 @@ import { ReviewStage } from "pages/review"; import { ReviewRatingPage } from "../shared/reviewRatingPage"; +import { ReviewRubric } from "./reviewRubric"; +import { ReviewAnswers } from "./reviewAnswers"; -export const ReviewDriveToLearnStage: React.FC = () => { +interface Props { + scores: Map; +} + +const reviewD2LScoringCriteria = [ + "Does not provide a relevant cause that resonates with them. Example: I'm really involved in social good causes, I'm a very emphathetic person so I tend to resonate with them when I come across something negative in the world", + "Does not provide a relevant cause that resonates with them. Example: I'm really involved in social good causes, I'm a very emphathetic person so I tend to resonate with them when I come across something negative in the world", + "Does not provide a relevant cause that resonates with them. Example: I'm really involved in social good causes, I'm a very emphathetic person so I tend to resonate with them when I come across something negative in the world", + "Does not provide a relevant cause that resonates with them. Example: I'm really involved in social good causes, I'm a very emphathetic person so I tend to resonate with them when I come across something negative in the world", + "Does not provide a relevant cause that resonates with them. Example: I'm really involved in social good causes, I'm a very emphathetic person so I tend to resonate with them when I come across something negative in the world", +]; + +const sampleQuestions = [ + "Tell us about a time you learned a new skill. What was your motivation to learn it and what was your approach?", + "Bonus: Tell us about a cause that resonates with you", +]; + +const sampleAnswers = [ + "The organization I'm volunteering for right now, IleTTonna, is a healthcare startup devoted to helping those struggling through the postpartum period. To be completely honest, it's mission didn't resonate with me as much as it does now than when I first started. At the beginning, I wasn't sure how helpful what we were doing was because our audience seemed to sniche. But now, after meeting with stakeholders and launching our MVP, we're getting a lot of responses. Seeing the impact of your work is incredible and does a lot to inspire more hard work. ", + "The organization I'm volunteering for right now, IleTTonna, is a healthcare startup devoted to helping those struggling through the postpartum period. To be completely honest, it's mission didn't resonate with me as much as it does now than when I first started. At the beginning, I wasn't sure how helpful what we were doing was because our audience seemed to sniche. But now, after meeting with stakeholders and launching our MVP, we're getting a lot of responses. Seeing the impact of your work is incredible and does a lot to inspire more hard work. ", +]; + +export const ReviewDriveToLearnStage: React.FC = ({ scores }) => { return ( + currentStageRubric={ + + } + currentStageAnswers={ + + } + scores={scores} + /> ); }; diff --git a/components/review/stages/reviewEndStage.tsx b/components/review/stages/reviewEndStage.tsx index 59c31cc7..578c1879 100644 --- a/components/review/stages/reviewEndStage.tsx +++ b/components/review/stages/reviewEndStage.tsx @@ -1,12 +1,110 @@ import { ReviewStage } from "pages/review"; -import { ReviewSplitPanelPage } from "../shared/reviewSplitPanelPage"; +import { + ReviewSplitPanelPage, +} from "../shared/reviewSplitPanelPage"; +import { useState } from "react"; + + +interface Props { + name: string; + scores: Map; +} + +export const ReviewEndStage: React.FC = ({ name, scores }) => { + const [selectedOption, setSelectedOption] = useState(''); // State to store the selected option + const [comment, setComment] = useState(''); // State to store the comment + + + // Function to handle option change + const handleOptionChange = (event: React.ChangeEvent) => { + setSelectedOption(event.target.value); + }; + const handleCommentChange = (event: React.ChangeEvent) => { + setComment(event.target.value); + }; + -export const ReviewEndStage: React.FC = () => { return ( +
+ Passion for Social Good + + {scores?.get(ReviewStage.PFSG)}/5 + +
+
+ Team Player + + {scores?.get(ReviewStage.TP)}/5 + +
+
+ Desire to Learn + + {scores?.get(ReviewStage.D2L)}/5 + +
+
+ Skill + + {scores?.get(ReviewStage.SKL)}/5 + +
+
+ } + tallyLeftTitle="TOPIC" + tallyRightTitle="RATING" + scores={scores} + totalTally={ +
+ Total + + {scores?.get(ReviewStage.PFSG) + + scores?.get(ReviewStage.TP) + + scores?.get(ReviewStage.D2L) + + scores?.get(ReviewStage.SKL)} + /5 + +
+ } + rightContent={ +
+

Skills Category

+ +
+ } + comment={ +
+

Comments

+