diff --git a/frontend/public/sample_interview_scheduler_input.csv b/frontend/public/sample_interview_scheduler_input.csv new file mode 100644 index 000000000..ec6cdb180 --- /dev/null +++ b/frontend/public/sample_interview_scheduler_input.csv @@ -0,0 +1,5 @@ +Email,First Name,Last Name,NetID +aaa111@cornell.edu,Alder,Alderson,aaa111 +ddd222@cornell.edu,Donald,Donaldson,ddd222 +cc2785@cornell.edu,Chris,Chen,cc2785 +personal@gmail.com,Jane,Doe,abc123 \ No newline at end of file diff --git a/frontend/src/API/InterviewSchedulerAPI.ts b/frontend/src/API/InterviewSchedulerAPI.ts new file mode 100644 index 000000000..39c0b1c62 --- /dev/null +++ b/frontend/src/API/InterviewSchedulerAPI.ts @@ -0,0 +1,55 @@ +import APIWrapper from './APIWrapper'; +import { backendURL } from '../environment'; + +export default class InterviewSchedulerAPI { + static async createNewInstance(instance: InterviewScheduler): Promise { + const response = APIWrapper.post(`${backendURL}/interview-scheduler`, instance); + return response.then((val) => val.data.uuid); + } + + static async getAllInstances(isApplicant: boolean): Promise { + const response = APIWrapper.get( + `${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}` + ); + return response.then((val) => val.data.instances); + } + + static async getInstance(uuid: string, isApplicant: boolean): Promise { + const response = APIWrapper.get( + `${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}/${uuid}` + ); + return response.then((val) => val.data.instance); + } + + static async updateInstance(instance: InterviewSchedulerEdit): Promise { + const response = APIWrapper.put(`${backendURL}/interview-scheduler`, instance); + return response.then((val) => val.data.instance); + } + + static async deleteInstance(uuid: string): Promise { + APIWrapper.delete(`${backendURL}/interview-scheduler/${uuid}`); + } + + static async getSlots(uuid: string, isApplicant: boolean): Promise { + return APIWrapper.get( + `${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}/${uuid}` + ).then((val) => val.data.slots); + } + + static async createSlots(slots: InterviewSlot[]): Promise { + return APIWrapper.post(`${backendURL}/interview-slots`, { slots }).then( + (val) => val.data.slots + ); + } + + static async updateSlot(edits: InterviewSlotEdit, isApplicant: boolean): Promise { + return APIWrapper.put( + `${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}`, + edits + ).then((val) => val.data.success); + } + + static async deleteSlot(uuid: string): Promise { + APIWrapper.delete(`${backendURL}/interview-slots/${uuid}`); + } +} diff --git a/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.module.css b/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.module.css new file mode 100644 index 000000000..241429b89 --- /dev/null +++ b/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.module.css @@ -0,0 +1,31 @@ +.uploadStatusSuccess { + margin-top: 2em; + padding: 1em; + color: green; + border: 1px black solid; + background-color: lightgray; +} + +.uploadStatusError { + margin-top: 2em; + padding: 1em; + color: red; + border: 1px black solid; + background-color: lightgray; +} + +.creatorContainer { + width: 60%; + margin-left: auto; + margin-right: auto; + margin-top: 5%; + margin-bottom: 5%; +} + +.submitButton { + margin-top: 1em !important; +} + +.editorContainer { + margin-top: 3em; +} diff --git a/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.tsx b/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.tsx new file mode 100644 index 000000000..95a22d303 --- /dev/null +++ b/frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { Button, Card, Checkbox, Form, Header, Loader } from 'semantic-ui-react'; +import styles from './AdminInterviewScheduler.module.css'; +import InterviewSchedulerAPI from '../../../API/InterviewSchedulerAPI'; +import InterviewSchedulerDeleteModal from '../../Modals/InterviewSchedulerDeleteModal'; +import { Emitters, parseCsv } from '../../../utils'; + +const InterviewSchedulerCreator = () => { + const [name, setName] = useState(''); + const [csv, setCsv] = useState(); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [duration, setDuration] = useState(30); + const [membersPerSlot, setMembersPerSlot] = useState(1); + + const onSubmit = async () => { + if (!csv) { + Emitters.generalError.emit({ + headerMsg: 'Submission Error', + contentMsg: 'No csv file was provided.' + }); + return; + } + + const [columnHeaders, responses] = await parseCsv(csv); + const requiredHeaders = ['first name', 'last name', 'email', 'netid']; + + const missingHeader = requiredHeaders.some((header) => { + if (!columnHeaders.includes(header)) { + Emitters.generalError.emit({ + headerMsg: 'CSV Parsing Error', + contentMsg: `The csv file does not contain a column with header: ${header}` + }); + return true; + } + return false; + }); + + if (missingHeader) return; + + if (name === '') { + Emitters.generalError.emit({ + headerMsg: 'Invalid Input', + contentMsg: 'The name must not be empty.' + }); + } else if (duration < 0) { + Emitters.generalError.emit({ + headerMsg: 'Invalid Duration', + contentMsg: 'The duration must be positive.' + }); + } else if (membersPerSlot < 0) { + Emitters.generalError.emit({ + headerMsg: 'Invalid Members per Slot', + contentMsg: 'Members per slot must be positive.' + }); + } else if (!startDate || !endDate) { + Emitters.generalError.emit({ + headerMsg: 'Invalid Date', + contentMsg: 'Start date and end date are undefined.' + }); + } else { + await InterviewSchedulerAPI.createNewInstance({ + name, + duration: duration * 60000, // convert to milliseconds + membersPerSlot, + startDate: (startDate as Date).getTime(), + endDate: (endDate as Date).getTime(), + isOpen: false, + uuid: '', + applicants: responses.map((response) => ({ + firstName: response[columnHeaders.indexOf('first name')], + lastName: response[columnHeaders.indexOf('last name')], + email: response[columnHeaders.indexOf('email')], + netid: response[columnHeaders.indexOf('netid')] + })) + }); + Emitters.generalSuccess.emit({ + headerMsg: 'Successfully created interview scheduler', + contentMsg: `Created interview scheduler instance: ${name}` + }); + setName(''); + setStartDate(null); + setEndDate(null); + } + }; + + return ( +
+
Create a new Interview Scheduler instance
+
+ setName(e.target.value)} /> +
Applicants
+ { + if (e.target.files) setCsv(e.target.files[0]); + }} + /> + +
Start and end date
+ { + const [start, end] = date as [Date | null, Date | null]; + if (start) { + start.setHours(0, 0, 0, 0); + } + setStartDate(start); + setEndDate(end); + }} + /> +
Duration (in minutes)
+ setDuration(Number(e.target.value))} + /> +
Members Per Slot
+ setMembersPerSlot(Number(e.target.value))} + /> + + +
+ ); +}; + +const InterviewSchedulerEditor = () => { + const [instances, setInstances] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + InterviewSchedulerAPI.getAllInstances(false).then((instances) => { + setInstances(instances); + setIsLoading(false); + }); + }, []); + + const toggleIsOpen = async (uuid: string) => { + const newInstances = await Promise.all( + instances.map(async (instance) => { + if (instance.uuid === uuid) { + await InterviewSchedulerAPI.updateInstance({ + uuid: instance.uuid, + isOpen: !instance.isOpen + }); + return { ...instance, isOpen: !instance.isOpen }; + } + return instance; + }) + ); + setInstances(newInstances); + }; + + return ( +
+
All Interview Scheduler Instances
+ {isLoading ? ( + + ) : ( + + {instances.map((instance) => ( + + + {instance.name} + {instance.isOpen ? 'Open' : 'Closed'} +
+ toggleIsOpen(instance.uuid)} + /> +
+ +
+
+
+
+ ))} +
+ )} +
+ ); +}; + +const AdminInterviewSchedulerBase = () => ( +
+ + +
+); + +export default AdminInterviewSchedulerBase; diff --git a/frontend/src/components/Modals/InterviewSchedulerDeleteModal.tsx b/frontend/src/components/Modals/InterviewSchedulerDeleteModal.tsx new file mode 100644 index 000000000..602e48614 --- /dev/null +++ b/frontend/src/components/Modals/InterviewSchedulerDeleteModal.tsx @@ -0,0 +1,49 @@ +import { Button, Modal } from 'semantic-ui-react'; +import { useState } from 'react'; +import InterviewSchedulerAPI from '../../API/InterviewSchedulerAPI'; +import { Emitters } from '../../utils'; + +const InterviewSchedulerDeleteModal: React.FC<{ + uuid: string; + setInstances: React.Dispatch>; +}> = ({ uuid, setInstances }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpen={() => setIsOpen(true)} + open={isOpen} + trigger={} + > + + Are you sure you want to delete this Interview Scheduler instance? + + + Deleting this instance will delete all time slots associated with this instance. + + + + + + + ); +}; + +export default InterviewSchedulerDeleteModal; diff --git a/frontend/src/pages/admin/index.tsx b/frontend/src/pages/admin/index.tsx index ea6f66933..9e137de53 100644 --- a/frontend/src/pages/admin/index.tsx +++ b/frontend/src/pages/admin/index.tsx @@ -70,6 +70,12 @@ const navCardItems: readonly NavigationCardItem[] = [ description: 'Review coffee chat submissions!', link: '/admin/coffee-chats', adminOnly: true + }, + { + header: 'Edit Interview Scheduler', + description: 'Create or edit interview scheduler instances', + link: '/admin/interview-scheduler', + adminOnly: true } ]; diff --git a/frontend/src/pages/admin/interview-scheduler.tsx b/frontend/src/pages/admin/interview-scheduler.tsx new file mode 100644 index 000000000..5c52cb229 --- /dev/null +++ b/frontend/src/pages/admin/interview-scheduler.tsx @@ -0,0 +1,3 @@ +import AdminInterviewSchedulerBase from '../../components/Admin/AdminInterviewScheduler/AdminInterviewScheduler'; + +export default AdminInterviewSchedulerBase; diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 58f510472..ec54b614d 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -179,3 +179,33 @@ export const formatLink = (link: string, linkType?: LinkType): string | undefine return extractedLink; }; + +/** + * Parses a csv file into a two-length array where the first element is an array of headers and + * the second element is an array of responses. The indices of the headers correspond to the + * response indices. + * @param csv A rectangular csv file + * @returns A promise that resolves into an array of headers and responses + */ +export const parseCsv = async (csv: File): Promise<[string[], string[][]]> => { + const text = await csv.text(); + const rows = text.split('\n').map((row) => row.trim()); + + const headers = rows[0].split(',').map((header) => header.toLowerCase()); + const responses = rows.splice(1).map((row) => row.split(',')); + return [headers, responses]; +}; + +/** + * + * @param csv + * @returns + */ +export const fpo = async (csv: File): Promise<[string[], string[][]]> => { + const text = await csv.text(); + const rows = text.split('\n').map((row) => row.trim()); + + const headers = rows[0].split(',').map((header) => header.toLowerCase()); + const responses = rows.splice(1).map((row) => row.split(',')); + return [headers, responses]; +};