Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interview Scheduler Admin View #855

Merged
merged 4 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/public/sample_interview_scheduler_input.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Email,First Name,Last Name,NetID
[email protected],Alder,Alderson,aaa111
[email protected],Donald,Donaldson,ddd222
[email protected],Chris,Chen,cc2785
[email protected],Jane,Doe,abc123
55 changes: 55 additions & 0 deletions frontend/src/API/InterviewSchedulerAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import APIWrapper from './APIWrapper';
import { backendURL } from '../environment';

export default class InterviewSchedulerAPI {
static async createNewInstance(instance: InterviewScheduler): Promise<string> {
const response = APIWrapper.post(`${backendURL}/interview-scheduler`, instance);
return response.then((val) => val.data.uuid);
}

static async getAllInstances(isApplicant: boolean): Promise<InterviewScheduler[]> {
const response = APIWrapper.get(
`${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}`
);
return response.then((val) => val.data.instances);
}

static async getInstance(uuid: string, isApplicant: boolean): Promise<InterviewScheduler> {
const response = APIWrapper.get(
`${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}/${uuid}`
);
return response.then((val) => val.data.instance);
}

static async updateInstance(instance: InterviewSchedulerEdit): Promise<InterviewScheduler> {
const response = APIWrapper.put(`${backendURL}/interview-scheduler`, instance);
return response.then((val) => val.data.instance);
}

static async deleteInstance(uuid: string): Promise<void> {
APIWrapper.delete(`${backendURL}/interview-scheduler/${uuid}`);
}

static async getSlots(uuid: string, isApplicant: boolean): Promise<InterviewSlot[]> {
return APIWrapper.get(
`${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}/${uuid}`
).then((val) => val.data.slots);
}

static async createSlots(slots: InterviewSlot[]): Promise<InterviewSlot[]> {
return APIWrapper.post(`${backendURL}/interview-slots`, { slots }).then(
(val) => val.data.slots
);
}

static async updateSlot(edits: InterviewSlotEdit, isApplicant: boolean): Promise<boolean> {
return APIWrapper.put(
`${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}`,
edits
).then((val) => val.data.success);
}

static async deleteSlot(uuid: string): Promise<void> {
APIWrapper.delete(`${backendURL}/interview-slots/${uuid}`);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string>('');
const [csv, setCsv] = useState<File>();
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [duration, setDuration] = useState<number>(30);
const [membersPerSlot, setMembersPerSlot] = useState<number>(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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is kind of complicated so maybe you could move it to a separate utility function that parses csv files


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 (
<div>
<Header as="h2">Create a new Interview Scheduler instance</Header>
<Form>
<Form.Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Header as="h4">Applicants</Header>
<input
type="file"
accept=".csv"
onChange={(e) => {
if (e.target.files) setCsv(e.target.files[0]);
}}
/>
<label>
{' '}
Format: .csv with at least a "Email", "NetID", "First Name", and "Last Name" column (case
insensitive).{' '}
<a href="/sample_interview_scheduler_input.csv">Download sample file here.</a>
</label>
<Header as="h4">Start and end date</Header>
<DatePicker
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(date) => {
const [start, end] = date as [Date | null, Date | null];
if (start) {
start.setHours(0, 0, 0, 0);
}
setStartDate(start);
setEndDate(end);
}}
/>
<Header as="h4">Duration (in minutes)</Header>
<input
type="number"
defaultValue={30}
onChange={(e) => setDuration(Number(e.target.value))}
/>
<Header as="h4">Members Per Slot</Header>
<input
type="number"
defaultValue={1}
onChange={(e) => setMembersPerSlot(Number(e.target.value))}
/>
<Button onClick={onSubmit} className={styles.submitButton}>
Create Interview Scheduler Instance
</Button>
</Form>
</div>
);
};

const InterviewSchedulerEditor = () => {
const [instances, setInstances] = useState<InterviewScheduler[]>([]);
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 (
<div className={styles.editorContainer}>
<Header as="h2">All Interview Scheduler Instances</Header>
{isLoading ? (
<Loader size="large" />
) : (
<Card.Group>
{instances.map((instance) => (
<Card key={instance.uuid}>
<Card.Content>
<Card.Header>{instance.name}</Card.Header>
<Card.Meta>{instance.isOpen ? 'Open' : 'Closed'}</Card.Meta>
<div>
<Checkbox
toggle
defaultChecked={instance.isOpen}
onChange={() => toggleIsOpen(instance.uuid)}
/>
<div>
<InterviewSchedulerDeleteModal
setInstances={setInstances}
uuid={instance.uuid}
/>
</div>
</div>
</Card.Content>
</Card>
))}
</Card.Group>
)}
</div>
);
};

const AdminInterviewSchedulerBase = () => (
<div className={styles.creatorContainer}>
<InterviewSchedulerCreator />
<InterviewSchedulerEditor />
</div>
);

export default AdminInterviewSchedulerBase;
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<InterviewScheduler[]>>;
}> = ({ uuid, setInstances }) => {
const [isOpen, setIsOpen] = useState(false);

return (
<Modal
onClose={() => setIsOpen(false)}
onOpen={() => setIsOpen(true)}
open={isOpen}
trigger={<Button negative>Delete</Button>}
>
<Modal.Header>
Are you sure you want to delete this Interview Scheduler instance?
</Modal.Header>
<Modal.Content>
Deleting this instance will delete all time slots associated with this instance.
</Modal.Content>
<Modal.Actions>
<Button negative onClick={() => setIsOpen(false)}>
No
</Button>
<Button
positive
onClick={() => {
InterviewSchedulerAPI.deleteInstance(uuid).then(() => {
setInstances((instances) => instances.filter((inst) => inst.uuid !== uuid));
});
setIsOpen(false);
Emitters.generalSuccess.emit({
headerMsg: 'Success.',
contentMsg: 'Successfully deleted interview scheduler instance.'
});
}}
>
Yes
</Button>
</Modal.Actions>
</Modal>
);
};

export default InterviewSchedulerDeleteModal;
6 changes: 6 additions & 0 deletions frontend/src/pages/admin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
];

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/admin/interview-scheduler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AdminInterviewSchedulerBase from '../../components/Admin/AdminInterviewScheduler/AdminInterviewScheduler';

export default AdminInterviewSchedulerBase;
Loading
Loading