richie-education 3.4.0 → 3.4.1-dev14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.storybook/main.js +11 -12
  2. package/js/api/joanie.ts +20 -0
  3. package/js/api/lms/index.spec.ts +33 -0
  4. package/js/api/lms/index.ts +1 -1
  5. package/js/api/lms/openedx-hawthorn.spec.ts +49 -0
  6. package/js/api/lms/openedx-hawthorn.ts +5 -2
  7. package/js/api/utils.ts +4 -3
  8. package/js/components/DownloadAgreementButton/index.tsx +51 -0
  9. package/js/components/DownloadBatchOrderSeatsButton/index.spec.tsx +46 -0
  10. package/js/components/DownloadBatchOrderSeatsButton/index.tsx +80 -0
  11. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +1 -1
  12. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +1 -1
  13. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +2 -1
  14. package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +5 -5
  15. package/js/components/SaleTunnel/index.full-process-b2c.spec.tsx +1 -1
  16. package/js/hooks/useBatchOrder/index.tsx +21 -1
  17. package/js/hooks/useDownloadAgreement/index.spec.tsx +136 -0
  18. package/js/hooks/useDownloadAgreement/index.tsx +25 -0
  19. package/js/hooks/useDownloadBatchOrderSeats/index.spec.tsx +132 -0
  20. package/js/hooks/useDownloadBatchOrderSeats/index.tsx +24 -0
  21. package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +19 -2
  22. package/js/pages/TeacherDashboardOrganizationQuotes/BatchOrderSeatInfoQuote.tsx +112 -0
  23. package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +17 -0
  24. package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +5 -2
  25. package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +7 -3
  26. package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +38 -26
  27. package/js/types/Joanie.ts +21 -1
  28. package/js/types/api.ts +1 -0
  29. package/js/types/commonDataProps.ts +2 -0
  30. package/js/utils/download.ts +3 -1
  31. package/js/utils/test/factories/joanie.ts +15 -1
  32. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderAgreementInfo.tsx +72 -0
  33. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.spec.tsx +114 -0
  34. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.tsx +133 -0
  35. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +17 -1
  36. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/batchOrderSeatInfoMessages.ts +24 -0
  37. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +16 -3
  38. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +6 -2
  39. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +2 -2
  40. package/js/widgets/Slider/index.tsx +7 -6
  41. package/package.json +2 -7
  42. package/scss/components/templates/richie/slider/_slider.scss +1 -1
  43. package/scss/objects/_course_glimpses.scss +1 -0
  44. package/scss/objects/_dashboard.scss +77 -0
@@ -0,0 +1,136 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import fetchMock from 'fetch-mock';
3
+ import { PropsWithChildren } from 'react';
4
+ import { QueryClientProvider } from '@tanstack/react-query';
5
+ import { IntlProvider } from 'react-intl';
6
+ import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
7
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
8
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
9
+ import { useDownloadAgreement } from 'hooks/useDownloadAgreement/index';
10
+ import { handle } from 'utils/errors/handle';
11
+ import { SessionProvider } from 'contexts/SessionContext';
12
+ import { Deferred } from 'utils/test/deferred';
13
+ import { OrganizationFactory } from 'utils/test/factories/joanie';
14
+ import { HttpStatusCode } from 'utils/errors/HttpError';
15
+
16
+ jest.mock('utils/errors/handle');
17
+ jest.mock('utils/context', () => ({
18
+ __esModule: true,
19
+ default: mockRichieContextFactory({
20
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
21
+ joanie_backend: { endpoint: 'https://joanie.test' },
22
+ }).one(),
23
+ }));
24
+
25
+ const mockHandle = handle as jest.MockedFn<typeof handle>;
26
+
27
+ describe('useDownloadAgreement', () => {
28
+ beforeEach(() => {
29
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
30
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
31
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
32
+ });
33
+
34
+ beforeAll(() => {
35
+ // eslint-disable-next-line compat/compat
36
+ URL.createObjectURL = jest.fn();
37
+ // eslint-disable-next-line compat/compat
38
+ URL.revokeObjectURL = jest.fn();
39
+ HTMLAnchorElement.prototype.click = jest.fn();
40
+ });
41
+
42
+ afterEach(() => {
43
+ jest.clearAllMocks();
44
+ fetchMock.restore();
45
+ });
46
+
47
+ const Wrapper = ({ children }: PropsWithChildren) => {
48
+ return (
49
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
50
+ <IntlProvider locale="en">
51
+ <SessionProvider>{children}</SessionProvider>
52
+ </IntlProvider>
53
+ </QueryClientProvider>
54
+ );
55
+ };
56
+
57
+ it('downloads the agreement PDF', async () => {
58
+ const organization = OrganizationFactory().one();
59
+ const agreementId = faker.string.uuid();
60
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/organizations/${organization.id}/agreements/${agreementId}/download/`;
61
+ const deferred = new Deferred();
62
+ fetchMock.get(DOWNLOAD_URL, deferred.promise);
63
+
64
+ const { result } = renderHook(() => useDownloadAgreement(), {
65
+ wrapper: Wrapper,
66
+ });
67
+ await waitFor(() => expect(result.current).not.toBeNull());
68
+
69
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
70
+ // eslint-disable-next-line compat/compat
71
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
72
+ // eslint-disable-next-line compat/compat
73
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
74
+ expect(result.current.loading).toBe(false);
75
+
76
+ act(() => {
77
+ result.current.download(organization.id, agreementId);
78
+ });
79
+ expect(result.current.loading).toBe(true);
80
+
81
+ deferred.resolve({
82
+ status: HttpStatusCode.OK,
83
+ body: new Blob(['%PDF-1.4']),
84
+ headers: {
85
+ 'Content-Disposition': 'attachment; filename="Convention_de_formation.pdf";',
86
+ 'Content-Type': 'application/pdf',
87
+ },
88
+ });
89
+
90
+ await waitFor(() => {
91
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
92
+ // eslint-disable-next-line compat/compat
93
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
94
+ // eslint-disable-next-line compat/compat
95
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
96
+ expect(result.current.loading).toBe(false);
97
+ });
98
+
99
+ fireEvent.blur(window);
100
+ // eslint-disable-next-line compat/compat
101
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('handles an error if agreement download request fails', async () => {
105
+ const organization = OrganizationFactory().one();
106
+ const agreementId = faker.string.uuid();
107
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/organizations/${organization.id}/agreements/${agreementId}/download/`;
108
+ fetchMock.get(DOWNLOAD_URL, HttpStatusCode.UNAUTHORIZED);
109
+
110
+ const { result } = renderHook(() => useDownloadAgreement(), {
111
+ wrapper: Wrapper,
112
+ });
113
+ await waitFor(() => expect(result.current).not.toBeNull());
114
+
115
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
116
+ expect(mockHandle).not.toHaveBeenCalled();
117
+ // eslint-disable-next-line compat/compat
118
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
119
+ // eslint-disable-next-line compat/compat
120
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
121
+
122
+ act(() => {
123
+ result.current.download(organization.id, agreementId);
124
+ });
125
+
126
+ await waitFor(() => {
127
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
128
+ expect(mockHandle).toHaveBeenNthCalledWith(1, new Error('Unauthorized'));
129
+ // eslint-disable-next-line compat/compat
130
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
131
+ // eslint-disable-next-line compat/compat
132
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
133
+ expect(result.current.loading).toBe(false);
134
+ });
135
+ });
136
+ });
@@ -0,0 +1,25 @@
1
+ import { useState } from 'react';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { browserDownloadFromBlob } from 'utils/download';
4
+
5
+ export const useDownloadAgreement = () => {
6
+ const [loading, setLoading] = useState(false);
7
+ const API = useJoanieApi();
8
+
9
+ return {
10
+ download: async (organizationId: string, agreementId: string) => {
11
+ setLoading(true);
12
+ try {
13
+ await browserDownloadFromBlob(() =>
14
+ API.organizations.agreements.download({
15
+ organization_id: organizationId,
16
+ id: agreementId,
17
+ }),
18
+ );
19
+ } finally {
20
+ setLoading(false);
21
+ }
22
+ },
23
+ loading,
24
+ };
25
+ };
@@ -0,0 +1,132 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { PropsWithChildren } from 'react';
3
+ import { QueryClientProvider } from '@tanstack/react-query';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
6
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
8
+ import { useDownloadBatchOrderSeats } from 'hooks/useDownloadBatchOrderSeats/index';
9
+ import { handle } from 'utils/errors/handle';
10
+ import { SessionProvider } from 'contexts/SessionContext';
11
+ import { Deferred } from 'utils/test/deferred';
12
+ import { BatchOrderReadFactory } from 'utils/test/factories/joanie';
13
+ import { HttpStatusCode } from 'utils/errors/HttpError';
14
+
15
+ jest.mock('utils/errors/handle');
16
+ jest.mock('utils/context', () => ({
17
+ __esModule: true,
18
+ default: mockRichieContextFactory({
19
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
20
+ joanie_backend: { endpoint: 'https://joanie.test' },
21
+ }).one(),
22
+ }));
23
+
24
+ const mockHandle = handle as jest.MockedFn<typeof handle>;
25
+
26
+ describe('useDownloadBatchOrderSeats', () => {
27
+ beforeEach(() => {
28
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
29
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
30
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
31
+ });
32
+
33
+ beforeAll(() => {
34
+ // eslint-disable-next-line compat/compat
35
+ URL.createObjectURL = jest.fn();
36
+ // eslint-disable-next-line compat/compat
37
+ URL.revokeObjectURL = jest.fn();
38
+ HTMLAnchorElement.prototype.click = jest.fn();
39
+ });
40
+
41
+ afterEach(() => {
42
+ jest.clearAllMocks();
43
+ fetchMock.restore();
44
+ });
45
+
46
+ const Wrapper = ({ children }: PropsWithChildren) => {
47
+ return (
48
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
49
+ <IntlProvider locale="en">
50
+ <SessionProvider>{children}</SessionProvider>
51
+ </IntlProvider>
52
+ </QueryClientProvider>
53
+ );
54
+ };
55
+
56
+ it('downloads the batch order seats CSV', async () => {
57
+ const batchOrder = BatchOrderReadFactory().one();
58
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/batch-orders/${batchOrder.id}/seats-export/`;
59
+ const deferred = new Deferred();
60
+ fetchMock.get(DOWNLOAD_URL, deferred.promise);
61
+
62
+ const { result } = renderHook(() => useDownloadBatchOrderSeats(), {
63
+ wrapper: Wrapper,
64
+ });
65
+ await waitFor(() => expect(result.current).not.toBeNull());
66
+
67
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
68
+ // eslint-disable-next-line compat/compat
69
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
70
+ // eslint-disable-next-line compat/compat
71
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
72
+ expect(result.current.loading).toBe(false);
73
+
74
+ act(() => {
75
+ result.current.download(batchOrder.id);
76
+ });
77
+ expect(result.current.loading).toBe(true);
78
+
79
+ deferred.resolve({
80
+ status: HttpStatusCode.OK,
81
+ body: new Blob(['last_name,first_name,email']),
82
+ headers: {
83
+ 'Content-Type': 'text/csv',
84
+ },
85
+ });
86
+
87
+ await waitFor(() => {
88
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
89
+ // eslint-disable-next-line compat/compat
90
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
91
+ // eslint-disable-next-line compat/compat
92
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
93
+ expect(result.current.loading).toBe(false);
94
+ });
95
+
96
+ fireEvent.blur(window);
97
+ // eslint-disable-next-line compat/compat
98
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
99
+ });
100
+
101
+ it('handles an error if seats export request fails', async () => {
102
+ const batchOrder = BatchOrderReadFactory().one();
103
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/batch-orders/${batchOrder.id}/seats-export/`;
104
+ fetchMock.get(DOWNLOAD_URL, HttpStatusCode.UNAUTHORIZED);
105
+
106
+ const { result } = renderHook(() => useDownloadBatchOrderSeats(), {
107
+ wrapper: Wrapper,
108
+ });
109
+ await waitFor(() => expect(result.current).not.toBeNull());
110
+
111
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
112
+ expect(mockHandle).not.toHaveBeenCalled();
113
+ // eslint-disable-next-line compat/compat
114
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
115
+ // eslint-disable-next-line compat/compat
116
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
117
+
118
+ act(() => {
119
+ result.current.download(batchOrder.id);
120
+ });
121
+
122
+ await waitFor(() => {
123
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
124
+ expect(mockHandle).toHaveBeenNthCalledWith(1, new Error('Unauthorized'));
125
+ // eslint-disable-next-line compat/compat
126
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
127
+ // eslint-disable-next-line compat/compat
128
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
129
+ expect(result.current.loading).toBe(false);
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,24 @@
1
+ import { useState } from 'react';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { browserDownloadFromBlob } from 'utils/download';
4
+
5
+ export const useDownloadBatchOrderSeats = () => {
6
+ const [loading, setLoading] = useState(false);
7
+ const API = useJoanieApi();
8
+
9
+ return {
10
+ download: async (batchOrderId: string, filename?: string) => {
11
+ setLoading(true);
12
+ try {
13
+ await browserDownloadFromBlob(
14
+ () => API.user.batchOrders.seats_export(batchOrderId),
15
+ false,
16
+ filename,
17
+ );
18
+ } finally {
19
+ setLoading(false);
20
+ }
21
+ },
22
+ loading,
23
+ };
24
+ };
@@ -2,7 +2,11 @@ import { findByRole, render, screen, waitFor } from '@testing-library/react';
2
2
  import { generatePath } from 'react-router';
3
3
  import fetchMock from 'fetch-mock';
4
4
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
- import { BatchOrderReadFactory } from 'utils/test/factories/joanie';
5
+ import {
6
+ AgreementFactory,
7
+ BatchOrderReadFactory,
8
+ BatchOrderSeatFactory,
9
+ } from 'utils/test/factories/joanie';
6
10
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
7
11
  import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
8
12
  import { expectUrlMatchLocationDisplayed } from 'utils/test/expectUrlMatchLocationDisplayed';
@@ -49,8 +53,21 @@ describe('<DashboardBatchOrderLayout />', () => {
49
53
 
50
54
  it('renders sidebar', async () => {
51
55
  const batchOrder = BatchOrderReadFactory().one();
52
-
56
+ const seats = BatchOrderSeatFactory().many(2);
53
57
  fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/`, batchOrder);
58
+ fetchMock.get(
59
+ `https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/?page=1&page_size=10`,
60
+ {
61
+ results: seats,
62
+ count: seats.length,
63
+ previous: null,
64
+ next: null,
65
+ },
66
+ );
67
+ fetchMock.get(
68
+ `https://joanie.endpoint/api/v1.0/organizations/${batchOrder.organization.id}/agreements/${batchOrder.contract_id}/`,
69
+ AgreementFactory().one(),
70
+ );
54
71
 
55
72
  render(
56
73
  WrapperWithDashboard(
@@ -0,0 +1,112 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { FormattedMessage, useIntl } from 'react-intl';
3
+ import { Button, Input } from '@openfun/cunningham-react';
4
+ import { Icon, IconTypeEnum } from 'components/Icon';
5
+ import Banner, { BannerType } from 'components/Banner';
6
+ import { useBatchOrderSeats } from 'hooks/useBatchOrder';
7
+ import { BatchOrderQuote, BatchOrderSeat } from 'types/Joanie';
8
+ import { batchOrderSeatInfoMessages } from 'widgets/Dashboard/components/DashboardItem/BatchOrder/batchOrderSeatInfoMessages';
9
+
10
+ const ITEMS_PER_PAGE = 10;
11
+
12
+ export const BatchOrderSeatInfoQuote = ({ batchOrder }: { batchOrder: BatchOrderQuote }) => {
13
+ const intl = useIntl();
14
+ const [query, setQuery] = useState('');
15
+ const [page, setPage] = useState(1);
16
+ const [allSeats, setAllSeats] = useState<BatchOrderSeat[]>([]);
17
+
18
+ const seatsOwnedCount = batchOrder.seats_owned ?? 0;
19
+
20
+ const {
21
+ items: seats,
22
+ meta,
23
+ states,
24
+ } = useBatchOrderSeats(
25
+ {
26
+ batch_order_id: batchOrder.id,
27
+ query: query || undefined,
28
+ page,
29
+ page_size: ITEMS_PER_PAGE,
30
+ },
31
+ { enabled: !!batchOrder.id },
32
+ );
33
+
34
+ useEffect(() => {
35
+ if (page === 1) {
36
+ setAllSeats(seats);
37
+ } else if (seats.length > 0) {
38
+ setAllSeats((prev) => [...prev, ...seats]);
39
+ }
40
+ }, [seats]);
41
+
42
+ useEffect(() => {
43
+ setPage(1);
44
+ }, [query]);
45
+
46
+ const totalCount = meta?.pagination?.count ?? 0;
47
+ const remainingCount = Math.min(ITEMS_PER_PAGE, totalCount - allSeats.length);
48
+
49
+ if (
50
+ !batchOrder.nb_seats ||
51
+ batchOrder.seats_owned === undefined ||
52
+ batchOrder.seats_to_own === undefined
53
+ ) {
54
+ return null;
55
+ }
56
+
57
+ return (
58
+ <div className="dashboard__quote__enrollment">
59
+ <div className="content">
60
+ <div className="enrollment-progress">
61
+ <span className="dashboard-item__label">
62
+ {intl.formatMessage(batchOrderSeatInfoMessages.enrolledParticipants, {
63
+ seats_owned: seatsOwnedCount,
64
+ nb_seats: batchOrder.nb_seats,
65
+ })}
66
+ </span>
67
+ <div className="enrollment-progress__bar">
68
+ <div
69
+ className="enrollment-progress__bar__fill"
70
+ style={{ width: `${(seatsOwnedCount / batchOrder.nb_seats) * 100}%` }}
71
+ />
72
+ </div>
73
+ </div>
74
+ {states.error && <Banner message={states.error} type={BannerType.ERROR} />}
75
+ <div className="enrollment-nested-section__content">
76
+ <Input
77
+ className="enrollment-search"
78
+ label={intl.formatMessage(batchOrderSeatInfoMessages.searchPlaceholder)}
79
+ value={query}
80
+ onChange={(e) => setQuery(e.target.value)}
81
+ rightIcon={<Icon name={IconTypeEnum.MAGNIFYING_GLASS} size="small" />}
82
+ />
83
+ {allSeats.length === 0 && query ? (
84
+ <FormattedMessage {...batchOrderSeatInfoMessages.noResults} />
85
+ ) : (
86
+ <>
87
+ <ul className="enrollment-list">
88
+ {allSeats.map((seat) => (
89
+ <li key={seat.id}>{seat.owner_name ?? seat.voucher}</li>
90
+ ))}
91
+ </ul>
92
+ {remainingCount > 0 && (
93
+ <Button
94
+ className="enrollment-load-more"
95
+ color="brand"
96
+ variant="secondary"
97
+ size="small"
98
+ onClick={() => setPage((p) => p + 1)}
99
+ disabled={states.fetching}
100
+ >
101
+ {intl.formatMessage(batchOrderSeatInfoMessages.loadMore, {
102
+ count: remainingCount,
103
+ })}
104
+ </Button>
105
+ )}
106
+ </>
107
+ )}
108
+ </div>
109
+ </div>
110
+ </div>
111
+ );
112
+ };
@@ -38,3 +38,20 @@
38
38
  display: flex;
39
39
  justify-content: center;
40
40
  }
41
+
42
+ .dashboard__quote__enrollment {
43
+ margin-top: 1rem;
44
+ padding-top: 1rem;
45
+ border-top: 1px solid r-theme-val(dashboard-card, base-color);
46
+
47
+ &__title {
48
+ margin: 0 0 0.5rem;
49
+ }
50
+
51
+ .content {
52
+ font-size: 0.8rem;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.5rem;
56
+ }
57
+ }
@@ -50,7 +50,7 @@ describe('full process for the organization quotes dashboard', () => {
50
50
  batch_order: {
51
51
  state: BatchOrderState.QUOTED,
52
52
  payment_method: PaymentMethod.CARD_PAYMENT,
53
- available_actions: { next_action: 'confirm_quote' },
53
+ available_actions: { next_action: 'confirm_quote', download_quote: true },
54
54
  },
55
55
  organization_signed_on: undefined,
56
56
  }).one();
@@ -214,7 +214,6 @@ describe('full process for the organization quotes dashboard', () => {
214
214
  },
215
215
  }).one();
216
216
  fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
217
-
218
217
  const quoteQuoted = OrganizationQuoteFactory({
219
218
  batch_order: {
220
219
  state: BatchOrderState.QUOTED,
@@ -272,6 +271,10 @@ describe('full process for the organization quotes dashboard', () => {
272
271
  `https://joanie.endpoint/api/v1.0/organizations/1/submit-for-signature-batch-order/`,
273
272
  200,
274
273
  );
274
+ fetchMock.get(
275
+ `https://joanie.endpoint/api/v1.0/batch-orders/${quoteCompleted.batch_order.id}/seats/?page=1&page_size=10`,
276
+ { results: [], count: 0, previous: null, next: null },
277
+ );
275
278
 
276
279
  render(<TeacherDashboardOrganizationQuotes />, {
277
280
  routerOptions: {
@@ -28,7 +28,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
28
28
  });
29
29
 
30
30
  it('should render a list of quotes for an organization', async () => {
31
- const quoteList = OrganizationQuoteFactory().many(1);
31
+ const quoteList = OrganizationQuoteFactory({
32
+ batch_order: { state: BatchOrderState.QUOTED },
33
+ }).many(1);
32
34
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
33
35
 
34
36
  fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
@@ -85,7 +87,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
85
87
  });
86
88
 
87
89
  it('should paginate', async () => {
88
- const quoteList = OrganizationQuoteFactory().many(30);
90
+ const quoteList = OrganizationQuoteFactory({
91
+ batch_order: { state: BatchOrderState.QUOTED },
92
+ }).many(30);
89
93
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
90
94
 
91
95
  fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
@@ -158,7 +162,7 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
158
162
  const quoteQuoted = OrganizationQuoteFactory({
159
163
  batch_order: {
160
164
  state: BatchOrderState.QUOTED,
161
- available_actions: { next_action: 'confirm_quote' },
165
+ available_actions: { next_action: 'confirm_quote', download_quote: true },
162
166
  },
163
167
  }).one();
164
168
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
@@ -14,6 +14,7 @@ import Badge from 'components/Badge';
14
14
  import { Icon, IconTypeEnum } from 'components/Icon';
15
15
  import { browserDownloadFromBlob } from 'utils/download';
16
16
  import { Spinner } from 'components/Spinner';
17
+ import { BatchOrderSeatInfoQuote } from './BatchOrderSeatInfoQuote';
17
18
 
18
19
  const messages = defineMessages({
19
20
  loading: {
@@ -360,35 +361,40 @@ const TeacherDashboardOrganizationQuotes = () => {
360
361
  );
361
362
  };
362
363
 
364
+ const renderDownloadButton = (quote: OrganizationQuote) => {
365
+ const batchOrder = quote.batch_order;
366
+
367
+ return (
368
+ <Button
369
+ size="small"
370
+ color="brand"
371
+ variant="secondary"
372
+ className="mr-2"
373
+ onClick={() => handleDownloadQuote(quote.id)}
374
+ icon={<span className="material-icons">download</span>}
375
+ disabled={!abilities?.download_quote || !batchOrder.available_actions.download_quote}
376
+ >
377
+ {intl.formatMessage(messages.downloadQuote)}
378
+ </Button>
379
+ );
380
+ };
381
+
363
382
  const renderActionButton = (quote: OrganizationQuote) => {
364
383
  const batchOrder = quote.batch_order;
365
384
  const state = batchOrder?.state;
366
385
  const paymentMethod = batchOrder?.payment_method;
367
386
 
368
- if (!batchOrder || !state || !paymentMethod || state === BatchOrderState.COMPLETED) return null;
369
-
370
- const confirmQuoteButtons = (
371
- <div>
372
- <Button
373
- size="small"
374
- color="brand"
375
- variant="secondary"
376
- className="mr-2"
377
- onClick={() => handleDownloadQuote(quote.id)}
378
- icon={<span className="material-icons">download</span>}
379
- disabled={!abilities?.download_quote}
380
- >
381
- {intl.formatMessage(messages.downloadQuote)}
382
- </Button>
383
- <Button
384
- size="small"
385
- onClick={() => handleOpenConfirm(quote.id)}
386
- icon={<span className="material-icons">check_circle</span>}
387
- disabled={!abilities?.confirm_quote}
388
- >
389
- {intl.formatMessage(messages.confirmQuote)}
390
- </Button>
391
- </div>
387
+ if (!batchOrder || !state || !paymentMethod) return null;
388
+
389
+ const confirmQuoteButton = (
390
+ <Button
391
+ size="small"
392
+ onClick={() => handleOpenConfirm(quote.id)}
393
+ icon={<span className="material-icons">check_circle</span>}
394
+ disabled={!abilities?.confirm_quote}
395
+ >
396
+ {intl.formatMessage(messages.confirmQuote)}
397
+ </Button>
392
398
  );
393
399
 
394
400
  const confirmPurchaseOrderButton = (
@@ -430,7 +436,7 @@ const TeacherDashboardOrganizationQuotes = () => {
430
436
 
431
437
  switch (batchOrder.available_actions?.next_action) {
432
438
  case 'confirm_quote':
433
- return confirmQuoteButtons;
439
+ return confirmQuoteButton;
434
440
  case 'confirm_purchase_order':
435
441
  return confirmPurchaseOrderButton;
436
442
  case 'confirm_bank_transfer':
@@ -485,7 +491,10 @@ const TeacherDashboardOrganizationQuotes = () => {
485
491
  </Badge>
486
492
  )}
487
493
  </div>
488
- <div className="dashboard__quote__header__action">{renderActionButton(quote)}</div>
494
+ <div className="dashboard__quote__header__action">
495
+ {renderDownloadButton(quote)}
496
+ {renderActionButton(quote)}
497
+ </div>
489
498
  </div>
490
499
  }
491
500
  defaultExpanded={false}
@@ -534,6 +543,9 @@ const TeacherDashboardOrganizationQuotes = () => {
534
543
  </div>
535
544
  )}
536
545
  </div>
546
+ {quote.batch_order.state === BatchOrderState.COMPLETED && (
547
+ <BatchOrderSeatInfoQuote batchOrder={quote.batch_order} />
548
+ )}
537
549
  </DashboardCard>
538
550
  ))}
539
551
  <Pagination {...pagination} />