diff --git a/pages/api/Schema/AccountListAnalytics/accountListAnalytics.graphql b/pages/api/Schema/AccountListAnalytics/accountListAnalytics.graphql index efa92e048..3095b5ae1 100644 --- a/pages/api/Schema/AccountListAnalytics/accountListAnalytics.graphql +++ b/pages/api/Schema/AccountListAnalytics/accountListAnalytics.graphql @@ -4,10 +4,8 @@ extend type Query { dateRange: String ): AccountListAnalytics! } + type AccountListAnalytics { - id: ID! - type: String! - createdAt: ISO8601DateTime! appointments: AppointmentsAccountListAnalytics! contacts: ContactsAccountListAnalytics! correspondence: CorrespondenceAccountListAnalytics! @@ -18,8 +16,6 @@ type AccountListAnalytics { textMessage: TextMessageAccountListAnalytics! startDate: ISO8601DateTime! endDate: ISO8601DateTime! - updatedAt: ISO8601DateTime! - updatedInDbAt: ISO8601DateTime! } type AppointmentsAccountListAnalytics { @@ -29,14 +25,14 @@ type AppointmentsAccountListAnalytics { type ContactsAccountListAnalytics { active: Int! referrals: Int! - referrals_on_hand: Int! + referralsOnHand: Int! } type CorrespondenceAccountListAnalytics { precall: Int! reminders: Int! - support_letters: Int! - thank_yous: Int! + supportLetters: Int! + thankYous: Int! newsletters: Int! } @@ -61,7 +57,7 @@ type PhoneAccountListAnalytics { attempted: Int! completed: Int! received: Int! - talktoinperson: Int! + talkToInPerson: Int! } type TextMessageAccountListAnalytics { diff --git a/pages/api/Schema/AccountListAnalytics/dataHandler.ts b/pages/api/Schema/AccountListAnalytics/dataHandler.ts index 94727b978..f80721d90 100644 --- a/pages/api/Schema/AccountListAnalytics/dataHandler.ts +++ b/pages/api/Schema/AccountListAnalytics/dataHandler.ts @@ -51,10 +51,7 @@ const getAccountListAnalytics = (data: { }; }): AccountListAnalytics => { const { - id, - type, attributes: { - created_at, appointments, contacts, correspondence, @@ -63,29 +60,38 @@ const getAccountListAnalytics = (data: { facebook, phone, text_message, - start_date, - end_date, - updated_at, - updated_in_db_at, + start_date: startDate, + end_date: endDate, }, } = data; return { - id: id, - type: type, - createdAt: created_at, - appointments: appointments, - contacts: contacts, - correspondence: correspondence, - electronic: electronic, - email: email, - facebook: facebook, - phone: phone, + appointments, + contacts: { + active: contacts.active, + referrals: contacts.referrals, + referralsOnHand: contacts.referrals_on_hand, + }, + correspondence: { + precall: correspondence.precall, + reminders: correspondence.reminders, + supportLetters: correspondence.support_letters, + thankYous: correspondence.thank_yous, + newsletters: correspondence.newsletters, + }, + electronic, + email, + facebook, + phone: { + appointments: phone.appointments, + attempted: phone.attempted, + completed: phone.completed, + received: phone.received, + talkToInPerson: phone.talktoinperson, + }, textMessage: text_message, - startDate: start_date, - endDate: end_date, - updatedAt: updated_at, - updatedInDbAt: updated_in_db_at, + startDate, + endDate, }; }; export { getAccountListAnalytics }; diff --git a/src/components/Coaching/CoachingDetail/Activity/Activity.graphql b/src/components/Coaching/CoachingDetail/Activity/Activity.graphql new file mode 100644 index 000000000..a346310d8 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/Activity/Activity.graphql @@ -0,0 +1,43 @@ +query CoachingDetailActivity($accountListId: ID!, $dateRange: String!) { + accountListAnalytics(accountListId: $accountListId, dateRange: $dateRange) { + appointments { + completed + } + contacts { + active + referrals + referralsOnHand + } + correspondence { + precall + reminders + supportLetters + thankYous + newsletters + } + electronic { + appointments + received + sent + } + email { + received + sent + } + facebook { + received + sent + } + phone { + attempted + appointments + completed + received + talkToInPerson + } + textMessage { + received + sent + } + } +} diff --git a/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx b/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx new file mode 100644 index 000000000..7e496d41f --- /dev/null +++ b/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx @@ -0,0 +1,295 @@ +import { ThemeProvider } from '@emotion/react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Settings } from 'luxon'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import theme from 'src/theme'; +import { AccountListTypeEnum, CoachingPeriodEnum } from '../CoachingDetail'; +import { Activity } from './Activity'; + +const mocks = { + CoachingDetailActivity: { + accountListAnalytics: { + appointments: { + completed: 10, + }, + contacts: { + active: 20, + referrals: 21, + referralsOnHand: 22, + }, + correspondence: { + precall: 30, + reminders: 31, + supportLetters: 32, + thankYous: 33, + newsletters: 34, + }, + electronic: { + appointments: 40, + received: 41, + sent: 42, + }, + email: { + received: 50, + sent: 51, + }, + facebook: { + received: 60, + sent: 61, + }, + phone: { + attempted: 70, + appointments: 71, + completed: 72, + received: 73, + talkToInPerson: 74, + }, + textMessage: { + received: 80, + sent: 81, + }, + }, + }, +}; +const mutationSpy = jest.fn(); + +const accountListId = 'account-list-1'; +const router = { + query: { + accountListId, + }, + isReady: true, +}; + +interface TestComponentProps { + accountListType?: AccountListTypeEnum; + period?: CoachingPeriodEnum; + noAppeal?: boolean; +} + +const TestComponent: React.FC = ({ + accountListType = AccountListTypeEnum.Coaching, + period = CoachingPeriodEnum.Weekly, + noAppeal = false, +}) => ( + + + + + + + +); + +describe('Activity', () => { + beforeEach(() => { + Settings.now = () => new Date(2020, 0, 8).valueOf(); + }); + + describe('period', () => { + it('is a week long when in weekly mode', async () => { + const { getByTestId } = render(); + + expect(getByTestId('ActivityPeriod')).toHaveTextContent('Jan 6 - Jan 12'); + await waitFor(() => + expect(mutationSpy.mock.calls[0][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2020-01-06..2020-01-12', + }, + }), + ); + }); + + it('is a month long when in monthly mode', async () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('ActivityPeriod')).toHaveTextContent('Jan 1 - Jan 31'); + await waitFor(() => + expect(mutationSpy.mock.calls[0][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2020-01-01..2020-01-31', + }, + }), + ); + }); + + it('shows the year when in a different year', () => { + const { getByRole, getByTestId } = render(); + + userEvent.click(getByRole('button', { name: 'Previous' })); + expect(getByTestId('ActivityPeriod')).toHaveTextContent( + 'Dec 30, 2019 - Jan 5, 2020', + ); + }); + + it('resets when the period changes', () => { + const { getByRole, getByTestId, rerender } = render( + , + ); + + userEvent.click(getByRole('button', { name: 'Previous' })); + rerender(); + expect(getByTestId('ActivityPeriod')).toHaveTextContent('Jan 6 - Jan 12'); + }); + }); + + describe('previous/next buttons', () => { + it('loads a new weekly period', async () => { + const { getByRole } = render(); + + const next = getByRole('button', { name: 'Next' }); + const previous = getByRole('button', { name: 'Previous' }); + + userEvent.click(previous); + await waitFor(() => + expect(mutationSpy.mock.calls[1][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2019-12-30..2020-01-05', + }, + }), + ); + + userEvent.click(next); + await waitFor(() => + expect(mutationSpy.mock.calls[2][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2020-01-06..2020-01-12', + }, + }), + ); + }); + + it('loads a new monthly period', async () => { + const { getByRole } = render( + , + ); + + const next = getByRole('button', { name: 'Next' }); + const previous = getByRole('button', { name: 'Previous' }); + + userEvent.click(previous); + await waitFor(() => + expect(mutationSpy.mock.calls[1][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2019-12-01..2019-12-31', + }, + }), + ); + + userEvent.click(next); + await waitFor(() => + expect(mutationSpy.mock.calls[2][0].operation).toMatchObject({ + operationName: 'CoachingDetailActivity', + variables: { + dateRange: '2020-01-01..2020-01-31', + }, + }), + ); + }); + + it('next button is disabled when on the last possible range', () => { + const { getByRole, getByTestId } = render(); + + const next = getByRole('button', { name: 'Next' }); + const previous = getByRole('button', { name: 'Previous' }); + const period = getByTestId('ActivityPeriod'); + + expect(next).toBeDisabled(); + + userEvent.click(previous); + expect(next).not.toBeDisabled(); + expect(period).toHaveTextContent('Dec 30, 2019 - Jan 5, 2020'); + + userEvent.click(next); + expect(next).toBeDisabled(); + expect(period).toHaveTextContent('Jan 6 - Jan 12'); + }); + }); + + it('renders the activity sections', async () => { + const { getByTestId } = render(); + + await waitFor(() => + expect(getByTestId('ActivitySectionContacts')).toHaveTextContent( + 'Contacts20Active22Referrals On-hand21Referrals Gained', + ), + ); + expect(getByTestId('ActivitySectionAppointments')).toHaveTextContent( + 'Appointments10Completed', + ); + expect(getByTestId('ActivitySectionCorrespondence')).toHaveTextContent( + 'Correspondence30Pre-call32Support33Thank You31Reminder', + ); + expect(getByTestId('ActivitySectionPhone')).toHaveTextContent( + 'Phone Calls142Outgoing145Talked To71Appts Produced72Completed70Attempted73Received', + ); + expect(getByTestId('ActivitySectionElectronic')).toHaveTextContent( + 'Electronic Messages42Sent41Received40Appts Produced51 Sent / 50 ReceivedEmail61 Sent / 60 ReceivedFacebook81 Sent / 80 ReceivedText Message', + ); + }); + + describe('appeal', () => { + it('renders when provided', async () => { + const { getByTestId } = render(); + + await waitFor(() => + expect(getByTestId('ActivitySectionAppeal')).toHaveTextContent( + 'Primary Appeal$200 / $1,000Ask$200 (20%) / $500 (50%) / $600 (60%)', + ), + ); + }); + + it('renders a placeholder when missing', async () => { + const { getByTestId } = render(); + + await waitFor(() => + expect(getByTestId('ActivitySectionAppeal')).toHaveTextContent( + 'Primary AppealNo Primary Appeal Set$0 (0%) / $0 (0%) / $0 (0%)', + ), + ); + }); + }); + + describe('links', () => { + it('are hidden when viewing coaching account list', async () => { + const { findByTestId, queryByRole } = render(); + + expect(await findByTestId('ActivitySectionContacts')).toBeInTheDocument(); + expect(queryByRole('link')).not.toBeInTheDocument(); + }); + + it('are shown when viewing own account list', async () => { + const { findAllByRole } = render( + , + ); + + expect((await findAllByRole('link')).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/Activity/Activity.tsx b/src/components/Coaching/CoachingDetail/Activity/Activity.tsx new file mode 100644 index 000000000..6f41e5a94 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/Activity/Activity.tsx @@ -0,0 +1,729 @@ +import { useEffect, useMemo, useState } from 'react'; +import CalendarMonthOutlined from '@mui/icons-material/CalendarMonthOutlined'; +import ChatBubbleOutline from '@mui/icons-material/ChatBubbleOutline'; +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import MailOutline from '@mui/icons-material/MailOutline'; +import MoneyOutlined from '@mui/icons-material/MoneyOutlined'; +import PeopleOutline from '@mui/icons-material/PeopleOutline'; +import SmartphoneOutlined from '@mui/icons-material/SmartphoneOutlined'; +import { + Button, + ButtonGroup, + CardHeader, + Link as MuiLink, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime, DateTimeUnit } from 'luxon'; +import NextLink from 'next/link'; +import { useTranslation } from 'react-i18next'; +import AnimatedCard from 'src/components/AnimatedCard'; +import HandoffLink from 'src/components/HandoffLink'; +import { useLocale } from 'src/hooks/useLocale'; +import { + currencyFormat, + dateFormat, + dateFormatWithoutYear, +} from 'src/lib/intlFormat'; +import { + ActivityTypeEnum, + Appeal, + ContactFilterSetInput, + ContactFilterStatusEnum, + ResultEnum, + TaskFilterSetInput, +} from '../../../../../graphql/types.generated'; +import { MultilineSkeleton } from '../../../Shared/MultilineSkeleton'; +import { AccountListTypeEnum, CoachingPeriodEnum } from '../CoachingDetail'; +import { HelpButton } from '../HelpButton'; +import { useCoachingDetailActivityQuery } from './Activity.generated'; +import { AppealProgress } from './AppealProgress'; + +const Header = styled(Typography)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), + '@media (max-width: 900px)': { + flexDirection: 'column', + alignItems: 'center', + }, +})); + +const PeriodText = styled('span')({ + textAlign: 'center', + flex: 1, +}); + +const StyledButton = styled(Button)({ + width: '6rem', +}); + +const SectionsContainer = styled('div')({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + '@media (max-width: 1500px)': { + gridTemplateColumns: 'repeat(2, 1fr)', + }, + '@media (max-width: 1150px)': { + gridTemplateColumns: '1fr', + }, +}); + +const ActivitySection = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + height: '280px', + paddingTop: theme.spacing(2), + // Only apply inner borders to the grid of sections + borderRight: `1px solid ${theme.palette.cruGrayMedium.main}`, + borderBottom: `1px solid ${theme.palette.cruGrayMedium.main}`, + '@media (max-width: 1150px)': { + // One column + ':nth-of-type(n + 6)': { + borderBottom: 'unset', + }, + borderRight: 'unset', + }, + '@media (min-width: 1151px) and (max-width: 1500px)': { + // Two columns + ':nth-of-type(n + 5)': { + borderBottom: 'unset', + }, + ':nth-of-type(2n)': { + borderRight: 'unset', + }, + }, + '@media (min-width: 1501px)': { + // Three columns + ':nth-of-type(n + 4)': { + borderBottom: 'unset', + }, + ':nth-of-type(3n)': { + borderRight: 'unset', + }, + }, +})); + +const SectionTitle = styled(Typography)({ + fontWeight: 'bold', + flex: 1, +}); + +const StatsRow = styled('div')(({ theme }) => ({ + display: 'flex', + width: '100%', + height: '68px', + ':nth-of-type(2)': { + backgroundColor: theme.palette.cruGrayLight.main, + }, + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + textAlign: 'center', +})); + +const StatsColumn = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + flex: 1, + padding: theme.spacing(0.5), + borderLeft: `1px solid ${theme.palette.cruGrayMedium.main}`, + ':first-of-type': { + borderLeft: 'none', + }, +})); + +const StatsColumnTitle = styled(Typography)({ + fontWeight: 'bold', + fontSize: '0.9em', +}); + +const StatsText = styled(Typography)({ + fontSize: '0.9em', +}); + +interface LinkProps { + accountListType: AccountListTypeEnum; + children: React.ReactNode; + href: string; +} + +const Link: React.FC = ({ accountListType, children, href }) => + // Only show links when the account list belongs to the user + accountListType === AccountListTypeEnum.Own ? ( + + {children} + + ) : ( + {children} + ); + +interface ActivityProps { + accountListId: string; + accountListType: AccountListTypeEnum; + period: CoachingPeriodEnum; + currency?: string; + primaryAppeal?: Pick< + Appeal, + | 'id' + | 'name' + | 'amount' + | 'pledgesAmountNotReceivedNotProcessed' + | 'pledgesAmountProcessed' + | 'pledgesAmountReceivedNotProcessed' + >; +} + +export const Activity: React.FC = ({ + accountListId, + accountListType, + period, + currency, + primaryAppeal, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const periodUnit: DateTimeUnit = + period === CoachingPeriodEnum.Weekly ? 'week' : 'month'; + const periodDuration = + period === CoachingPeriodEnum.Weekly ? { weeks: 1 } : { months: 1 }; + const [start, setStart] = useState(DateTime.now().startOf(periodUnit)); + const end = useMemo(() => start.endOf(periodUnit), [start, periodUnit]); + + useEffect(() => { + setStart(DateTime.now().startOf(periodUnit)); + }, [periodUnit]); + + const { data, loading } = useCoachingDetailActivityQuery({ + variables: { + accountListId, + dateRange: `${start.toISODate()}..${start.endOf(periodUnit).toISODate()}`, + }, + }); + + const formattedDate = useMemo(() => { + const format = + start.year === DateTime.now().year ? dateFormatWithoutYear : dateFormat; + return `${format(start, locale)} - ${format(end, locale)}`; + }, [start, end, locale]); + + const contactsLink = (filter: ContactFilterSetInput) => + `/accountLists/${accountListId}/contacts?filters=${encodeURIComponent( + JSON.stringify(filter), + )}`; + + const tasksLink = (filter: TaskFilterSetInput) => + `/accountLists/${accountListId}/tasks?filters=${encodeURIComponent( + JSON.stringify(filter), + )}`; + + const taskBaseFilter: TaskFilterSetInput = { + completedAt: { + min: start.toISODate(), + max: end.toISODate(), + }, + completed: true, + }; + const taskCallFilter: TaskFilterSetInput = { + ...taskBaseFilter, + activityType: [ActivityTypeEnum.Call], + }; + const taskElectronicFilter: TaskFilterSetInput = { + ...taskBaseFilter, + activityType: [ + ActivityTypeEnum.Email, + ActivityTypeEnum.FacebookMessage, + ActivityTypeEnum.TextMessage, + ], + }; + + return ( + + + {t('Activity')} + + {formattedDate} + + + setStart(start.minus(periodDuration))} + > + + {t('Previous')} + + setStart(start.plus(periodDuration))} + disabled={end > DateTime.now()} + > + {t('Next')} + + + + + + } + /> + + + + {t('Contacts')} + {loading ? ( + + ) : ( + + + + + {data?.accountListAnalytics.contacts.active} + + {t('Active')} + + + + + + {data?.accountListAnalytics.contacts.referralsOnHand} + + {t('Referrals On-hand')} + + + + + {data?.accountListAnalytics.contacts.referrals} + + {t('Referrals Gained')} + + + )} + + + + {t('Appointments')} + {loading ? ( + + ) : ( + + + + + {data?.accountListAnalytics.appointments.completed} + + {t('Completed')} + + + + )} + + + + {t('Correspondence')} + {loading ? ( + + ) : ( + <> + + + + + {data?.accountListAnalytics.correspondence.precall} + + {t('Pre-call')} + + + + + + {data?.accountListAnalytics.correspondence.supportLetters} + + {t('Support')} + + + + + + + + {data?.accountListAnalytics.correspondence.thankYous} + + {t('Thank You')} + + + + + + {data?.accountListAnalytics.correspondence.reminders} + + {t('Reminder')} + + + + + )} + + + + {t('Phone Calls')} + {loading ? ( + + ) : ( + <> + + + + + {data && + data.accountListAnalytics.phone.attempted + + data.accountListAnalytics.phone.completed} + + {t('Outgoing')} + + + + + + {data && + data.accountListAnalytics.phone.completed + + data.accountListAnalytics.phone.received} + + {t('Talked To')} + + + + + + {data?.accountListAnalytics.phone.appointments} + + {t('Appts Produced')} + + + + + + + + {data?.accountListAnalytics.phone.completed} + + {t('Completed')} + + + + + + {data?.accountListAnalytics.phone.attempted} + + {t('Attempted')} + + + + + + {data?.accountListAnalytics.phone.received} + + {t('Received')} + + + + + )} + + + + {t('Electronic Messages')} + {loading ? ( + + ) : ( + <> + + + + + {data?.accountListAnalytics.electronic.sent} + + {t('Sent')} + + + + + + {data?.accountListAnalytics.electronic.received} + + {t('Received')} + + + + + + {data?.accountListAnalytics.electronic.appointments} + + {t('Appts Produced')} + + + + + + + + {data?.accountListAnalytics.email.sent} {t('Sent')} + + {' / '} + + {data?.accountListAnalytics.email.received}{' '} + {t('Received')} + + + {t('Email')} + + + + + {data?.accountListAnalytics.facebook.sent} {t('Sent')} + + {' / '} + + {data?.accountListAnalytics.facebook.received}{' '} + {t('Received')} + + + {t('Facebook')} + + + + + {data?.accountListAnalytics.textMessage.sent} {t('Sent')} + + {' / '} + + {data?.accountListAnalytics.textMessage.received}{' '} + {t('Received')} + + + {t('Text Message')} + + + + )} + + + + + {accountListType === AccountListTypeEnum.Own ? ( + + {t('Primary Appeal')} + + ) : ( + t('Primary Appeal') + )} + + {loading ? ( + + ) : ( + <> + + {primaryAppeal ? ( + + + {currencyFormat( + primaryAppeal.pledgesAmountProcessed, + currency, + locale, + )} + {' / '} + {currencyFormat( + primaryAppeal.amount ?? 0, + currency, + locale, + )} + + + {accountListType === AccountListTypeEnum.Own ? ( + + + {primaryAppeal.name} + + + ) : ( + primaryAppeal.name + )} + + + ) : ( + {t('No Primary Appeal Set')} + )} + + + + + + )} + + + + ); +}; diff --git a/src/components/Coaching/CoachingDetail/Activity/AppealProgress.test.tsx b/src/components/Coaching/CoachingDetail/Activity/AppealProgress.test.tsx new file mode 100644 index 000000000..d46266751 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/Activity/AppealProgress.test.tsx @@ -0,0 +1,55 @@ +import { ThemeProvider } from '@emotion/react'; +import { render } from '@testing-library/react'; +import theme from 'src/theme'; +import { AppealProgress } from './AppealProgress'; + +const appeal = { + amount: 200, + pledgesAmountNotReceivedNotProcessed: 12.34, + pledgesAmountProcessed: 23.45, + pledgesAmountReceivedNotProcessed: 34.56, +}; + +describe('AppealProgress', () => { + it('renders the amounts, percentages, and progress bar', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('AppealProgressAmounts')).toHaveTextContent( + '$23 (12%) / $58 (29%) / $70 (35%)', + ); + expect(getByTestId('AppealProgressSegmentProcessed')).toHaveStyle( + 'width: 11.72%', + ); + expect(getByTestId('AppealProgressSegmentReceived')).toHaveStyle( + 'width: 29.01%', + ); + expect(getByTestId('AppealProgressSegmentCommitted')).toHaveStyle( + 'width: 35.18%', + ); + }); + + it('renders zeros without an appeal', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('AppealProgressAmounts')).toHaveTextContent( + '$0 (0%) / $0 (0%) / $0 (0%)', + ); + expect(getByTestId('AppealProgressSegmentProcessed')).toHaveStyle( + 'width: 0.00%', + ); + expect(getByTestId('AppealProgressSegmentReceived')).toHaveStyle( + 'width: 0.00%', + ); + expect(getByTestId('AppealProgressSegmentCommitted')).toHaveStyle( + 'width: 0.00%', + ); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/Activity/AppealProgress.tsx b/src/components/Coaching/CoachingDetail/Activity/AppealProgress.tsx new file mode 100644 index 000000000..2a0743c35 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/Activity/AppealProgress.tsx @@ -0,0 +1,121 @@ +import { Tooltip } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat, percentageFormat } from 'src/lib/intlFormat'; +import { Appeal } from '../../../../../graphql/types.generated'; + +const Amounts = styled('div')(({ theme }) => ({ + fontSize: '0.9em', + paddingBottom: theme.spacing(0.5), + textAlign: 'right', +})); + +const ProcessedText = styled('span')(({ theme }) => ({ + color: theme.palette.progressBarYellow.main, +})); + +const ReceivedText = styled('span')(({ theme }) => ({ + color: theme.palette.progressBarOrange.main, +})); + +const CommittedText = styled('span')(({ theme }) => ({ + color: theme.palette.progressBarGray.main, +})); + +const ProgressWrapper = styled('div')(({ theme }) => ({ + padding: theme.spacing(0.5, 1), +})); + +const ProgressBar = styled('div')(({ theme }) => ({ + width: '100%', + height: '24px', + overflowX: 'hidden', + textAlign: 'left', + whiteSpace: 'nowrap', + borderRadius: '10px', + backgroundColor: theme.palette.cruGrayDark.main, +})); + +const ProgressSegment = styled('div')({ + display: 'inline-block', + height: '100%', +}); + +interface AppealProgressProps { + appeal?: Pick< + Appeal, + | 'amount' + | 'pledgesAmountNotReceivedNotProcessed' + | 'pledgesAmountProcessed' + | 'pledgesAmountReceivedNotProcessed' + >; + currency?: string; +} + +export const AppealProgress: React.FC = ({ + appeal, + currency, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const processed = appeal?.pledgesAmountProcessed ?? 0; + const received = processed + (appeal?.pledgesAmountReceivedNotProcessed ?? 0); + const committed = + received + (appeal?.pledgesAmountNotReceivedNotProcessed ?? 0); + const max = appeal?.amount ?? 1; + const processedFraction = processed / max; + const receivedFraction = received / max; + const committedFraction = committed / max; + + return ( + + + + + {currencyFormat(processed, currency, locale)} ( + {percentageFormat(processedFraction, locale)}) + + + {' / '} + + + {currencyFormat(received, currency, locale)} ( + {percentageFormat(receivedFraction, locale)}) + + + {' / '} + + + {currencyFormat(committed, currency, locale)} ( + {percentageFormat(committedFraction, locale)}) + + + + + ({ + width: `${(processedFraction * 100).toFixed(2)}%`, + backgroundColor: theme.palette.progressBarYellow.main, + })} + /> + ({ + width: `${(receivedFraction * 100).toFixed(2)}%`, + backgroundColor: theme.palette.progressBarOrange.main, + })} + /> + ({ + width: `${(committedFraction * 100).toFixed(2)}%`, + backgroundColor: theme.palette.progressBarGray.main, + })} + /> + + + ); +}; diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx index 1e21db2b5..5e8599093 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; import userEvent from '@testing-library/user-event'; import { DateTime } from 'luxon'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { render, waitFor } from '__tests__/util/testingLibraryReactMock'; +import theme from 'src/theme'; import { afterTestResizeObserver, beforeTestResizeObserver, @@ -41,25 +43,27 @@ describe('LoadCoachingDetail', () => { it('view', async () => { const { findByText } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: 55, + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + id: accountListId, + name: 'John Doe', + currency: 'USD', + monthlyGoal: 55, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); expect(await findByText('John Doe')).toBeVisible(); expect(await findByText('Monthly $55')).toBeVisible(); @@ -67,25 +71,27 @@ describe('LoadCoachingDetail', () => { }); it('null goal', async () => { const { findByText } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: null, + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + id: accountListId, + name: 'John Doe', + currency: 'USD', + monthlyGoal: null, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); expect(await findByText('John Doe')).toBeVisible(); expect(await findByText('Monthly $0')).toBeVisible(); @@ -94,27 +100,29 @@ describe('LoadCoachingDetail', () => { it('view isAccountList', async () => { const { findByText } = render( - - - mocks={{ - LoadAccountListCoachingDetail: { - accountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: 55, + + + + mocks={{ + LoadAccountListCoachingDetail: { + accountList: { + id: accountListId, + name: 'John Doe', + currency: 'USD', + monthlyGoal: 55, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); expect(await findByText('John Doe')).toBeVisible(); expect(await findByText('Monthly $55')).toBeVisible(); @@ -122,53 +130,57 @@ describe('LoadCoachingDetail', () => { }); it('null goal isAccountList', async () => { const { findByText } = render( - - - mocks={{ - LoadAccountListCoachingDetail: { - accountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: null, - }, - }, - }} - > - - - , - ); - expect(await findByText('John Doe')).toBeVisible(); - expect(await findByText('Monthly $0')).toBeVisible(); - expect(await findByText('Monthly Activity')).toBeVisible(); - }); - - describe('period', () => { - it('toggles between weekly and monthly', async () => { - const { getByRole } = render( + - + mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - balance: 1000, + LoadAccountListCoachingDetail: { + accountList: { + id: accountListId, + name: 'John Doe', currency: 'USD', + monthlyGoal: null, }, }, }} > - , + + , + ); + expect(await findByText('John Doe')).toBeVisible(); + expect(await findByText('Monthly $0')).toBeVisible(); + expect(await findByText('Monthly Activity')).toBeVisible(); + }); + + describe('period', () => { + it('toggles between weekly and monthly', async () => { + const { getByRole } = render( + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + balance: 1000, + currency: 'USD', + }, + }, + }} + > + + + + , ); await waitFor(() => @@ -205,23 +217,25 @@ describe('LoadCoachingDetail', () => { describe('balance', () => { it('displays the account list balance', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - balance: 1000, - currency: 'USD', + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + balance: 1000, + currency: 'USD', + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -233,26 +247,28 @@ describe('LoadCoachingDetail', () => { describe('staff ids', () => { it('displays comma-separated staff ids', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - designationAccounts: [ - { accountNumber: '123' }, - { accountNumber: '456' }, - ], + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '123' }, + { accountNumber: '456' }, + ], + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -262,26 +278,28 @@ describe('LoadCoachingDetail', () => { it('ignores empty staff ids', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - designationAccounts: [ - { accountNumber: '' }, - { accountNumber: '456' }, - ], + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '' }, + { accountNumber: '456' }, + ], + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -291,26 +309,28 @@ describe('LoadCoachingDetail', () => { it('displays none when there are no staff ids', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - designationAccounts: [ - { accountNumber: '123' }, - { accountNumber: '456' }, - ], + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '123' }, + { accountNumber: '456' }, + ], + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -322,36 +342,38 @@ describe('LoadCoachingDetail', () => { describe('last prayer letter', () => { it('formats the prayer letter date', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + }, }, - }, - GetTaskAnalytics: { - taskAnalytics: { - lastElectronicNewsletterCompletedAt: DateTime.local( - 2023, - 1, - 1, - ).toISO(), - lastPhysicalNewsletterCompletedAt: DateTime.local( - 2023, - 1, - 2, - ).toISO(), + GetTaskAnalytics: { + taskAnalytics: { + lastElectronicNewsletterCompletedAt: DateTime.local( + 2023, + 1, + 1, + ).toISO(), + lastPhysicalNewsletterCompletedAt: DateTime.local( + 2023, + 1, + 2, + ).toISO(), + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -363,28 +385,30 @@ describe('LoadCoachingDetail', () => { it('displays none when there are no prayer letters', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + }, }, - }, - GetTaskAnalytics: { - taskAnalytics: { - lastElectronicNewsletterCompletedAt: null, - lastPhysicalNewsletterCompletedAt: null, + GetTaskAnalytics: { + taskAnalytics: { + lastElectronicNewsletterCompletedAt: null, + lastPhysicalNewsletterCompletedAt: null, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -398,26 +422,28 @@ describe('LoadCoachingDetail', () => { describe('MPD info', () => { it('displays info', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - activeMpdStartAt: DateTime.local(2023, 1, 1).toISO(), - activeMpdFinishAt: DateTime.local(2024, 1, 1).toISO(), - activeMpdMonthlyGoal: 1000, - weeksOnMpd: 12, + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + activeMpdStartAt: DateTime.local(2023, 1, 1).toISO(), + activeMpdFinishAt: DateTime.local(2024, 1, 1).toISO(), + activeMpdMonthlyGoal: 1000, + weeksOnMpd: 12, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -436,25 +462,27 @@ describe('LoadCoachingDetail', () => { it('displays none when info is missing', async () => { const { getByTestId } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - activeMpdStartAt: null, - activeMpdFinishAt: null, - activeMpdMonthlyGoal: null, + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + activeMpdStartAt: null, + activeMpdFinishAt: null, + activeMpdMonthlyGoal: null, + }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => @@ -472,44 +500,46 @@ describe('LoadCoachingDetail', () => { describe('users', () => { it('shows the user names and contact info', async () => { const { getByText } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - users: { - nodes: [ - { - firstName: 'John', - lastName: 'Doe', - emailAddresses: { - nodes: [ - { - email: 'john.doe@cru.org', - }, - ], - }, - phoneNumbers: { - nodes: [ - { - number: '111-111-1111', - }, - ], + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + users: { + nodes: [ + { + firstName: 'John', + lastName: 'Doe', + emailAddresses: { + nodes: [ + { + email: 'john.doe@cru.org', + }, + ], + }, + phoneNumbers: { + nodes: [ + { + number: '111-111-1111', + }, + ], + }, }, - }, - ], + ], + }, }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument()); @@ -521,44 +551,46 @@ describe('LoadCoachingDetail', () => { describe('coaches', () => { it('shows the user names and contact info', async () => { const { getByText } = render( - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - currency: 'USD', - coaches: { - nodes: [ - { - firstName: 'John', - lastName: 'Coach', - emailAddresses: { - nodes: [ - { - email: 'john.coach@cru.org', - }, - ], - }, - phoneNumbers: { - nodes: [ - { - number: '222-222-2222', - }, - ], + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + coaches: { + nodes: [ + { + firstName: 'John', + lastName: 'Coach', + emailAddresses: { + nodes: [ + { + email: 'john.coach@cru.org', + }, + ], + }, + phoneNumbers: { + nodes: [ + { + number: '222-222-2222', + }, + ], + }, }, - }, - ], + ], + }, }, }, - }, - }} - > - - - , + }} + > + + + + , ); await waitFor(() => expect(getByText('John Coach')).toBeInTheDocument()); diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index e9bca2bdb..0b7aeff8f 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -14,6 +14,7 @@ import { dateFormat } from 'src/lib/intlFormat/intlFormat'; import theme from 'src/theme'; import { MultilineSkeleton } from '../../Shared/MultilineSkeleton'; import { AppealProgress } from '../AppealProgress/AppealProgress'; +import { Activity } from './Activity/Activity'; import { ActivitySummary } from './ActivitySummary/ActivitySummary'; import { AppointmentResults } from './AppointmentResults/AppointmentResults'; import { CollapsibleEmailList } from './CollapsibleEmailList'; @@ -338,6 +339,13 @@ export const CoachingDetail: React.FC = ({ period={period} /> + )} diff --git a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql index 005688f28..04488dec6 100644 --- a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql +++ b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql @@ -29,14 +29,12 @@ query LoadCoachingDetail($coachingAccountListId: ID!) { accountNumber } primaryAppeal { - active - amount - amountCurrency id name + amount pledgesAmountNotReceivedNotProcessed pledgesAmountProcessed - pledgesAmountTotal + pledgesAmountReceivedNotProcessed } currency monthlyGoal @@ -69,14 +67,12 @@ query LoadAccountListCoachingDetail($accountListId: ID!) { accountNumber } primaryAppeal { - active - amount - amountCurrency id name + amount pledgesAmountNotReceivedNotProcessed pledgesAmountProcessed - pledgesAmountTotal + pledgesAmountReceivedNotProcessed } currency monthlyGoal diff --git a/src/components/Shared/MultilineSkeleton.tsx b/src/components/Shared/MultilineSkeleton.tsx index 57849eda8..3431815b8 100644 --- a/src/components/Shared/MultilineSkeleton.tsx +++ b/src/components/Shared/MultilineSkeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@mui/material'; +import { Skeleton, SkeletonProps } from '@mui/material'; import { styled } from '@mui/material/styles'; const StyledSkeleton = styled(Skeleton)(({ theme }) => ({ @@ -6,16 +6,17 @@ const StyledSkeleton = styled(Skeleton)(({ theme }) => ({ margin: theme.spacing(1), })); -interface MultilineSkeletonProps { +interface MultilineSkeletonProps extends SkeletonProps { lines: number; } export const MultilineSkeleton: React.FC = ({ lines, + ...props }) => ( <> {new Array(lines).fill(undefined).map((_, index) => ( - + ))} );