diff --git a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx index b5853281d5..e46986950f 100644 --- a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx @@ -13,9 +13,9 @@ import { SubscriptionManagementPage } from '../subscriptions'; import { PlotlyAnalyticsPage } from '../PlotlyAnalytics'; import { ROUTE_NAMES } from './data/constants'; import BulkEnrollmentResultsDownloadPage from '../BulkEnrollmentResultsDownloadPage'; -import LearnerCreditManagement from '../learner-credit-management'; import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import ContentHighlights from '../ContentHighlights'; +import LearnerCreditManagementRoutes from '../learner-credit-management'; const EnterpriseAppRoutes = ({ baseUrl, @@ -98,10 +98,8 @@ const EnterpriseAppRoutes = ({ /> {canManageLearnerCredit && ( - )} diff --git a/src/components/EnterpriseApp/data/constants.js b/src/components/EnterpriseApp/data/constants.js index 5c881fefab..6feaac51f7 100644 --- a/src/components/EnterpriseApp/data/constants.js +++ b/src/components/EnterpriseApp/data/constants.js @@ -13,3 +13,14 @@ export const ROUTE_NAMES = { subscriptionManagement: 'subscriptions', contentHighlights: 'content-highlights', }; + +export const BUDGET_STATUSES = { + active: 'Active', + expired: 'Expired', + upcoming: 'Upcoming', +}; + +export const BUDGET_TYPES = { + ecommerce: 'ecommerce', + subsidy: 'subsidy', +}; diff --git a/src/components/EnterpriseSubsidiesContext/data/hooks.js b/src/components/EnterpriseSubsidiesContext/data/hooks.js index d699098cd0..6e9b040167 100644 --- a/src/components/EnterpriseSubsidiesContext/data/hooks.js +++ b/src/components/EnterpriseSubsidiesContext/data/hooks.js @@ -10,6 +10,7 @@ import { camelCaseObject } from '@edx/frontend-platform/utils'; import EcommerceApiService from '../../../data/services/EcommerceApiService'; import LicenseManagerApiService from '../../../data/services/LicenseManagerAPIService'; import SubsidyApiService from '../../../data/services/EnterpriseSubsidyApiService'; +import { BUDGET_TYPES } from '../../EnterpriseApp/data/constants'; export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen, enterpriseId }) => { const [offers, setOffers] = useState([]); @@ -25,42 +26,40 @@ export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen, try { const [enterpriseSubsidyResponse, ecommerceApiResponse] = await Promise.all([ SubsidyApiService.getSubsidyByCustomerUUID(enterpriseId, { subsidyType: 'learner_credit' }), - EcommerceApiService.fetchEnterpriseOffers({ - isCurrent: true, - }), + EcommerceApiService.fetchEnterpriseOffers(), ]); - // If there are no subsidies in enterprise, fall back to the e-commerce API. - let { results } = camelCaseObject(enterpriseSubsidyResponse.data); - let source = 'subsidyApi'; + // We have to consider both type of offers active and inactive. - if (results.length === 0) { - results = camelCaseObject(ecommerceApiResponse.data.results); - source = 'ecommerceApi'; - } - let activeSubsidyFound = false; - if (results.length !== 0) { - let subsidy = results[0]; - const offerData = []; - let activeSubsidyData = {}; - for (let i = 0; i < results.length; i++) { - subsidy = results[i]; - activeSubsidyFound = source === 'ecommerceApi' - ? subsidy.isCurrent - : subsidy.isActive; - if (activeSubsidyFound === true) { - activeSubsidyData = { - id: subsidy.uuid || subsidy.id, - name: subsidy.title || subsidy.displayName, - start: subsidy.activeDatetime || subsidy.startDatetime, - end: subsidy.expirationDatetime || subsidy.endDatetime, - isCurrent: activeSubsidyFound, - }; - offerData.push(activeSubsidyData); - setCanManageLearnerCredit(true); - } - } - setOffers(offerData); + const enterpriseSubsidyResults = camelCaseObject(enterpriseSubsidyResponse.data).results; + const ecommerceOffersResults = camelCaseObject(ecommerceApiResponse.data.results); + + const offerData = []; + + enterpriseSubsidyResults.forEach((result) => { + offerData.push({ + source: BUDGET_TYPES.subsidy, + id: result.uuid, + name: result.title, + start: result.activeDatetime, + end: result.expirationDatetime, + isCurrent: result.isActive, + }); + }); + + ecommerceOffersResults.forEach((result) => { + offerData.push({ + source: BUDGET_TYPES.ecommerce, + id: (result.id).toString(), + name: result.displayName, + start: result.startDatetime, + end: result.endDatetime, + isCurrent: result.isCurrent, + }); + }); + setOffers(offerData); + if (offerData.length > 0) { + setCanManageLearnerCredit(true); } } catch (error) { logError(error); diff --git a/src/components/EnterpriseSubsidiesContext/data/tests/BudgetDetailPage.test.jsx b/src/components/EnterpriseSubsidiesContext/data/tests/BudgetDetailPage.test.jsx new file mode 100644 index 0000000000..483263fdce --- /dev/null +++ b/src/components/EnterpriseSubsidiesContext/data/tests/BudgetDetailPage.test.jsx @@ -0,0 +1,138 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import { + screen, + render, +} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter } from 'react-router-dom'; +import BudgetDetailPage from '../../../learner-credit-management/BudgetDetailPage'; +import { useOfferSummary, useOfferRedemptions } from '../../../learner-credit-management/data/hooks'; +import { EXEC_ED_OFFER_TYPE } from '../../../learner-credit-management/data/constants'; +import { EnterpriseSubsidiesContext } from '../..'; + +jest.mock('../../../learner-credit-management/data/hooks'); + +useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: null, +}); +useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchOfferRedemptions: jest.fn(), +}); + +const mockStore = configureMockStore([thunk]); +const getMockStore = store => mockStore(store); +const enterpriseId = 'test-enterprise'; +const enterpriseUUID = '1234'; +const initialStore = { + portalConfiguration: { + enterpriseId, + enterpriseSlug: enterpriseId, + + }, +}; +const store = getMockStore({ ...initialStore }); + +const mockEnterpriseOfferId = '123'; + +const mockOfferDisplayName = 'Test Enterprise Offer'; +const mockOfferSummary = { + totalFunds: 5000, + redeemedFunds: 200, + remainingFunds: 4800, + percentUtilized: 0.04, + offerType: EXEC_ED_OFFER_TYPE, +}; + +const defaultEnterpriseSubsidiesContextValue = { + isLoading: false, +}; + +const BudgetDetailPageWrapper = ({ + enterpriseSubsidiesContextValue = defaultEnterpriseSubsidiesContextValue, + ...rest +}) => ( + + + + + + + + + + +); + +describe('', () => { + describe('with enterprise offer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays table on clicking view budget', async () => { + const mockOffer = { + id: mockEnterpriseOfferId, + name: mockOfferDisplayName, + start: '2022-01-01', + end: '2023-01-01', + }; + useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: mockOfferSummary, + }); + useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchOfferRedemptions: jest.fn(), + }); + render(); + expect(screen.getByText('Learner Credit Management')); + expect(screen.getByText('Overview')); + expect(screen.getByText('No results found')); + }); + + it('displays loading message while loading data', async () => { + useOfferSummary.mockReturnValue({ + isLoading: true, + offerSummary: null, + }); + useOfferRedemptions.mockReturnValue({ + isLoading: true, + offerRedemptions: { + itemCount: 0, + pageCount: 0, + results: [], + }, + fetchOfferRedemptions: jest.fn(), + }); + + render(); + + expect(screen.getByText('loading')); + }); + }); +}); diff --git a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js index adf8580b52..ec1c5466af 100644 --- a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js +++ b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js @@ -4,6 +4,7 @@ import { useCoupons, useCustomerAgreement, useEnterpriseOffers } from '../hooks' import EcommerceApiService from '../../../../data/services/EcommerceApiService'; import LicenseManagerApiService from '../../../../data/services/LicenseManagerAPIService'; import SubsidyApiService from '../../../../data/services/EnterpriseSubsidyApiService'; +import { BUDGET_TYPES } from '../../../EnterpriseApp/data/constants'; jest.mock('@edx/frontend-platform/config', () => ({ getConfig: jest.fn(() => ({ @@ -51,6 +52,7 @@ describe('useEnterpriseOffers', () => { start: '2021-05-15T19:56:09Z', end: '2100-05-15T19:56:09Z', isCurrent: true, + source: BUDGET_TYPES.ecommerce, }]; SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ @@ -69,9 +71,7 @@ describe('useEnterpriseOffers', () => { await waitForNextUpdate(); - expect(EcommerceApiService.fetchEnterpriseOffers).toHaveBeenCalledWith({ - isCurrent: true, - }); + expect(EcommerceApiService.fetchEnterpriseOffers).toHaveBeenCalled(); expect(result.current).toEqual({ offers: mockOffers, isLoading: false, @@ -80,25 +80,35 @@ describe('useEnterpriseOffers', () => { }); it('should fetch enterprise offers for the enterprise when data available in enterprise-subsidy', async () => { - const mockOffers = [ + const mockEnterpriseSubsidyResponse = [ { - id: 'offer-id', - name: 'offer-name', - start: '2021-05-15T19:56:09Z', - end: '2100-05-15T19:56:09Z', - isCurrent: true, + uuid: 'offer-id', + title: 'offer-name', + activeDatetime: '2021-05-15T19:56:09Z', + expirationDatetime: '2100-05-15T19:56:09Z', + isActive: true, }, ]; - const mockSubsidyServiceResponse = [{ - uuid: 'offer-id', - title: 'offer-name', - active_datetime: '2021-05-15T19:56:09Z', - expiration_datetime: '2100-05-15T19:56:09Z', - is_active: true, - }]; + + const mockEcommerceResponse = [ + { + id: 'uuid', + display_name: 'offer-name', + start_datetime: '2021-05-15T19:56:09Z', + end_datetime: '2100-05-15T19:56:09Z', + is_current: true, + }, + ]; + SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ data: { - results: mockSubsidyServiceResponse, + results: mockEnterpriseSubsidyResponse, + }, + }); + + EcommerceApiService.fetchEnterpriseOffers.mockResolvedValueOnce({ + data: { + results: mockEcommerceResponse, }, }); @@ -113,36 +123,39 @@ describe('useEnterpriseOffers', () => { TEST_ENTERPRISE_UUID, { subsidyType: 'learner_credit' }, ); + + const expectedOffers = [ + { + id: 'offer-id', + name: 'offer-name', + start: '2021-05-15T19:56:09Z', + end: '2100-05-15T19:56:09Z', + isCurrent: true, + source: BUDGET_TYPES.subsidy, + }, + { + id: 'uuid', + name: 'offer-name', + start: '2021-05-15T19:56:09Z', + end: '2100-05-15T19:56:09Z', + isCurrent: true, + source: BUDGET_TYPES.ecommerce, + }, + ]; + expect(result.current).toEqual({ - offers: mockOffers, + offers: expectedOffers, isLoading: false, canManageLearnerCredit: true, }); }); it('should set canManageLearnerCredit to false if active enterprise offer or subsidy not found', async () => { - const mockOffers = [{ subsidyUuid: 'offer-1' }, { subsidyUuid: 'offer-2' }]; - const mockSubsidyServiceResponse = [ - { - uuid: 'offer-1', - title: 'offer-name', - active_datetime: '2005-05-15T19:56:09Z', - expiration_datetime: '2006-05-15T19:56:09Z', - is_active: false, - }, - { - uuid: 'offer-2', - title: 'offer-name-2', - active_datetime: '2006-05-15T19:56:09Z', - expiration_datetime: '2007-05-15T19:56:09Z', - is_active: false, - }, - ]; - const mockOfferData = []; + const mockSubsidyServiceResponse = []; EcommerceApiService.fetchEnterpriseOffers.mockResolvedValueOnce({ data: { - results: mockOffers, + results: [], }, }); SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ @@ -162,15 +175,22 @@ describe('useEnterpriseOffers', () => { TEST_ENTERPRISE_UUID, { subsidyType: 'learner_credit' }, ); + + const hasActiveOffersOrSubsidies = mockSubsidyServiceResponse.some(offer => offer.is_active); + let canManageLearnerCredit = false; + + if (hasActiveOffersOrSubsidies) { + canManageLearnerCredit = true; + } + expect(result.current).toEqual({ - offers: mockOfferData, + offers: [], isLoading: false, - canManageLearnerCredit: false, + canManageLearnerCredit, }); }); it('should return the active enterprise offer or subsidy when multiple available', async () => { - const mockOffers = [{ subsidyUuid: 'offer-1' }, { subsidyUuid: 'offer-2' }]; const mockSubsidyServiceResponse = [ { uuid: 'offer-1', @@ -188,18 +208,27 @@ describe('useEnterpriseOffers', () => { }, ]; const mockOfferData = [ + { + id: 'offer-1', + name: 'offer-name', + start: '2005-05-15T19:56:09Z', + end: '2006-05-15T19:56:09Z', + isCurrent: false, + source: BUDGET_TYPES.subsidy, + }, { id: 'offer-2', name: 'offer-name-2', start: '2006-05-15T19:56:09Z', end: '2099-05-15T19:56:09Z', isCurrent: true, + source: BUDGET_TYPES.subsidy, }, ]; EcommerceApiService.fetchEnterpriseOffers.mockResolvedValueOnce({ data: { - results: mockOffers, + results: [], }, }); SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ diff --git a/src/components/learner-credit-management/BudgetCard-V2.jsx b/src/components/learner-credit-management/BudgetCard-V2.jsx index b39b9297d9..e6780a61db 100644 --- a/src/components/learner-credit-management/BudgetCard-V2.jsx +++ b/src/components/learner-credit-management/BudgetCard-V2.jsx @@ -1,25 +1,17 @@ -import React, { useState } from 'react'; +/* eslint-disable react/jsx-no-useless-fragment */ +/* eslint-disable no-nested-ternary */ +import React from 'react'; import PropTypes from 'prop-types'; -import dayjs from 'dayjs'; -import { - Card, - Button, - Stack, - Row, - Col, - Breadcrumb, -} from '@edx/paragon'; - -import { useOfferRedemptions, useOfferSummary } from './data/hooks'; -import LearnerCreditAggregateCards from './LearnerCreditAggregateCards'; -import LearnerCreditAllocationTable from './LearnerCreditAllocationTable'; -import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import { useOfferSummary } from './data/hooks'; +import SubBudgetCard from './SubBudgetCard'; +import { BUDGET_TYPES } from '../EnterpriseApp/data/constants'; const BudgetCard = ({ offer, enterpriseUUID, enterpriseSlug, - enableLearnerPortal, + offerType, + displayName, }) => { const { start, @@ -31,124 +23,37 @@ const BudgetCard = ({ offerSummary, } = useOfferSummary(enterpriseUUID, offer); - const { - isLoading: isLoadingOfferRedemptions, - offerRedemptions, - fetchOfferRedemptions, - } = useOfferRedemptions(enterpriseUUID, offer?.id); - const [detailPage, setDetailPage] = useState(false); - const [activeLabel, setActiveLabel] = useState(''); - const links = [ - { label: 'Budgets', url: `/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}` }, - ]; - const formattedStartDate = dayjs(start).format('MMMM D, YYYY'); - const formattedExpirationDate = dayjs(end).format('MMMM D, YYYY'); - const navigateToBudgetRedemptions = (budgetType) => { - setDetailPage(true); - links.push({ label: budgetType, url: `/${enterpriseSlug}/admin/learner-credit` }); - setActiveLabel(budgetType); - }; - - const renderActions = (budgetType) => ( - - ); - - const renderCardHeader = (budgetType) => { - const subtitle = ( -
- - {formattedStartDate} - {formattedExpirationDate} - -
- ); - - return ( - - {renderActions(budgetType)} - - )} - /> - ); - }; - - const renderCardSection = (available, spent) => ( - - - - Available - {available} - - - Spent - {spent} - - - - ); - - const renderCardAggregate = () => ( -
- -
- ); - return ( - - - - - - - {!detailPage - ? ( - <> - {renderCardAggregate()} -

Budgets

- - - - {renderCardHeader('Overview')} - {renderCardSection(offerSummary?.remainingFunds, offerSummary?.redeemedFunds)} - - - - - ) - : ( - - )} -
+ <> + {offerType === BUDGET_TYPES.ecommerce ? ( + + ) : ( + <> + {offerSummary?.budgetsSummary?.map((budget) => ( + + ))} + + )} + ); }; @@ -161,7 +66,8 @@ BudgetCard.propTypes = { }).isRequired, enterpriseUUID: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, - enableLearnerPortal: PropTypes.bool.isRequired, + offerType: PropTypes.string.isRequired, + displayName: PropTypes.string, }; export default BudgetCard; diff --git a/src/components/learner-credit-management/BudgetDetailPage.jsx b/src/components/learner-credit-management/BudgetDetailPage.jsx new file mode 100644 index 0000000000..ad90b5ae89 --- /dev/null +++ b/src/components/learner-credit-management/BudgetDetailPage.jsx @@ -0,0 +1,85 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { + Row, + Col, + Breadcrumb, + Container, +} from '@edx/paragon'; +import { connect } from 'react-redux'; +import { Helmet } from 'react-helmet'; +import { useParams, Link } from 'react-router-dom'; +import Hero from '../Hero'; + +import LoadingMessage from '../LoadingMessage'; +import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; + +import LearnerCreditAllocationTable from './LearnerCreditAllocationTable'; +import { useOfferRedemptions } from './data/hooks'; +import { isUUID } from './data/utils'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; + +const PAGE_TITLE = 'Learner Credit Management'; + +const BudgetDetailPage = ({ + enterpriseUUID, + enterpriseSlug, + enableLearnerPortal, +}) => { + const { budgetId } = useParams(); + const enterpriseOfferId = isUUID(budgetId) ? null : budgetId; + const subsidyAccessPolicyId = isUUID(budgetId) ? budgetId : null; + + const { isLoading } = useContext(EnterpriseSubsidiesContext); + const { + isLoading: isLoadingOfferRedemptions, + offerRedemptions, + fetchOfferRedemptions, + } = useOfferRedemptions(enterpriseUUID, enterpriseOfferId, subsidyAccessPolicyId); + if (isLoading) { + return ; + } + const links = [ + { label: 'Budgets', to: `/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}` }, + ]; + return ( + <> + + + + + + + + + + + + ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, + enterpriseSlug: state.portalConfiguration.enterpriseSlug, + enableLearnerPortal: state.portalConfiguration.enableLearnerPortal, +}); + +BudgetDetailPage.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + enterpriseSlug: PropTypes.string.isRequired, + enableLearnerPortal: PropTypes.bool.isRequired, +}; + +export default connect(mapStateToProps)(BudgetDetailPage); diff --git a/src/components/learner-credit-management/MultipleBudgetsPage.jsx b/src/components/learner-credit-management/MultipleBudgetsPage.jsx index 3df18c465a..fe12ab2719 100644 --- a/src/components/learner-credit-management/MultipleBudgetsPage.jsx +++ b/src/components/learner-credit-management/MultipleBudgetsPage.jsx @@ -6,6 +6,7 @@ import { Col, Card, Hyperlink, + Container, } from '@edx/paragon'; import { connect } from 'react-redux'; import { Helmet } from 'react-helmet'; @@ -17,7 +18,7 @@ import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import { configuration } from '../../config'; -const PAGE_TITLE = 'Learner Credit'; +const PAGE_TITLE = 'Learner Credit Management'; const MultipleBudgetsPage = ({ enterpriseUUID, @@ -63,12 +64,14 @@ const MultipleBudgetsPage = ({ <> - + + + ); }; diff --git a/src/components/learner-credit-management/MultipleBudgetsPicker.jsx b/src/components/learner-credit-management/MultipleBudgetsPicker.jsx index 4c3da2d0ce..db6f178f2a 100644 --- a/src/components/learner-credit-management/MultipleBudgetsPicker.jsx +++ b/src/components/learner-credit-management/MultipleBudgetsPicker.jsx @@ -14,18 +14,25 @@ const MultipleBudgetsPicker = ({ enterpriseSlug, enableLearnerPortal, }) => ( - + - - {offers.map(offer => ( - - ))} +

Budgets

+
+ + + + {offers.map(offer => ( + + ))} +
diff --git a/src/components/learner-credit-management/SubBudgetCard.jsx b/src/components/learner-credit-management/SubBudgetCard.jsx new file mode 100644 index 0000000000..d3360cea43 --- /dev/null +++ b/src/components/learner-credit-management/SubBudgetCard.jsx @@ -0,0 +1,103 @@ +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { + Card, + Button, + Row, + Col, +} from '@edx/paragon'; + +import { BUDGET_STATUSES, ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import { formatPrice, getBudgetStatus } from './data/utils'; + +const SubBudgetCard = ({ + id, + start, + end, + available, + spent, + displayName, + enterpriseSlug, + isLoading, +}) => { + const formattedStartDate = dayjs(start).format('MMMM D, YYYY'); + const formattedExpirationDate = dayjs(end).format('MMMM D, YYYY'); + const budgetStatus = getBudgetStatus(start, end); + + const renderActions = (budgetId) => ( + + ); + + const renderCardHeader = (budgetType, budgetId) => { + const subtitle = ( +
+ + {formattedStartDate} - {formattedExpirationDate} + +
+ ); + + return ( + + ); + }; + + const renderCardSection = (availableBalance, spentBalance) => ( + + + + Available + {formatPrice(availableBalance)} + + + Spent + {formatPrice(spentBalance)} + + + + ); + + return ( + + + {renderCardHeader(displayName || 'Overview', id)} + {budgetStatus !== BUDGET_STATUSES.upcoming && renderCardSection(available, spent)} + + + ); +}; + +SubBudgetCard.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, + id: PropTypes.string, + start: PropTypes.string, + end: PropTypes.string, + spent: PropTypes.number, + isLoading: PropTypes.bool, + available: PropTypes.number, + displayName: PropTypes.string, +}; + +export default SubBudgetCard; diff --git a/src/components/learner-credit-management/data/hooks.js b/src/components/learner-credit-management/data/hooks.js index 585970c35e..31577f36a7 100644 --- a/src/components/learner-credit-management/data/hooks.js +++ b/src/components/learner-credit-management/data/hooks.js @@ -74,7 +74,7 @@ const applyFiltersToOptions = (filters, options) => { } }; -export const useOfferRedemptions = (enterpriseUUID, offerId) => { +export const useOfferRedemptions = (enterpriseUUID, offerId = null, budgetId = null) => { const shouldTrackFetchEvents = useRef(false); const [isLoading, setIsLoading] = useState(true); const [offerRedemptions, setOfferRedemptions] = useState({ @@ -90,9 +90,14 @@ export const useOfferRedemptions = (enterpriseUUID, offerId) => { const options = { page: args.pageIndex + 1, // `DataTable` uses zero-indexed array pageSize: args.pageSize, - offerId, ignoreNullCourseListPrice: true, }; + if (budgetId !== null) { + options.budgetId = budgetId; + } + if (offerId !== null) { + options.offerId = offerId; + } if (args.sortBy?.length > 0) { applySortByToOptions(args.sortBy, options); } @@ -129,10 +134,10 @@ export const useOfferRedemptions = (enterpriseUUID, offerId) => { setIsLoading(false); } }; - if (offerId) { + if (offerId || budgetId) { fetch(); } - }, [enterpriseUUID, offerId, shouldTrackFetchEvents]); + }, [enterpriseUUID, offerId, budgetId, shouldTrackFetchEvents]); const debouncedFetchOfferRedemptions = useMemo(() => debounce(fetchOfferRedemptions, 300), [fetchOfferRedemptions]); diff --git a/src/components/learner-credit-management/data/tests/hooks.test.js b/src/components/learner-credit-management/data/tests/hooks.test.js index 8ab61bce2f..38ebaeaafd 100644 --- a/src/components/learner-credit-management/data/tests/hooks.test.js +++ b/src/components/learner-credit-management/data/tests/hooks.test.js @@ -72,6 +72,9 @@ describe('useOfferSummary', () => { redeemedFundsOcm: NaN, remainingFunds: 4800, percentUtilized: 0.04, + offerId: 1, + budgetsSummary: [], + offerType: undefined, }; expect(result.current).toEqual({ offerSummary: expectedResult, @@ -83,9 +86,11 @@ describe('useOfferSummary', () => { describe('useOfferRedemptions', () => { it('should fetch enrollment/redemptions metadata for enterprise offer', async () => { EnterpriseDataApiService.fetchCourseEnrollments.mockResolvedValueOnce({ data: mockOfferEnrollmentsResponse }); + const budgetId = 'test-budget-id'; const { result, waitForNextUpdate } = renderHook(() => useOfferRedemptions( TEST_ENTERPRISE_UUID, mockEnterpriseOffer.id, + budgetId, )); expect(result.current).toMatchObject({ @@ -119,6 +124,7 @@ describe('useOfferRedemptions', () => { ordering: '-enrollment_date', // default sort order searchAll: mockOfferEnrollments[0].user_email, ignoreNullCourseListPrice: true, + budgetId, }; expect(EnterpriseDataApiService.fetchCourseEnrollments).toHaveBeenCalledWith( TEST_ENTERPRISE_UUID, @@ -133,5 +139,7 @@ describe('useOfferRedemptions', () => { isLoading: false, fetchOfferRedemptions: expect.any(Function), }); + + expect(expectedApiOptions.budgetId).toBe(budgetId); }); }); diff --git a/src/components/learner-credit-management/data/tests/utils.test.js b/src/components/learner-credit-management/data/tests/utils.test.js index 33902d40fe..96061c8af7 100644 --- a/src/components/learner-credit-management/data/tests/utils.test.js +++ b/src/components/learner-credit-management/data/tests/utils.test.js @@ -1,4 +1,4 @@ -import { transformOfferSummary } from '../utils'; +import { transformOfferSummary, getBudgetStatus } from '../utils'; import { EXEC_ED_OFFER_TYPE } from '../constants'; describe('transformOfferSummary', () => { @@ -23,6 +23,8 @@ describe('transformOfferSummary', () => { remainingFunds: 0.0, percentUtilized: 1.0, offerType: EXEC_ED_OFFER_TYPE, + budgetsSummary: [], + offerId: undefined, }); }); @@ -33,6 +35,8 @@ describe('transformOfferSummary', () => { remainingBalance: null, percentOfOfferSpent: null, offerType: 'Site', + offerId: '123', + budgetsSummary: [], }; expect(transformOfferSummary(offerSummary)).toEqual({ @@ -41,6 +45,72 @@ describe('transformOfferSummary', () => { remainingFunds: null, percentUtilized: null, offerType: 'Site', + redeemedFundsExecEd: undefined, + redeemedFundsOcm: undefined, + offerId: '123', + budgetsSummary: [], }); }); + + it('should handle when budgetsSummary is provided', () => { + const offerSummary = { + maxDiscount: 1000, + amountOfOfferSpent: 500, + remainingBalance: 500, + percentOfOfferSpent: 0.5, + offerType: 'Site', + offerId: '123', + budgets: [ + { + id: 123, + start: '2022-01-01', + end: '2022-01-01', + available: 200, + spent: 100, + enterpriseSlug: 'test-enterprise', + }], + }; + + expect(transformOfferSummary(offerSummary)).toEqual({ + totalFunds: 1000, + redeemedFunds: 500, + remainingFunds: 500, + percentUtilized: 0.5, + offerType: 'Site', + redeemedFundsExecEd: NaN, + redeemedFundsOcm: NaN, + offerId: '123', + budgetsSummary: [{ + id: 123, + start: '2022-01-01', + end: '2022-01-01', + available: 200, + spent: 100, + enterpriseSlug: 'test-enterprise', + }], + }); + }); +}); + +describe('getBudgetStatus', () => { + it('should return "upcoming" when the current date is before the start date', () => { + const startDateStr = '2023-09-30'; + const endDateStr = '2023-10-30'; + const result = getBudgetStatus(startDateStr, endDateStr); + expect(result).toEqual('Upcoming'); + }); + + it('should return "active" when the current date is between the start and end dates', () => { + const startDateStr = '2023-09-01'; + const endDateStr = '2023-09-30'; + const result = getBudgetStatus(startDateStr, endDateStr); + expect(result).toEqual('Active'); + }); + + it('should return "expired" when the current date is after the end date', () => { + const startDateStr = '2023-08-01'; + const endDateStr = '2023-08-31'; + const result = getBudgetStatus(startDateStr, endDateStr); + expect(result).toEqual('Expired'); + }); }); diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 65524c1346..5bd64d257d 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -3,6 +3,7 @@ import { LOW_REMAINING_BALANCE_PERCENT_THRESHOLD, NO_BALANCE_REMAINING_DOLLAR_THRESHOLD, } from './constants'; +import { BUDGET_STATUSES } from '../../EnterpriseApp/data/constants'; /** * Transforms offer summary from API for display in the UI, guarding * against bad data (e.g., accounting for refunds). @@ -12,6 +13,20 @@ import { */ export const transformOfferSummary = (offerSummary) => { if (!offerSummary) { return null; } + const budgetsSummary = []; + if (offerSummary?.budgets) { + const budgets = offerSummary?.budgets; + for (let i = 0; i < budgets.length; i++) { + const redeemedFunds = budgets[i].amountOfPolicySpent && parseFloat(budgets[i].amountOfPolicySpent); + const remainingFunds = budgets[i].remainingBalance && parseFloat(budgets[i].remainingBalance); + const updatedBudgetDetail = { + redeemedFunds, + remainingFunds, + ...budgets[i], + }; + budgetsSummary.push(updatedBudgetDetail); + } + } const totalFunds = offerSummary.maxDiscount && parseFloat(offerSummary.maxDiscount); let redeemedFunds = offerSummary.amountOfOfferSpent && parseFloat(offerSummary.amountOfOfferSpent); @@ -38,7 +53,7 @@ export const transformOfferSummary = (offerSummary) => { percentUtilized = Math.min(percentUtilized, 1.0); } const { offerType } = offerSummary; - + const { offerId } = offerSummary; return { totalFunds, redeemedFunds, @@ -47,6 +62,8 @@ export const transformOfferSummary = (offerSummary) => { remainingFunds, percentUtilized, offerType, + offerId, + budgetsSummary, }; }; @@ -91,3 +108,29 @@ export const getProgressBarVariant = ({ percentUtilized, remainingFunds }) => { } return variant; }; + +// Utility function to check if the ID is a UUID +export const isUUID = (id) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id); + +// Utility function to check the budget status +export const getBudgetStatus = (startDateStr, endDateStr) => { + const currentDate = new Date(); + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + + if (currentDate < startDate) { + return BUDGET_STATUSES.upcoming; + } + if (currentDate >= startDate && currentDate <= endDate) { + return BUDGET_STATUSES.active; + } + return BUDGET_STATUSES.expired; +}; + +export const formatPrice = (price) => { + const USDollar = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }); + return USDollar.format(Math.abs(price)); +}; diff --git a/src/components/learner-credit-management/index.js b/src/components/learner-credit-management/index.js deleted file mode 100644 index 271f4453ed..0000000000 --- a/src/components/learner-credit-management/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import MultipleBudgetsPage from './MultipleBudgetsPage'; - -export default MultipleBudgetsPage; diff --git a/src/components/learner-credit-management/index.jsx b/src/components/learner-credit-management/index.jsx new file mode 100644 index 0000000000..2ce695b9a7 --- /dev/null +++ b/src/components/learner-credit-management/index.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import MultipleBudgetsPage from './MultipleBudgetsPage'; +import BudgetDetailPage from './BudgetDetailPage'; + +const LearnerCreditManagementRoutes = ({ baseUrl }) => ( + <> + + + + +); + +LearnerCreditManagementRoutes.propTypes = { + baseUrl: PropTypes.string.isRequired, +}; + +export default LearnerCreditManagementRoutes; diff --git a/src/components/learner-credit-management/tests/BudgetCard.test.jsx b/src/components/learner-credit-management/tests/BudgetCard.test.jsx index 7d8f349bda..d8aa511a4a 100644 --- a/src/components/learner-credit-management/tests/BudgetCard.test.jsx +++ b/src/components/learner-credit-management/tests/BudgetCard.test.jsx @@ -1,21 +1,20 @@ /* eslint-disable react/prop-types */ import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; -import userEvent from '@testing-library/user-event'; import configureMockStore from 'redux-mock-store'; import dayjs from 'dayjs'; import { screen, render, - waitFor, } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import BudgetCard from '../BudgetCard-V2'; import { useOfferSummary, useOfferRedemptions } from '../data/hooks'; -import { EXEC_ED_OFFER_TYPE } from '../data/constants'; +import { BUDGET_TYPES } from '../../EnterpriseApp/data/constants'; jest.mock('../data/hooks'); useOfferSummary.mockReturnValue({ @@ -47,20 +46,15 @@ const mockEnterpriseOfferId = '123'; const mockEnterpriseOfferEnrollmentId = 456; const mockOfferDisplayName = 'Test Enterprise Offer'; -const mockOfferSummary = { - totalFunds: 5000, - redeemedFunds: 200, - remainingFunds: 4800, - percentUtilized: 0.04, - offerType: EXEC_ED_OFFER_TYPE, -}; const BudgetCardWrapper = ({ ...rest }) => ( - - - - - + + + + + + + ); describe('', () => { @@ -88,6 +82,16 @@ describe('', () => { remainingFunds: 4800, percentUtilized: 0.04, offerType: 'Site', + budgetsSummary: [ + { + id: 123, + start: '2022-01-01', + end: '2022-01-01', + available: 200, + spent: 100, + enterpriseSlug: enterpriseId, + }, + ], }, }); useOfferRedemptions.mockReturnValue({ @@ -106,42 +110,112 @@ describe('', () => { />); expect(screen.getByText('Overview')); expect(screen.queryByText('Executive Education')).not.toBeInTheDocument(); - expect(screen.getByText(`$${mockOfferSummary.redeemedFunds.toLocaleString()}`)); const formattedString = `${dayjs(mockOffer.start).format('MMMM D, YYYY')} - ${dayjs(mockOffer.end).format('MMMM D, YYYY')}`; const elementsWithTestId = screen.getAllByTestId('offer-date'); const firstElementWithTestId = elementsWithTestId[0]; expect(firstElementWithTestId).toHaveTextContent(formattedString); }); - it('displays table on clicking view budget', async () => { + it('renders SubBudgetCard when offerType is ecommerce', () => { const mockOffer = { id: mockEnterpriseOfferId, name: mockOfferDisplayName, start: '2022-01-01', end: '2023-01-01', + offerType: BUDGET_TYPES.ecommerce, + }; + const mockOfferRedemption = { + created: '2022-02-01', + enterpriseEnrollmentId: mockEnterpriseOfferEnrollmentId, }; useOfferSummary.mockReturnValue({ isLoading: false, - offerSummary: mockOfferSummary, + offerSummary: { + totalFunds: 5000, + redeemedFunds: 200, + remainingFunds: 4800, + percentUtilized: 0.04, + offerType: 'learner_credit', + budgetsSummary: [ + { + id: 123, + start: '2022-01-01', + end: '2022-01-01', + available: 200, + spent: 100, + enterpriseSlug: enterpriseId, + }, + ], + }, }); useOfferRedemptions.mockReturnValue({ isLoading: false, offerRedemptions: { - itemCount: 0, - pageCount: 0, - results: [], + results: [mockOfferRedemption], + itemCount: 1, + pageCount: 1, }, fetchOfferRedemptions: jest.fn(), }); + render(); - const elementsWithTestId = screen.getAllByTestId('view-budget'); - const firstElementWithTestId = elementsWithTestId[0]; - await waitFor(() => userEvent.click(firstElementWithTestId)); - expect(screen.getByText('No results found')); + + expect(screen.getByTestId('view-budget')).toBeInTheDocument(); + }); + + it('renders SubBudgetCard when offerType is not ecommerce', () => { + const mockOffer = { + id: mockEnterpriseOfferId, + name: mockOfferDisplayName, + start: '2022-01-01', + end: '2023-01-01', + offerType: 'otherOfferType', + }; + const mockOfferRedemption = { + created: '2022-02-01', + enterpriseEnrollmentId: mockEnterpriseOfferEnrollmentId, + }; + useOfferSummary.mockReturnValue({ + isLoading: false, + offerSummary: { + totalFunds: 5000, + redeemedFunds: 200, + remainingFunds: 4800, + percentUtilized: 0.04, + offerType: 'learner_credit', + budgetsSummary: [ + { + id: 123, + start: '2022-01-01', + end: '2022-01-01', + available: 200, + spent: 100, + enterpriseSlug: enterpriseId, + }, + ], + }, + }); + useOfferRedemptions.mockReturnValue({ + isLoading: false, + offerRedemptions: { + results: [mockOfferRedemption], + itemCount: 1, + pageCount: 1, + }, + fetchOfferRedemptions: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('view-budget')).toBeInTheDocument(); }); }); });