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
@@ -567,6 +567,14 @@ export interface BatchOrderRead {
567
567
  funding_entity?: string;
568
568
  funding_amount?: number;
569
569
  offering: OfferingBatchOrder;
570
+ seats_to_own?: number;
571
+ seats_owned?: number;
572
+ }
573
+
574
+ export interface BatchOrderSeat {
575
+ id: string;
576
+ owner_name: string | null;
577
+ voucher: string | null;
570
578
  }
571
579
 
572
580
  export interface Relation {
@@ -585,7 +593,8 @@ export type BatchOrderAction =
585
593
  | 'confirm_quote'
586
594
  | 'confirm_purchase_order'
587
595
  | 'confirm_bank_transfer'
588
- | 'submit_for_signature';
596
+ | 'submit_for_signature'
597
+ | 'download_quote';
589
598
 
590
599
  export type BatchOrderAvailableActions = {
591
600
  [K in BatchOrderAction]: boolean;
@@ -603,6 +612,8 @@ export interface BatchOrderQuote {
603
612
  payment_method: PaymentMethod;
604
613
  contract_submitted: boolean;
605
614
  nb_seats: number;
615
+ seats_to_own?: number;
616
+ seats_owned?: number;
606
617
  available_actions: BatchOrderAvailableActions;
607
618
  }
608
619
 
@@ -741,6 +752,10 @@ export interface OfferingQueryFilters extends PaginatedResourceQuery {
741
752
  export interface BatchOrderQueryFilters extends PaginatedResourceQuery {
742
753
  id?: BatchOrderRead['id'];
743
754
  }
755
+ export interface BatchOrderSeatsQueryFilters extends PaginatedResourceQuery {
756
+ batch_order_id?: string;
757
+ query?: string;
758
+ }
744
759
  export interface OrganizationQuoteQueryFilters extends PaginatedResourceQuery {
745
760
  organization_id?: Organization['id'];
746
761
  batch_order_id?: BatchOrder['id'];
@@ -834,6 +849,10 @@ interface APIUser {
834
849
  submit_for_payment: {
835
850
  create(filters: ResourcesQuery): Promise<any>;
836
851
  };
852
+ seats: {
853
+ get(filters?: BatchOrderSeatsQueryFilters): Promise<PaginatedResponse<BatchOrderSeat>>;
854
+ };
855
+ seats_export(id: string): Promise<File>;
837
856
  };
838
857
  certificates: {
839
858
  download(id: string): Promise<File>;
@@ -947,6 +966,7 @@ export interface API {
947
966
  ): ContractResourceQuery extends { id: string }
948
967
  ? Promise<Nullable<Agreement>>
949
968
  : Promise<PaginatedResponse<Agreement>>;
969
+ download(filters: { organization_id: string; id: string }): Promise<File>;
950
970
  };
951
971
  };
952
972
  courseRuns: {
package/js/types/api.ts CHANGED
@@ -63,6 +63,7 @@ export interface APIRoute {
63
63
 
64
64
  export interface APIOptions {
65
65
  routes: APIRoute;
66
+ nextURL?: string;
66
67
  }
67
68
 
68
69
  export enum APIBackend {
@@ -9,6 +9,7 @@ export interface LMSBackend {
9
9
  backend: string;
10
10
  course_regexp: RegExp | string;
11
11
  endpoint: string;
12
+ next_url?: string;
12
13
  }
13
14
 
14
15
  export interface AuthenticationBackend {
@@ -26,6 +27,7 @@ export interface AuthenticationBackend {
26
27
  username: string;
27
28
  email: string;
28
29
  };
30
+ next_url?: string;
29
31
  }
30
32
 
31
33
  enum FEATURES {
@@ -5,11 +5,13 @@ import { handle } from './errors/handle';
5
5
  *
6
6
  * @param downloadFunction, an api promise that return a File
7
7
  * @param newWindow, does it open in a new window or not
8
+ * @param filename, optional filename override; if provided, takes precedence over file.name
8
9
  * @returns boolean, true for success
9
10
  */
10
11
  export const browserDownloadFromBlob = async (
11
12
  downloadFunction: () => Promise<File>,
12
13
  newWindow: boolean = false,
14
+ filename?: string,
13
15
  ) => {
14
16
  try {
15
17
  const file = await downloadFunction();
@@ -24,7 +26,7 @@ export const browserDownloadFromBlob = async (
24
26
 
25
27
  const $link = document.createElement('a');
26
28
  $link.href = url;
27
- $link.download = file.name;
29
+ $link.download = filename || file.name;
28
30
 
29
31
  const revokeObject = () => {
30
32
  // eslint-disable-next-line compat/compat
@@ -46,6 +46,7 @@ import {
46
46
  BatchOrderQuote,
47
47
  Relation,
48
48
  Agreement,
49
+ BatchOrderSeat,
49
50
  } from 'types/Joanie';
50
51
  import { Payment, PaymentMethod, PaymentProviders } from 'components/PaymentInterfaces/types';
51
52
  import { CourseStateFactory } from 'utils/test/factories/richie';
@@ -214,12 +215,15 @@ export const BatchOrderQuoteFactory = factory((): BatchOrderQuote => {
214
215
  relation: RelationFactory().one(),
215
216
  payment_method: faker.helpers.arrayElement(Object.values(PaymentMethod)),
216
217
  contract_submitted: faker.datatype.boolean(),
217
- nb_seats: faker.number.int({ min: 1, max: 100 }),
218
+ nb_seats: faker.number.int({ min: 10, max: 100 }),
219
+ seats_owned: faker.number.int({ min: 0, max: 10 }),
220
+ seats_to_own: faker.number.int({ min: 90, max: 100 }),
218
221
  available_actions: {
219
222
  confirm_quote: false,
220
223
  confirm_purchase_order: false,
221
224
  confirm_bank_transfer: false,
222
225
  submit_for_signature: false,
226
+ download_quote: false,
223
227
  next_action: null,
224
228
  },
225
229
  };
@@ -551,6 +555,8 @@ export const BatchOrderReadFactory = factory((): BatchOrderRead => {
551
555
  funding_entity: faker.company.name(),
552
556
  funding_amount: faker.number.int({ min: 100, max: 10000 }),
553
557
  offering: OfferingBatchOrderFactory().one(),
558
+ seats_owned: faker.number.int({ min: 1, max: 200 }),
559
+ seats_to_own: faker.number.int({ min: 1, max: 200 }),
554
560
  };
555
561
  });
556
562
 
@@ -678,3 +684,11 @@ export const SaleTunnelContextFactory = factory(
678
684
  setPaymentMode: noop,
679
685
  }),
680
686
  );
687
+
688
+ export const BatchOrderSeatFactory = factory((): BatchOrderSeat => {
689
+ return {
690
+ id: faker.string.uuid(),
691
+ owner_name: null,
692
+ voucher: faker.string.alphanumeric(10),
693
+ };
694
+ });
@@ -0,0 +1,72 @@
1
+ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
+ import { BatchOrderRead } from 'types/Joanie';
3
+ import DownloadAgreementButton from 'components/DownloadAgreementButton';
4
+ import { DashboardSubItem } from 'widgets/Dashboard/components/DashboardItem/DashboardSubItem';
5
+ import { useOrganizationAgreement } from 'hooks/useOrganizationAgreements.tsx';
6
+ import useDateFormat from 'hooks/useDateFormat';
7
+
8
+ const messages = defineMessages({
9
+ title: {
10
+ id: 'batchOrder.agreement.title',
11
+ description: 'Step label for the agreement document in the batch order detail',
12
+ defaultMessage: 'Agreement',
13
+ },
14
+ organizationSignedOn: {
15
+ id: 'batchOrder.agreement.organizationSignedOn',
16
+ description: 'Label displayed once the organization has counter-signed the agreement',
17
+ defaultMessage: 'Signed by the organization on {date}.',
18
+ },
19
+ waitingOrganization: {
20
+ id: 'batchOrder.agreement.waitingOrganization',
21
+ description:
22
+ 'Label displayed when the agreement is waiting for the organization counter-signature',
23
+ defaultMessage: 'Waiting for the organization to counter-sign the agreement.',
24
+ },
25
+ });
26
+
27
+ interface BatchOrderAgreementInfoProps {
28
+ batchOrder: BatchOrderRead;
29
+ }
30
+
31
+ export const BatchOrderAgreementInfo = ({ batchOrder }: BatchOrderAgreementInfoProps) => {
32
+ const intl = useIntl();
33
+ const formatDate = useDateFormat();
34
+ const {
35
+ item: agreement,
36
+ states: { isFetched, error },
37
+ } = useOrganizationAgreement(batchOrder.contract_id!, {
38
+ organization_id: batchOrder.organization.id,
39
+ });
40
+
41
+ if (!isFetched || error || !agreement) {
42
+ return null;
43
+ }
44
+
45
+ const signedOn = agreement.organization_signed_on;
46
+
47
+ return (
48
+ <DashboardSubItem
49
+ title={intl.formatMessage(messages.title)}
50
+ footer={
51
+ <div className="content">
52
+ {signedOn ? (
53
+ <>
54
+ <p>
55
+ <FormattedMessage
56
+ {...messages.organizationSignedOn}
57
+ values={{ date: formatDate(signedOn) }}
58
+ />
59
+ </p>
60
+ <DownloadAgreementButton
61
+ organizationId={batchOrder.organization.id}
62
+ agreementId={batchOrder.contract_id!}
63
+ />
64
+ </>
65
+ ) : (
66
+ <FormattedMessage {...messages.waitingOrganization} />
67
+ )}
68
+ </div>
69
+ }
70
+ />
71
+ );
72
+ };
@@ -0,0 +1,114 @@
1
+ import { screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import fetchMock from 'fetch-mock';
4
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
+ import { BatchOrderReadFactory, BatchOrderSeatFactory } from 'utils/test/factories/joanie';
6
+ import { BatchOrderState } from 'types/Joanie';
7
+ import { HttpStatusCode } from 'utils/errors/HttpError';
8
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
9
+ import { render } from 'utils/test/render';
10
+ import { expectBannerError } from 'utils/test/expectBanner';
11
+ import { BatchOrderSeatInfo } from './BatchOrderSeatInfo';
12
+
13
+ jest.mock('utils/context', () => ({
14
+ __esModule: true,
15
+ default: mockRichieContextFactory({
16
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
17
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
18
+ }).one(),
19
+ }));
20
+
21
+ describe('<BatchOrderSeatInfo />', () => {
22
+ setupJoanieSession();
23
+
24
+ const paginatedResponse = (results: object[], count?: number) => ({
25
+ results,
26
+ count: count ?? results.length,
27
+ next: null,
28
+ previous: null,
29
+ });
30
+
31
+ it('renders enrollment progress and seat list, and searches by query param', async () => {
32
+ const ownedSeat = BatchOrderSeatFactory({ owner_name: 'Alice Martin' }).one();
33
+ const voucherSeat = BatchOrderSeatFactory().one();
34
+ const batchOrder = BatchOrderReadFactory({
35
+ state: BatchOrderState.COMPLETED,
36
+ nb_seats: 10,
37
+ seats_owned: 1,
38
+ seats_to_own: 9,
39
+ }).one();
40
+
41
+ fetchMock.get(
42
+ `begin:https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/`,
43
+ paginatedResponse([ownedSeat, voucherSeat], 10),
44
+ );
45
+
46
+ render(<BatchOrderSeatInfo batchOrder={batchOrder} />);
47
+
48
+ expect(await screen.findByText('1/10 enrolled participants')).toBeVisible();
49
+ expect(await screen.findByText('Alice Martin')).toBeVisible();
50
+ expect(await screen.findByText(voucherSeat.voucher!)).toBeVisible();
51
+
52
+ const user = userEvent.setup();
53
+ await user.type(screen.getByRole('textbox'), 'Alice');
54
+
55
+ await waitFor(() => {
56
+ const urls = fetchMock
57
+ .calls(`begin:https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/`)
58
+ .map(([url]) => url);
59
+ expect(urls.some((url) => url.includes('query=Alice'))).toBe(true);
60
+ });
61
+ });
62
+
63
+ it('loads more seats when clicking the load more button', async () => {
64
+ const firstPage = BatchOrderSeatFactory().many(10);
65
+ const secondPage = BatchOrderSeatFactory().many(5);
66
+ const batchOrder = BatchOrderReadFactory({
67
+ state: BatchOrderState.COMPLETED,
68
+ nb_seats: 15,
69
+ seats_owned: 15,
70
+ seats_to_own: 0,
71
+ }).one();
72
+
73
+ fetchMock.get(
74
+ `https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/?page=1&page_size=10`,
75
+ { results: firstPage, count: 15, next: 'next-url', previous: null },
76
+ );
77
+ fetchMock.get(
78
+ `https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/?page=2&page_size=10`,
79
+ { results: secondPage, count: 15, next: null, previous: 'prev-url' },
80
+ );
81
+
82
+ render(<BatchOrderSeatInfo batchOrder={batchOrder} />);
83
+
84
+ expect(await screen.findByText(firstPage[0].owner_name ?? firstPage[0].voucher!)).toBeVisible();
85
+ expect(screen.queryByText(secondPage[0].owner_name ?? secondPage[0].voucher!)).toBeNull();
86
+ expect(screen.getByRole('button', { name: 'Load 5 more' })).toBeVisible();
87
+
88
+ const user = userEvent.setup();
89
+ await user.click(screen.getByRole('button', { name: 'Load 5 more' }));
90
+
91
+ expect(
92
+ await screen.findByText(secondPage[0].owner_name ?? secondPage[0].voucher!),
93
+ ).toBeVisible();
94
+ expect(screen.queryByText('Load 5 more')).toBeNull();
95
+ expect(screen.getByText(firstPage[0].owner_name ?? firstPage[0].voucher!)).toBeVisible();
96
+ });
97
+
98
+ it('shows an error banner when the seats API fails', async () => {
99
+ const batchOrder = BatchOrderReadFactory({
100
+ state: BatchOrderState.COMPLETED,
101
+ nb_seats: 10,
102
+ seats_owned: 1,
103
+ seats_to_own: 9,
104
+ }).one();
105
+
106
+ fetchMock.get(`begin:https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/seats/`, {
107
+ status: HttpStatusCode.INTERNAL_SERVER_ERROR,
108
+ });
109
+
110
+ render(<BatchOrderSeatInfo batchOrder={batchOrder} />);
111
+
112
+ await expectBannerError('An error occurred while fetching resources. Please retry later.');
113
+ });
114
+ });
@@ -0,0 +1,133 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { defineMessages, 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 { DashboardSubItem } from 'widgets/Dashboard/components/DashboardItem/DashboardSubItem';
7
+ import { useBatchOrderSeats } from 'hooks/useBatchOrder';
8
+ import DownloadBatchOrderSeatsButton from 'components/DownloadBatchOrderSeatsButton';
9
+ import { BatchOrderRead, BatchOrderSeat } from 'types/Joanie';
10
+ import { batchOrderSeatInfoMessages } from './batchOrderSeatInfoMessages';
11
+
12
+ const messages = defineMessages({
13
+ enrollmentManagement: {
14
+ id: 'batchOrder.enrollmentManagement.title',
15
+ description: 'Title for enrollment management section',
16
+ defaultMessage: 'Enrollment',
17
+ },
18
+ });
19
+
20
+ const ITEMS_PER_PAGE = 10;
21
+
22
+ interface BatchOrderSeatInfoProps {
23
+ batchOrder: BatchOrderRead;
24
+ }
25
+
26
+ export const BatchOrderSeatInfo = ({ batchOrder }: BatchOrderSeatInfoProps) => {
27
+ const intl = useIntl();
28
+ const [query, setQuery] = useState('');
29
+ const [page, setPage] = useState(1);
30
+ const [allSeats, setAllSeats] = useState<BatchOrderSeat[]>([]);
31
+
32
+ const seatsOwnedCount = batchOrder.seats_owned ?? 0;
33
+
34
+ const {
35
+ items: seats,
36
+ meta,
37
+ states,
38
+ } = useBatchOrderSeats(
39
+ {
40
+ batch_order_id: batchOrder.id,
41
+ query: query || undefined,
42
+ page,
43
+ page_size: ITEMS_PER_PAGE,
44
+ },
45
+ { enabled: !!batchOrder.id },
46
+ );
47
+
48
+ useEffect(() => {
49
+ if (page === 1) {
50
+ setAllSeats(seats);
51
+ } else if (seats.length > 0) {
52
+ setAllSeats((prev) => [...prev, ...seats]);
53
+ }
54
+ }, [seats]);
55
+
56
+ useEffect(() => {
57
+ setPage(1);
58
+ }, [query]);
59
+
60
+ const totalCount = meta?.pagination?.count ?? 0;
61
+ const remainingCount = Math.min(ITEMS_PER_PAGE, totalCount - allSeats.length);
62
+
63
+ if (
64
+ !batchOrder.nb_seats ||
65
+ batchOrder.seats_owned === undefined ||
66
+ batchOrder.seats_to_own === undefined
67
+ ) {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <DashboardSubItem
73
+ title={intl.formatMessage(messages.enrollmentManagement)}
74
+ footer={
75
+ <div className="content">
76
+ <div className="enrollment-progress">
77
+ <span className="dashboard-item__label">
78
+ {intl.formatMessage(batchOrderSeatInfoMessages.enrolledParticipants, {
79
+ seats_owned: seatsOwnedCount,
80
+ nb_seats: batchOrder.nb_seats,
81
+ })}
82
+ </span>
83
+ <div className="enrollment-progress__bar">
84
+ <div
85
+ className="enrollment-progress__bar__fill"
86
+ style={{ width: `${(seatsOwnedCount / batchOrder.nb_seats) * 100}%` }}
87
+ />
88
+ </div>
89
+ </div>
90
+ {states.error && <Banner message={states.error} type={BannerType.ERROR} />}
91
+ <div className="enrollment-nested-section__content">
92
+ <Input
93
+ className="enrollment-search"
94
+ label={intl.formatMessage(batchOrderSeatInfoMessages.searchPlaceholder)}
95
+ value={query}
96
+ onChange={(e) => setQuery(e.target.value)}
97
+ rightIcon={<Icon name={IconTypeEnum.MAGNIFYING_GLASS} size="small" />}
98
+ />
99
+ {allSeats.length === 0 && query ? (
100
+ <FormattedMessage {...batchOrderSeatInfoMessages.noResults} />
101
+ ) : (
102
+ <>
103
+ <ul className="enrollment-list">
104
+ {allSeats.map((seat) => (
105
+ <li key={seat.id}>{seat.owner_name ?? seat.voucher}</li>
106
+ ))}
107
+ </ul>
108
+ {remainingCount > 0 && (
109
+ <Button
110
+ className="enrollment-load-more"
111
+ color="brand"
112
+ variant="secondary"
113
+ size="small"
114
+ onClick={() => setPage((p) => p + 1)}
115
+ disabled={states.fetching}
116
+ >
117
+ {intl.formatMessage(batchOrderSeatInfoMessages.loadMore, {
118
+ count: remainingCount,
119
+ })}
120
+ </Button>
121
+ )}
122
+ </>
123
+ )}
124
+ </div>
125
+ <DownloadBatchOrderSeatsButton
126
+ batchOrderId={batchOrder.id}
127
+ productTitle={batchOrder.offering?.product.title ?? ''}
128
+ />
129
+ </div>
130
+ }
131
+ />
132
+ );
133
+ };
@@ -1,8 +1,10 @@
1
1
  import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
2
  import { PaymentMethod } from 'components/PaymentInterfaces/types';
3
- import { BatchOrderRead } from 'types/Joanie';
3
+ import { BatchOrderRead, BatchOrderState } from 'types/Joanie';
4
4
  import { DashboardSubItem } from 'widgets/Dashboard/components/DashboardItem/DashboardSubItem';
5
5
  import { DashboardSubItemsList } from '../DashboardSubItemsList';
6
+ import { BatchOrderSeatInfo } from './BatchOrderSeatInfo';
7
+ import { BatchOrderAgreementInfo } from './BatchOrderAgreementInfo';
6
8
 
7
9
  const messages = defineMessages({
8
10
  stepCompany: {
@@ -144,6 +146,12 @@ const DashboardItemField = ({
144
146
  export const DashboardBatchOrderSubItems = ({ batchOrder }: { batchOrder: BatchOrderRead }) => {
145
147
  const intl = useIntl();
146
148
 
149
+ const displaySeatsInfo =
150
+ batchOrder.state === BatchOrderState.COMPLETED &&
151
+ !!batchOrder.nb_seats &&
152
+ batchOrder.seats_owned !== undefined &&
153
+ batchOrder.seats_to_own !== undefined;
154
+
147
155
  const items = [
148
156
  <DashboardSubItem
149
157
  key="company"
@@ -312,5 +320,13 @@ export const DashboardBatchOrderSubItems = ({ batchOrder }: { batchOrder: BatchO
312
320
  );
313
321
  }
314
322
 
323
+ if (batchOrder.contract_id) {
324
+ items.push(<BatchOrderAgreementInfo key="agreement" batchOrder={batchOrder} />);
325
+ }
326
+
327
+ if (displaySeatsInfo) {
328
+ items.push(<BatchOrderSeatInfo key="enrollment-management" batchOrder={batchOrder} />);
329
+ }
330
+
315
331
  return <DashboardSubItemsList subItems={items} />;
316
332
  };
@@ -0,0 +1,24 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ export const batchOrderSeatInfoMessages = defineMessages({
4
+ enrolledParticipants: {
5
+ id: 'batchOrder.enrollmentManagement.enrolledParticipants',
6
+ description: 'Progress label showing enrolled participants out of total seats',
7
+ defaultMessage: '{seats_owned}/{nb_seats} enrolled participants',
8
+ },
9
+ searchPlaceholder: {
10
+ id: 'batchOrder.enrollmentManagement.searchPlaceholder',
11
+ description: 'Placeholder for the seat search input (student name or voucher)',
12
+ defaultMessage: 'Student name',
13
+ },
14
+ noResults: {
15
+ id: 'batchOrder.enrollmentManagement.noResults',
16
+ description: 'Message shown when the student search returns no results',
17
+ defaultMessage: 'No student matches your search.',
18
+ },
19
+ loadMore: {
20
+ id: 'batchOrder.enrollmentManagement.loadMore',
21
+ description: 'Button to load more seats',
22
+ defaultMessage: 'Load {count} more',
23
+ },
24
+ });
@@ -14,7 +14,12 @@ const messages = defineMessages({
14
14
  seats: {
15
15
  id: 'batchOrder.seats',
16
16
  description: 'Text displayed for seats value in batch order',
17
- defaultMessage: 'Seats',
17
+ defaultMessage: '{nb_seats} seats',
18
+ },
19
+ seatsCount: {
20
+ id: 'batchOrder.seatsCount',
21
+ description: 'Text displayed for seats count in batch order (owned / total)',
22
+ defaultMessage: '{seats_owned}/{nb_seats} seats',
18
23
  },
19
24
  [BatchOrderState.DRAFT]: {
20
25
  id: 'batchOrder.status.draft',
@@ -136,8 +141,16 @@ export const DashboardItemBatchOrder = ({
136
141
  {batchOrder.nb_seats && (
137
142
  <div className="dashboard-item__block__information">
138
143
  <Icon name={IconTypeEnum.GROUPS} size="small" />
139
- <span>{batchOrder.nb_seats}</span>
140
- <span>{intl.formatMessage(messages.seats)}</span>
144
+ <span>
145
+ {batchOrder.seats_owned
146
+ ? intl.formatMessage(messages.seatsCount, {
147
+ seats_owned: batchOrder.seats_owned,
148
+ nb_seats: batchOrder.nb_seats,
149
+ })
150
+ : intl.formatMessage(messages.seats, {
151
+ nb_seats: batchOrder.nb_seats,
152
+ })}
153
+ </span>
141
154
  </div>
142
155
  )}
143
156
  {batchOrder.payment_method && (
@@ -162,12 +162,16 @@
162
162
  padding: 0.5rem 1rem;
163
163
  font-size: 0.8rem;
164
164
  display: flex;
165
- gap: 1rem;
166
- align-items: center;
165
+ flex-direction: column;
166
+ gap: 0.5rem;
167
167
  }
168
168
  }
169
169
  }
170
170
 
171
+ .dashboard-item__action-button {
172
+ align-self: flex-end;
173
+ }
174
+
171
175
  .dashboard-item__course-enrolling {
172
176
  &__infos {
173
177
  align-items: center;
@@ -82,7 +82,7 @@ export const TEACHER_DASHBOARD_ROUTE_LABELS = defineMessages<TeacherDashboardPat
82
82
  [TeacherDashboardPaths.ORGANIZATION_COURSE_PRODUCT_LEARNER_LIST]: {
83
83
  id: 'components.TeacherDashboard.TeacherDashboardRoutes.organization.course.product.learnerList.label',
84
84
  description: "Label to display the organization product's learner list view.",
85
- defaultMessage: 'Learners',
85
+ defaultMessage: 'Buyers',
86
86
  },
87
87
  [TeacherDashboardPaths.COURSE]: {
88
88
  id: 'components.TeacherDashboard.TeacherDashboardRoutes.course.label',
@@ -102,7 +102,7 @@ export const TEACHER_DASHBOARD_ROUTE_LABELS = defineMessages<TeacherDashboardPat
102
102
  [TeacherDashboardPaths.COURSE_PRODUCT_LEARNER_LIST]: {
103
103
  id: 'components.TeacherDashboard.TeacherDashboardRoutes.course.product.learnerList.label',
104
104
  description: "Label to display the product's learner list view.",
105
- defaultMessage: 'Learners',
105
+ defaultMessage: 'Buyers',
106
106
  },
107
107
  [TeacherDashboardPaths.COURSE_PRODUCT_CONTRACTS]: {
108
108
  id: 'components.TeacherDashboard.TeacherDashboardRoutes.course.product.contracts.label',
@@ -101,18 +101,19 @@ const Slider = ({ slides, title }: SliderProps) => {
101
101
  return (
102
102
  <div
103
103
  className="slider"
104
- ref={emblaRef}
105
104
  aria-roledescription="carousel"
106
105
  aria-label={title}
107
106
  role="button"
108
107
  tabIndex={0}
109
108
  onKeyDown={handleKeyDown}
110
109
  >
111
- <Slideshow
112
- slides={slides}
113
- onNextSlide={() => emblaApi?.scrollNext()}
114
- onPreviousSlide={() => emblaApi?.scrollPrev()}
115
- />
110
+ <div ref={emblaRef}>
111
+ <Slideshow
112
+ slides={slides}
113
+ onNextSlide={() => emblaApi?.scrollNext()}
114
+ onPreviousSlide={() => emblaApi?.scrollPrev()}
115
+ />
116
+ </div>
116
117
  <SlidePanel
117
118
  slides={slides}
118
119
  activeSlideIndex={activeSlideIndex}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.4.0",
3
+ "version": "3.4.1-dev14",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -57,13 +57,9 @@
57
57
  "@openfun/cunningham-tokens": "3.0.0",
58
58
  "@sentry/browser": "10.40.0",
59
59
  "@sentry/types": "10.40.0",
60
- "@storybook/addon-actions": "9.0.8",
61
- "@storybook/addon-essentials": "8.6.17",
62
- "@storybook/addon-interactions": "8.6.17",
63
60
  "@storybook/addon-links": "10.2.13",
64
61
  "@storybook/react": "10.2.13",
65
62
  "@storybook/react-webpack5": "10.2.13",
66
- "@storybook/test": "8.6.17",
67
63
  "@tanstack/query-core": "5.90.20",
68
64
  "@tanstack/query-sync-storage-persister": "5.90.23",
69
65
  "@tanstack/react-query": "5.90.21",
@@ -136,7 +132,7 @@
136
132
  "react-router": "7.12.0",
137
133
  "sass": "1.97.3",
138
134
  "source-map-loader": "5.0.0",
139
- "storybook": "8.6.17",
135
+ "storybook": "10.2.13",
140
136
  "tsconfig-paths-webpack-plugin": "4.2.0",
141
137
  "typescript": "5.9.3",
142
138
  "uuid": "13.0.0",
@@ -162,7 +158,6 @@
162
158
  "yarn": "1.22.22"
163
159
  },
164
160
  "devDependencies": {
165
- "@storybook/addon-mdx-gfm": "8.6.17",
166
161
  "@storybook/addon-webpack5-compiler-babel": "4.0.0"
167
162
  }
168
163
  }