Skip to content

Commit dec3009

Browse files
authored
[interview scheduler] View Unassigned Applicants feature (#1123)
In this PR, I added the first part of a feature that allows Ops Leads to view applicants who haven't signed up for a slot for interviews in a Interview Scheduler instance. With the new feature, Ops leads have access to a "view unassigned applicants" button that opens a sidebar, and they are able to copy emails of all unassigned applicants, and using this list of emails, they are able to mass send an email via bcc to this list of applicants to send a reminder. <img width="1206" height="678" alt="image" src="https://github.com/user-attachments/assets/2408386f-6d4d-4cf6-b401-8c92ff2fe5a1" /> Future steps for this feature would include an auto-reminder email, but because I wanted to ship a barebones functional version of this feature for immediate use, this allows Ops leads to just copy the emails and remind applicants. Additional things included in this PR: 1. UI nit that makes the "Hover over this cell ..." left aligned with the rest of the text content 2. added more top padding to the scheduling side panel to offset the removed text line for better visual alignment <img width="1213" height="666" alt="image" src="https://github.com/user-attachments/assets/ca91bed5-3f36-434c-91ba-5f2a5ca72a72" /> ### Test Plan <!-- Required --> 1. change role to ops and test out functionality -- things to check for are the ability to copy all emails, cases where there are no applicants remaining should have placeholder/text that says there are no unassigned applicants ### Notes <!-- Optional -->
1 parent e579edb commit dec3009

File tree

5 files changed

+248
-79
lines changed

5 files changed

+248
-79
lines changed

frontend/src/components/Interview-Scheduler/InterviewScheduler.tsx

Lines changed: 106 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react';
2-
import { Button, Card, Dropdown, Header, Message } from 'semantic-ui-react';
2+
import { Button, Card, Dropdown, Header, Message, Sidebar } from 'semantic-ui-react';
33
import Link from 'next/link';
44
import { LEAD_ROLES } from 'common-types/constants';
55
import InterviewSchedulerAPI from '../../API/InterviewSchedulerAPI';
@@ -11,6 +11,7 @@ import { addToGoogleCalendar } from '../../calendar';
1111
import SchedulingSidePanel from './SchedulingSidePanel';
1212
import { EditAvailabilityContext, SchedulerDisplay, SetSlotsContext } from './SlotHooks';
1313
import { useUserEmail } from '../Common/UserProvider/UserProvider';
14+
import UnassignedApplicantsSidebar from './UnassignedApplicantsSidebar';
1415

1516
const displayOptions: { text: string; value: SchedulerDisplay }[] = [
1617
{
@@ -142,6 +143,9 @@ const InterviewScheduler: React.FC<{ uuid: string }> = ({ uuid }) => {
142143
const userEmail = useUserEmail();
143144
const member = useMember(userEmail);
144145
const isLead = member && LEAD_ROLES.includes(member.role);
146+
// ops lead check below to view applicants who have yet to sign up for slots:
147+
const isOpsLead = member && member.role === 'ops-lead';
148+
const [showUnassignedSidebar, setShowUnassignedSidebar] = useState(false);
145149

146150
const refreshSlots = () =>
147151
InterviewSchedulerAPI.getSlots(uuid, !isMember).then((res) => {
@@ -178,89 +182,113 @@ const InterviewScheduler: React.FC<{ uuid: string }> = ({ uuid }) => {
178182
return <InviteCard scheduler={scheduler} slot={possessedSlot} />;
179183

180184
return (
181-
<div className={styles.schedulerContainer}>
182-
{!scheduler ? (
183-
<p>Loading...</p>
184-
) : (
185-
<SetSlotsContext.Provider value={{ display, setSlots, setSelectedSlot, setHoveredSlot }}>
186-
<div className={styles.headerContainer}>
187-
<div>
188-
<Header as="h2">{scheduler.name}</Header>
189-
<p>{`${getDateString(scheduler.startDate, false)} - ${getDateString(scheduler.endDate, false)}`}</p>
190-
<Message info>
191-
<Message.Header>Please note</Message.Header>
192-
Once you sign up for an interview slot, you cannot cancel. You will have to email us
193-
at <a href="mailto:hello@cornelldti.org">hello@cornelldti.org</a> for scheduling
194-
conflicts. Please plan accordingly.
195-
</Message>
196-
</div>
197-
{isLead && (
198-
<div>
199-
{isEditing ? (
185+
<Sidebar.Pushable>
186+
{scheduler && (
187+
<UnassignedApplicantsSidebar
188+
visible={showUnassignedSidebar}
189+
onClose={() => setShowUnassignedSidebar(false)}
190+
scheduler={scheduler}
191+
slots={slots}
192+
/>
193+
)}
194+
<Sidebar.Pusher>
195+
<div className={styles.schedulerContainer}>
196+
{!scheduler ? (
197+
<p>Loading...</p>
198+
) : (
199+
<SetSlotsContext.Provider
200+
value={{ display, setSlots, setSelectedSlot, setHoveredSlot }}
201+
>
202+
<div className={styles.headerContainer}>
203+
<div>
204+
<Header as="h2">{scheduler.name}</Header>
205+
<p>{`${getDateString(scheduler.startDate, false)} - ${getDateString(scheduler.endDate, false)}`}</p>
206+
<p>
207+
Hover over to review time slots. Click to show more information, sign up, or
208+
cancel.
209+
</p>
210+
<Message info>
211+
<Message.Header>Please note</Message.Header>
212+
Once you sign up for an interview slot, you cannot cancel. You will have to
213+
email us at <a href="mailto:hello@cornelldti.org">hello@cornelldti.org</a> for
214+
scheduling conflicts. Please plan accordingly.
215+
</Message>
216+
</div>
217+
{isLead && (
200218
<div>
201-
<Button
202-
basic
203-
color="red"
204-
onClick={() => {
205-
setIsEditing(false);
206-
setTentativeSlots([]);
207-
}}
208-
>
209-
Cancel
210-
</Button>
211-
<Button basic onClick={handleSaveSlots}>
212-
Save
213-
</Button>
219+
{isEditing ? (
220+
<div>
221+
<Button
222+
basic
223+
color="red"
224+
onClick={() => {
225+
setIsEditing(false);
226+
setTentativeSlots([]);
227+
}}
228+
>
229+
Cancel
230+
</Button>
231+
<Button basic onClick={handleSaveSlots}>
232+
Save
233+
</Button>
234+
</div>
235+
) : (
236+
<>
237+
<Dropdown
238+
selection
239+
value={display}
240+
options={displayOptions}
241+
onChange={(_, data) => {
242+
setDisplay(data.value as SchedulerDisplay);
243+
}}
244+
/>
245+
<Button
246+
basic
247+
onClick={() => {
248+
setIsEditing(true);
249+
setSelectedSlot(undefined);
250+
}}
251+
>
252+
Add availabilities
253+
</Button>
254+
{isOpsLead && (
255+
<Button
256+
basic
257+
onClick={() => {
258+
setShowUnassignedSidebar(true);
259+
}}
260+
>
261+
View unassigned applicants
262+
</Button>
263+
)}
264+
</>
265+
)}
214266
</div>
215-
) : (
216-
<>
217-
<Dropdown
218-
selection
219-
value={display}
220-
options={displayOptions}
221-
onChange={(_, data) => {
222-
setDisplay(data.value as SchedulerDisplay);
223-
}}
224-
/>
225-
<Button
226-
basic
227-
onClick={() => {
228-
setIsEditing(true);
229-
setSelectedSlot(undefined);
230-
}}
231-
>
232-
Add availabilities
233-
</Button>
234-
</>
235267
)}
236268
</div>
237-
)}
238-
</div>
239-
<div className={styles.contentContainer}>
240-
<EditAvailabilityContext.Provider
241-
value={{ isEditing, setIsEditing, tentativeSlots, setTentativeSlots }}
242-
>
243-
<SchedulingCalendar scheduler={scheduler} slots={slots} />
244-
</EditAvailabilityContext.Provider>
245-
{!isEditing && (
246-
<div className={styles.sidebarContainer}>
247-
<p>
248-
Hover over to review time slots. Click to show more information, sign up, or
249-
cancel.
250-
</p>
251-
{(hoveredSlot || selectedSlot) && (
252-
<SchedulingSidePanel
253-
displayedSlot={(hoveredSlot || selectedSlot) as InterviewSlot}
254-
scheduler={scheduler}
255-
refresh={refreshSlots}
256-
/>
269+
<div className={styles.contentContainer}>
270+
<EditAvailabilityContext.Provider
271+
value={{ isEditing, setIsEditing, tentativeSlots, setTentativeSlots }}
272+
>
273+
<SchedulingCalendar scheduler={scheduler} slots={slots} />
274+
</EditAvailabilityContext.Provider>
275+
{!isEditing && (
276+
<div className={styles.sidebarContainer}>
277+
{(hoveredSlot || selectedSlot) && (
278+
<SchedulingSidePanel
279+
displayedSlot={(hoveredSlot || selectedSlot) as InterviewSlot}
280+
scheduler={scheduler}
281+
refresh={refreshSlots}
282+
/>
283+
)}
284+
</div>
257285
)}
258286
</div>
259-
)}
260-
</div>
261-
</SetSlotsContext.Provider>
262-
)}
263-
</div>
287+
</SetSlotsContext.Provider>
288+
)}
289+
</div>
290+
</Sidebar.Pusher>
291+
</Sidebar.Pushable>
264292
);
265293
};
266294

frontend/src/components/Interview-Scheduler/SchedulingSidePanel.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
.contentContainer {
2+
width: 60%;
3+
margin-top: 2em;
4+
overflow-x: auto;
5+
}
6+
17
.buttonContainer {
28
margin-top: 1em;
39
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.sidebar {
2+
background: white;
3+
position: fixed;
4+
right: 0;
5+
top: 0;
6+
}
7+
8+
.sidebarContent {
9+
display: flex;
10+
flex-direction: column;
11+
padding: 2em;
12+
position: relative;
13+
opacity: 100%;
14+
}
15+
16+
.header {
17+
display: flex;
18+
justify-content: space-between;
19+
align-items: center;
20+
margin-bottom: 1.5em;
21+
}
22+
23+
.closeIcon {
24+
cursor: pointer;
25+
}
26+
27+
.buttonContainer {
28+
display: flex;
29+
gap: 1em;
30+
margin-bottom: 2em;
31+
}
32+
33+
.applicantsList {
34+
flex: 1;
35+
overflow-y: auto;
36+
}
37+
38+
.applicantsList ul {
39+
list-style: none;
40+
padding: 0;
41+
margin: 0;
42+
}
43+
44+
.applicantItem {
45+
padding: 1em;
46+
margin-bottom: 1em;
47+
border: 1px solid var(--border-default);
48+
border-radius: 4px;
49+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Button, Icon, Sidebar } from 'semantic-ui-react';
2+
import { useMemo } from 'react';
3+
import { Emitters } from '../../utils';
4+
import styles from './UnassignedApplicantsSidebar.module.css';
5+
6+
interface UnassignedApplicantsSidebarProps {
7+
visible: boolean;
8+
onClose: () => void;
9+
scheduler: InterviewScheduler;
10+
slots: InterviewSlot[];
11+
}
12+
13+
const UnassignedApplicantsSidebar: React.FC<UnassignedApplicantsSidebarProps> = ({
14+
visible,
15+
onClose,
16+
scheduler,
17+
slots
18+
}) => {
19+
// finding unassigned applicants
20+
const unassignedApplicants = useMemo(() => {
21+
const assignedEmails = new Set(slots.flatMap((slot) => slot.applicant?.email ?? []));
22+
return scheduler.applicants.filter((applicant) => !assignedEmails.has(applicant.email));
23+
}, [scheduler.applicants, slots]);
24+
25+
const handleCopyAllEmails = () => {
26+
const emails = unassignedApplicants.map((app) => app.email).join(', ');
27+
navigator.clipboard
28+
.writeText(emails)
29+
.then(() => {
30+
Emitters.generalSuccess.emit({
31+
headerMsg: 'Emails Copied',
32+
contentMsg: `Copied ${unassignedApplicants.length} email${unassignedApplicants.length !== 1 ? 's' : ''} to clipboard.`
33+
});
34+
})
35+
.catch(() => {
36+
Emitters.generalError.emit({
37+
headerMsg: 'Unable to Copy',
38+
contentMsg: 'Failed to copy emails to clipboard!'
39+
});
40+
});
41+
};
42+
43+
return (
44+
<Sidebar
45+
as="div"
46+
animation="overlay"
47+
direction="right"
48+
visible={visible}
49+
onHide={onClose}
50+
width="wide"
51+
className={styles.sidebar}
52+
>
53+
<div className={styles.sidebarContent}>
54+
<div className={styles.header}>
55+
<h3>Unassigned Applicants</h3>
56+
<Icon name="close" onClick={onClose} className={styles.closeIcon} />
57+
</div>
58+
<div className={styles.buttonContainer}>
59+
<Button basic onClick={handleCopyAllEmails}>
60+
Copy all emails
61+
</Button>
62+
</div>
63+
<div className={styles.applicantsList}>
64+
{unassignedApplicants.length === 0 ? (
65+
<p>All applicants have been assigned to slots.</p>
66+
) : (
67+
<ul>
68+
{unassignedApplicants.map((applicant) => (
69+
<li key={applicant.email} className={styles.applicantItem}>
70+
<div>
71+
<strong>
72+
{applicant.firstName} {applicant.lastName}
73+
</strong>
74+
</div>
75+
<div>{applicant.email}</div>
76+
</li>
77+
))}
78+
</ul>
79+
)}
80+
</div>
81+
</div>
82+
</Sidebar>
83+
);
84+
};
85+
86+
export default UnassignedApplicantsSidebar;

new-dti-website-redesign/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"noEmit": true,
99
"esModuleInterop": true,
1010
"module": "esnext",
11-
"moduleResolution": "bundler",
11+
"moduleResolution": "node",
1212
"resolveJsonModule": true,
1313
"isolatedModules": true,
1414
"jsx": "preserve",

0 commit comments

Comments
 (0)