=> ({
+ // Configuration for the filter dropdown
+ filterDropdown: ({
+ setSelectedKeys,
+ selectedKeys,
+ confirm,
+ clearFilters,
+ close,
+ }) => (
+ // Custom filter dropdown UI
+ e.stopPropagation()}>
+ {/* Input for searching */}
+
+ setSelectedKeys(e.target.value ? [e.target.value] : [])
+ }
+ onPressEnter={() =>
+ handleSearch(selectedKeys as string[], confirm, dataIndex)
+ }
+ style={{ marginBottom: 8, display: 'block' }}
+ />
+ {/* Buttons for search, reset, filter, and close */}
+
+
+
+ {/* Filter button with logic to set search parameters */}
+
+ {/* Close button for the filter dropdown */}
+
+
+
+ ),
+ // Configuration for the filter icon
+ filterIcon: (filtered: boolean) => (
+
+ ),
+ // Filtering logic applied on each record
+ onFilter: (value, record) =>
+ (record[dataIndex] ?? '')
+ .toString()
+ .toLowerCase()
+ .includes((value as string).toLowerCase()),
+ // Callback when the filter dropdown visibility changes
+ onFilterDropdownOpenChange: visible => {
+ // Select the search input when the filter dropdown opens
+ if (visible) {
+ setTimeout(() => searchInput.current?.select(), 100);
+ }
+ },
+ // Render function to highlight search results
+ render: text =>
+ searchedColumn === dataIndex ? (
+
+ ) : (
+ text
+ ),
+ });
+
+ const clearFilters = () => {
+ setFilteredInfo({});
+ };
+
+ // function to export what is on the table at the time to an excel file
+ const handleExport = () => {
+ const workbook = XLSX.utils.table_to_book(
+ document.querySelector('#table-container')
+ );
+ const excelBuffer = XLSX.write(workbook, {
+ bookType: 'xlsx',
+ type: 'array',
+ });
+ const blob = new Blob([excelBuffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+ // TODO: automatically add date to file name for easier organization
+ saveAs(blob, 'volunteers.xlsx');
+ };
+
+ const [messageApi, contextHolder] = message.useMessage();
+
+ const successMessage = (message: string) => {
+ messageApi.open({
+ type: 'success',
+ content: message,
+ });
+ };
+
+ const errorMessage = (message: string) => {
+ messageApi.open({
+ type: 'error',
+ content: message,
+ });
+ };
+
+ return (
+ <>
+ {contextHolder}
+
+
+
+
+
+ >
+ );
+};
+
+export default ApplicationTable;
diff --git a/components/Table/ApplicationTable/ApplicationTableImpl.tsx b/components/Table/ApplicationTable/ApplicationTableImpl.tsx
new file mode 100644
index 0000000..f420804
--- /dev/null
+++ b/components/Table/ApplicationTable/ApplicationTableImpl.tsx
@@ -0,0 +1,162 @@
+import { VolunteerLogRowData } from '@/utils/table-types';
+import { calculateTotalCharacters, fetcher } from '@/utils/utils';
+import {
+ QueriedApplicationResponseData,
+ QueriedVolunteerApplicationData,
+ QueriedVolunteerLogDTO,
+} from 'bookem-shared/src/types/database';
+import React, { useContext, useEffect, useState } from 'react';
+import useSWR from 'swr';
+import Link from 'next/link';
+import { Table } from 'antd';
+import {
+ convertResponseDataToRowData,
+ convertVolunteerLogDataToRowData,
+} from '@/utils/table-utils';
+import { TableContainer } from '@/styles/table.styles';
+import { ColumnsType, Key } from 'antd/es/table/interface';
+import { ApplicationTableContext } from './ApplicationTable';
+import { LOCALE_DATE_FORMAT } from '@/utils/constants';
+import TableHeader from './TableHeader';
+
+const ApplicationTableImpl = () => {
+ const {
+ getColumnSearchProps,
+ rowSelection,
+ sortedInfo,
+ handleChange,
+ errorMessage,
+ event,
+ } = useContext(ApplicationTableContext);
+
+ const [status, setStatus] = useState('pending');
+ const [tableWidth, setTableWidth] = useState(0);
+ const { data, error, isLoading, mutate } =
+ useSWR(
+ '/api/event/' +
+ event._id +
+ '/applications?' +
+ new URLSearchParams({ status }),
+ fetcher,
+ {
+ onSuccess: data => {
+ // setDataForTable(convertVolunteerLogDataToRowData(data));
+ // console.log(data);
+
+ const newColumns: any[] = [];
+
+ data.questions.forEach((question, index) => {
+ newColumns.push({
+ title: question.title,
+ dataIndex: index,
+ key: index,
+ render: (_: any, { answers }: any) => {
+ return <>{answers[index].text.join(', ')}>;
+ },
+ ellipsis: false,
+ });
+ });
+
+ const finalColumns = [...defaultColumns, ...newColumns];
+
+ // console.log(finalColumns);
+ // Hacky way to auto-extend table width since ANTD doesn't support it
+ setTableWidth(
+ calculateTotalCharacters(finalColumns.map(c => c.title)) * 15
+ );
+ setColumns(finalColumns);
+ setDataForTable(convertResponseDataToRowData(data.responses));
+ },
+ revalidateOnFocus: true,
+ revalidateOnReconnect: true,
+ }
+ );
+
+ const handleSelectStatus = (value: string) => {
+ fetch(
+ '/api/event/' +
+ event._id +
+ '/applications?' +
+ new URLSearchParams({ status: value })
+ )
+ .then(data => data.json())
+ .then(data => {
+ setDataForTable(data.responses);
+ setStatus(value);
+ })
+ .catch(err => {
+ errorMessage('Sorry an error occurred');
+ console.error(err);
+ });
+ };
+
+ const [dataForTable, setDataForTable] = useState([]);
+
+ const defaultColumns: ColumnsType = [
+ {
+ title: 'Volunteer',
+ dataIndex: ['user', 'name'],
+ key: 'userName',
+ ...getColumnSearchProps('userName'),
+ ellipsis: true,
+ },
+ {
+ title: 'Volunteer Email',
+ dataIndex: ['user', 'email'],
+ key: 'userEmail',
+ render(_: any, { user }) {
+ return {user.email};
+ },
+ ellipsis: true,
+ },
+ {
+ title: 'Status',
+ dataIndex: 'status',
+ key: 'status',
+ },
+ ];
+
+ const [columns, setColumns] = useState>(defaultColumns);
+
+ useEffect(() => {
+ fetch('/api/event/' + event._id + '/applications')
+ .then(res => res.json())
+ .then(console.log);
+ });
+
+ // Refetch data when data is updated
+ useEffect(() => {
+ mutate();
+ }, [mutate, data]);
+
+ // check for errors and loading
+ if (error) {
+ console.error(error);
+ return Failed to load application table
;
+ }
+ if (isLoading) return Loading...
;
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default ApplicationTableImpl;
diff --git a/components/Table/ApplicationTable/TableHeader.tsx b/components/Table/ApplicationTable/TableHeader.tsx
new file mode 100644
index 0000000..e10adaf
--- /dev/null
+++ b/components/Table/ApplicationTable/TableHeader.tsx
@@ -0,0 +1,145 @@
+import {
+ HeaderContainer,
+ SelectContainer,
+} from '@/styles/components/Table/VolunteerLogTable.styles';
+import { Button, Popconfirm, Select } from 'antd';
+import { VolunteerLogStatus } from 'bookem-shared/src/types/database';
+import React, { useContext, useEffect, useState } from 'react';
+import { ApplicationTableContext } from './ApplicationTable';
+import { error } from 'console';
+
+const TableHeader = ({
+ handleSelectStatus,
+ mutate,
+ status,
+}: {
+ handleSelectStatus: (value: string) => void;
+ mutate: () => void;
+ status: string;
+}) => {
+ const { rowSelection, errorMessage, successMessage, event } = useContext(
+ ApplicationTableContext
+ );
+
+ const [statusOptions, setStatusOptions] = useState([]);
+
+ useEffect(() => {
+ setStatusOptions(
+ Object.values(VolunteerLogStatus).map(value => ({ value, label: value }))
+ );
+ }, []);
+
+ const handleApprove = () => {
+ if (rowSelection.selectedRowKeys.length === 0) {
+ errorMessage('No rows selected');
+ return;
+ }
+
+ fetch('/api/event/' + event._id + '/application/approve', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(rowSelection.selectedRowKeys),
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to approve hours');
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (data.status === 'error') {
+ errorMessage(data.message);
+ } else {
+ successMessage(data.message);
+ }
+ })
+ .then(() => mutate())
+ .catch(err => {
+ errorMessage('Sorry an error occurred');
+ console.error(err);
+ });
+ console.log(rowSelection);
+ };
+
+ const handleReject = () => {
+ if (rowSelection.selectedRowKeys.length === 0) {
+ errorMessage('No rows selected');
+ return;
+ }
+ fetch('/api/event/' + event._id + '/application/reject', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(rowSelection.selectedRowKeys),
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to reject hours');
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (data.status === 'error') {
+ errorMessage(data.message);
+ } else {
+ successMessage(data.message);
+ }
+ })
+ .then(() => mutate())
+ .catch(err => {
+ errorMessage('Sorry an error occurred');
+ console.error(err);
+ });
+ };
+
+ return (
+ <>
+
+
+ Choose status:
+
+
+
+
+ {rowSelection.selectedRowKeys.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {rowSelection.selectedRowKeys.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+};
+
+export default TableHeader;
diff --git a/pages/api/event/[id]/application/approve.ts b/pages/api/event/[id]/application/approve.ts
new file mode 100644
index 0000000..8b0ce98
--- /dev/null
+++ b/pages/api/event/[id]/application/approve.ts
@@ -0,0 +1,89 @@
+import dbConnect from '@/lib/dbConnect';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import ApplicationResponse from 'bookem-shared/src/models/ApplicationResponse';
+import mongoose from 'mongoose';
+import {
+ ApplicationStatus,
+ ApplicationResponseData,
+} from 'bookem-shared/src/types/database';
+import { enumChecking as checkEnum } from '@/utils/utils';
+import Users from 'bookem-shared/src/models/Users';
+import VolunteerEvents from 'bookem-shared/src/models/VolunteerEvents';
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ // Get request parameter
+ const {
+ query: { id },
+ method,
+ } = req;
+
+ switch (method) {
+ /**
+ * @route PUT /api/event/[id]/application/approve
+ * @desc update the status of a response to approved
+ * @req responseIds (array of response ids)
+ * @res a message telling whether the response is updated
+ */
+ case 'PUT':
+ try {
+ await dbConnect();
+
+ const session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const responseIds = req.body as string[];
+ const updated = await ApplicationResponse.updateMany(
+ { _id: { $in: responseIds } },
+ { status: 'approved' }
+ );
+ if (!updated) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ await Promise.all(
+ responseIds.map(async responseId => {
+ const response = await ApplicationResponse.findById(responseId);
+ if (!response) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ const [user, event] = await Promise.all([
+ Users.findById(response.user),
+ VolunteerEvents.findById(response.event),
+ ]);
+ if (!user || !event) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ event.volunteers.push(user._id);
+ user.events.push(event._id);
+
+ event.save();
+ user.save();
+ })
+ );
+ });
+ await session.endSession();
+
+ return res.status(200).json({
+ message: 'The applications have been approved!',
+ status: 'success',
+ });
+ } catch (error: any) {
+ console.error(error);
+ res.status(500).json({ message: error.message });
+ }
+ break;
+ }
+}
diff --git a/pages/api/event/[id]/application/reject.ts b/pages/api/event/[id]/application/reject.ts
new file mode 100644
index 0000000..6d182ab
--- /dev/null
+++ b/pages/api/event/[id]/application/reject.ts
@@ -0,0 +1,89 @@
+import dbConnect from '@/lib/dbConnect';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import ApplicationResponse from 'bookem-shared/src/models/ApplicationResponse';
+import mongoose from 'mongoose';
+import {
+ ApplicationStatus,
+ ApplicationResponseData,
+} from 'bookem-shared/src/types/database';
+import { enumChecking as checkEnum } from '@/utils/utils';
+import Users from 'bookem-shared/src/models/Users';
+import VolunteerEvents from 'bookem-shared/src/models/VolunteerEvents';
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ // Get request parameter
+ const {
+ query: { id },
+ method,
+ } = req;
+
+ switch (method) {
+ /**
+ * @route PUT /api/event/[id]/application/reject
+ * @desc update the status of a response to rejected
+ * @req responseIds (array of response ids)
+ * @res a message telling whether the response is updated
+ */
+ case 'PUT':
+ try {
+ await dbConnect();
+
+ const session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const responseIds = req.body as string[];
+ const updated = await ApplicationResponse.updateMany(
+ { _id: { $in: responseIds } },
+ { status: 'rejected' }
+ );
+ if (!updated) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ await Promise.all(
+ responseIds.map(async responseId => {
+ const response = await ApplicationResponse.findById(responseId);
+ if (!response) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ const [user, event] = await Promise.all([
+ Users.findById(response.user),
+ VolunteerEvents.findById(response.event),
+ ]);
+ if (!user || !event) {
+ await session.abortTransaction();
+ return res
+ .status(200)
+ .json({ message: 'Response not found', status: 'error' });
+ }
+
+ user.events.splice(user.events.indexOf(event._id), 1);
+ event.volunteers.splice(event.volunteers.indexOf(user._id), 1);
+
+ event.save();
+ user.save();
+ })
+ );
+ });
+ await session.endSession();
+
+ return res.status(200).json({
+ message: 'The applications have been rejected!',
+ status: 'success',
+ });
+ } catch (error: any) {
+ console.error(error);
+ res.status(500).json({ message: error.message });
+ }
+ break;
+ }
+}
diff --git a/pages/api/event/[id]/applications.ts b/pages/api/event/[id]/applications.ts
index 6a4406f..eaf2af3 100644
--- a/pages/api/event/[id]/applications.ts
+++ b/pages/api/event/[id]/applications.ts
@@ -12,7 +12,7 @@ export default async function handler(
) {
// Get request parameter
const {
- query: { id },
+ query: { id, status },
method,
} = req;
@@ -27,10 +27,10 @@ export default async function handler(
try {
await dbConnect();
- await ApplicationResponse.find();
- await Users.find();
+ await ApplicationResponse.findOne();
+ await Users.findOne();
- const application = await VolunteerApplications.find({
+ const application = await VolunteerApplications.findOne({
event: id,
})
.populate({
@@ -40,6 +40,7 @@ export default async function handler(
model: 'User',
select: 'name email',
},
+ match: { status },
})
.exec();
diff --git a/pages/event/[pid]/responses.tsx b/pages/event/[pid]/responses.tsx
new file mode 100644
index 0000000..e931e66
--- /dev/null
+++ b/pages/event/[pid]/responses.tsx
@@ -0,0 +1,43 @@
+import Link from 'next/link';
+import Image from 'next/image';
+import ApplicationTable from '@/components/Table/ApplicationTable/ApplicationTable';
+import { PageTitle, PageLayout } from '@/styles/table.styles';
+import { QueriedVolunteerEventDTO } from 'bookem-shared/src/types/database';
+import { useState } from 'react';
+import { useRouter } from 'next/router';
+import useSWR from 'swr';
+import { fetcher } from '@/utils/utils';
+
+export default function VolunteerApplicationResponses() {
+ const router = useRouter();
+ const { pid } = router.query;
+ const {
+ data: event,
+ error,
+ isLoading,
+ } = useSWR('/api/event/' + pid, fetcher);
+
+ if (!event || isLoading) return Loading...
;
+ if (error) return Error loading event
;
+
+ return (
+ <>
+
+
+
window.history.back()}
+ style={{
+ margin: '0 20px 0 0',
+ }}>
+
+
+
Applications to {event.name}
+
+
+
+ >
+ );
+}
+
+export { getServerSideProps } from '@/lib/getServerSideProps';
diff --git a/utils/table-types.ts b/utils/table-types.ts
index 23e0dbf..79bc8ea 100644
--- a/utils/table-types.ts
+++ b/utils/table-types.ts
@@ -4,6 +4,8 @@ import {
QueriedAdminData,
QueriedUserData,
QueriedVolunteerLogDTO,
+ QueriedVolunteerApplicationData,
+ ApplicationResponseData,
} from 'bookem-shared/src/types/database';
import { ObjectId } from 'mongodb';
diff --git a/utils/table-utils.ts b/utils/table-utils.ts
index 888f333..8b1e0df 100644
--- a/utils/table-utils.ts
+++ b/utils/table-utils.ts
@@ -60,3 +60,10 @@ export const convertVolunteerLogDataToRowData = (
};
});
};
+
+export const convertResponseDataToRowData = (data: any[]) => {
+ return data.map(response => ({
+ ...response,
+ key: response._id.toString(),
+ }));
+};
diff --git a/utils/utils.ts b/utils/utils.ts
index 19814ad..515dbc1 100644
--- a/utils/utils.ts
+++ b/utils/utils.ts
@@ -61,3 +61,16 @@ export const handleExport = (fileName: string) => {
});
saveAs(blob, fileName + '.xlsx');
};
+
+/**
+ * Calculate the total number of characters in an array of strings
+ * @param strings Array of strings
+ * @returns
+ */
+export const calculateTotalCharacters = (strings: string[]): number => {
+ let totalCharacters = 0;
+ strings.forEach(str => {
+ totalCharacters += str.length;
+ });
+ return totalCharacters;
+};