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();
});
});
});