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 Scheduling Interface #870

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.schedulerContainer {
padding: 2%;
}

.contentContainer {
display: flex;
gap: 3%;
}

.sidebarContainer {
width: 40%;
padding-top: 4em;
}

.inviteCardContainer {
display: flex;
justify-content: center;
align-items: center;
height: calc(100vh - 80px);
}

.inviteCard {
width: 40% !important;
}

.inviteHeader {
margin-bottom: 1em;
}

.headerContainer {
display: flex;
justify-content: space-between;
}
168 changes: 168 additions & 0 deletions frontend/src/components/Interview-Scheduler/InterviewScheduler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { Button, Card, Header } from 'semantic-ui-react';
import Link from 'next/link';
import InterviewSchedulerAPI from '../../API/InterviewSchedulerAPI';
import { useHasAdminPermission, useHasMemberPermission } from '../Common/FirestoreDataProvider';
import styles from './InterviewScheduler.module.css';
import SchedulingCalendar from './SchedulingCalendar';
import { Emitters, getDateString, getTimeString } from '../../utils';
import SchedulingSidePanel from './SchedulingSidePanel';
import { EditAvailabilityContext, SetSlotsContext } from './SlotHooks';
import { useUserEmail } from '../Common/UserProvider/UserProvider';

const InviteCard: React.FC<{ scheduler: InterviewScheduler; slot?: InterviewSlot }> = ({
scheduler,
slot
}) => (
<div className={styles.inviteCardContainer}>
<Card className={styles.inviteCard}>
<Card.Content>
{slot ? (
<>
<Card.Header className={styles.inviteHeader}>Thank you for signing up!</Card.Header>
<div>
<p>{scheduler.name}</p>
<p>
{getTimeString(slot.startTime)} -{' '}
{getTimeString(slot.startTime + scheduler.duration)}
</p>
<p>{slot.room}</p>
</div>
</>
) : (
<>
<Card.Header>Could not find time slot.</Card.Header>
<p>
We could not find a time slot that you signed up for. For assistance, please contact
the email below.
</p>
</>
)}
</Card.Content>
<Card.Content extra>
Need help? Send a message to{' '}
<Link href="mailto:[email protected]">[email protected]</Link>
</Card.Content>
</Card>
</div>
);

const InterviewScheduler: React.FC<{ uuid: string }> = ({ uuid }) => {
const [scheduler, setScheduler] = useState<InterviewScheduler>();
const [slots, setSlots] = useState<InterviewSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<InterviewSlot | undefined>();
const [hoveredSlot, setHoveredSlot] = useState<InterviewSlot | undefined>();
const [possessedSlot, setPossessedSlot] = useState<InterviewSlot | undefined>();
const [isEditing, setIsEditing] = useState(false);
const [tentativeSlots, setTentativeSlots] = useState<InterviewSlot[]>([]);

const isMember = useHasMemberPermission();
const isAdmin = useHasAdminPermission();
const userEmail = useUserEmail();

const refreshSlots = () =>
InterviewSchedulerAPI.getSlots(uuid, !isMember).then((res) => {
setSlots(res);
});

useEffect(() => {
InterviewSchedulerAPI.getInstance(uuid, !isMember).then((inst) => {
setScheduler(inst);
});
refreshSlots();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
setPossessedSlot(slots.find((slot) => slot.applicant && slot.applicant.email === userEmail));
}, [userEmail, slots]);

const handleSaveSlots = () => {
if (tentativeSlots.length !== 0) {
InterviewSchedulerAPI.createSlots(tentativeSlots).then((val) => {
setSlots([...slots, ...val]);
setIsEditing(false);
Emitters.generalSuccess.emit({
headerMsg: 'Create Interview Slots',
contentMsg: `You have successfully added ${tentativeSlots.length} slots.`
});
setTentativeSlots([]);
});
}
};

if (scheduler && !scheduler.isOpen && !isMember)
return <InviteCard scheduler={scheduler} slot={possessedSlot} />;

return (
<div className={styles.schedulerContainer}>
{!scheduler ? (
<p>Loading...</p>
) : (
<SetSlotsContext.Provider value={{ setSlots, setSelectedSlot, setHoveredSlot }}>
<div className={styles.headerContainer}>
<div>
<Header as="h2">{scheduler.name}</Header>
<p>{`${getDateString(scheduler.startDate, false)} - ${getDateString(scheduler.endDate, false)}`}</p>
</div>
{isAdmin && (
<div>
{isEditing ? (
<div>
<Button
basic
color="red"
onClick={() => {
setIsEditing(false);
setTentativeSlots([]);
}}
>
Cancel
</Button>
<Button basic onClick={handleSaveSlots}>
Save
</Button>
</div>
) : (
<Button
basic
onClick={() => {
setIsEditing(true);
setSelectedSlot(undefined);
}}
>
Add availabilities
</Button>
)}
</div>
)}
</div>
<div className={styles.contentContainer}>
<EditAvailabilityContext.Provider
value={{ isEditing, setIsEditing, tentativeSlots, setTentativeSlots }}
>
<SchedulingCalendar scheduler={scheduler} slots={slots} />
</EditAvailabilityContext.Provider>
{!isEditing && (
<div className={styles.sidebarContainer}>
<p>
Hover over to review time slots. Click to show more information, sign up, or
cancel.
</p>
{(hoveredSlot || selectedSlot) && (
<SchedulingSidePanel
displayedSlot={(hoveredSlot || selectedSlot) as InterviewSlot}
scheduler={scheduler}
refresh={refreshSlots}
/>
)}
</div>
)}
</div>
</SetSlotsContext.Provider>
)}
</div>
);
};

export default InterviewScheduler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.listContainer {
padding: 2%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { Card, Header, Loader } from 'semantic-ui-react';
import styles from './InterviewSchedulerList.module.css';
import InterviewSchedulerAPI from '../../API/InterviewSchedulerAPI';
import { useHasMemberPermission } from '../Common/FirestoreDataProvider';

const InterviewSchedulerList = () => {
const [schedulers, setSchedulers] = useState<InterviewScheduler[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const isMember = useHasMemberPermission();

useEffect(() => {
InterviewSchedulerAPI.getAllInstances(!isMember)
.then((val) => setSchedulers(val))
.then(() => setIsLoading(false));
}, [isMember]);

return isLoading ? (
<Loader size="massive" />
) : (
<div className={styles.listContainer}>
<Header as="h2">Interview Schedulers</Header>
{!schedulers || schedulers.length === 0 ? (
<p>
You currently do not have access to any interview schedulers. If you think this is an
error please contact {isMember ? '#idol-support' : '[email protected]'}.
</p>
) : (
<Card.Group>
{schedulers.map((scheduler) => (
<Card href={`interview-scheduler/${scheduler.uuid}`} key={scheduler.uuid}>
<Card.Content>
<Card.Header>{scheduler.name}</Card.Header>
</Card.Content>
</Card>
))}
</Card.Group>
)}
</div>
);
};

export default InterviewSchedulerList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
.timeslot {
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
border: 1px solid black;
height: 15px;
width: 150px;
}

.column {
position: relative;
}

.calendarContainer {
width: 60%;
margin-top: 2em;
overflow-x: auto;
}

.calendarContainerFull {
width: 100%;
margin-top: 2em;
overflow-x: auto;
}

.columnContainer {
display: flex;
margin-top: 2rem;
--hour-block-width: 150px;
--hour-block-height: 60px;
}

.timeLabelColumn {
text-align: right;
width: 100px;
padding-right: 10px;
}

.timeLabel {
height: var(--hour-block-height);
margin: 0;
}

.slotButton {
position: absolute;
width: var(--hour-block-width);
padding: 0 !important;
border-radius: 0 !important;
}

.roomName {
text-align: center;
margin-bottom: 5px;
}

.roomForm {
display: flex;
gap: 1em;
margin-top: 1em;
}
Loading
Loading