diff --git a/components/Event/Event.tsx b/components/Event/Event.tsx index e06238a..d29624b 100644 --- a/components/Event/Event.tsx +++ b/components/Event/Event.tsx @@ -174,7 +174,9 @@ const Event = ({ pid }: { pid: string }) => { ) : ( diff --git a/components/Table/ApplicationTable/ApplicationTable.tsx b/components/Table/ApplicationTable/ApplicationTable.tsx new file mode 100644 index 0000000..0bd9a62 --- /dev/null +++ b/components/Table/ApplicationTable/ApplicationTable.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect, useRef, createContext } from 'react'; +import type { TableProps } from 'antd'; +import { Button, Space, Table, Input, Tag, InputRef, message } from 'antd'; +import type { + ColumnType, + FilterValue, + SorterResult, +} from 'antd/es/table/interface'; +import Highlighter from 'react-highlight-words'; +import { SearchOutlined } from '@ant-design/icons'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { TableContainer } from '@/styles/table.styles'; +import type { FilterConfirmProps } from 'antd/es/table/interface'; +import { + VolunteerLogDataIndex, + VolunteerLogRowData, +} from '@/utils/table-types'; +import ApplicationTableImpl from './ApplicationTableImpl'; +import { QueriedVolunteerEventDTO } from 'bookem-shared/src/types/database'; + +export const ApplicationTableContext = createContext<{ + getColumnSearchProps: (dataIndex: any) => ColumnType; + rowSelection: any; + sortedInfo: SorterResult; + handleChange: TableProps['onChange']; + successMessage: (str: string) => void; + errorMessage: (str: string) => void; + event: QueriedVolunteerEventDTO; +}>({ + getColumnSearchProps: () => ({}), + rowSelection: {}, + sortedInfo: {}, + handleChange: () => {}, + successMessage: () => {}, + errorMessage: () => {}, + event: {} as QueriedVolunteerEventDTO, +}); + +const ApplicationTable = ({ event }: { event: QueriedVolunteerEventDTO }) => { + const [filterTable, setFilterTable] = useState([]); + const [isFiltering, setIsFilter] = useState(false); + const [filteredInfo, setFilteredInfo] = useState< + Record + >({}); + const [sortedInfo, setSortedInfo] = useState< + SorterResult + >({}); + const [searchText, setSearchText] = useState(''); + const [searchedColumn, setSearchedColumn] = useState(''); + const searchInput = useRef(null); + + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const onSelectChange = newSelectedRowKeys => { + setSelectedRowKeys(newSelectedRowKeys); + }; + + const rowSelection = { + selectedRowKeys, + onChange: onSelectChange, + }; + + const handleChange: TableProps['onChange'] = ( + pagination, + filters, + sorter + ) => { + setFilteredInfo(filters); + setSortedInfo(sorter as SorterResult); + }; + + const handleSearch = ( + selectedKeys: string[], + confirm: (param?: FilterConfirmProps) => void, + dataIndex: VolunteerLogDataIndex + ) => { + confirm(); + setSearchText(selectedKeys[0]); + setSearchedColumn(dataIndex); + }; + + const handleReset = (clearFilters: () => void) => { + clearFilters(); + setSearchText(''); + }; + + // Function to get column search properties for VolunteerLogRowData + const getColumnSearchProps = ( + dataIndex: VolunteerLogDataIndex + ): ColumnType => ({ + // 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: +