diff --git a/src/components/common/VerticalFormItem/index.tsx b/src/components/common/VerticalFormItem/index.tsx index 71ecb5b2..7ef3b469 100644 --- a/src/components/common/VerticalFormItem/index.tsx +++ b/src/components/common/VerticalFormItem/index.tsx @@ -1,5 +1,6 @@ -import { HTMLInputTypeAttribute, ReactNode } from 'react'; -import { UseFormRegisterReturn } from 'react-hook-form'; +import React, { HTMLInputTypeAttribute, ReactNode, useState } from 'react'; +import { UseFormRegisterReturn, useForm } from 'react-hook-form'; +import { AiOutlineSearch } from 'react-icons/ai'; import styles from './style.module.scss'; interface InputTypeProps { @@ -11,6 +12,10 @@ interface SelectTypeProps { element: 'select'; options: (number | string)[]; } +interface SelectMultipleTypeProps { + element: 'select-multiple'; + options: string[]; +} interface FormItemProps { icon: ReactNode; name: string; @@ -20,10 +25,14 @@ interface FormItemProps { inputHeight?: string; } -type VerticalFormProps = FormItemProps & (InputTypeProps | SelectTypeProps); +type VerticalFormProps = FormItemProps & + (InputTypeProps | SelectTypeProps | SelectMultipleTypeProps); const VerticalFormItem = (props: VerticalFormProps) => { const { icon, placeholder, formRegister, element, error, inputHeight } = props; + const { setValue } = useForm(); + const [selectedOptions, setSelectedOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); if (element === 'input') { const { type } = props; @@ -61,6 +70,9 @@ const VerticalFormItem = (props: VerticalFormProps) => { lineHeight: inputHeight, }} > + {options.map(value => ( ))} @@ -71,6 +83,54 @@ const VerticalFormItem = (props: VerticalFormProps) => { ); } + if (element === 'select-multiple') { + const { options } = props; + const handleSelectionChange = (event: React.ChangeEvent) => { + const selectedValues = Array.from(event.target.selectedOptions, option => option.value); + setSelectedOptions(selectedValues); + setValue('email', selectedValues, { shouldValidate: true }); + }; + const filteredOptions = options.filter(option => + option.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+
+ +
+
+

Selected: {selectedOptions.join(', ')}

+
+ setSearchTerm(e.target.value)} + className={styles.inputField} + /> + +
+
+
+

{error?.message}

+
+ ); + } + return null; }; diff --git a/src/components/common/VerticalFormItem/style.module.scss b/src/components/common/VerticalFormItem/style.module.scss index d143cb0c..9ff0cc86 100644 --- a/src/components/common/VerticalFormItem/style.module.scss +++ b/src/components/common/VerticalFormItem/style.module.scss @@ -43,6 +43,28 @@ display: none; } } + + .selectMultipleField { + align-items: center; + appearance: none; + background-color: var(--theme-background); + border: 0; + border-radius: 0.25em; + cursor: pointer; + font-family: inherit; + font-size: 1em; + height: auto; /* Adjust height to match your dropdown */ + line-height: 1.75rem; + margin: 0; + outline: none; + padding: 2px; + transition: 0.3s ease; + width: 100%; + + + } + + } .formError { diff --git a/src/components/common/VerticalFormItem/style.module.scss.d.ts b/src/components/common/VerticalFormItem/style.module.scss.d.ts index 2d4474f8..af399523 100644 --- a/src/components/common/VerticalFormItem/style.module.scss.d.ts +++ b/src/components/common/VerticalFormItem/style.module.scss.d.ts @@ -5,6 +5,7 @@ export type Styles = { iconContainer: string; inputField: string; selectField: string; + selectMultipleField: string; }; export type ClassNames = keyof Styles; diff --git a/src/lib/api/AdminAPI.ts b/src/lib/api/AdminAPI.ts index 5eea06c0..8b4f86fe 100644 --- a/src/lib/api/AdminAPI.ts +++ b/src/lib/api/AdminAPI.ts @@ -1,5 +1,15 @@ import { config } from '@/lib'; -import type { ModifyUserAccessLevelResponse, PrivateProfile } from '@/lib/types/apiResponses'; +import { Bonus, Milestone, SubmitAttendanceForUsersRequest } from '@/lib/types/apiRequests'; +import { UUID } from '@/lib/types'; +import { + CreateBonusResponse, + GetAllNamesAndEmailsResponse, + SubmitAttendanceForUsersResponse, + type ModifyUserAccessLevelResponse, + type PrivateProfile, + type NameAndEmail, + CreateMilestoneResponse, +} from '@/lib/types/apiResponses'; import axios from 'axios'; /** @@ -31,4 +41,95 @@ export const manageUserAccess = async ( return response.data.updatedUsers; }; -export default manageUserAccess; +/** + * Get all users emails + */ +export const getAllUserEmails = async (token: string): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.admin.emails}`; + + const response = await axios.get(requestUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.namesAndEmails; +}; + +/** + * Retroactively add bonus points + * @param token Bearer token + * @param users Users that the bonus is going to + * @param description Description for the bonus + * @param points Number of points awarded + * @returns Updated users + */ +export const addBonus = async ( + token: string, + users: string[], + description: string, + points: number +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.admin.bonus}`; + const bonus: Bonus = { users: users, description: description, points: points }; + const response = await axios.post( + requestUrl, + { bonus: bonus }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; +}; + +/** + * Retroactively add attendance points + * @param token Bearer token + * @param users List of users + * @param event Event for whcih attendance is added + * @param points Number of points awarded + */ +export const addAttendance = async ( + token: string, + users: string[], + event: UUID +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.admin.attendance}`; + const attendance: SubmitAttendanceForUsersRequest = { + users: users, + event: event, + }; + + const response = await axios.post(requestUrl, attendance, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return response.data; +}; + +export const createMilestone = async ( + token: string, + name: string, + points: number +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.admin.milestone}`; + const milestone: Milestone = { + name: name, + points: points, + }; + + const response = await axios.post( + requestUrl, + { milestone }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return response.data; +}; diff --git a/src/lib/config.ts b/src/lib/config.ts index 3ebecd6e..c3b649d4 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -27,6 +27,7 @@ const config = { bonus: '/admin/bonus', emails: '/admin/email', access: '/admin/access', + milestone: '/admin/milestone', }, event: { event: '/event', diff --git a/src/lib/types/apiResponses.ts b/src/lib/types/apiResponses.ts index a434d9ec..98927f1a 100644 --- a/src/lib/types/apiResponses.ts +++ b/src/lib/types/apiResponses.ts @@ -42,6 +42,10 @@ export interface CreateBonusResponse extends ApiResponse { emails: string[]; } +export interface AddAttendanceResponse extends ApiResponse { + emails: string[]; +} + export interface UploadBannerResponse extends ApiResponse { banner: string; } @@ -50,6 +54,15 @@ export interface GetAllEmailsResponse extends ApiResponse { emails: string[]; } +export interface NameAndEmail { + firstName: string; + lastName: string; + email: string; +} +export interface GetAllNamesAndEmailsResponse extends ApiResponse { + namesAndEmails: NameAndEmail[]; +} + export interface SubmitAttendanceForUsersResponse extends ApiResponse { attendances: PublicAttendance[]; } diff --git a/src/pages/admin/attendance.tsx b/src/pages/admin/attendance.tsx index 3e8df4c0..b5b2e4c7 100644 --- a/src/pages/admin/attendance.tsx +++ b/src/pages/admin/attendance.tsx @@ -4,28 +4,48 @@ import { VerticalFormItem, VerticalFormTitle, } from '@/components/common'; -import { config } from '@/lib'; -import withAccessType from '@/lib/hoc/withAccessType'; -import { PermissionService, ValidationService } from '@/lib/services'; -import type { GetServerSideProps, NextPage } from 'next'; +import { showToast, config } from '@/lib'; +import { reportError } from '@/lib/utils'; +import { PermissionService } from '@/lib/services'; +import withAccessType, { GetServerSidePropsWithAuth } from '@/lib/hoc/withAccessType'; +import type { NextPage } from 'next'; +import { AdminAPI, EventAPI } from '@/lib/api'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { AiOutlineMail } from 'react-icons/ai'; -import { VscLock } from 'react-icons/vsc'; +import { AiOutlineArrowDown, AiOutlineCalendar } from 'react-icons/ai'; +import { PublicEvent } from '@/lib/types/apiResponses'; interface FormValues { - email: string; + email: string[]; + event: string; +} +interface AwardPointsPageProps { + title: string; description: string; - points: number; + allEvents: PublicEvent[]; + authToken: string; + sortedEmails: string[]; } -const AwardPointsPage: NextPage = () => { +const AwardPointsPage: NextPage = ({ + allEvents, + authToken, + sortedEmails, +}) => { const { register, handleSubmit, formState: { errors }, } = useForm(); - const onSubmit: SubmitHandler = () => { - // TODO + const eventDict = Object.fromEntries(allEvents.map(event => [event.title, event.uuid])); + + const onSubmit: SubmitHandler = async ({ email, event }) => { + try { + const selectedUUID: string | undefined = eventDict[event]; + await AdminAPI.addAttendance(authToken, email, selectedUUID || ''); + showToast('Successfully awarded attendance!'); + } catch (error) { + reportError('Error found!', error); + } }; return ( @@ -35,41 +55,28 @@ const AwardPointsPage: NextPage = () => { description="Mark members as attended for past events" /> } - element="input" + icon={} + element="select-multiple" name="email" - type="email" + options={sortedEmails} placeholder="User Email (user@ucsd.edu)" formRegister={register('email', { - validate: email => { - const validation = ValidationService.isValidEmail(email); - return validation.valid || validation.error; - }, - })} - error={errors.email} - /> - } - element="input" - name="description" - type="text" - placeholder="Description" - formRegister={register('description', { required: 'Required', })} - error={errors.description} + error={errors.email} /> } - name="points" - element="input" - type="number" - placeholder="Point Value" - formRegister={register('points', { + icon={} + element="select" + name="event" + options={allEvents.map(event => event.title)} + placeholder="Select an Event" + formRegister={register('event', { required: 'Required', })} - error={errors.points} + error={errors.event} /> + { export default AwardPointsPage; -const getServerSidePropsFunc: GetServerSideProps = async () => ({ - props: { - title: 'Retroactive Attendance', - description: 'Mark members as attended for past events', - }, -}); +const getServerSidePropsFunc: GetServerSidePropsWithAuth = async ({ authToken }) => { + const allEvents = await EventAPI.getAllPastEvents(); + + const allUsers = await AdminAPI.getAllUserEmails(authToken); + + const properEmails = allUsers.map(user => user.email); + const sortedEmails = properEmails.sort((a, b) => a.localeCompare(b)); + + return { + props: { + title: 'Retroactive Attendance', + description: 'Mark members as attended for past events', + allEvents, + authToken, + sortedEmails, + }, + }; +}; export const getServerSideProps = withAccessType( getServerSidePropsFunc, diff --git a/src/pages/admin/milestone.tsx b/src/pages/admin/milestone.tsx index 24ede901..588a7a69 100644 --- a/src/pages/admin/milestone.tsx +++ b/src/pages/admin/milestone.tsx @@ -4,27 +4,38 @@ import { VerticalFormItem, VerticalFormTitle, } from '@/components/common'; -import { config } from '@/lib'; -import withAccessType from '@/lib/hoc/withAccessType'; +import { showToast, config } from '@/lib'; +import { AdminAPI } from '@/lib/api'; +import { reportError } from '@/lib/utils'; +import withAccessType, { GetServerSidePropsWithAuth } from '@/lib/hoc/withAccessType'; import { PermissionService } from '@/lib/services'; -import type { GetServerSideProps, NextPage } from 'next'; +import type { NextPage } from 'next'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { AiOutlineMail } from 'react-icons/ai'; +import { AiOutlineStar } from 'react-icons/ai'; import { VscLock } from 'react-icons/vsc'; interface FormValues { name: string; points: number; } -const AwardPointsPage: NextPage = () => { + +interface AwardPointsPageProps { + authToken: string; +} +const AwardPointsPage: NextPage = ({ authToken }) => { const { register, handleSubmit, formState: { errors }, } = useForm(); - const onSubmit: SubmitHandler = () => { - // TODO + const onSubmit: SubmitHandler = async ({ name, points }) => { + try { + await AdminAPI.createMilestone(authToken, name, Number(points)); + showToast('Successfully awarded attendance!'); + } catch (error) { + reportError('Error found!', error); + } }; return ( @@ -34,7 +45,7 @@ const AwardPointsPage: NextPage = () => { description="Award points to all active users (e.g. for ACM's 8 year anniversary)" /> } + icon={} element="input" name="name" type="text" @@ -52,6 +63,7 @@ const AwardPointsPage: NextPage = () => { placeholder="Point Value" formRegister={register('points', { required: 'Required', + valueAsNumber: true, })} error={errors.points} /> @@ -67,10 +79,11 @@ const AwardPointsPage: NextPage = () => { export default AwardPointsPage; -const getServerSidePropsFunc: GetServerSideProps = async () => ({ +const getServerSidePropsFunc: GetServerSidePropsWithAuth = async ({ authToken }) => ({ props: { title: 'Create Milestone', description: "Award points to all active users (e.g. for ACM's 8 year anniversary)", + authToken, }, }); diff --git a/src/pages/admin/points.tsx b/src/pages/admin/points.tsx index d641bfcd..d8386a93 100644 --- a/src/pages/admin/points.tsx +++ b/src/pages/admin/points.tsx @@ -4,28 +4,53 @@ import { VerticalFormItem, VerticalFormTitle, } from '@/components/common'; -import { config } from '@/lib'; -import withAccessType from '@/lib/hoc/withAccessType'; -import { PermissionService, ValidationService } from '@/lib/services'; -import type { GetServerSideProps, NextPage } from 'next'; +import { showToast, config } from '@/lib'; +import withAccessType, { GetServerSidePropsWithAuth } from '@/lib/hoc/withAccessType'; +import { PermissionService } from '@/lib/services'; +import { reportError } from '@/lib/utils'; +import { AdminAPI, EventAPI } from '@/lib/api'; + +import type { NextPage } from 'next'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { AiOutlineMail } from 'react-icons/ai'; +import { AiOutlineMail, AiOutlineCalendar } from 'react-icons/ai'; import { VscLock } from 'react-icons/vsc'; interface FormValues { - email: string; + event: string; + email: string[]; description: string; points: number; } -const AwardPointsPage: NextPage = () => { + +interface AwardPointsPageProps { + title: string; + description: string; + properEvents: (number | string)[]; + authToken: string; + sortedEmails: string[]; +} + +const AwardPointsPage: NextPage = ({ + properEvents, + authToken, + sortedEmails, +}) => { const { register, handleSubmit, formState: { errors }, } = useForm(); - const onSubmit: SubmitHandler = () => { - // TODO + const onSubmit: SubmitHandler = async ({ email, description, points }) => { + try { + const parsedPoints = Number(points); + + await AdminAPI.addBonus(authToken, email, description, parsedPoints); + + showToast('Successfully awarded points!'); + } catch (error) { + reportError('Error found!', error); + } }; return ( @@ -34,20 +59,30 @@ const AwardPointsPage: NextPage = () => { text="Award Bonus Points" description="Grant Bonus Points to a Specific User" /> + + } + element="select" + name="event" + options={properEvents} + placeholder="Select an Event" + formRegister={register('event', { + required: 'Required', + })} + error={errors.event} + /> } - element="input" + element="select-multiple" name="email" - type="email" - placeholder="User Email (user@ucsd.edu)" + options={sortedEmails} + placeholder="" formRegister={register('email', { - validate: email => { - const validation = ValidationService.isValidEmail(email); - return validation.valid || validation.error; - }, + required: 'Required', })} error={errors.email} /> + } element="input" @@ -74,7 +109,9 @@ const AwardPointsPage: NextPage = () => { type="button" display="button1" text="Award Points" - onClick={handleSubmit(onSubmit)} + onClick={() => { + handleSubmit(onSubmit)(); // Ensure this is called + }} /> ); @@ -82,9 +119,24 @@ const AwardPointsPage: NextPage = () => { export default AwardPointsPage; -const getServerSidePropsFunc: GetServerSideProps = async () => ({ - props: { title: 'Award Bonus Points', description: 'Grant bonus points to a specific user' }, -}); +const getServerSidePropsFunc: GetServerSidePropsWithAuth = async ({ authToken }) => { + const allEvents = await EventAPI.getAllEvents(); + const allUsers = await AdminAPI.getAllUserEmails(authToken); + + const properEvents = allEvents.map(event => event.title); + const properEmails = allUsers.map(user => user.email); + const sortedEmails = properEmails.sort((a, b) => a.localeCompare(b)); + + return { + props: { + title: 'Award Bonus Points', + description: 'Grant bonus points to a specific user', + properEvents, + authToken, + sortedEmails, + }, + }; +}; export const getServerSideProps = withAccessType( getServerSidePropsFunc,