diff --git a/src/components/Admin/Admin.test.jsx b/src/components/Admin/Admin.test.jsx index 91dda8471f..c0a1105c74 100644 --- a/src/components/Admin/Admin.test.jsx +++ b/src/components/Admin/Admin.test.jsx @@ -8,9 +8,13 @@ import thunk from 'redux-thunk'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { screen, render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import dayjs from 'dayjs'; import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; import Admin from './index'; import { CSV_CLICK_SEGMENT_EVENT_NAME } from '../DownloadCsvButton'; +import { useEnterpriseBudgets } from '../EnterpriseSubsidiesContext/data/hooks'; jest.mock('@edx/frontend-enterprise-utils', () => { const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); @@ -20,10 +24,20 @@ jest.mock('@edx/frontend-enterprise-utils', () => { }); }); +jest.mock('../EnterpriseSubsidiesContext/data/hooks', () => ({ + ...jest.requireActual('../EnterpriseSubsidiesContext/data/hooks'), + useEnterpriseBudgets: jest.fn().mockReturnValue({ + data: [], + }), +})); + const mockStore = configureMockStore([thunk]); const store = mockStore({ portalConfiguration: { enterpriseId: 'test-enterprise-id', + enterpriseFeatures: { + topDownAssignmentRealTimeLcm: true, + }, }, table: {}, csv: {}, @@ -472,6 +486,7 @@ describe('', () => { }); }); }); + describe('reset form button', () => { it('should not be present if there is no query', () => { const wrapper = mount(( @@ -551,4 +566,38 @@ describe('', () => { expect(link.first().props().to).toEqual(`${path}?${nonSearchQuery}`); }); }); + + describe('renders expiry component when threshold is met', () => { + it('renders when date is within threshold', () => { + useEnterpriseBudgets.mockReturnValue( + { + data: [ + { + end: dayjs().add(60, 'day').toString(), + }, + ], + }, + ); + + render(); + + expect(screen.getByTestId('expiry-notification-alert')).toBeInTheDocument(); + }); + + it('does not render when date is not within threshold', () => { + useEnterpriseBudgets.mockReturnValue( + { + data: [ + { + end: dayjs().add(160, 'day').toString(), + }, + ], + }, + ); + + render(); + + expect(screen.queryByTestId('expiry-notification-alert')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Admin/__snapshots__/Admin.test.jsx.snap b/src/components/Admin/__snapshots__/Admin.test.jsx.snap index 86f9a59e99..3bd82d7d32 100644 --- a/src/components/Admin/__snapshots__/Admin.test.jsx.snap +++ b/src/components/Admin/__snapshots__/Admin.test.jsx.snap @@ -20,6 +20,9 @@ exports[` renders correctly calls fetchDashboardAnalytics prop 1`] = ` /> +
@@ -190,6 +193,9 @@ exports[` renders correctly with dashboard analytics data renders # cou />
+
@@ -1059,6 +1065,9 @@ exports[` renders correctly with dashboard analytics data renders # of />
+
@@ -1928,6 +1937,9 @@ exports[` renders correctly with dashboard analytics data renders # of />
+
@@ -2797,6 +2809,9 @@ exports[` renders correctly with dashboard analytics data renders colla />
+
@@ -3666,6 +3681,9 @@ exports[` renders correctly with dashboard analytics data renders full />
+
@@ -4535,6 +4553,9 @@ exports[` renders correctly with dashboard analytics data renders inact />
+
@@ -5404,6 +5425,9 @@ exports[` renders correctly with dashboard analytics data renders inact />
+
@@ -6273,6 +6297,9 @@ exports[` renders correctly with dashboard analytics data renders learn />
+
@@ -7142,6 +7169,9 @@ exports[` renders correctly with dashboard analytics data renders regis />
+
@@ -8011,6 +8041,9 @@ exports[` renders correctly with dashboard analytics data renders top a />
+
@@ -8880,6 +8913,9 @@ exports[` renders correctly with dashboard insights data renders dashbo />
+
@@ -9798,6 +9834,9 @@ exports[` renders correctly with error state 1`] = ` />
+
@@ -9952,6 +9991,9 @@ exports[` renders correctly with loading state 1`] = ` />
+
@@ -10167,6 +10209,9 @@ exports[` renders correctly with no dashboard insights data 1`] = ` />
+
diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index d6e958a2ca..9c9ad2ddda 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -27,6 +27,7 @@ import EmbeddedSubscription from './EmbeddedSubscription'; import { withLocation, withParams } from '../../hoc'; import AIAnalyticsSummary from './AIAnalyticsSummary'; import AIAnalyticsSummarySkeleton from './AIAnalyticsSummarySkeleton'; +import BudgetExpiryAlertAndModal from '../BudgetExpiryAlertAndModal'; class Admin extends React.Component { componentDidMount() { @@ -308,6 +309,9 @@ class Admin extends React.Component { <> +
+ +
@@ -367,22 +371,21 @@ class Admin extends React.Component {
{lastUpdatedDate && ( - <> - Showing data as of {formatTimestamp({ timestamp: lastUpdatedDate })} - + <> + Showing data as of {formatTimestamp({ timestamp: lastUpdatedDate })} + )} -
{this.renderDownloadButton()}
{this.displaySearchBar() && ( - this.props.searchEnrollmentsList()} - tableData={this.getTableData() ? this.getTableData().results : []} - /> + this.props.searchEnrollmentsList()} + tableData={this.getTableData() ? this.getTableData().results : []} + /> )} )} diff --git a/src/components/BudgetExpiryAlertAndModal/data/constants.js b/src/components/BudgetExpiryAlertAndModal/data/constants.js new file mode 100644 index 0000000000..624722b614 --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/data/constants.js @@ -0,0 +1,8 @@ +export const SEEN_ENTERPRISE_EXPIRATION_ALERT_COOKIE_PREFIX = 'seen-enterprise-expiration-alert-'; + +export const SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX = 'seen-enterprise-expiration-modal-'; + +export const PLAN_EXPIRY_VARIANTS = { + expired: 'Expired', + expiring: 'Expiring', +}; diff --git a/src/components/BudgetExpiryAlertAndModal/data/expiryThresholds.js b/src/components/BudgetExpiryAlertAndModal/data/expiryThresholds.js new file mode 100644 index 0000000000..ab8c384dac --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/data/expiryThresholds.js @@ -0,0 +1,82 @@ +import sanitizeHTML from 'sanitize-html'; +import parse from 'html-react-parser'; +import { PLAN_EXPIRY_VARIANTS } from './constants'; + +const expiryThresholds = { + 120: ({ date }) => ({ + notificationTemplate: { + title: 'Your Learner Credit plan expires soon', + variant: 'info', + message: `Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.`, + dismissible: true, + }, + modalTemplate: { + title: 'Your plan expires soon', + message: parse(sanitizeHTML(`Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.`)), + }, + variant: PLAN_EXPIRY_VARIANTS.expiring, + }), + 90: ({ date }) => ({ + notificationTemplate: { + title: 'Reminder: Your Learner Credit plan expires soon', + variant: 'info', + message: `Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.`, + dismissible: true, + }, + modalTemplate: { + title: 'Reminder: Your plan expires soon', + message: parse(sanitizeHTML(`Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.`)), + }, + variant: PLAN_EXPIRY_VARIANTS.expiring, + }), + 60: ({ date }) => ({ + notificationTemplate: { + title: `Your Learner Credit plan expires ${date}`, + variant: 'warning', + message: 'When your Learner Credit plan expires, you will no longer have access to administrative functions and the remaining balance of your budget(s) will be unusable. Contact support today to renew your plan.', + dismissible: true, + }, + modalTemplate: { + title: `Your Learner Credit plan expires ${date}`, + message: parse(sanitizeHTML(`Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.`)), + }, + variant: PLAN_EXPIRY_VARIANTS.expiring, + }), + 30: ({ date }) => ({ + notificationTemplate: { + title: 'Your Learner Credit plan expires in less than 30 days', + variant: 'danger', + message: 'When your plan expires you will lose access to administrative functions and the remaining balance of your plan’s budget(s) will be unusable. Contact support today to renew your plan.', + }, + modalTemplate: { + title: 'Your Learner Credit plan expires in less than 30 days', + message: parse(sanitizeHTML(`

Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.

`)), + }, + variant: PLAN_EXPIRY_VARIANTS.expiring, + }), + 10: ({ date, days, hours }) => ({ + notificationTemplate: { + title: `Reminder: Your Learner Credit plan expires ${date}`, + variant: 'danger', + message: parse(sanitizeHTML(`Your Learner Credit plan expires in ${days} days and ${hours} hours. Contact support today to renew your plan.`)), + }, + modalTemplate: { + title: `Reminder: Your Learner Credit plan expires ${date}`, + message: parse(sanitizeHTML(`

Your Learner Credit plan expires ${date}. Contact support today to renew your plan and keep people learning.

`)), + }, + variant: PLAN_EXPIRY_VARIANTS.expiring, + }), + 0: ({ date }) => ({ + notificationTemplate: null, + modalTemplate: { + title: 'Your Learner Credit plan has expired', + message: parse(sanitizeHTML( + `

Your Learner Credit Plan expired on ${date}. You are no longer have access to administrative functions and the remaining balance of your plan's budget(s) are no longer available to spend

` + + '

Please contact your representative if you have any questions or concerns.

', + )), + }, + variant: PLAN_EXPIRY_VARIANTS.expired, + }), +}; + +export default expiryThresholds; diff --git a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx new file mode 100644 index 0000000000..ab8ba52383 --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react'; +import { + getEnterpriseBudgetExpiringAlertCookieName, + getEnterpriseBudgetExpiringModalCookieName, + getExpirationMetadata, +} from '../utils'; + +const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, alertClose) => { + const [notification, setNotification] = useState(null); + const [expirationThreshold, setExpirationThreshold] = useState(null); + const [modal, setModal] = useState(null); + + useEffect(() => { + if (!budgets || budgets.length === 0) { + return; + } + + const earliestExpiryBudget = budgets.reduce( + (earliestBudget, currentBudget) => (currentBudget.end < earliestBudget.end ? currentBudget : earliestBudget), + budgets[0], + ); + + const { thresholdKey, threshold } = getExpirationMetadata(earliestExpiryBudget.end); + + if (thresholdKey !== null) { + const { notificationTemplate, modalTemplate } = threshold; + + setNotification(notificationTemplate); + setModal(modalTemplate); + setExpirationThreshold({ + thresholdKey, + threshold, + }); + } + + const seenCurrentExpiringModalCookieName = getEnterpriseBudgetExpiringModalCookieName({ + expirationThreshold: thresholdKey, + enterpriseId, + }); + + const seenCurrentExpiringAlertCookieName = getEnterpriseBudgetExpiringAlertCookieName({ + expirationThreshold: thresholdKey, + enterpriseId, + }); + + const isModalDismissed = global.localStorage.getItem(seenCurrentExpiringModalCookieName); + const isAlertDismissed = global.localStorage.getItem(seenCurrentExpiringAlertCookieName); + + if (!isModalDismissed) { + modalOpen(); + } + + if (!isAlertDismissed) { + alertOpen(); + } + }, [budgets, enterpriseId, modalOpen, alertOpen]); + + const dismissModal = () => { + const seenCurrentExpirationModalCookieName = getEnterpriseBudgetExpiringModalCookieName({ + expirationThreshold: expirationThreshold.thresholdKey, + enterpriseId, + }); + + global.localStorage.setItem(seenCurrentExpirationModalCookieName, 'true'); + + modalClose(); + }; + + const dismissAlert = () => { + const seenCurrentExpirationAlertCookieName = getEnterpriseBudgetExpiringAlertCookieName({ + expirationThreshold: expirationThreshold.thresholdKey, + enterpriseId, + }); + + global.localStorage.setItem(seenCurrentExpirationAlertCookieName, 'true'); + + alertClose(); + }; + + return { + notification, modal, dismissModal, dismissAlert, + }; +}; + +export default useExpiry; diff --git a/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx new file mode 100644 index 0000000000..d5d20fb075 --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.test.jsx @@ -0,0 +1,64 @@ +import { renderHook } from '@testing-library/react-hooks'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; // Assuming this is the path to your expiryThresholds file +import useExpiry from './useExpiry'; +import expiryThresholds from '../expiryThresholds'; +import { formatDate } from '../../../learner-credit-management/data'; + +dayjs.extend(duration); + +const modalOpen = jest.fn(); +const modalClose = jest.fn(); +const alertOpen = jest.fn(); +const alertClose = jest.fn(); + +describe('useExpiry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + (() => { + const endDate = dayjs().add(120, 'day'); + return { endDate, expected: expiryThresholds[120]({ date: formatDate(endDate.toString()) }) }; + })(), + (() => { + const endDate = dayjs().add(90, 'day'); + return { endDate, expected: expiryThresholds[90]({ date: formatDate(endDate.toString()) }) }; + })(), + (() => { + const endDate = dayjs().add(60, 'day'); + return { endDate, expected: expiryThresholds[60]({ date: formatDate(endDate.toString()) }) }; + })(), + (() => { + const endDate = dayjs().add(30, 'day'); + return { endDate, expected: expiryThresholds[30]({ date: formatDate(endDate.toString()) }) }; + })(), + (() => { + const endDate = dayjs().add(10, 'day'); + const today = dayjs().add(1, 'minutes'); + const durationDiff = dayjs.duration(endDate.diff(today)); + + return { + endDate, + expected: expiryThresholds[10]({ + date: formatDate(endDate.toString()), + days: durationDiff.days(), + hours: durationDiff.hours(), + }), + }; + })(), + (() => { + const endDate = dayjs().subtract(1, 'day'); + return { endDate, expected: expiryThresholds[0]({ date: formatDate(endDate.toString()) }) }; + })(), + ])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected }) => { + const budgets = [{ end: endDate }]; // Mock data with an expiring budget + + const { result } = renderHook(() => useExpiry('enterpriseId', budgets, modalOpen, modalClose, alertOpen, alertClose)); + + expect(result.current.notification).toEqual(expected.notificationTemplate); + expect(result.current.modal).toEqual(expected.modalTemplate); + expect(result.current.status).toEqual(expected.status); + }); +}); diff --git a/src/components/BudgetExpiryAlertAndModal/data/utils.js b/src/components/BudgetExpiryAlertAndModal/data/utils.js new file mode 100644 index 0000000000..7c768010ad --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/data/utils.js @@ -0,0 +1,50 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { PLAN_EXPIRY_VARIANTS, SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX, SEEN_ENTERPRISE_EXPIRATION_ALERT_COOKIE_PREFIX } from './constants'; +import ExpiryThresholds from './expiryThresholds'; + +dayjs.extend(duration); + +export const getExpirationMetadata = (endDateStr) => { + const endDate = dayjs(endDateStr); + const today = dayjs(); + const durationDiff = dayjs.duration(endDate.diff(today)); + + const thresholdKeys = Object.keys(ExpiryThresholds).sort((a, b) => a - b); + const thresholdKey = thresholdKeys.find((key) => durationDiff.asDays() <= key); + + if (thresholdKey === undefined) { + return { + thresholdKey: null, + threshold: null, + }; + } + + return { + thresholdKey, + threshold: ExpiryThresholds[thresholdKey]({ + date: endDate.format('MMM D, YYYY'), + days: durationDiff.days(), + hours: durationDiff.hours(), + minutes: durationDiff.minutes(), + }), + }; +}; + +export const isPlanApproachingExpiry = (endDateStr) => { + const { thresholdKey, threshold } = getExpirationMetadata(endDateStr); + + if (thresholdKey === null) { + return false; + } + + return threshold.variant === PLAN_EXPIRY_VARIANTS.expiring; +}; + +export const getEnterpriseBudgetExpiringModalCookieName = ({ + expirationThreshold, enterpriseId, +}) => `${SEEN_ENTERPRISE_EXPIRATION_MODAL_COOKIE_PREFIX}${expirationThreshold}-${enterpriseId}`; + +export const getEnterpriseBudgetExpiringAlertCookieName = ({ + expirationThreshold, enterpriseId, +}) => `${SEEN_ENTERPRISE_EXPIRATION_ALERT_COOKIE_PREFIX}${expirationThreshold}-${enterpriseId}`; diff --git a/src/components/BudgetExpiryAlertAndModal/index.jsx b/src/components/BudgetExpiryAlertAndModal/index.jsx new file mode 100644 index 0000000000..44f232f50f --- /dev/null +++ b/src/components/BudgetExpiryAlertAndModal/index.jsx @@ -0,0 +1,138 @@ +import React, { useMemo } from 'react'; +import { + ActionRow, + Alert, AlertModal, + Button, Hyperlink, + useToggle, +} from '@edx/paragon'; + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useEnterpriseBudgets } from '../EnterpriseSubsidiesContext/data/hooks'; +import { configuration } from '../../config'; +import EVENT_NAMES from '../../eventTracking'; + +import useExpiry from './data/hooks/useExpiry'; + +const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => { + const [modalIsOpen, modalOpen, modalClose] = useToggle(false); + const [alertIsOpen, alertOpen, alertClose] = useToggle(false); + + const location = useLocation(); + + const budgetDetailRouteMatch = matchPath( + '/:enterpriseSlug/admin/learner-credit/:budgetId', + location.pathname, + ); + + const supportUrl = configuration.ENTERPRISE_SUPPORT_URL; + + const { data: budgets } = useEnterpriseBudgets({ + isTopDownAssignmentEnabled: enterpriseFeatures.topDownAssignmentRealTimeLcm, + enterpriseId: enterpriseUUID, + enablePortalLearnerCreditManagementScreen: true, + queryOptions: { + select: (data) => { + if (!budgetDetailRouteMatch?.params?.budgetId) { + return data.budgets; + } + + return data.budgets.filter(budget => budget.id === budgetDetailRouteMatch.params.budgetId); + }, + }, + }); + + const { + notification, modal, dismissModal, dismissAlert, + } = useExpiry( + enterpriseUUID, + budgets, + modalOpen, + modalClose, + alertOpen, + alertClose, + ); + + const trackEventMetadata = useMemo(() => { + if (modal === null && notification === null) { return {}; } + return { + modal, + notification, + }; + }, [modal, notification]); + + return ( + <> + {notification && ( + sendEnterpriseTrackEvent( + enterpriseUUID, + EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.BUDGET_EXPIRY_ALERT_CONTACT_SUPPORT, + trackEventMetadata, + )} + className="flex-shrink-0" + > + Contact support + , + ]} + dismissible={notification.dismissible} + onClose={dismissAlert} + data-testid="expiry-notification-alert" + > + {notification.title} +

{notification.message}

+
+ )} + + {modal && ( + + + + + )} + > + {modal.message} + + )} + + ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, +}); + +BudgetExpiryAlertAndModal.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + enterpriseFeatures: PropTypes.shape({ + topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired, + }), +}; + +export default connect(mapStateToProps)(BudgetExpiryAlertAndModal); diff --git a/src/components/EnterpriseApp/data/constants.js b/src/components/EnterpriseApp/data/constants.js index c19fcc0a62..a17c95b6eb 100644 --- a/src/components/EnterpriseApp/data/constants.js +++ b/src/components/EnterpriseApp/data/constants.js @@ -17,6 +17,7 @@ export const ROUTE_NAMES = { export const BUDGET_STATUSES = { active: 'Active', expired: 'Expired', + expiring: 'Expiring', scheduled: 'Scheduled', retired: 'Retired', }; diff --git a/src/components/EnterpriseSubsidiesContext/data/hooks.js b/src/components/EnterpriseSubsidiesContext/data/hooks.js index 0770ae1e8f..ba5fee9e72 100644 --- a/src/components/EnterpriseSubsidiesContext/data/hooks.js +++ b/src/components/EnterpriseSubsidiesContext/data/hooks.js @@ -110,6 +110,7 @@ export const useEnterpriseBudgets = ({ enablePortalLearnerCreditManagementScreen, enterpriseId, isTopDownAssignmentEnabled, + queryOptions = {}, }) => useQuery({ queryKey: learnerCreditManagementQueryKeys.budgets(enterpriseId), queryFn: (args) => fetchEnterpriseBudgets({ @@ -118,6 +119,7 @@ export const useEnterpriseBudgets = ({ enterpriseId, enablePortalLearnerCreditManagementScreen, }), + ...queryOptions, }); export const useCustomerAgreement = ({ enterpriseId }) => { diff --git a/src/components/learner-credit-management/BudgetDetailPageBreadcrumbs.jsx b/src/components/learner-credit-management/BudgetDetailPageBreadcrumbs.jsx index 5d4c9f4294..c7650c3486 100644 --- a/src/components/learner-credit-management/BudgetDetailPageBreadcrumbs.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageBreadcrumbs.jsx @@ -8,7 +8,7 @@ import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import EVENT_NAMES from '../../eventTracking'; import { useBudgetId, useSubsidyAccessPolicy } from './data'; -const BudgetDetailPageBreadcrumbs = ({ enterpriseId, enterpriseSlug, budgetDisplayName }) => { +const BudgetDetailPageBreadcrumbs = ({ enterpriseId, enterpriseSlug, displayName }) => { const { subsidyAccessPolicyId } = useBudgetId(); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); @@ -39,7 +39,7 @@ const BudgetDetailPageBreadcrumbs = ({ enterpriseId, enterpriseSlug, budgetDispl to: `/${enterpriseSlug}/admin/${ROUTE_NAMES.learnerCredit}`, }]} linkAs={Link} - activeLabel={budgetDisplayName} + activeLabel={displayName} clickHandler={() => sendEnterpriseTrackEvent( enterpriseId, EVENT_NAMES.LEARNER_CREDIT_MANAGEMENT.BREADCRUMB_FROM_BUDGET_DETAIL_TO_BUDGETS, @@ -58,7 +58,7 @@ const mapStateToProps = state => ({ BudgetDetailPageBreadcrumbs.propTypes = { enterpriseId: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, - budgetDisplayName: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, }; export default connect(mapStateToProps)(BudgetDetailPageBreadcrumbs); diff --git a/src/components/learner-credit-management/BudgetDetailPageHeader.jsx b/src/components/learner-credit-management/BudgetDetailPageHeader.jsx index b46bae5935..f7170e14bf 100644 --- a/src/components/learner-credit-management/BudgetDetailPageHeader.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageHeader.jsx @@ -1,104 +1,33 @@ import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import { Card, Skeleton, Stack } from '@edx/paragon'; +import { + Stack, +} from '@edx/paragon'; import { useBudgetId, useSubsidyAccessPolicy, - useBudgetDetailHeaderData, useEnterpriseOffer, - useSubsidySummaryAnalyticsApi, } from './data'; + import BudgetDetailPageBreadcrumbs from './BudgetDetailPageBreadcrumbs'; -import BudgetDetailPageOverviewAvailability from './BudgetDetailPageOverviewAvailability'; -import BudgetDetailPageOverviewUtilization from './BudgetDetailPageOverviewUtilization'; -import { BUDGET_TYPES } from '../EnterpriseApp/data/constants'; -import BudgetStatusSubtitle from './BudgetStatusSubtitle'; +import BudgetOverviewContent from './BudgetOverviewContent'; +import BudgetExpiryAlertAndModal from '../BudgetExpiryAlertAndModal'; -const BudgetDetailPageHeader = ({ enterpriseUUID, enterpriseFeatures }) => { +const BudgetDetailPageHeader = () => { const { subsidyAccessPolicyId, enterpriseOfferId } = useBudgetId(); - const budgetType = (enterpriseOfferId !== null) ? BUDGET_TYPES.ecommerce : BUDGET_TYPES.policy; - - const { isLoading: isLoadingSubsidySummary, subsidySummary } = useSubsidySummaryAnalyticsApi( - enterpriseUUID, - enterpriseOfferId, - budgetType, - ); - const { isLoading: isLoadingEnterpriseOffer, data: enterpriseOfferMetadata } = useEnterpriseOffer(enterpriseOfferId); + const { data: enterpriseOfferMetadata } = useEnterpriseOffer(enterpriseOfferId); const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); - const policyOrOfferId = subsidyAccessPolicyId || enterpriseOfferId; - const { - budgetId, - budgetDisplayName, - budgetTotalSummary, - budgetAggregates, - status, - badgeVariant, - term, - date, - isAssignable, - } = useBudgetDetailHeaderData({ - subsidyAccessPolicy, - subsidySummary, - budgetId: policyOrOfferId, - enterpriseOfferMetadata, - isTopDownAssignmentEnabled: enterpriseFeatures.topDownAssignmentRealTimeLcm, - }); - - if (!subsidyAccessPolicy && (isLoadingSubsidySummary || isLoadingEnterpriseOffer)) { - return ( -
- - Loading budget header data -
- ); - } + const displayName = subsidyAccessPolicy?.displayName || enterpriseOfferMetadata?.displayName || 'Overview'; return ( - - - -

{budgetDisplayName}

- - - -
-
+ + +
); }; -const mapStateToProps = state => ({ - enterpriseUUID: state.portalConfiguration.enterpriseId, - enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, -}); - -BudgetDetailPageHeader.propTypes = { - enterpriseUUID: PropTypes.string.isRequired, - enterpriseFeatures: PropTypes.shape({ - topDownAssignmentRealTimeLcm: PropTypes.bool, - }).isRequired, -}; - -export default connect(mapStateToProps)(BudgetDetailPageHeader); +export default (BudgetDetailPageHeader); diff --git a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx index fac16b7719..e0ca34f50f 100644 --- a/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx +++ b/src/components/learner-credit-management/BudgetDetailPageOverviewAvailability.jsx @@ -14,10 +14,27 @@ import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from './data'; import useEnterpriseGroup from './data/hooks/useEnterpriseGroup'; import EVENT_NAMES from '../../eventTracking'; import { LEARNER_CREDIT_ROUTE } from './constants'; +import { BUDGET_STATUSES } from '../EnterpriseApp/data/constants'; -const BudgetDetail = ({ available, utilized, limit }) => { +const BudgetDetail = ({ + available, utilized, limit, status, +}) => { const currentProgressBarLimit = (available / limit) * 100; + if (status === BUDGET_STATUSES.expired) { + return ( + +

Spent

+ + {formatPrice(utilized)} + + Unspent {formatPrice(available)} + + +
+ ); + } + return (

Available

@@ -41,10 +58,15 @@ BudgetDetail.propTypes = { available: PropTypes.number.isRequired, utilized: PropTypes.number.isRequired, limit: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, }; const BudgetActions = ({ - budgetId, isAssignable, enterpriseId, enterpriseGroupsV1, + budgetId, + isAssignable, + enterpriseId, + enterpriseGroupsV1, + status, }) => { const { enterpriseSlug, enterpriseAppPage } = useParams(); const { subsidyAccessPolicyId } = useBudgetId(); @@ -71,6 +93,32 @@ const BudgetActions = ({ ); } + if (status === BUDGET_STATUSES.expired) { + return ( +
+
+

Keep people learning with a new plan

+

+ This budget has expired. To create a new budget, please contact support. +

+ +
+
+ ); + } + if (!isAssignable) { if (enterpriseGroupsV1) { if (appliesToAllContexts === true) { @@ -166,6 +214,7 @@ BudgetActions.propTypes = { budgetId: PropTypes.string.isRequired, isAssignable: PropTypes.bool.isRequired, enterpriseId: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, enterpriseGroupsV1: PropTypes.bool.isRequired, }; @@ -175,11 +224,12 @@ const BudgetDetailPageOverviewAvailability = ({ budgetTotalSummary: { available, utilized, limit }, enterpriseFeatures, enterpriseId, + status, }) => ( - + ); -const budgetTotalSummaryShape = { - utilized: PropTypes.number.isRequired, - available: PropTypes.number.isRequired, - limit: PropTypes.number.isRequired, -}; - BudgetDetailPageOverviewAvailability.propTypes = { budgetId: PropTypes.string.isRequired, - budgetTotalSummary: PropTypes.shape(budgetTotalSummaryShape).isRequired, + budgetTotalSummary: PropTypes.shape({ + utilized: PropTypes.number.isRequired, + available: PropTypes.number.isRequired, + limit: PropTypes.number.isRequired, + }).isRequired, isAssignable: PropTypes.bool.isRequired, enterpriseFeatures: PropTypes.shape({ topDownAssignmentRealTimeLcm: PropTypes.bool, enterpriseGroupsV1: PropTypes.bool, }).isRequired, enterpriseId: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, }; const mapStateToProps = state => ({ diff --git a/src/components/learner-credit-management/BudgetDetailRedemptions.jsx b/src/components/learner-credit-management/BudgetDetailRedemptions.jsx index bc63691a08..a29d03b056 100644 --- a/src/components/learner-credit-management/BudgetDetailRedemptions.jsx +++ b/src/components/learner-credit-management/BudgetDetailRedemptions.jsx @@ -35,7 +35,7 @@ const BudgetDetailRedemptions = ({ enterpriseFeatures, enterpriseUUID }) => { }, [navigate, location, locationState]); return ( -
+

Spent

Spent activity is driven by completed enrollments.{' '} diff --git a/src/components/learner-credit-management/BudgetOverviewContent.jsx b/src/components/learner-credit-management/BudgetOverviewContent.jsx new file mode 100644 index 0000000000..94f99e800b --- /dev/null +++ b/src/components/learner-credit-management/BudgetOverviewContent.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Card, Skeleton } from '@edx/paragon'; + +import { + useBudgetDetailHeaderData, + useBudgetId, + useEnterpriseOffer, useSubsidyAccessPolicy, + useSubsidySummaryAnalyticsApi, +} from './data'; +import BudgetDetailPageOverviewAvailability from './BudgetDetailPageOverviewAvailability'; +import BudgetDetailPageOverviewUtilization from './BudgetDetailPageOverviewUtilization'; +import { BUDGET_TYPES } from '../EnterpriseApp/data/constants'; +import BudgetStatusSubtitle from './BudgetStatusSubtitle'; + +const BudgetOverviewContent = ({ + enterpriseUUID, + enterpriseFeatures, +}) => { + const { subsidyAccessPolicyId, enterpriseOfferId } = useBudgetId(); + const budgetType = (enterpriseOfferId !== null) ? BUDGET_TYPES.ecommerce : BUDGET_TYPES.policy; + + const { isLoading: isLoadingSubsidySummary, subsidySummary } = useSubsidySummaryAnalyticsApi( + enterpriseUUID, + enterpriseOfferId, + budgetType, + ); + + const { isLoading: isLoadingEnterpriseOffer, data: enterpriseOfferMetadata } = useEnterpriseOffer(enterpriseOfferId); + const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + + const policyOrOfferId = subsidyAccessPolicyId || enterpriseOfferId; + const { + budgetId, + budgetDisplayName, + budgetTotalSummary, + budgetAggregates, + status, + badgeVariant, + term, + date, + isAssignable, + } = useBudgetDetailHeaderData({ + subsidyAccessPolicy, + subsidySummary, + budgetId: policyOrOfferId, + enterpriseOfferMetadata, + isTopDownAssignmentEnabled: enterpriseFeatures.topDownAssignmentRealTimeLcm, + }); + + if (!subsidyAccessPolicy && (isLoadingSubsidySummary || isLoadingEnterpriseOffer)) { + return ( +

+ + Loading budget header data +
+ ); + } + + return ( + + +

{budgetDisplayName}

+ + + +
+
+ ); +}; + +const mapStateToProps = state => ({ + enterpriseUUID: state.portalConfiguration.enterpriseId, + enterpriseFeatures: state.portalConfiguration.enterpriseFeatures, +}); + +BudgetOverviewContent.propTypes = { + enterpriseUUID: PropTypes.string.isRequired, + enterpriseFeatures: PropTypes.shape({ + topDownAssignmentRealTimeLcm: PropTypes.bool, + }).isRequired, +}; + +export default connect(mapStateToProps)(BudgetOverviewContent); diff --git a/src/components/learner-credit-management/SubBudgetCard.jsx b/src/components/learner-credit-management/SubBudgetCard.jsx index f27e0f0c77..6f4a4fc3ef 100644 --- a/src/components/learner-credit-management/SubBudgetCard.jsx +++ b/src/components/learner-credit-management/SubBudgetCard.jsx @@ -106,12 +106,12 @@ const BaseSubBudgetCard = ({ title={{budgetType}} subtitle={{subtitle}} actions={ - budgetLabel.status !== BUDGET_STATUSES.scheduled - ? renderActions(budgetId) - : undefined - } + budgetLabel.status !== BUDGET_STATUSES.scheduled + ? renderActions(budgetId) + : undefined + } className={classNames('align-items-center', { - 'mb-4.5': budgetLabel.status !== BUDGET_STATUSES.active, + 'mb-4.5': budgetLabel.status !== BUDGET_STATUSES.active && budgetLabel.status !== BUDGET_STATUSES.expiring, })} /> ); @@ -155,7 +155,8 @@ const BaseSubBudgetCard = ({ {renderCardHeader(displayName || 'Overview', id)} - {budgetLabel.status === BUDGET_STATUSES.active && renderCardSection()} + {(budgetLabel.status === BUDGET_STATUSES.active || budgetLabel.status === BUDGET_STATUSES.expiring) + && renderCardSection()} diff --git a/src/components/learner-credit-management/data/tests/constants.js b/src/components/learner-credit-management/data/tests/constants.js index f2e7b2dbe0..76206356f8 100644 --- a/src/components/learner-credit-management/data/tests/constants.js +++ b/src/components/learner-credit-management/data/tests/constants.js @@ -1,10 +1,13 @@ +const today = Date.now(); + export const mockEnterpriseOfferId = '123'; + export const mockSubsidyAccessPolicyUUID = 'c17de32e-b80b-468f-b994-85e68fd32751'; export const mockAssignableSubsidyAccessPolicy = { uuid: mockSubsidyAccessPolicyUUID, - subsidyActiveDatetime: '2023-11-01T13:06:46Z', - subsidyExpirationDatetime: '2024-02-29T13:06:59Z', + subsidyActiveDatetime: new Date(today).toISOString(), + subsidyExpirationDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), policyType: 'AssignedLearnerCreditAccessPolicy', displayName: 'Assignable Learner Credit', spendLimit: 10000 * 100, @@ -22,8 +25,8 @@ export const mockAssignableSubsidyAccessPolicy = { export const mockAssignableSubsidyAccessPolicyWithNoUtilization = { uuid: mockSubsidyAccessPolicyUUID, - subsidyActiveDatetime: '2023-11-01T13:06:46Z', - subsidyExpirationDatetime: '2024-02-29T13:06:59Z', + subsidyActiveDatetime: new Date(today).toISOString(), + subsidyExpirationDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), policyType: 'AssignedLearnerCreditAccessPolicy', displayName: 'Assignable Learner Credit', spendLimit: 10000 * 100, @@ -41,8 +44,8 @@ export const mockAssignableSubsidyAccessPolicyWithNoUtilization = { export const mockAssignableSubsidyAccessPolicyWithSpendNoAllocations = { uuid: mockSubsidyAccessPolicyUUID, - subsidyActiveDatetime: '2023-11-01T13:06:46Z', - subsidyExpirationDatetime: '2024-02-29T13:06:59Z', + subsidyActiveDatetime: new Date(today).toISOString(), + subsidyExpirationDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), policyType: 'AssignedLearnerCreditAccessPolicy', displayName: 'Assignable Learner Credit', spendLimit: 10000 * 100, @@ -60,8 +63,8 @@ export const mockAssignableSubsidyAccessPolicyWithSpendNoAllocations = { export const mockAssignableSubsidyAccessPolicyWithSpendNoRedeemed = { uuid: mockSubsidyAccessPolicyUUID, - subsidyActiveDatetime: '2023-11-01T13:06:46Z', - subsidyExpirationDatetime: '2024-02-29T13:06:59Z', + subsidyActiveDatetime: new Date(today).toISOString(), + subsidyExpirationDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), policyType: 'AssignedLearnerCreditAccessPolicy', displayName: 'Assignable Learner Credit', spendLimit: 10000 * 100, @@ -79,8 +82,8 @@ export const mockAssignableSubsidyAccessPolicyWithSpendNoRedeemed = { export const mockPerLearnerSpendLimitSubsidyAccessPolicy = { uuid: mockSubsidyAccessPolicyUUID, - subsidyActiveDatetime: '2023-11-01T13:06:46Z', - subsidyExpirationDatetime: '2024-02-29T13:06:59Z', + subsidyActiveDatetime: new Date(today).toISOString(), + subsidyExpirationDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), policyType: 'PerLearnerSpendCreditAccessPolicy', displayName: 'Per Learner Spend Limit', spendLimit: 10000 * 100, @@ -105,7 +108,7 @@ export const mockSubsidySummary = { export const mockEnterpriseOfferMetadata = { id: 99511, - startDatetime: '2022-09-01T00:00:00Z', - endDatetime: '2024-09-01T00:00:00Z', + startDatetime: new Date(today).toISOString(), + endDatetime: new Date(today + 130 * 24 * 60 * 60 * 1000).toISOString(), displayName: 'Test Display Name', }; diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 9f35a3ed05..5b78ddc73a 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -12,6 +12,7 @@ import { BUDGET_STATUSES } from '../../EnterpriseApp/data/constants'; import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService'; import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService'; import SubsidyApiService from '../../../data/services/EnterpriseSubsidyApiService'; +import { isPlanApproachingExpiry } from '../../BudgetExpiryAlertAndModal/data/utils'; /** * Transforms subsidy (offer or Subsidy) summary from API for display in the UI, guarding @@ -179,6 +180,15 @@ export const getBudgetStatus = ({ }; } + if (isPlanApproachingExpiry(endDateStr)) { + return { + status: BUDGET_STATUSES.expiring, + badgeVariant: 'warning', + term: 'Expiring', + date: endDateStr, + }; + } + // Check if budget is current (today's date between start/end dates) if (currentDate >= startDate && currentDate <= endDate) { return { @@ -218,10 +228,11 @@ export const formatPrice = (price, options = {}) => { */ export const orderBudgets = (budgets) => { const statusOrder = { - Active: 0, - Scheduled: 1, - Expired: 2, - Retired: 3, + Expiring: 1, + Active: 1, + Scheduled: 2, + Expired: 3, + Retired: 4, }; budgets?.sort((budgetA, budgetB) => { diff --git a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx index d52d12015f..797fc93629 100644 --- a/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx +++ b/src/components/learner-credit-management/tests/BudgetDetailPage.test.jsx @@ -618,7 +618,7 @@ describe('', () => { expect(screen.queryByText('Catalog')).not.toBeInTheDocument(); // Spent table and messaging is visible within Activity tab contents - const spentSection = within(screen.getByText('Spent').closest('section')); + const spentSection = within(screen.getByTestId('spent-section')); expect(spentSection.getByText('No results found')).toBeInTheDocument(); expect(spentSection.getByText('Spent activity is driven by completed enrollments.', { exact: false })).toBeInTheDocument(); const isSubsidyAccessPolicyWithAnalyicsApi = ( diff --git a/src/components/learner-credit-management/tests/BudgetDetailPageWrapper.test.jsx b/src/components/learner-credit-management/tests/BudgetDetailPageWrapper.test.jsx index 7ce956ab94..acc6fc728f 100644 --- a/src/components/learner-credit-management/tests/BudgetDetailPageWrapper.test.jsx +++ b/src/components/learner-credit-management/tests/BudgetDetailPageWrapper.test.jsx @@ -278,7 +278,7 @@ describe('', () => { const mockBudgetDisplayName = 'Test Budget'; renderWithRouter( - + , ); const previousBreadcrumb = screen.getByText('Budgets'); diff --git a/src/containers/AdminPage/AdminPage.test.jsx b/src/containers/AdminPage/AdminPage.test.jsx index 2e30fbade3..a1b4431d28 100644 --- a/src/containers/AdminPage/AdminPage.test.jsx +++ b/src/containers/AdminPage/AdminPage.test.jsx @@ -8,10 +8,20 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import AdminPage from '.'; +jest.mock('../../components/EnterpriseSubsidiesContext/data/hooks', () => ({ + ...jest.requireActual('../../components/EnterpriseSubsidiesContext/data/hooks'), + useEnterpriseBudgets: jest.fn().mockReturnValue({ + data: [], + }), +})); + const mockStore = configureMockStore([thunk]); const store = mockStore({ portalConfiguration: { enterpriseId: 'test-enterprise', + enterpriseFeatures: { + topDownAssignmentRealTimeLcm: true, + }, }, dashboardAnalytics: { active_learners: { diff --git a/src/eventTracking.js b/src/eventTracking.js index bec2ce5e2e..c3c72a4f6d 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -35,6 +35,9 @@ const BUDGET_DETAIL_SEARCH_PREFIX = `${BUDGET_DETAIL_CATALOG_TAB_PREFIX}.search` const BUDGET_DETAIL_ASSIGNMENT_MODAL_PREFIX = `${BUDGET_DETAIL_CATALOG_TAB_PREFIX}.assignment_modal`; const BUDGET_INVITE_MEMBERS_MODAL_PREFIX = `${BUDGET_DETAIL_CATALOG_TAB_PREFIX}.invite_modal`; +// BudgetExpiry +const LEARNER_CREDIT_BUDGET_EXPIRY_PREFIX = `${LEARNER_CREDIT_MANAGEMENT_PREFIX}.budget_expiry`; + export const SUBSCRIPTION_TABLE_EVENTS = { // Pagination PAGINATION_NEXT: `${SUBSCRIPTION_TABLE_PREFIX}.pagination.next.clicked`, @@ -169,6 +172,9 @@ export const LEARNER_CREDIT_MANAGEMENT_EVENTS = { ASSIGNMENT_ALLOCATION_LEARNER_ASSIGNMENT: `${BUDGET_DETAIL_ASSIGNMENT_MODAL_PREFIX}.assignment_allocation.assigned`, ASSIGNMENT_EMAIL_ADDRESS_VALIDATION: `${BUDGET_DETAIL_ASSIGNMENT_MODAL_PREFIX}.email_validation.changed`, ASSIGNMENT_ALLOCATION_ERROR: `${BUDGET_DETAIL_ASSIGNMENT_MODAL_PREFIX}.assignment_allocation.errored`, + // Budget Expiry + BUDGET_EXPIRY_ALERT_CONTACT_SUPPORT: `${LEARNER_CREDIT_BUDGET_EXPIRY_PREFIX}.alert.contact_support.clicked`, + BUDGET_EXPIRY_MODAL_CONTACT_SUPPORT: `${LEARNER_CREDIT_BUDGET_EXPIRY_PREFIX}.modal.contact_support.clicked`, }; const EVENT_NAMES = {