diff --git a/.eslintrc.json b/.eslintrc.json index bfe88bb1d8..6cc24a7329 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -54,12 +54,12 @@ ], "import/no-duplicates": "error", "tsdoc/syntax": "error", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-inferrable-types": "off", + "@typescript-eslint/ban-ts-comment": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-inferrable-types": "error", "@typescript-eslint/no-non-null-asserted-optional-chain": "error", "@typescript-eslint/no-non-null-assertion": "error", - "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-var-requires": "error", "@typescript-eslint/ban-types": "error", "@typescript-eslint/no-duplicate-enum-values": "error", "@typescript-eslint/array-type": "error", @@ -146,4 +146,4 @@ "version": "detect" } } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bf2071e9b0..ae4b723c47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "graphql-tag": "^2.12.6", "graphql-ws": "^5.16.0", "history": "^5.3.0", + "html2canvas": "^1.4.1", "i18next": "^21.8.14", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^1.4.1", @@ -7527,6 +7528,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8911,6 +8920,14 @@ "postcss": "^8.4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", @@ -12909,6 +12926,18 @@ "webpack": "^5.20.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -23251,6 +23280,14 @@ "node": ">=8" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -24071,6 +24108,14 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/src/GraphQl/Queries/Queries.ts b/src/GraphQl/Queries/Queries.ts index befd1e6e42..102769e49b 100644 --- a/src/GraphQl/Queries/Queries.ts +++ b/src/GraphQl/Queries/Queries.ts @@ -274,6 +274,9 @@ export const EVENT_DETAILS = gql` allDay location recurring + baseRecurringEvent { + _id + } organization { _id members { @@ -289,6 +292,20 @@ export const EVENT_DETAILS = gql` } `; +export const RECURRING_EVENTS = gql` + query RecurringEvents($baseRecurringEventId: ID!) { + getRecurringEvents(baseRecurringEventId: $baseRecurringEventId) { + _id + startDate + title + attendees { + _id + gender + } + } + } +`; + export const EVENT_ATTENDEES = gql` query Event($id: ID!) { event(id: $id) { diff --git a/src/components/EventManagement/EventAttendance/AttendedEventList.tsx b/src/components/EventManagement/EventAttendance/AttendedEventList.tsx index 8aae18bbb2..ba7bca9207 100644 --- a/src/components/EventManagement/EventAttendance/AttendedEventList.tsx +++ b/src/components/EventManagement/EventAttendance/AttendedEventList.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { TableBody, TableCell, TableRow, Table } from '@mui/material'; import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; import { useQuery } from '@apollo/client'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { formatDate } from 'utils/dateFormatter'; import { ReactComponent as DateIcon } from 'assets/svgs/cardItemDate.svg'; @@ -16,7 +16,7 @@ const AttendedEventList: React.FC = ({ eventId }) => { }); if (loading) return

Loading...

; - + const { orgId: currentOrg } = useParams(); const event = data?.event; return ( @@ -25,7 +25,7 @@ const AttendedEventList: React.FC = ({ eventId }) => { {event && ( - + { - const renderComponent = (): any => + const renderComponent = () => render( - - - - - - - + + + + + , ); @@ -75,9 +73,9 @@ describe('EventAttendance Component', () => { renderComponent(); await waitFor(() => { - expect(screen.getByText('Attendance Statistics')).toBeInTheDocument(); + expect(screen.getByText('Historical Statistics')).toBeInTheDocument(); expect(screen.getByText('Sort')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Search event')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search member')).toBeInTheDocument(); }); }); @@ -101,8 +99,8 @@ describe('EventAttendance Component', () => { expect(screen.getByText('Jane Smith')).toBeInTheDocument(); expect(screen.getByText('Member')).toBeInTheDocument(); expect(screen.getByText('Admin')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getAllByText('0').length).toBeGreaterThan(0); + expect(screen.getAllByText('2').length).toBeGreaterThan(0); }); }); @@ -111,7 +109,39 @@ describe('EventAttendance Component', () => { await waitFor(() => { const rows = screen.getAllByTestId('row'); - expect(rows.length).toBe(3); + expect(rows.length).toBeGreaterThan(2); + }); + }); + + test('filters attendees by search text', async () => { + renderComponent(); + + await waitFor(() => { + const searchInput = screen.getByPlaceholderText('Search member'); + fireEvent.change(searchInput, { target: { value: 'John' } }); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Smith')).toBeNull(); + }); + }); + + test('sorts attendees by name', async () => { + renderComponent(); + + await waitFor(() => { + const sortButton = screen.getByText('Sort'); + fireEvent.click(sortButton); + const firstAttendee = screen.getAllByTestId('row')[1]; + expect(firstAttendee).toHaveTextContent('John Doe'); + }); + }); + + test('shows modal with attendance statistics', async () => { + renderComponent(); + + await waitFor(() => { + const statsButton = screen.getByText('Historical Statistics'); + fireEvent.click(statsButton); + expect(screen.getByText('Attendance Rate')).toBeInTheDocument(); }); }); }); diff --git a/src/components/EventManagement/EventAttendance/EventAttendance.tsx b/src/components/EventManagement/EventAttendance/EventAttendance.tsx index 0cf95718db..fc1be68d05 100644 --- a/src/components/EventManagement/EventAttendance/EventAttendance.tsx +++ b/src/components/EventManagement/EventAttendance/EventAttendance.tsx @@ -4,6 +4,7 @@ import { Paper, TableBody, TableCell, + tableCellClasses, TableContainer, TableHead, TableRow, @@ -23,7 +24,7 @@ import { useParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AttendanceStatisticsModal } from './EventStatistics'; import AttendedEventList from './AttendedEventList'; -import { maxHeight } from '@mui/system'; +import { maxHeight, styled } from '@mui/system'; interface InterfaceMember { createdAt: string; @@ -119,30 +120,27 @@ function EventAttendance(): JSX.Element { return attendeeDate.getFullYear() === now.getFullYear(); } - return true; // This should never happen, but it's a fallback + return true; }); }; const filterAndSortAttendees = ( attendees: InterfaceMember[], ): InterfaceMember[] => { - const filtered = filterAttendees(attendees); - const sorted = sortAttendees(filtered); - return sorted; + return sortAttendees(filterAttendees(attendees)); }; const searchEventAttendees = (value: string): void => { setSearchText(value); - if (memberData?.event?.attendees) { - const filtered = memberData?.event?.attendees.filter( + const searchValueLower = value.toLowerCase(); + const filtered = + memberData?.event?.attendees?.filter( (attendee: InterfaceMember) => - attendee.firstName?.toLowerCase().includes(value.toLowerCase()) || - attendee.lastName?.toLowerCase().includes(value.toLowerCase()) || - attendee.email?.toLowerCase().includes(value.toLowerCase()), - ); - const updatedAttendees = filterAndSortAttendees(filtered); - setFilteredAttendees(updatedAttendees); - } + attendee.firstName?.toLowerCase().includes(searchValueLower) || + attendee.lastName?.toLowerCase().includes(searchValueLower) || + attendee.email?.toLowerCase().includes(searchValueLower), + ) ?? []; + setFilteredAttendees(filterAndSortAttendees(filtered)); }; const showModal = (): void => setShow(true); @@ -155,6 +153,28 @@ function EventAttendance(): JSX.Element { const attendanceRate = totalMembers > 0 ? (membersAttended / totalMembers) * 100 : 0; + const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: theme.palette.common.black, + color: theme.palette.common.white, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, + })); + + const StyledTableRow = styled(TableRow)(({ theme }) => ({ + '&:nth-of-type(odd)': { + backgroundColor: '#90EE90', // Light green color for odd rows + }, + '&:nth-of-type(even)': { + backgroundColor: '#90EE90', // White color for even rows + }, + // hide last border + '&:last-child td, &:last-child th': { + border: 0, + }, + })); return (
@@ -171,7 +190,7 @@ function EventAttendance(): JSX.Element { className={`border-1 bg-white text-success ${styles.actionBtn}`} onClick={showModal} > - Attendance Statistics + Historical Statistics
@@ -239,6 +258,7 @@ function EventAttendance(): JSX.Element {
+ {/*

{totalMembers}

*/} @@ -258,11 +278,11 @@ function EventAttendance(): JSX.Element { {filteredAttendees.map((member: InterfaceMember, index: number) => ( - - + + {index + 1} - - + + {member.firstName} {member.lastName} - - + + {member.__typename === 'User' ? t('Member') : t('Admin')} - + ))} > - + {member.eventsAttended ? member.eventsAttended.length : '0'} - + - + {member.tagsAssignedWith ? ( member.tagsAssignedWith.edges.map( (edge: { node: { name: string } }, index: number) => ( @@ -312,8 +332,8 @@ function EventAttendance(): JSX.Element { ) : (
None
)} -
-
+ + ))}
diff --git a/src/components/EventManagement/EventAttendance/EventStatistics.tsx b/src/components/EventManagement/EventAttendance/EventStatistics.tsx index 4bdf6127f5..716859ca38 100644 --- a/src/components/EventManagement/EventAttendance/EventStatistics.tsx +++ b/src/components/EventManagement/EventAttendance/EventStatistics.tsx @@ -5,164 +5,101 @@ import { ButtonGroup, Tooltip, OverlayTrigger, - Dropdown + Dropdown, } from 'react-bootstrap'; +import 'react-datepicker/dist/react-datepicker.css'; import 'chart.js/auto'; import { Bar, Line } from 'react-chartjs-2'; import { useParams } from 'react-router-dom'; -import { - EVENT_DETAILS, - ORGANIZATION_EVENT_CONNECTION_LIST, -} from 'GraphQl/Queries/Queries'; +import { EVENT_DETAILS, RECURRING_EVENTS } from 'GraphQl/Queries/Queries'; import { useLazyQuery } from '@apollo/client'; -import { exportToPDF, exportToCSV } from 'utils/chartToPdf'; - -interface InterfaceAttendanceStatisticsModalProps { - show: boolean; - handleClose: () => void; - statistics: { - totalMembers: number; - membersAttended: number; - attendanceRate: number; - }; - memberData: InterfaceMember[]; -} - -interface InterfaceMember { - createdAt: string; - firstName: string; - lastName: string; - email: string; - gender: string; - eventsAttended?: { - _id: string; - }[]; - birthDate: Date; - __typename: string; - _id: string; - tagsAssignedWith: { - edges: { - node: { - name: string; - }; - }[]; - }; -} - -interface InterfaceEvent { - _id: string; - title: string; - description: string; - startDate: string; - endDate: string; - location: string; - startTime: string; - endTime: string; - allDay: boolean; - recurring: boolean; - recurrenceRule: { - recurrenceStartDate: string; - recurrenceEndDate?: string | null; - frequency: string; - weekDays: string[]; - interval: number; - count?: number; - weekDayOccurenceInMonth?: number; - }; - isRecurringEventException: boolean; - isPublic: boolean; - isRegisterable: boolean; - attendees: { - _id: string; - firstName: string; - lastName: string; - email: string; - gender: string; - birthDate: string; - }[]; - __typename: string; -} +import { exportToCSV } from 'utils/chartToPdf'; +import type { + InterfaceAttendanceStatisticsModalProps, + InterfaceEvent, +} from './InterfaceEvents'; export const AttendanceStatisticsModal: React.FC< InterfaceAttendanceStatisticsModalProps -> = ({ show, handleClose, statistics, memberData, eventId }): JSX.Element => { +> = ({ show, handleClose, statistics, memberData }): JSX.Element => { const [selectedCategory, setSelectedCategory] = useState('Gender'); - const { orgId } = useParams(); + const { orgId, eventId } = useParams(); const [currentPage, setCurrentPage] = useState(0); const eventsPerPage = 10; - - const eventIdPrefix = eventId?.slice(0, 10).toString(); + const [selectedDate, setSelectedDate] = useState(new Date()); const [loadEventDetails, { data: eventData }] = useLazyQuery(EVENT_DETAILS); - const [loadRecurringEvents, { data: recurringData }] = useLazyQuery( - ORGANIZATION_EVENT_CONNECTION_LIST, - ); + const [loadRecurringEvents, { data: recurringData }] = + useLazyQuery(RECURRING_EVENTS); - useEffect(() => { - if (eventId) { - loadEventDetails({ variables: { id: eventId } }); - } - }, [eventId, loadEventDetails]); + const isEventRecurring = eventData?.event?.recurring; - useEffect(() => { - if (eventIdPrefix && orgId) { - loadRecurringEvents({ - variables: { - organization_id: orgId, - id_starts_with: eventIdPrefix, - orderBy: 'startDate_DESC', - first: eventsPerPage, - skip: currentPage * eventsPerPage, - }, - }); - } - }, [eventIdPrefix, orgId, loadRecurringEvents, currentPage]); + const currentDate = selectedDate || new Date(); + const filteredRecurringEvents = useMemo( + () => + recurringData?.getRecurringEvents.filter( + (event: InterfaceEvent) => new Date(event.startDate) >= currentDate, + ) || [], + [recurringData, currentDate], + ); - const isEventRecurring = eventData?.event?.recurring; + const totalEvents = filteredRecurringEvents.length; + const totalPages = Math.ceil(totalEvents / eventsPerPage); + + const paginatedRecurringEvents = useMemo(() => { + const startIndex = currentPage * eventsPerPage; + const endIndex = Math.min(startIndex + eventsPerPage, totalEvents); + return filteredRecurringEvents.slice(startIndex, endIndex); + }, [filteredRecurringEvents, currentPage, eventsPerPage]); const attendeeCounts = useMemo( () => - recurringData?.eventsByOrganizationConnection.map( + paginatedRecurringEvents.map( (event: InterfaceEvent) => event.attendees.length, - ) || [], - [recurringData], + ), + [paginatedRecurringEvents], ); + const eventLabels = useMemo( () => - recurringData?.eventsByOrganizationConnection.map( - (event: InterfaceEvent) => - new Date(event.endDate).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }), - ) || [], - [recurringData], + paginatedRecurringEvents.map((event: InterfaceEvent) => + new Date(event.startDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + ), + [paginatedRecurringEvents], ); + const maleCounts = useMemo( () => - recurringData?.eventsByOrganizationConnection.map( + paginatedRecurringEvents.map( (event: InterfaceEvent) => event.attendees.filter((attendee) => attendee.gender === 'MALE') .length, - ) || [], - [recurringData], + ), + [paginatedRecurringEvents], ); + const femaleCounts = useMemo( () => - recurringData?.eventsByOrganizationConnection.map( + paginatedRecurringEvents.map( (event: InterfaceEvent) => event.attendees.filter((attendee) => attendee.gender === 'FEMALE') .length, - ) || [], - [recurringData], + ), + [paginatedRecurringEvents], ); + const otherCounts = useMemo( () => - recurringData?.eventsByOrganizationConnection.map( + paginatedRecurringEvents.map( (event: InterfaceEvent) => - event.attendees.filter((attendee) => attendee.gender === 'OTHER') - .length, - ) || [], - [recurringData], + event.attendees.filter( + (attendee) => + attendee.gender === 'OTHER' || attendee.gender === null, + ).length, + ), + [paginatedRecurringEvents], ); const chartData = useMemo( @@ -203,7 +140,14 @@ export const AttendanceStatisticsModal: React.FC< }, []); const handleNextPage = useCallback(() => { - setCurrentPage((prevPage) => prevPage + 1); + if (currentPage < totalPages - 1) { + setCurrentPage((prevPage) => prevPage + 1); + } + }, [currentPage, totalPages]); + + const handleDateChange = useCallback((date: Date | null) => { + setSelectedDate(date); + setCurrentPage(0); }, []); const categoryLabels = useMemo( @@ -213,6 +157,7 @@ export const AttendanceStatisticsModal: React.FC< : ['Under 18', '18-40', 'Over 40'], [selectedCategory], ); + const categoryData = useMemo( () => selectedCategory === 'Gender' @@ -247,6 +192,7 @@ export const AttendanceStatisticsModal: React.FC< const handleCategoryChange = useCallback((category: string): void => { setSelectedCategory(category); }, []); + const exportTrendsToCSV = useCallback(() => { const headers = [ 'Date', @@ -257,7 +203,7 @@ export const AttendanceStatisticsModal: React.FC< ]; const data = [ headers, - ...eventLabels.map((label, index) => [ + ...eventLabels.map((label: string, index: number) => [ label, attendeeCounts[index], maleCounts[index], @@ -276,19 +222,36 @@ export const AttendanceStatisticsModal: React.FC< ]; exportToCSV(data, `${selectedCategory.toLowerCase()}_demographics.csv`); }, [selectedCategory, categoryLabels, categoryData]); - const handleExport = (eventKey: string | null) => { + + const handleExport = (eventKey: string | null): number | void => { switch (eventKey) { - case 'pdf': - exportToPDF('pdf-content', 'attendance_statistics'); - break; case 'trends': exportTrendsToCSV(); break; case 'demographics': exportDemographicsToCSV(); break; + default: + return 0; } }; + + useEffect(() => { + if (eventId) { + loadEventDetails({ variables: { id: eventId } }); + } + }, [eventId, loadEventDetails]); + + useEffect(() => { + if (eventId && orgId && eventData?.event?.baseRecurringEvent?._id) { + loadRecurringEvents({ + variables: { + baseRecurringEventId: eventData.event.baseRecurringEvent._id, + }, + }); + } + }, [eventId, orgId, eventData, loadRecurringEvents]); + return ( - - - Export - - - - Export PDF - {isEventRecurring && ( - Export Trends CSV - )} - Export Demographics CSV - - - + >
{isEventRecurring ? (

Trends

Previous Page} > + Next Page} > -
+ + + + Export Data + + + Trends + Demographics + + + + ); }; diff --git a/src/components/EventManagement/EventAttendance/EventsAttendance.module.css b/src/components/EventManagement/EventAttendance/EventsAttendance.module.css index 924b5ad927..909e0edd5d 100644 --- a/src/components/EventManagement/EventAttendance/EventsAttendance.module.css +++ b/src/components/EventManagement/EventAttendance/EventsAttendance.module.css @@ -29,4 +29,12 @@ .large-doughnut { width: 400px; /* Adjust as needed */ height: 400px; /* Adjust as needed */ + } + + .table-body > .table-row { + background-color: #fff !important; + } + + .table-body > .table-row:nth-child(2n) { + background: #afffe8 !important; } \ No newline at end of file diff --git a/src/components/EventManagement/EventAttendance/InterfaceEvents.ts b/src/components/EventManagement/EventAttendance/InterfaceEvents.ts new file mode 100644 index 0000000000..05417c7e79 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/InterfaceEvents.ts @@ -0,0 +1,67 @@ +import React from 'react'; + +export interface InterfaceAttendanceStatisticsModalProps { + show: boolean; + handleClose: () => void; + statistics: { + totalMembers: number; + membersAttended: number; + attendanceRate: number; + }; + memberData: InterfaceMember[]; +} + +export interface InterfaceMember { + createdAt: string; + firstName: string; + lastName: string; + email: string; + gender: string; + eventsAttended?: { + _id: string; + }[]; + birthDate: Date; + __typename: string; + _id: string; + tagsAssignedWith: { + edges: { + node: { + name: string; + }; + }[]; + }; +} + +export interface InterfaceEvent { + _id: string; + title: string; + description: string; + startDate: string; + endDate: string; + location: string; + startTime: string; + endTime: string; + allDay: boolean; + recurring: boolean; + recurrenceRule: { + recurrenceStartDate: string; + recurrenceEndDate?: string | null; + frequency: string; + weekDays: string[]; + interval: number; + count?: number; + weekDayOccurenceInMonth?: number; + }; + isRecurringEventException: boolean; + isPublic: boolean; + isRegisterable: boolean; + attendees: { + _id: string; + firstName: string; + lastName: string; + email: string; + gender: string; + birthDate: string; + }[]; + __typename: string; +} diff --git a/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx b/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx index 33cec61796..d345c197e0 100644 --- a/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx +++ b/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx @@ -1,14 +1,21 @@ import { SIGNUP_MUTATION } from 'GraphQl/Mutations/mutations'; -import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; import React, { useState } from 'react'; import { Modal, Form, Button, Spinner } from 'react-bootstrap'; import { useParams } from 'react-router-dom'; -import { useMutation, useQuery } from '@apollo/client'; +import { useMutation } from '@apollo/client'; import { toast, ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import type { + InterfaceAddOnSpotAttendeeProps, + InterfaceFormData, +} from 'utils/interfaces'; -const AddOnSpotAttendee = ({ show, handleClose, reloadMembers }) => { - const [formData, setFormData] = useState({ +const AddOnSpotAttendee: React.FC = ({ + show, + handleClose, + reloadMembers, +}) => { + const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '', @@ -16,14 +23,24 @@ const AddOnSpotAttendee = ({ show, handleClose, reloadMembers }) => { gender: '', }); - const handleChange = (e) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >, + ): void => { + const target = e.target as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement; + setFormData({ ...formData, [target.name]: target.value }); }; - const { orgId } = useParams(); + const { orgId } = useParams<{ orgId: string }>(); const [isSubmitting, setIsSubmitting] = useState(false); const [addSignUp] = useMutation(SIGNUP_MUTATION); - const handleSubmit = async (e) => { + const handleSubmit = async ( + e: React.FormEvent, + ): Promise => { e.preventDefault(); if (!orgId) { toast.error('Organization ID is missing. Please try again.'); @@ -54,7 +71,7 @@ const AddOnSpotAttendee = ({ show, handleClose, reloadMembers }) => { }; return ( <> - + On-spot Attendee diff --git a/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx b/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx index 8b8ddd2e75..2ac3f7490e 100644 --- a/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx +++ b/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Modal, Button } from 'react-bootstrap'; import { toast } from 'react-toastify'; -import { useLazyQuery, useMutation, useQuery } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; import { EVENT_ATTENDEES, MEMBERS_LIST } from 'GraphQl/Queries/Queries'; import { ADD_EVENT_ATTENDEE, @@ -13,9 +13,7 @@ import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import Autocomplete from '@mui/material/Autocomplete'; -import { Add } from '@mui/icons-material'; import AddOnSpotAttendee from './AddOnSpotAttendee'; -import { use } from 'i18next'; type ModalPropType = { show: boolean; @@ -30,16 +28,22 @@ interface InterfaceUser { lastName: string; } -export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { +export const EventRegistrantsModal = ({ + show, + eventId, + orgId, + handleClose, +}: ModalPropType): JSX.Element => { const [member, setMember] = useState(null); - const [show, setShow] = useState(false); const [isAdding, setIsAdding] = useState(false); const [addRegistrantMutation] = useMutation(ADD_EVENT_ATTENDEE, { refetchQueries: [ - { query: EVENT_ATTENDEES, variables: { id: props.eventId } }, - { query: MEMBERS_LIST, variables: { id: props.orgId } }, + { query: EVENT_ATTENDEES, variables: { id: eventId } }, + { query: MEMBERS_LIST, variables: { id: orgId } }, ], }); + const [open, setOpen] = useState(false); + const [removeRegistrantMutation] = useMutation(REMOVE_EVENT_ATTENDEE); const { @@ -47,7 +51,7 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { loading: attendeesLoading, refetch: attendeesRefetch, } = useQuery(EVENT_ATTENDEES, { - variables: { id: props.eventId }, + variables: { id: eventId }, }); const { @@ -55,13 +59,13 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { loading: memberLoading, refetch: memberRefetch, } = useQuery(MEMBERS_LIST, { - variables: { id: props.orgId }, + variables: { id: orgId }, pollInterval: 500, }); const addRegistrant = (): void => { if (member == null) { - toast.warning('Please choose an user to add first!'); + toast.warning('Please choose a user to add first!'); return; } setIsAdding(true); @@ -69,7 +73,7 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { addRegistrantMutation({ variables: { userId: member._id, - eventId: props.eventId, + eventId: eventId, }, }) .then(() => { @@ -84,15 +88,13 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { setIsAdding(false); // Set loading state to false }); }; - const showModal = (): void => { - setShow(true); - }; + const deleteRegistrant = (userId: string): void => { toast.warn('Removing the attendee...'); removeRegistrantMutation({ variables: { userId, - eventId: props.eventId, + eventId: eventId, }, }) .then(() => { @@ -104,8 +106,9 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { toast.error(err.message); }); }; + useEffect(() => { - if (props.show) { + if (show) { const refetchInterval = setInterval(() => { attendeesRefetch(); memberRefetch(); @@ -113,7 +116,8 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { return () => clearInterval(refetchInterval); } - }, [props.show, attendeesRefetch, memberRefetch]); + }, [show, attendeesRefetch, memberRefetch]); + // Render the loading screen if (attendeesLoading || memberLoading) { return ( @@ -125,16 +129,11 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { return ( <> - + setShow(false)} - reloadMembers={(): void => { + show={open} + handleClose={() => setOpen(false)} + reloadMembers={() => { memberRefetch(); attendeesRefetch(); }} @@ -144,8 +143,8 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => {
Registered Registrants
- {attendeesData.event.attendees.length == 0 - ? `There are no registered attendees for this event.` + {attendeesData.event.attendees.length === 0 + ? 'There are no registered attendees for this event.' : null} {attendeesData.event.attendees.map((attendee: InterfaceUser) => ( @@ -156,7 +155,7 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { label={`${attendee.firstName} ${attendee.lastName}`} variant="outlined" key={attendee._id} - onDelete={(): void => deleteRegistrant(attendee._id)} + onDelete={() => deleteRegistrant(attendee._id)} /> ))} @@ -164,14 +163,20 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { { + onChange={(_, newMember) => { setMember(newMember); }} noOptionsText={ <> @@ -181,10 +186,10 @@ export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { getOptionLabel={(member: InterfaceUser): string => `${member.firstName} ${member.lastName}` } - renderInput={(params): React.ReactNode => ( + renderInput={(params) => ( )} diff --git a/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx index b621f8673a..fdde5b2a68 100644 --- a/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx +++ b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx @@ -9,7 +9,7 @@ type PropType = { orgId: string; }; -export const EventRegistrantsWrapper = (props: PropType): JSX.Element => { +export const EventRegistrantsWrapper: React.FC = ({ eventId, orgId }) => { const [showModal, setShowModal] = useState(false); return ( @@ -37,8 +37,8 @@ export const EventRegistrantsWrapper = (props: PropType): JSX.Element => { handleClose={(): void => { setShowModal(false); }} - eventId={props.eventId} - orgId={props.orgId} + eventId={eventId} + orgId={orgId} /> )} diff --git a/src/utils/chartToPdf.ts b/src/utils/chartToPdf.ts index 9ba65a7dc4..bd3403af6d 100644 --- a/src/utils/chartToPdf.ts +++ b/src/utils/chartToPdf.ts @@ -1,55 +1,7 @@ -import React from 'react'; -import Button from '@mui/material/Button'; -import html2canvas from 'html2canvas'; -import { generate } from '@pdfme/generator'; +type CSVData = (string | number)[][]; -export const exportToPDF = async (elementId: string, fileName: string) => { - const pdfContentElement = document.getElementById(elementId); - if (pdfContentElement) { - const canvas = await html2canvas(pdfContentElement); - const imgData = canvas.toDataURL('image/png'); - - // Define the PDF template - const template = { - basePdf: null, - schemas: [ - { - text: { - x: 50, - y: 50, - width: 500, - height: 50, - fontSize: 20, - text: 'Attendance Statistics', - }, - image: { - x: 50, - y: 100, - width: 600, - height: 400, - image: imgData, - }, - }, - ], - }; - - // Define the inputs for the PDF - const inputs = [{}]; - - // Generate the PDF - const pdfBytes = await generate({ template, inputs }); - const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' }); - const url = URL.createObjectURL(pdfBlob); - const link = document.createElement('a'); - link.href = url; - link.download = `${fileName}.pdf`; - link.click(); - } -}; - -export const exportToCSV = (data, filename) => { - const csvContent = - 'data:text/csv;charset=utf-8,' + data.map((e) => e.join(',')).join('\n'); +export const exportToCSV = (data: CSVData, filename: string): void => { + const csvContent = 'data:text/csv;charset=utf-8,' + data.map((e) => e.join(',')).join('\n'); const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); link.setAttribute('href', encodedUri); @@ -58,7 +10,15 @@ export const exportToCSV = (data, filename) => { link.click(); document.body.removeChild(link); }; -export const exportTrendsToCSV = () => { + +export const exportTrendsToCSV = ( + eventLabels: string[], + attendeeCounts: number[], + maleCounts: number[], + femaleCounts: number[], + otherCounts: number[], +): void => { + const heading = 'Attendance Trends'; const headers = [ 'Date', 'Attendee Count', @@ -66,7 +26,9 @@ export const exportTrendsToCSV = () => { 'Female Attendees', 'Other Attendees', ]; - const data = [ + const data: CSVData = [ + [heading], + [], headers, ...eventLabels.map((label, index) => [ label, @@ -79,9 +41,21 @@ export const exportTrendsToCSV = () => { exportToCSV(data, 'attendance_trends.csv'); }; -export const exportDemographicsToCSV = () => { - const headers = [selectedCategory, 'Count']; - const data = [ +export const exportDemographicsToCSV = ( + selectedCategory: string, + categoryLabels: string[], + categoryData: number[], +): void => { + const heading = `${selectedCategory} Demographics`; + const headers = [ + selectedCategory, + 'Count', + 'Age Distribution', + 'Gender Distribution', + ]; + const data: CSVData = [ + [heading], + [], headers, ...categoryLabels.map((label, index) => [label, categoryData[index]]), ]; diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 3945003f40..82cfd555da 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -472,3 +472,17 @@ export interface InterfaceAgendaItemCategoryInfo { export interface InterfaceAgendaItemCategoryList { agendaItemCategoriesByOrganization: InterfaceAgendaItemCategoryInfo[]; } + +export interface InterfaceAddOnSpotAttendeeProps { + show: boolean; + handleClose: () => void; + reloadMembers: () => void; +} + +export interface InterfaceFormData { + firstName: string; + lastName: string; + email: string; + phoneNo: string; + gender: string; +}