From 017d3d8eb933050543238f549e48247b1b914cd5 Mon Sep 17 00:00:00 2001 From: celineung Date: Fri, 8 Nov 2024 17:41:56 +0100 Subject: [PATCH] style convention summary page --- .../conventions/ConventionValidation.tsx | 57 +- .../ConventionValidationDetails.tsx | 194 ----- .../forms/convention/ConventionForm.tsx | 332 ++++----- .../convention/ConventionFormWrapper.tsx | 34 +- .../forms/convention/ConventionSignForm.tsx | 47 +- .../forms/convention/ConventionSummary.tsx | 75 -- .../convention/ConventionSummarySection.tsx | 363 ---------- .../contents/admin/conventionValidation.tsx | 534 -------------- front/src/app/contents/admin/types.ts | 20 - .../convention/conventionSummary.helpers.tsx | 684 ++++++++++++++++++ .../convention/labelAndSeverityByStatus.ts | 43 ++ .../contents/forms/convention/textSetup.tsx | 2 + .../convention/ConventionImmersionPage.tsx | 7 +- .../convention/ConventionMiniStagePage.tsx | 7 +- .../pages/convention/ConventionSignPage.tsx | 167 +++-- .../convention-summary/ConventionSummary.scss | 74 ++ .../ConventionSummary.stories.tsx | 38 + .../ConventionSummary.styles.ts | 15 + .../convention-summary/ConventionSummary.tsx | 253 +++++++ .../components/convention-summary/index.ts | 2 + .../components/copy-button/CopyButton.scss | 5 + .../copy-button/CopyButton.stories.tsx | 51 ++ .../copy-button/CopyButton.styles.ts | 5 + .../components/copy-button/CopyButton.tsx | 53 ++ .../components/copy-button/index.ts | 1 + .../src/immersionFacile/components/index.ts | 2 + .../components/page-header/PageHeader.tsx | 3 + playwright/utils/convention.ts | 33 +- playwright/utils/utils.ts | 2 +- shared/src/schedule/ScheduleUtils.ts | 32 + shared/src/utils.ts | 5 +- 31 files changed, 1642 insertions(+), 1498 deletions(-) delete mode 100644 front/src/app/components/admin/conventions/ConventionValidationDetails.tsx delete mode 100644 front/src/app/components/forms/convention/ConventionSummary.tsx delete mode 100644 front/src/app/components/forms/convention/ConventionSummarySection.tsx delete mode 100644 front/src/app/contents/admin/conventionValidation.tsx create mode 100644 front/src/app/contents/convention/conventionSummary.helpers.tsx create mode 100644 front/src/app/contents/convention/labelAndSeverityByStatus.ts create mode 100644 libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.scss create mode 100644 libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.stories.tsx create mode 100644 libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.styles.ts create mode 100644 libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.tsx create mode 100644 libs/react-design-system/src/immersionFacile/components/convention-summary/index.ts create mode 100644 libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.scss create mode 100644 libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.stories.tsx create mode 100644 libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.styles.ts create mode 100644 libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.tsx create mode 100644 libs/react-design-system/src/immersionFacile/components/copy-button/index.ts diff --git a/front/src/app/components/admin/conventions/ConventionValidation.tsx b/front/src/app/components/admin/conventions/ConventionValidation.tsx index d5018cbb26..05fd47db8d 100644 --- a/front/src/app/components/admin/conventions/ConventionValidation.tsx +++ b/front/src/app/components/admin/conventions/ConventionValidation.tsx @@ -1,8 +1,20 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import { Badge } from "@codegouvfr/react-dsfr/Badge"; import { formatDistance } from "date-fns"; -import { fr } from "date-fns/locale"; +import { fr as french } from "date-fns/locale"; import React from "react"; -import type { ConventionReadDto, ConventionStatus } from "shared"; -import { ConventionValidationDetails } from "./ConventionValidationDetails"; +import { + ConventionRenewedInformations, + ConventionSummary, +} from "react-design-system"; +import { + ConventionReadDto, + isConventionRenewed, + toDisplayedDate, +} from "shared"; +import { labelAndSeverityByStatus } from "src/app/contents/convention/labelAndSeverityByStatus"; +import { useStyles } from "tss-react/dsfr"; +import { makeConventionSections } from "../../../contents/convention/conventionSummary.helpers"; const beforeAfterString = (date: string) => { const eventDate = new Date(date); @@ -10,22 +22,10 @@ const beforeAfterString = (date: string) => { return formatDistance(eventDate, currentDate, { addSuffix: true, - locale: fr, + locale: french, }); }; -const labelByStatus: Record = { - ACCEPTED_BY_COUNSELLOR: "[📗 DEMANDE ÉLIGIBLE]", - ACCEPTED_BY_VALIDATOR: "[✅ DEMANDE VALIDÉE]", - CANCELLED: "[đŸ—‘ïž CONVENTION ANNULÉE]", - DRAFT: "[📕 BROUILLON]", - IN_REVIEW: "[📙 DEMANDE À ETUDIER]", - PARTIALLY_SIGNED: "[✍ Partiellement signĂ©e]", - READY_TO_SIGN: "[📄 En cours de signature]", - REJECTED: "[❌ DEMANDE REJETÉE]", - DEPRECATED: "[❌ DEMANDE OBSOLÈTE]", -}; - export interface ConventionValidationProps { convention: ConventionReadDto; } @@ -33,6 +33,8 @@ export interface ConventionValidationProps { export const ConventionValidation = ({ convention, }: ConventionValidationProps) => { + const { cx } = useStyles(); + const { status, signatories: { beneficiary }, @@ -42,19 +44,34 @@ export const ConventionValidation = ({ } = convention; const title = - `${labelByStatus[status]} ` + `${beneficiary.lastName.toUpperCase()} ${ beneficiary.firstName - } chez ${businessName} ` + - `${beforeAfterString(dateStart)}`; + } chez ${businessName} ` + `${beforeAfterString(dateStart)}`; return ( <> + + {labelAndSeverityByStatus[status].label} +

{title}

{convention.statusJustification && (

Justification : {convention.statusJustification}

)} - + {isConventionRenewed(convention) && ( + + )} + ); }; diff --git a/front/src/app/components/admin/conventions/ConventionValidationDetails.tsx b/front/src/app/components/admin/conventions/ConventionValidationDetails.tsx deleted file mode 100644 index e2eef84ef2..0000000000 --- a/front/src/app/components/admin/conventions/ConventionValidationDetails.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { fr } from "@codegouvfr/react-dsfr"; -import React, { ReactNode, useState } from "react"; -import { ConventionRenewedInformations } from "react-design-system"; -import { path, ConventionReadDto, isConventionRenewed } from "shared"; -import { sections } from "src/app/contents/admin/conventionValidation"; -import { - ColField, - FieldsAndTitle, - RowFields, -} from "src/app/contents/admin/types"; -import { useCopyButton } from "src/app/hooks/useCopyButton"; -import { useStyles } from "tss-react/dsfr"; -import type { ConventionValidationProps } from "./ConventionValidation"; - -const cellStyles = { - overflow: "hidden", - whitespace: "nowrap", -}; - -export const ConventionValidationDetails = ({ - convention, -}: ConventionValidationProps) => { - const { copyButtonIsDisabled, copyButtonLabel, onCopyButtonClick } = - useCopyButton("Copier"); - - return ( - <> -

- Convention{" "} - - {convention.id} - - -

- {isConventionRenewed(convention) && ( - - )} - {sections.map((list, index) => ( - - ))} - - ); -}; - -export const ConventionValidationSection = ({ - convention, - list, - index, -}: { - convention: ConventionReadDto; - list: FieldsAndTitle; - index: number; -}) => { - const { cx } = useStyles(); - const [markedAsRead, setMarkedAsRead] = useState(false); - - const buildContent = (field: ColField): ReactNode => { - let value: React.ReactNode; - if (field?.key) { - value = path(field.key, convention) as string; - if (field.getValue) { - value = ( - <> - {field.getValue?.(convention)} - {field.copyButton?.(convention)} - - ); - } - } - - return value; - }; - - const renderRows = (rowFields: RowFields[]) => { - const relevantRows = rowFields.filter( - (row) => - row.fields.filter((field) => { - if (!field) return false; - - const pathValue = path(field.key, convention); - const fieldValue = field?.getValue?.(convention); - - if (pathValue === undefined) return false; - return fieldValue ?? pathValue; - }).length, - ); - - return relevantRows.map( - (row, index) => - row.fields.length > 0 && ( - - {row.title && ( - - {row.title} - - )} - - {row.fields.map((field, index) => - field ? ( - - {buildContent(field)} - - ) : ( - // biome-ignore lint/suspicious/noArrayIndexKey: Index is ok here - - ), - )} - - ), - ); - }; - - return ( -
- - - - {!markedAsRead && ( - <> - - - {list.cols?.map((col, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: Index is ok here - - ))} - {!list.cols && - list.rowFields[0] && - list.rowFields[0].fields.map((field) => - field ? ( - - ) : null, - )} - - - {renderRows(list.rowFields)} - - )} -
- {list.listTitle} -
- setMarkedAsRead((read) => !read)} - className={fr.cx("fr-toggle__input")} - id={`fr-toggle__input-${index}`} - checked={markedAsRead} - /> - -
-
- {col} - - {field.colLabel} -
-
- ); -}; diff --git a/front/src/app/components/forms/convention/ConventionForm.tsx b/front/src/app/components/forms/convention/ConventionForm.tsx index ed4b8ee64d..25dddaacc7 100644 --- a/front/src/app/components/forms/convention/ConventionForm.tsx +++ b/front/src/app/components/forms/convention/ConventionForm.tsx @@ -365,27 +365,28 @@ export const ConventionForm = ({ }, [fetchedConvention, methods.reset]); return ( - - {conventionIsLoading && } - -
{t.intro.welcome}
- - -

- Tous les champs marqués d'une astérisque (*) sont obligatoires. -

+
+ + {conventionIsLoading && } + +
{t.intro.welcome}
+ + +

+ Tous les champs marqués d'une astérisque (*) sont obligatoires. +

<> -
- - } - {...makeAccordionProps(1)} - > - +
+ } - /> - - - + - } - {...makeAccordionProps(2)} - > - - - - } - {...makeAccordionProps(3)} - > - - - + + + } + {...makeAccordionProps(2)} + > + - } - {...makeAccordionProps(4)} - > - - + } - setFormValue={({ address }) => - setValue( - "immersionAddress", - addressDtoToString(address), - ) + {...makeAccordionProps(3)} + > + + + } - disabled={isFetchingSiret} - {...getFieldError("immersionAddress")} - /> - - + + + setValue( + "immersionAddress", + addressDtoToString(address), + ) + } + disabled={isFetchingSiret} + {...getFieldError("immersionAddress")} /> - } - {...makeAccordionProps(5)} - > - - -
- - 0 - } - /> - {keys(emailValidationErrors).length > 0 && ( - -

- Notre vérificateur d'email a détecté des emails non - valides dans votre convention. -

-
    - {keys(emailValidationErrors).map((key) => ( -
  • - {key} : {emailValidationErrors[key]} -
  • - ))} -
- +
+ + } + {...makeAccordionProps(5)} + > + + +
+ + 0 } /> - )} + {keys(emailValidationErrors).length > 0 && ( + +

+ Notre vérificateur d'email a détecté des emails + non valides dans votre convention. +

+
    + {keys(emailValidationErrors).map((key) => ( +
  • + {key} : {emailValidationErrors[key]} +
  • + ))} +
+ + } + /> + )} + - -
- +
); }; diff --git a/front/src/app/components/forms/convention/ConventionFormWrapper.tsx b/front/src/app/components/forms/convention/ConventionFormWrapper.tsx index fd7b4e9e45..6b0316549e 100644 --- a/front/src/app/components/forms/convention/ConventionFormWrapper.tsx +++ b/front/src/app/components/forms/convention/ConventionFormWrapper.tsx @@ -3,7 +3,7 @@ import { Alert } from "@codegouvfr/react-dsfr/Alert"; import { ButtonsGroup } from "@codegouvfr/react-dsfr/ButtonsGroup"; import { createModal } from "@codegouvfr/react-dsfr/Modal"; import React, { useEffect } from "react"; -import { Loader } from "react-design-system"; +import { ConventionSummary, Loader } from "react-design-system"; import { createPortal } from "react-dom"; import { useDispatch } from "react-redux"; import { @@ -12,11 +12,11 @@ import { InternshipKind, decodeMagicLinkJwtWithoutSignatureCheck, domElementIds, + toDisplayedDate, } from "shared"; -import { ConventionValidationSection } from "src/app/components/admin/conventions/ConventionValidationDetails"; import { ConventionFeedbackNotification } from "src/app/components/forms/convention/ConventionFeedbackNotification"; import { ConventionForm } from "src/app/components/forms/convention/ConventionForm"; -import { sections } from "src/app/contents/admin/conventionValidation"; +import { makeConventionSections } from "src/app/contents/convention/conventionSummary.helpers"; import { useAppSelector } from "src/app/hooks/reduxHooks"; import { useScrollToTop } from "src/app/hooks/window.hooks"; import { type ConventionImmersionPageRoute } from "src/app/pages/convention/ConventionImmersionPage"; @@ -27,6 +27,7 @@ import { routes, useRoute } from "src/app/routes/routes"; import { conventionSelectors } from "src/core-logic/domain/convention/convention.selectors"; import { conventionSlice } from "src/core-logic/domain/convention/convention.slice"; import { match } from "ts-pattern"; +import { useStyles } from "tss-react/dsfr"; import { Route } from "type-route"; const { @@ -95,7 +96,7 @@ export const ConventionFormWrapper = ({ }, [dispatch, mode, route.params.jwt]); return ( -
+ <> {match({ showSummary, formSuccessfullySubmitted, @@ -151,11 +152,12 @@ export const ConventionFormWrapper = ({ /> )) .exhaustive()} -
+ ); }; const ConventionSummarySection = () => { + const { cx } = useStyles(); const dispatch = useDispatch(); const isLoading = useAppSelector(conventionSelectors.isLoading); const convention = useAppSelector(conventionSelectors.convention); @@ -197,21 +199,19 @@ const ConventionSummarySection = () => { }; return ( -
+
{ //TODO il y a déjà un LOADER dans le composant parent. Nécéssaire? isLoading && } - {convention && - sections.map((list, index) => ( - - ))} + {convention && ( + + )} {convention && ( { , document.body, )} -
+ ); }; diff --git a/front/src/app/components/forms/convention/ConventionSignForm.tsx b/front/src/app/components/forms/convention/ConventionSignForm.tsx index a24e83a2f3..f3126c6ce5 100644 --- a/front/src/app/components/forms/convention/ConventionSignForm.tsx +++ b/front/src/app/components/forms/convention/ConventionSignForm.tsx @@ -2,7 +2,10 @@ import { fr } from "@codegouvfr/react-dsfr"; import { Alert } from "@codegouvfr/react-dsfr/Alert"; import { mergeDeepRight } from "ramda"; import React, { useState } from "react"; -import { ConventionRenewedInformations } from "react-design-system"; +import { + ConventionRenewedInformations, + ConventionSummary, +} from "react-design-system"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { useDispatch } from "react-redux"; import { @@ -11,10 +14,10 @@ import { UpdateConventionStatusRequestDto, domElementIds, isConventionRenewed, + toDisplayedDate, } from "shared"; -import { ConventionValidationSection } from "src/app/components/admin/conventions/ConventionValidationDetails"; import { ConventionFeedbackNotification } from "src/app/components/forms/convention/ConventionFeedbackNotification"; -import { sections } from "src/app/contents/admin/conventionValidation"; +import { makeConventionSections } from "src/app/contents/convention/conventionSummary.helpers"; import { useConventionTexts } from "src/app/contents/forms/convention/textSetup"; import { useAppSelector } from "src/app/hooks/reduxHooks"; import { @@ -26,6 +29,7 @@ import { ConventionSubmitFeedback, conventionSlice, } from "src/core-logic/domain/convention/convention.slice"; +import { useStyles } from "tss-react/dsfr"; import { SignatureActions } from "./SignatureActions"; type ConventionSignFormProperties = { @@ -39,6 +43,7 @@ export const ConventionSignForm = ({ submitFeedback, convention, }: ConventionSignFormProperties): JSX.Element => { + const { cx } = useStyles(); const dispatch = useDispatch(); const { signatory: currentSignatory } = useAppSelector( conventionSelectors.signatoryData, @@ -103,15 +108,13 @@ export const ConventionSignForm = ({ severity="success" className={fr.cx("fr-mb-5v")} /> - {sections.map((list, index) => ( - - ))} + ); } @@ -127,17 +130,15 @@ export const ConventionSignForm = ({ )}

{t.sign.regulations}

- {currentSignatory && - convention && - sections.map((list, index) => ( - - ))} + {currentSignatory && convention && ( + + )} { - const convention = useAppSelector(conventionSelectors.convention); - const agency = useAppSelector(agenciesSelectors.details); - const agencyIsLoading = useAppSelector(agenciesSelectors.isLoading); - const agencyfeedback = useAppSelector(agenciesSelectors.feedback); - const { cx } = useStyles(); - const dispatch = useDispatch(); - useEffect(() => { - if (convention) { - dispatch( - agenciesSlice.actions.fetchAgencyInfoRequested(convention.agencyId), - ); - } - }, [convention, dispatch]); - useScrollToTop(true); - - if (agencyIsLoading) return ; - if (agencyfeedback.kind === "errored") - return ( - - ); - if (!convention || !agency) return null; - - const fields = getFormContents( - formConventionFieldsLabels(convention.internshipKind), - ).getFormFields(); - - return ( -
- {makeSummarySections(convention, agency, fields).map((section) => ( -
-

{section.title}

- {"fields" in section ? ( - - ) : ( - section.subfields - .filter((sub) => sub.fields.length > 0) - .map(({ fields, subtitle }) => ( -
-

{subtitle}

-
- - )) - )} - - ))} - - ); -}; diff --git a/front/src/app/components/forms/convention/ConventionSummarySection.tsx b/front/src/app/components/forms/convention/ConventionSummarySection.tsx deleted file mode 100644 index 2066c0461f..0000000000 --- a/front/src/app/components/forms/convention/ConventionSummarySection.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import React, { ReactNode } from "react"; -import { - AddressDto, - AgencyPublicDisplayDto, - ConventionReadDto, - DateIntervalDto, - DotNestedKeys, - ScheduleDto, - addressDtoToString, - convertLocaleDateToUtcTimezoneDate, - prettyPrintSchedule, - toDisplayedDate, -} from "shared"; -import { FormConventionFieldsLabels } from "src/app/contents/forms/convention/formConvention"; -import { FormFieldsObject } from "src/app/hooks/formContents.hooks"; - -type ConventionSummaryRow = [ - keyof ConventionReadDto | DotNestedKeys | string, - ReactNode | undefined, -]; - -type SummarySection = { - title: string; -} & ( - | { - fields: ConventionSummaryRow[]; - } - | { - subfields: { - subtitle: string; - fields: ConventionSummaryRow[]; - }[]; - } -); - -const filterEmptyRows = (row: ConventionSummaryRow) => - row[1] !== undefined && row[1] !== ""; - -const displayAddress = (address: AddressDto) => - `${address.streetNumberAndAddress} ${address.postcode} ${address.city}`; - -const prettyPrintScheduleAsJSX = ( - schedule: ScheduleDto, - interval: DateIntervalDto, -): JSX.Element => ( -
    - {prettyPrintSchedule(schedule, interval) - .split("\n") - .map((line, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: Index is ok here -
  • {line}
  • - ))} -
-); - -export const makeSummarySections = ( - convention: ConventionReadDto, - agency: AgencyPublicDisplayDto, - fields: FormFieldsObject, -): SummarySection[] => [ - { - title: "Signataires de la convention", - subfields: [ - { - subtitle: "BĂ©nĂ©ficiaire", - fields: signatoriesBeneficiary(convention, fields), - }, - { - subtitle: "ReprĂ©sentant du bĂ©nĂ©ficiaire", - fields: signatoriesBeneficiaryRepresentative(convention, fields), - }, - { - subtitle: "ReprĂ©sentant de l'entreprise", - fields: signatoriesEstablishementRepresentative(convention, fields), - }, - { - subtitle: "Employeur actuel", - fields: signatoriesBeneficiaryCurrentEmployer(convention, fields), - }, - ], - }, - { - title: "Structure d'accompagnement du candidat", - fields: agencySummary(agency, fields), - }, - { - title: "Informations du candidat", - fields: beneficiarySummary(convention, fields), - }, - { - title: "Informations de l'entreprise d'accueil", - fields: establishmentSummary(convention, fields), - }, - { - title: - convention.internshipKind === "immersion" - ? "Conditions d'immersion" - : "Conditions de stage", - fields: immersionConditionsSummary(convention, fields), - }, -]; - -const signatoriesBeneficiaryCurrentEmployer = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -): ConventionSummaryRow[] => - ( - [ - [ - fields["signatories.beneficiaryCurrentEmployer.firstName"].label, - convention.signatories.beneficiaryCurrentEmployer?.firstName, - ], - [ - fields["signatories.beneficiaryCurrentEmployer.lastName"].label, - convention.signatories.beneficiaryCurrentEmployer?.lastName, - ], - [ - fields["signatories.beneficiaryCurrentEmployer.email"].label, - convention.signatories.beneficiaryCurrentEmployer?.email, - ], - [ - fields["signatories.beneficiaryCurrentEmployer.phone"].label, - convention.signatories.beneficiaryCurrentEmployer?.phone, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const signatoriesBeneficiaryRepresentative = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -): ConventionSummaryRow[] => - ( - [ - [ - fields["signatories.beneficiaryRepresentative.firstName"].label, - convention.signatories.beneficiaryRepresentative?.firstName, - ], - [ - fields["signatories.beneficiaryRepresentative.lastName"].label, - convention.signatories.beneficiaryRepresentative?.lastName, - ], - [ - fields["signatories.beneficiaryRepresentative.email"].label, - convention.signatories.beneficiaryRepresentative?.email, - ], - [ - fields["signatories.beneficiaryRepresentative.phone"].label, - convention.signatories.beneficiaryRepresentative?.phone, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const signatoriesEstablishementRepresentative = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -) => - ( - [ - [ - fields["signatories.establishmentRepresentative.firstName"].label, - convention.signatories.establishmentRepresentative?.firstName, - ], - [ - fields["signatories.establishmentRepresentative.lastName"].label, - convention.signatories.establishmentRepresentative?.lastName, - ], - [ - fields["signatories.establishmentRepresentative.email"].label, - convention.signatories.establishmentRepresentative.email, - ], - [ - fields["signatories.establishmentRepresentative.phone"].label, - convention.signatories.establishmentRepresentative.phone, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const signatoriesBeneficiary = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -) => - ( - [ - [ - fields["signatories.beneficiary.firstName"].label, - convention.signatories.beneficiary.firstName, - ], - [ - fields["signatories.beneficiary.lastName"].label, - convention.signatories.beneficiary.lastName, - ], - [ - fields["signatories.beneficiary.email"].label, - convention.signatories.beneficiary.email, - ], - [ - fields["signatories.beneficiary.phone"].label, - convention.signatories.beneficiary.phone, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const agencySummary = ( - agency: AgencyPublicDisplayDto, - fields: FormFieldsObject, -): ConventionSummaryRow[] => - ( - [ - [ - fields.agencyId.label, - `${agency.name} (${displayAddress(agency.address)})`, - ], - [ - fields.agencyRefersTo.label, - agency.refersToAgency && - `${agency.refersToAgency.name} (${displayAddress( - agency.refersToAgency.address, - )})`, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const beneficiarySummary = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -) => - ( - [ - [ - fields["signatories.beneficiary.firstName"].label, - convention.signatories.beneficiary.firstName, - ], - [ - fields["signatories.beneficiary.lastName"].label, - convention.signatories.beneficiary.lastName, - ], - [ - fields["signatories.beneficiary.birthdate"].label, - displayDate(convention.signatories.beneficiary.birthdate), - ], - ...(convention.internshipKind === "mini-stage-cci" && - convention.signatories.beneficiary.address - ? ([ - [ - fields["signatories.beneficiary.address"].label, - addressDtoToString(convention.signatories.beneficiary.address), - ], - ] satisfies ConventionSummaryRow[]) - : []), - ...(convention.internshipKind === "mini-stage-cci" - ? ([ - [ - fields["signatories.beneficiary.levelOfEducation"].label, - convention.signatories.beneficiary.levelOfEducation, - ], - [ - fields["signatories.beneficiary.schoolName"].label, - convention.signatories.beneficiary.schoolName, - ], - [ - fields["signatories.beneficiary.schoolPostcode"].label, - convention.signatories.beneficiary.schoolPostcode, - ], - ] satisfies ConventionSummaryRow[]) - : []), - [ - fields["signatories.beneficiary.financiaryHelp"].label, - convention.signatories.beneficiary.financiaryHelp, - ], - [ - fields["signatories.beneficiary.emergencyContact"].label, - convention.signatories.beneficiary?.emergencyContact, - ], - [ - fields["signatories.beneficiary.emergencyContactPhone"].label, - convention.signatories.beneficiary?.emergencyContactPhone, - ], - [ - fields["signatories.beneficiary.emergencyContactEmail"].label, - convention.signatories.beneficiary?.emergencyContactEmail, - ], - [ - fields["signatories.beneficiary.isRqth"].label, - convention.signatories.beneficiary.isRqth ? "✅" : undefined, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const establishmentSummary = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -) => - ( - [ - [fields.businessName.label, convention.businessName], - [fields.siret.label, convention.siret], - [fields.immersionAddress.label, convention.immersionAddress], - [ - fields["establishmentTutor.firstName"].label, - convention.establishmentTutor?.firstName, - ], - [ - fields["establishmentTutor.lastName"].label, - convention.establishmentTutor?.lastName, - ], - [ - fields["establishmentTutor.email"].label, - convention.establishmentTutor?.email, - ], - [ - fields["establishmentTutor.phone"].label, - convention.establishmentTutor?.phone, - ], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const immersionConditionsSummary = ( - convention: ConventionReadDto, - fields: FormFieldsObject, -) => - ( - [ - [fields.dateStart.label, displayDate(convention.dateStart)], - [fields.dateEnd.label, displayDate(convention.dateEnd)], - [ - "Emploi du temps", - prettyPrintScheduleAsJSX(convention.schedule, { - start: new Date(convention.dateStart), - end: new Date(convention.dateEnd), - }), - ], - [ - fields.individualProtection.label, - convention.individualProtection ? "✅" : "❌", - ], - [ - fields.individualProtectionDescription.label, - convention.individualProtectionDescription, - ], - [ - fields.sanitaryPrevention.label, - convention.sanitaryPrevention ? "✅" : "❌", - ], - [ - fields.sanitaryPreventionDescription.label, - convention.sanitaryPreventionDescription, - ], - [fields.immersionObjective.label, convention.immersionObjective], - [ - fields.immersionAppellation.label, - convention.immersionAppellation.appellationLabel, - ], - [fields.workConditions.label, convention.workConditions], - [fields.immersionActivities.label, convention.immersionActivities], - [fields.businessAdvantages.label, convention.businessAdvantages], - [fields.immersionSkills.label, convention.immersionSkills], - ] satisfies ConventionSummaryRow[] - ).filter(filterEmptyRows); - -const displayDate = (date: string) => - toDisplayedDate({ - date: convertLocaleDateToUtcTimezoneDate(new Date(date)), - }); diff --git a/front/src/app/contents/admin/conventionValidation.tsx b/front/src/app/contents/admin/conventionValidation.tsx deleted file mode 100644 index b21d76516f..0000000000 --- a/front/src/app/contents/admin/conventionValidation.tsx +++ /dev/null @@ -1,534 +0,0 @@ -import { fr } from "@codegouvfr/react-dsfr"; -import React from "react"; -import { - ConventionReadDto, - addressDtoToString, - displayEmergencyContactInfos, - makeSiretDescriptionLink, - prettyPrintSchedule, - toDisplayedDate, -} from "shared"; -import { useCopyButton } from "src/app/hooks/useCopyButton"; -import { P, match } from "ts-pattern"; -import { ColField, FieldsAndTitle } from "./types"; - -export const signToBooleanDisplay = (value: string | undefined) => - value ? `✅ (${toDisplayedDate({ date: new Date(value) })})` : "❌"; - -const booleanToCheck = (value: boolean) => (value ? "✅" : "❌"); - -const renderSchedule = (convention: ConventionReadDto) => ( -
- {prettyPrintSchedule( - convention.schedule, - { - start: new Date(convention.dateStart), - end: new Date(convention.dateEnd), - }, - false, - )} -
-); - -const renderSiret = (siret: string) => ( - - {siret} - -); - -const renderEmail = (email: string) => ( - - {email} - -); - -const CopyButton = ({ - textLabel, - value, -}: { textLabel: string; value: string }) => { - const { copyButtonIsDisabled, copyButtonLabel, onCopyButtonClick } = - useCopyButton(textLabel); - return ( - - ); -}; - -const beneficiaryFields: ColField[] = [ - { - key: "signatories.beneficiary.signedAt", - colLabel: "Signé", - getValue: (convention) => - signToBooleanDisplay(convention.signatories.beneficiary.signedAt), - }, - { - key: "signatories.beneficiary.email", - colLabel: "Mail de demandeur", - getValue: (convention) => - renderEmail(convention.signatories.beneficiary.email), - copyButton: (convention) => ( - - ), - }, - { - key: "signatories.beneficiary.phone", - colLabel: "Numéro de téléphone", - }, - { - key: "signatories.beneficiary.firstName", - colLabel: "Prénom", - }, - { - key: "signatories.beneficiary.lastName", - colLabel: "Nom", - }, - { - key: "additionnalInfos", - colLabel: "Infos additionnelles", - getValue: (convention) => ( - -
- Date de naissance :{" "} - {toDisplayedDate({ - date: new Date(convention.signatories.beneficiary.birthdate), - })} -
-
- Contact d'urgence :{" "} - {displayEmergencyContactInfos({ - ...convention.signatories, - })} -
- {convention.signatories.beneficiary.financiaryHelp && ( -
- Aide matérielle :{" "} - {convention.signatories.beneficiary.financiaryHelp} -
- )} - -
- RQTH : {convention.signatories.beneficiary.isRqth ? "Oui" : "Non"} -
- {convention.internshipKind === "mini-stage-cci" && ( -
- Adresse du candidat :{" "} - {convention.signatories.beneficiary.address && - addressDtoToString(convention.signatories.beneficiary.address)} -
- Niveau d'Ă©tudes :{" "} - {convention.signatories.beneficiary.levelOfEducation} -
- Établissement : {convention.signatories.beneficiary.schoolName} -
- Code postal de l'Ă©tablissement :{" "} - {convention.signatories.beneficiary.schoolPostcode} -
- )} -
- ), - }, -]; - -const beneficiaryRepresentativeFields: ColField[] = [ - { - key: "signatories.beneficiaryRepresentative.signedAt", - colLabel: "Signé", - getValue: (convention) => - signToBooleanDisplay( - convention.signatories.beneficiaryRepresentative?.signedAt, - ), - }, - { - key: "signatories.beneficiaryRepresentative.email", - colLabel: "Mail du représentant", - getValue: (convention) => - convention.signatories.beneficiaryRepresentative - ? renderEmail(convention.signatories.beneficiaryRepresentative.email) - : "", - copyButton: (convention) => - convention.signatories.beneficiaryRepresentative ? ( - - ) : null, - }, - { - key: "signatories.beneficiaryRepresentative.phone", - colLabel: "Numéro de téléphone", - }, - { - key: "signatories.beneficiaryRepresentative.firstName", - colLabel: "Prénom", - }, - { - key: "signatories.beneficiaryRepresentative.lastName", - colLabel: "Nom", - }, - null, -]; - -const beneficiaryCurrentEmployerFields: ColField[] = [ - { - key: "signatories.beneficiaryCurrentEmployer.signedAt", - colLabel: "Signé", - getValue: (convention) => - signToBooleanDisplay( - convention.signatories.beneficiaryCurrentEmployer?.signedAt, - ), - }, - { - key: "signatories.beneficiaryCurrentEmployer.email", - colLabel: "Mail du représentant", - getValue: (convention) => - convention.signatories.beneficiaryCurrentEmployer - ? renderEmail(convention.signatories.beneficiaryCurrentEmployer.email) - : "", - copyButton: (convention) => - convention.signatories.beneficiaryCurrentEmployer ? ( - - ) : ( - "" - ), - }, - { - key: "signatories.beneficiaryCurrentEmployer.phone", - colLabel: "Numéro de téléphone", - }, - { - key: "signatories.beneficiaryCurrentEmployer.firstName", - colLabel: "Prénom", - }, - { - key: "signatories.beneficiaryCurrentEmployer.lastName", - colLabel: "Nom", - }, - { - key: "additionnalInfos", - colLabel: "Infos additionnelles", - getValue: (convention) => - convention.signatories.beneficiaryCurrentEmployer ? ( - - {renderSiret( - convention.signatories.beneficiaryCurrentEmployer.businessSiret, - )} -
- Poste : {convention.signatories.beneficiaryCurrentEmployer.job} -
-
- ) : ( - "" - ), - }, -]; - -const establishmentRepresentativeFields: ColField[] = [ - { - key: "signatories.establishmentRepresentative.signedAt", - colLabel: "SignĂ©", - getValue: (convention) => - signToBooleanDisplay( - convention.signatories.establishmentRepresentative.signedAt, - ), - }, - { - key: "signatories.establishmentRepresentative.email", - colLabel: "Mail du reprĂ©sentant", - getValue: (convention) => - convention.signatories.establishmentRepresentative - ? renderEmail(convention.signatories.establishmentRepresentative.email) - : "", - copyButton: (convention) => ( - - ), - }, - { - key: "signatories.establishmentRepresentative.phone", - colLabel: "NumĂ©ro de tĂ©lĂ©phone", - }, - { - key: "signatories.establishmentRepresentative.firstName", - colLabel: "PrĂ©nom", - }, - { - key: "signatories.establishmentRepresentative.lastName", - colLabel: "Nom", - }, - { - key: "siret", - colLabel: "Siret", - getValue: (convention) => renderSiret(convention.siret), - }, -]; - -const enterpriseFields: ColField[] = [ - { - key: "businessName", - colLabel: "Entreprise", - }, - { - key: "siret", - colLabel: "Siret", - getValue: (convention) => renderSiret(convention.siret), - }, -]; -const establishmentTutorFields: ColField[] = [ - { - key: "establishmentTutor.email", - colLabel: "Mail du tuteur", - getValue: (convention) => - convention.establishmentTutor - ? renderEmail(convention.establishmentTutor.email) - : "", - copyButton: (convention) => ( - - ), - }, - { - key: "establishmentTutor.phone", - colLabel: "NumĂ©ro de tĂ©lĂ©phone du tuteur", - }, - { - key: "establishmentTutor.firstName", - colLabel: "PrĂ©nom", - }, - { - key: "establishmentTutor.lastName", - colLabel: "PrĂ©nom", - }, - { - key: "establishmentTutor.job", - colLabel: "Poste", - }, -]; - -const agencyFields: ColField[] = [ - { - key: "agencyName", - colLabel: "Nom de la structure", - copyButton: (convention) => ( - - ), - getValue: (convention) => convention.agencyName, - }, - { - key: "dateValidation", - colLabel: "Date de validation", - getValue: (convention) => - match({ - agencyRefersTo: convention.agencyRefersTo, - dateValidation: convention.dateValidation, - dateApproval: convention.dateApproval, - }) - .with( - { agencyRefersTo: P.not(P.nullish), dateApproval: P.not(P.nullish) }, - ({ dateApproval }) => - toDisplayedDate({ date: new Date(dateApproval) }), - ) - .with( - { agencyRefersTo: P.not(P.nullish), dateApproval: undefined }, - () => "", - ) - .with({ dateValidation: undefined }, () => "") - .with({ dateValidation: P.string }, ({ dateValidation }) => - toDisplayedDate({ date: new Date(dateValidation) }), - ) - .exhaustive(), - }, -]; - -const agencyRefersToFields: ColField[] = [ - { - key: "agencyRefersTo.name", - colLabel: "Nom de la structure", - }, - { - key: "dateValidation", - colLabel: "Date de validation", - getValue: (convention) => - convention.dateValidation && convention.agencyRefersTo?.id - ? toDisplayedDate({ date: new Date(convention.dateValidation) }) - : "", - }, -]; - -const immersionPlaceDateFields: ColField[] = [ - { - key: "dateSubmission", - colLabel: "Date de soumission", - getValue: (convention) => - toDisplayedDate({ date: new Date(convention.dateSubmission) }), - }, - { - key: "dateStart", - colLabel: "DĂ©but", - getValue: (convention) => - toDisplayedDate({ date: new Date(convention.dateStart) }), - }, - { - key: "dateEnd", - colLabel: "Fin", - getValue: (convention) => - toDisplayedDate({ date: new Date(convention.dateEnd) }), - }, - { - key: "immersionAddress", - colLabel: "Adresse d'immersion", - }, - { - key: "schedule", - colLabel: "Horaires", - getValue: (convention) => renderSchedule(convention), - }, -]; - -const immersionJobFields: ColField[] = [ - { - key: "immersionAppellation", - colLabel: "MĂ©tier observĂ©", - getValue: (convention) => convention.immersionAppellation.appellationLabel, - }, - { - key: "immersionActivities", - colLabel: "ActivitĂ©s", - }, - { - key: "immersionSkills", - colLabel: "CompĂ©tences Ă©valuĂ©es", - }, - { - key: "immersionAddress", - colLabel: "Adresse d'immersion", - }, - { - key: "immersionObjective", - colLabel: "Objectif", - }, - { - key: "individualProtection", - colLabel: "Protection individuelle", - getValue: (convention) => booleanToCheck(convention.individualProtection), - }, - { - key: "sanitaryPrevention", - colLabel: "Mesures de prĂ©vention sanitaire", - getValue: (convention) => booleanToCheck(convention.sanitaryPrevention), - }, - { - key: "workConditions", - colLabel: "Conditions de travail particuliĂšres", - }, - { - key: "businessAdvantages", - colLabel: "Avantages proposĂ©s par l'entreprise", - }, -]; - -export const sections: FieldsAndTitle[] = [ - { - listTitle: "Signataires", - cols: [ - "", - "Convention signĂ©e ?", - "E-mail", - "TĂ©lĂ©phone", - "PrĂ©nom", - "Nom", - "Infos additionnelles", - ], - rowFields: [ - { - title: "BĂ©nĂ©ficiaire", - fields: beneficiaryFields, - }, - { - title: "Rep. lĂ©gal bĂ©nĂ©ficiaire", - fields: beneficiaryRepresentativeFields, - }, - { - title: "Employeur actuel bĂ©nĂ©ficiaire", - fields: beneficiaryCurrentEmployerFields, - }, - { - title: "Rep. lĂ©gal de l'entreprise", - fields: establishmentRepresentativeFields, - }, - ], - additionalClasses: "fr-table--green-emeraude", - }, - { - listTitle: "Entreprise", - cols: [ - "Entreprise", - "Siret", - "Email tuteur", - "TĂ©lĂ©phone tuteur", - "PrĂ©nom", - "Nom", - "Poste", - ], - rowFields: [ - { - fields: [...enterpriseFields, ...establishmentTutorFields], - }, - ], - additionalClasses: " fr-table--blue-cumulus", - }, - { - listTitle: "Structure", - rowFields: [ - { - fields: agencyFields, - }, - { - fields: agencyRefersToFields, - }, - ], - additionalClasses: "fr-table--layout-fixed fr-table--blue-ecume", - }, - { - listTitle: "Infos sur l'immersion - date et lieu", - rowFields: [ - { - fields: immersionPlaceDateFields, - }, - ], - additionalClasses: "fr-table--green-archipel", - }, - { - listTitle: "Infos sur l'immersion - mĂ©tier", - rowFields: [ - { - fields: immersionJobFields, - }, - ], - additionalClasses: "fr-table--green-archipel", - }, -]; diff --git a/front/src/app/contents/admin/types.ts b/front/src/app/contents/admin/types.ts index ab4229e34d..fcc861f980 100644 --- a/front/src/app/contents/admin/types.ts +++ b/front/src/app/contents/admin/types.ts @@ -1,4 +1,3 @@ -import { ReactNode } from "react"; import { AgencyRefersToInConvention, Beneficiary, @@ -9,13 +8,6 @@ import { EstablishmentTutor, } from "shared"; -export type FieldsAndTitle = { - listTitle: string; - cols?: string[]; - rowFields: RowFields[]; - additionalClasses?: string; -}; - export type ConventionField = | keyof ConventionReadDto | `agencyRefersTo.${keyof AgencyRefersToInConvention}` @@ -25,15 +17,3 @@ export type ConventionField = | `signatories.beneficiaryRepresentative.${keyof BeneficiaryRepresentative}` | `signatories.beneficiaryCurrentEmployer.${keyof BeneficiaryCurrentEmployer}` | `signatories.establishmentRepresentative.${keyof EstablishmentRepresentative}`; - -export type RowFields = { - title?: string; - fields: ColField[]; -}; - -export type ColField = { - key: ConventionField | "additionnalInfos"; - colLabel: string; - getValue?: (convention: ConventionReadDto) => string | ReactNode; - copyButton?: (convention: ConventionReadDto) => string | ReactNode; -} | null; diff --git a/front/src/app/contents/convention/conventionSummary.helpers.tsx b/front/src/app/contents/convention/conventionSummary.helpers.tsx new file mode 100644 index 0000000000..703e68e796 --- /dev/null +++ b/front/src/app/contents/convention/conventionSummary.helpers.tsx @@ -0,0 +1,684 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import React from "react"; +import { CopyButton } from "react-design-system"; +import { + ConventionSummaryField, + ConventionSummarySection, + ConventionSummarySubSection, + conventionSummaryStyles, +} from "react-design-system/src/immersionFacile/components/convention-summary"; +import { + ConventionReadDto, + addressDtoToString, + makeSiretDescriptionLink, + makeWeeklySchedule, + removeEmptyValue, + toDisplayedDate, +} from "shared"; +import { Cx } from "tss-react"; + +const makeSignatoriesSubsections = ( + convention: ConventionReadDto, +): ConventionSummarySubSection[] => { + return removeEmptyValue([ + { + key: "beneficiary", + title: "BĂ©nĂ©ficiaire", + fields: removeEmptyValue([ + convention.signatories.beneficiary.signedAt + ? ({ + key: "beneficiarySignedAt", + value: + convention.signatories.beneficiary.signedAt && + toDisplayedDate({ + date: new Date(convention.signatories.beneficiary.signedAt), + }), + badgeSeverity: convention.signatories.beneficiary.signedAt + ? "success" + : "warning", + } satisfies ConventionSummaryField) + : null, + { + key: "beneficiaryFirstname", + label: "PrĂ©nom", + value: convention.signatories.beneficiary.firstName, + }, + { + key: "beneficiaryLastname", + label: "Nom", + value: convention.signatories.beneficiary.lastName, + }, + { + key: "beneficiaryEmail", + label: "Email", + value: ( + + {convention.signatories.beneficiary.email} + + ), + copyButton: ( + + ), + }, + { + key: "beneficiaryPhone", + label: "TĂ©lĂ©phone", + value: convention.signatories.beneficiary.phone, + }, + ]), + }, + convention.signatories.beneficiaryRepresentative + ? { + key: "beneficiaryRepresentative", + title: "ReprĂ©sentant lĂ©gal du bĂ©nĂ©ficiaire", + fields: removeEmptyValue([ + convention.signatories.beneficiaryRepresentative.signedAt + ? { + key: "beneficiaryRepSignedAt", + value: + convention.signatories.beneficiaryRepresentative.signedAt && + toDisplayedDate({ + date: new Date( + convention.signatories.beneficiaryRepresentative + .signedAt, + ), + }), + badgeSeverity: convention.signatories + .beneficiaryRepresentative.signedAt + ? "success" + : "warning", + } + : null, + { + key: "beneficiaryRepFirstname", + label: "PrĂ©nom", + value: convention.signatories.beneficiaryRepresentative.firstName, + }, + { + key: "beneficiaryRepLastname", + label: "Nom", + value: convention.signatories.beneficiaryRepresentative.lastName, + }, + { + key: "beneficiaryRepEmail", + label: "Email", + value: ( + + {convention.signatories.beneficiaryRepresentative.email} + + ), + copyButton: ( + + ), + }, + { + key: "beneficiaryRepPhone", + label: "TĂ©lĂ©phone", + value: convention.signatories.beneficiaryRepresentative.phone, + }, + ]), + } + : null, + { + key: "establishmentRepresentative", + title: "ReprĂ©sentant de l'entreprise", + fields: removeEmptyValue([ + convention.signatories.establishmentRepresentative.signedAt + ? ({ + key: "establishmentRepSignedAt", + value: + convention.signatories.establishmentRepresentative.signedAt && + toDisplayedDate({ + date: new Date( + convention.signatories.establishmentRepresentative.signedAt, + ), + }), + badgeSeverity: convention.signatories.establishmentRepresentative + .signedAt + ? "success" + : "warning", + } satisfies ConventionSummaryField) + : null, + { + key: "establishmentRepFirstname", + label: "PrĂ©nom", + value: convention.signatories.establishmentRepresentative.firstName, + }, + { + key: "establishmentRepLastname", + label: "Nom", + value: convention.signatories.establishmentRepresentative.lastName, + }, + { + key: "establishmentRepEmail", + label: "Email", + value: ( + + {convention.signatories.establishmentRepresentative.email} + + ), + copyButton: ( + + ), + }, + { + key: "establishmentRepPhone", + label: "TĂ©lĂ©phone", + value: convention.signatories.establishmentRepresentative.phone, + }, + { + key: "establishmentRepSiret", + label: "Siret", + value: renderSiret(convention.siret), + }, + ]), + }, + convention.signatories.beneficiaryCurrentEmployer + ? { + key: "beneficiaryCurrentEmployer", + title: "Employeur actuel du bĂ©nĂ©ficiaire", + fields: removeEmptyValue([ + convention.signatories.beneficiaryCurrentEmployer.signedAt + ? ({ + key: "beneficiaryCurrentEmployerSignedAt", + value: + convention.signatories.beneficiaryCurrentEmployer + .signedAt && + toDisplayedDate({ + date: new Date( + convention.signatories.beneficiaryCurrentEmployer + .signedAt, + ), + }), + badgeSeverity: convention.signatories + .beneficiaryCurrentEmployer.signedAt + ? "success" + : "warning", + } satisfies ConventionSummaryField) + : null, + { + key: "beneficiaryCurrentEmployerFirstname", + label: "PrĂ©nom", + value: + convention.signatories.beneficiaryCurrentEmployer.firstName, + }, + { + key: "beneficiaryCurrentEmployerLastname", + label: "Nom", + value: convention.signatories.beneficiaryCurrentEmployer.lastName, + }, + { + key: "beneficiaryCurrentEmployerEmail", + label: "Email", + value: ( + + {convention.signatories.beneficiaryCurrentEmployer.email} + + ), + copyButton: ( + + ), + }, + { + key: "beneficiaryCurrentEmployerPhone", + label: "TĂ©lĂ©phone", + value: convention.signatories.beneficiaryCurrentEmployer.phone, + }, + { + key: "beneficiaryCurrentEmployerSiret", + label: "Siret", + value: renderSiret( + convention.signatories.beneficiaryCurrentEmployer.businessSiret, + ), + }, + convention.signatories.beneficiaryCurrentEmployer.job + ? { + key: "beneficiaryCurrentEmployerJob", + label: "Poste", + value: convention.signatories.beneficiaryCurrentEmployer.job, + } + : null, + ]), + } + : null, + { + key: "agency", + title: "Structure du bĂ©nĂ©ficiaire", + fields: removeEmptyValue([ + convention.agencyRefersTo && convention.dateApproval + ? ({ + key: "dateApproval", + value: + convention.dateApproval && + toDisplayedDate({ + date: new Date(convention.dateApproval), + }), + badgeSeverity: convention.dateApproval ? "success" : "warning", + } satisfies ConventionSummaryField) + : null, + convention.agencyRefersTo + ? { + key: "agencyWithRefersTo", + label: "Structure d'accompagnement", + value: convention.agencyRefersTo.name, + copyButton: ( + + ), + } + : null, + convention.dateValidation + ? ({ + key: "dateValidation", + value: + convention.dateValidation && + toDisplayedDate({ + date: new Date(convention.dateValidation), + }), + badgeSeverity: convention.dateValidation ? "success" : "warning", + } satisfies ConventionSummaryField) + : null, + { + key: "agencyName", + label: `Prescripteur ${convention.agencyRefersTo ? "liĂ©" : ""}`, + value: convention.agencyName, + copyButton: ( + + ), + }, + ]), + isFullWidthDisplay: true, + hasBackgroundColor: true, + }, + ]); +}; + +const makeBeneficiarySubSections = ( + convention: ConventionReadDto, +): ConventionSummarySubSection[] => { + return [ + { + key: "beneficiairy", + title: "BĂ©nĂ©ficiaire", + fields: removeEmptyValue([ + { + key: "beneficiaryBirthdate", + label: "Date de naissance", + value: convention.signatories.beneficiary.birthdate, + }, + { + key: "beneficiaryRqth", + label: "RQTH", + value: convention.signatories.beneficiary.isRqth ? "Oui" : "Non", + }, + { + key: "beneficiaryFinanciaryHelp", + label: "Aide matĂ©rielle", + value: convention.signatories.beneficiary.financiaryHelp || "Aucune", + }, + convention.internshipKind === "mini-stage-cci" && + convention.signatories.beneficiary.address + ? { + key: "beneficiaryAddress", + label: "Adresse du candidat", + value: + convention.internshipKind === "mini-stage-cci" && + convention.signatories.beneficiary.address && + addressDtoToString(convention.signatories.beneficiary.address), + } + : null, + convention.internshipKind === "mini-stage-cci" && + convention.signatories.beneficiary.levelOfEducation + ? { + key: "beneficiaryLevelOfEducation", + label: "Niveau d'Ă©tudes", + value: convention.signatories.beneficiary.levelOfEducation, + } + : null, + convention.internshipKind === "mini-stage-cci" && + convention.signatories.beneficiary.schoolName + ? { + key: "beneficiarySchoolName", + label: "Établissement", + value: convention.signatories.beneficiary.schoolName, + } + : null, + convention.internshipKind === "mini-stage-cci" && + convention.signatories.beneficiary.schoolPostcode + ? { + key: "beneficiarySchoolName", + label: "Code postal de l'Ă©tablissement", + value: convention.signatories.beneficiary.schoolPostcode, + } + : null, + ]), + }, + { + key: "emergencyContac", + title: "Contact d'urgence", + fields: [ + { + key: "emergencyContactName", + label: "PrĂ©nom et nom", + value: convention.signatories.beneficiary.emergencyContact || "-", + }, + { + key: "emergencyContactEmail", + label: "Email", + value: + convention.signatories.beneficiary.emergencyContactEmail || "-", + }, + { + key: "emergencyContactPhone", + label: "TĂ©lĂ©phone", + value: + convention.signatories.beneficiary.emergencyContactPhone || "-", + }, + ], + }, + ]; +}; + +const makeEstablishmentSubSections = ( + convention: ConventionReadDto, +): ConventionSummarySubSection[] => { + return [ + { + key: "establishment", + title: "Entreprise", + fields: [ + { + key: "businessName", + label: "Nom (raison sociale)", + value: convention.businessName, + }, + { + key: "siret", + label: "Siret", + value: renderSiret(convention.siret), + }, + ], + }, + { + key: "establishmentTutor", + title: "Tuteur", + fields: [ + { + key: "establishmentTutorFirstname", + label: "PrĂ©nom", + value: convention.establishmentTutor.firstName, + }, + { + key: "establishmentTutorLastname", + label: "Nom", + value: convention.establishmentTutor.lastName, + }, + { + key: "establishmentTutorJob", + label: "Poste", + value: convention.establishmentTutor.job, + }, + { + key: "establishmentTutorEmail", + label: "Email", + value: ( + + {convention.establishmentTutor.email} + + ), + copyButton: ( + + ), + }, + { + key: "establishmentTutorPhone", + label: "TĂ©lĂ©phone", + value: convention.establishmentTutor.phone, + }, + ], + }, + ]; +}; + +const makeImmersionSubSections = ( + convention: ConventionReadDto, + cx: Cx, +): ConventionSummarySubSection[] => { + return [ + { + key: "immersionAddress", + isFullWidthDisplay: true, + fields: [ + { + key: "immersionAddress", + label: "Lieu oĂč se fera l'immersion", + value: convention.immersionAddress, + }, + ], + }, + { + key: "immersion", + title: `MĂ©tier observĂ© : ${convention.immersionAppellation.appellationLabel}`, + isFullWidthDisplay: true, + fields: [ + { + key: "immersionObjective", + label: "Objectif", + value: convention.immersionObjective, + }, + { + key: "immersionActivities", + label: "ActivitĂ©s observĂ©es", + value: convention.immersionActivities, + }, + { + key: "immersionSkills", + label: "CompĂ©tences Ă©valuĂ©es", + value: convention.immersionSkills || "-", + }, + { + key: "workConditions", + label: "Conditions de travail (propres au mĂ©tier observĂ©)", + value: convention.workConditions || "-", + }, + { + key: "businessAdvantages", + label: "Avantages proposĂ©s par l'Ă©tablissement d'accueil", + value: convention.businessAdvantages || "-", + }, + ], + }, + { + key: "period", + title: "Emploi du temps", + isFullWidthDisplay: true, + fields: [ + { + key: "dateStart", + label: "Date de dĂ©but", + value: toDisplayedDate({ date: new Date(convention.dateStart) }), + }, + { + key: "dateEnd", + label: "Date de fin", + value: toDisplayedDate({ date: new Date(convention.dateEnd) }), + }, + ], + }, + { + key: "schedule", + fields: [ + { + key: "schedule", + value: printWeekSchedule(convention, cx), + }, + ], + isFullWidthDisplay: true, + hasBackgroundColor: true, + isSchedule: true, + }, + ]; +}; + +const makeAdditionalInformationSubSections = ( + convention: ConventionReadDto, +): ConventionSummarySubSection[] => { + return [ + { + key: "additionalInformation", + isFullWidthDisplay: true, + fields: [ + { + key: "individualProtection", + label: "Protection individuelle", + value: convention.individualProtection + ? `Oui: ${convention.individualProtectionDescription}` + : "Non", + }, + { + key: "sanitaryPreventionDescription", + label: "Mesures de prĂ©vention sanitaire", + value: convention.sanitaryPrevention + ? `Oui: ${convention.sanitaryPreventionDescription}` + : "Non", + }, + ], + }, + ]; +}; + +export const makeConventionSections = ( + convention: ConventionReadDto, + cx: Cx, +): ConventionSummarySection[] => { + return [ + { + title: "Signataires de la convention", + subSections: makeSignatoriesSubsections(convention), + }, + { + title: "Informations sur le bĂ©nĂ©ficiaire", + subSections: makeBeneficiarySubSections(convention), + }, + { + title: "Informations de l'entreprise", + subSections: makeEstablishmentSubSections(convention), + }, + { + title: "Informations sur l'immersion", + subSections: makeImmersionSubSections(convention, cx), + }, + { + title: "Informations complĂ©mentaires", + subSections: makeAdditionalInformationSubSections(convention), + }, + ]; +}; + +const printWeekSchedule = (convention: ConventionReadDto, cx: Cx) => { + const weeklySchedule = makeWeeklySchedule(convention.schedule, { + start: new Date(convention.dateStart), + end: new Date(convention.dateEnd), + }); + return ( +
+ {weeklySchedule.map((week, index) => ( +
+
+ Semaine {index + 1} +
+ {week.period?.start && week.period?.end && ( +
+ Du {toDisplayedDate({ date: week.period.start })} au{" "} + {toDisplayedDate({ date: week.period.end })} +
+ )} + +

+ {week.weeklyHours} heures de travail hebdomadaires +

+
    + {week.schedule.map((daySchedule) => ( +
  • + {daySchedule} +
  • + ))} +
+
+ ))} +
+ ); +}; + +const renderSiret = (siret: string) => ( + + {siret} + +); diff --git a/front/src/app/contents/convention/labelAndSeverityByStatus.ts b/front/src/app/contents/convention/labelAndSeverityByStatus.ts new file mode 100644 index 0000000000..1137172b4c --- /dev/null +++ b/front/src/app/contents/convention/labelAndSeverityByStatus.ts @@ -0,0 +1,43 @@ +import { ConventionStatus } from "shared"; + +export const labelAndSeverityByStatus: Record< + ConventionStatus, + { label: string; color: string } +> = { + ACCEPTED_BY_COUNSELLOR: { + label: "📄 Demande Ă©ligible", + color: "fr-badge--purple-glycine", + }, + ACCEPTED_BY_VALIDATOR: { + label: "✅ Demande validĂ©e", + color: "fr-badge--green-emeraude", + }, + CANCELLED: { + label: "❌ Convention annulĂ©e", + color: "fr-badge--error", + }, + DRAFT: { + label: "📄 Brouillon", + color: "fr-badge--info", + }, + IN_REVIEW: { + label: "📄 Demande Ă  Ă©tudier", + color: "fr-badge--purple-glycine", + }, + PARTIALLY_SIGNED: { + label: "✍ Partiellement signĂ©e", + color: "fr-badge--purple-glycine", + }, + READY_TO_SIGN: { + label: "✍ En cours de signature", + color: "fr-badge--purple-glycine", + }, + REJECTED: { + label: "❌ Demande rejetĂ©e", + color: "fr-badge--error", + }, + DEPRECATED: { + label: "❌ Demande obsolĂšte", + color: "fr-badge--error", + }, +}; diff --git a/front/src/app/contents/forms/convention/textSetup.tsx b/front/src/app/contents/forms/convention/textSetup.tsx index 0cb0bcd609..9b1bd88c8c 100644 --- a/front/src/app/contents/forms/convention/textSetup.tsx +++ b/front/src/app/contents/forms/convention/textSetup.tsx @@ -19,6 +19,8 @@ const immersionTexts = (internshipKind: InternshipKind) => ({ internshipKind === "immersion" ? "VĂ©rifier la demande de convention" : "S’informer sur les mĂ©tiers, dĂ©couvrir l’entreprise", + conventionSummaryDescription: + "Merci d’avoir complĂ©tĂ© la convention. Veuillez vĂ©rifier attentivement les informations ci-dessous avant d’envoyer votre demande de signature.", conventionSignTitle: internshipKind === "immersion" ? "Signer la convention d'immersion" diff --git a/front/src/app/pages/convention/ConventionImmersionPage.tsx b/front/src/app/pages/convention/ConventionImmersionPage.tsx index 9ba7640e7e..48d5fdd05d 100644 --- a/front/src/app/pages/convention/ConventionImmersionPage.tsx +++ b/front/src/app/pages/convention/ConventionImmersionPage.tsx @@ -90,12 +90,17 @@ export const ConventionImmersionPage = ({ } - /> + > +

+ {t.intro.conventionSummaryDescription} +

+ ) } > diff --git a/front/src/app/pages/convention/ConventionMiniStagePage.tsx b/front/src/app/pages/convention/ConventionMiniStagePage.tsx index 7eb9a434cc..ff607760e8 100644 --- a/front/src/app/pages/convention/ConventionMiniStagePage.tsx +++ b/front/src/app/pages/convention/ConventionMiniStagePage.tsx @@ -14,7 +14,12 @@ export const ConventionMiniStagePage = () => { return ( } + vSpacing={3} + pageHeader={ + + {t.intro.conventionSummaryDescription} + + } > diff --git a/front/src/app/pages/convention/ConventionSignPage.tsx b/front/src/app/pages/convention/ConventionSignPage.tsx index 71c8ca96d5..110f592fea 100644 --- a/front/src/app/pages/convention/ConventionSignPage.tsx +++ b/front/src/app/pages/convention/ConventionSignPage.tsx @@ -1,7 +1,13 @@ import { fr } from "@codegouvfr/react-dsfr"; import { Alert } from "@codegouvfr/react-dsfr/Alert"; +import { Badge } from "@codegouvfr/react-dsfr/Badge"; import React, { useEffect } from "react"; -import { Loader, MainWrapper, PageHeader } from "react-design-system"; +import { + ConventionSummary, + Loader, + MainWrapper, + PageHeader, +} from "react-design-system"; import { useDispatch } from "react-redux"; import { ConventionJwtPayload, @@ -10,11 +16,13 @@ import { decodeMagicLinkJwtWithoutSignatureCheck, errors, isSignatory, + toDisplayedDate, } from "shared"; -import { ConventionValidationSection } from "src/app/components/admin/conventions/ConventionValidationDetails"; import { ConventionSignForm } from "src/app/components/forms/convention/ConventionSignForm"; -import { sections } from "src/app/contents/admin/conventionValidation"; +import { makeConventionSections } from "src/app/contents/convention/conventionSummary.helpers"; +import { labelAndSeverityByStatus } from "src/app/contents/convention/labelAndSeverityByStatus"; import { P, match } from "ts-pattern"; +import { useStyles } from "tss-react/dsfr"; import { Route } from "type-route"; import { conventionSlice } from "../../../core-logic/domain/convention/convention.slice"; import { HeaderFooterLayout } from "../../components/layout/HeaderFooterLayout"; @@ -63,6 +71,7 @@ type ConventionSignPageContentProperties = { const ConventionSignPageContent = ({ jwt, }: ConventionSignPageContentProperties): JSX.Element => { + const { cx } = useStyles(); const dispatch = useDispatch(); const { applicationId: conventionId } = decodeMagicLinkJwtWithoutSignatureCheck(jwt); @@ -120,16 +129,15 @@ const ConventionSignPageContent = ({ }, vous la recevrez par email.`} className={fr.cx("fr-mb-5v")} /> - {convention && - sections.map((list, index) => ( - - ))} + {convention && ( + + )}
)) .with({ hasConvention: false }, () => ( @@ -144,68 +152,87 @@ const ConventionSignPageContent = ({ hasConvention: true, }, () => ( - } - > - {convention && ( - <> - {convention.status === "REJECTED" && ( - -

- {t.sign.rejected.detail} -

-

{t.sign.rejected.contact}

- - } - /> - )} - {convention.status === "DRAFT" && ( - - {t.sign.needsModification.detail} -

- } - /> - )} - {convention.status === "DEPRECATED" && ( - + <> + + {labelAndSeverityByStatus[convention.status].label} + + ) + } + /> + } + > + {convention && ( + <> + {convention.status === "REJECTED" && ( + +

+ {t.sign.rejected.detail} +

+

{t.sign.rejected.contact}

+ + } + /> + )} + {convention.status === "DRAFT" && ( + - {t.sign.deprecated.detail} + {t.sign.needsModification.detail}

- {convention.statusJustification ? ( -

- Les raisons sont :{" "} - {convention.statusJustification} + } + /> + )} + {convention.status === "DEPRECATED" && ( + +

+ {t.sign.deprecated.detail}

- ) : null} - - } - /> - )} - {convention.status !== "DRAFT" && - convention.status !== "REJECTED" && - convention.status !== "DEPRECATED" && ( - + Les raisons sont :{" "} + {convention.statusJustification} +

+ ) : null} + + } /> )} - - )} -
+ {convention.status !== "DRAFT" && + convention.status !== "REJECTED" && + convention.status !== "DEPRECATED" && ( + + )} + + )} +
+ ), ) .exhaustive()} diff --git a/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.scss b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.scss new file mode 100644 index 0000000000..6c7f4376f0 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.scss @@ -0,0 +1,74 @@ +@import "../../../config/responsive"; + +.im-convention-summary { + &__section { + border: 1px solid var(--grey-900-175); + + h2 { + @include for-screen-min($bp-md) { + margin-bottom: 0 !important; + } + } + + img { + width: 100px; + } + + &--fluid { + justify-content: space-between; + align-items: center; + } + } + + &__subsection { + border-top: 1px solid var(--grey-900-175); + + @include for-screen-min($bp-md) { + border-top: none; + margin-top: 0; + } + + &--highlighted { + border-top: none; + background-color: var(--background-alt-blue-france), + } + + &__value { + font-weight: 700; + } + + @include for-screen-min($bp-md) { + &__horizontal-separator { + width: 100%; + height: 1px; + background-color: var(--grey-900-175); + } + + &--vertical-separator { + display: flex; + justify-content: space-between; + + &::after { + content: ""; + width: 1px; + height: 100%; + border-right: 1px solid var(--grey-900-175); + } + } + } + + &__schedule { + &__week { + font-weight: 700; + } + + &__day { + text-transform: capitalize; + font-weight: 700; + list-style-type: none; + margin: 0; + padding: 0; + } + } + } +} diff --git a/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.stories.tsx b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.stories.tsx new file mode 100644 index 0000000000..9d6048fb43 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.stories.tsx @@ -0,0 +1,38 @@ +import type { ArgTypes, Meta, StoryObj } from "@storybook/react"; +import { + ConventionSummary, + ConventionSummaryProperties, +} from "./ConventionSummary"; + +const Component = ConventionSummary; +type Story = StoryObj; +const argTypes: Partial> | undefined = {}; + +const componentDescription = ` +Affiche un élément section ayant une bordure et contenant un titre. + +\`\`\`tsx +import { ConventionSummary } from "react-design-system"; +\`\`\` +`; + +export default { + title: "ConventionSummary", + component: Component, + argTypes, + parameters: { + docs: { + description: { + component: componentDescription, + }, + }, + }, +} as Meta; + +export const Default: Story = { + args: { + conventionId: "Titre de la section", + submittedAt: "", + summary: [], + }, +}; diff --git a/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.styles.ts b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.styles.ts new file mode 100644 index 0000000000..07b2f8c0d7 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.styles.ts @@ -0,0 +1,15 @@ +import "./ConventionSummary.scss"; + +export const conventionSummaryStyles = { + section: "im-convention-summary__section", + sectionFluid: "im-convention-summary__section--fluid", + subsection: "im-convention-summary__subsection", + subsectionHighlighted: "im-convention-summary__subsection--highlighted", + subsectionHorizontalSeparator: + "im-convention-summary__subsection__horizontal-separator", + subsectionVerticalSeparator: + "im-convention-summary__subsection--vertical-separator", + subsectionValue: "im-convention-summary__subsection__value", + subsectionScheduleDay: "im-convention-summary__subsection__schedule__day", + subsectionScheduleWeek: "im-convention-summary__subsection__schedule__week", +}; diff --git a/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.tsx b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.tsx new file mode 100644 index 0000000000..2a894040fc --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/convention-summary/ConventionSummary.tsx @@ -0,0 +1,253 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import { Badge } from "@codegouvfr/react-dsfr/Badge"; +import React, { ReactNode } from "react"; +import { useStyles } from "tss-react/dsfr"; +import { CopyButton } from "../copy-button/CopyButton"; +import { conventionSummaryStyles } from "./ConventionSummary.styles"; + +export type ConventionSummaryProperties = { + conventionId?: string; + submittedAt: string; + summary: ConventionSummarySection[]; +}; + +export type ConventionSummarySection = { + title: string; + subSections: ConventionSummarySubSection[]; +}; + +export type ConventionSummarySubSection = { + key: string; + title?: string; + fields: ConventionSummaryField[]; + isFullWidthDisplay?: boolean; + hasBackgroundColor?: boolean; + isSchedule?: boolean; +}; + +export type ConventionSummaryField = { key: string } & ( + | { + label: string; + value: string | ReactNode; + copyButton?: string | ReactNode; + hasBackgroundColor?: boolean; + } + | { + value: string | ReactNode; + badgeSeverity: "success" | "warning"; + } + | { value: ReactNode } +); + +export const ConventionSummary = ({ + conventionId, + submittedAt, + summary, +}: ConventionSummaryProperties) => { + const { cx } = useStyles(); + + const isNextSubsectionFullwidth = ( + subsections: ConventionSummarySubSection[], + index: number, + ): boolean => { + const nextSubsection = subsections.at(index + 1); + + if (!nextSubsection) return false; + return !!nextSubsection.isFullWidthDisplay; + }; + + return ( + <> + {conventionId && ( +
+
+ +
+

Convention

+
Date de soumission : {submittedAt}
+
+ ID : + {conventionId} +
+
+
+ +
+ )} + {summary.map((section) => { + return ( +
+

+ {section.title} +

+
+ {section.subSections.map((subSection, index) => { + return ( + + ); + })} +
+
+ ); + })} + + ); +}; + +const SubSection = ({ + subSection, + index, + isNextSubsectionFullwidth, +}: { + subSection: ConventionSummarySubSection; + index: number; + isNextSubsectionFullwidth: boolean; +}) => { + const { cx } = useStyles(); + + return ( + <> + {shouldDisplayHorizontalSeparator( + index, + !!subSection.hasBackgroundColor, + !!subSection.isFullWidthDisplay, + ) && ( +
+ )} +
+
+ {subSection.title && ( +
{subSection.title}
+ )} + {subSection.isSchedule && } + {!subSection.isSchedule && ( +
+ {subSection.fields.map((field) => { + if ("badgeSeverity" in field) { + return ( +
+ + {field.badgeSeverity === "success" + ? `Signée - Le ${field.value}` + : "Signature en attente"} + +
+ ); + } + return ( +
+ {"label" in field && ( +
+
+ {field.label} +
+
+ {field.value} + {field.copyButton} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ + ); +}; + +const Schedule = ({ fields }: { fields: ConventionSummaryField[] }) => { + return ( + <> + {fields + .filter((field) => !!field) + .map((field) => { + return {field.value}; + })} + + ); +}; + +const shouldDisplayHorizontalSeparator = ( + index: number, + hasBackgroundColor: boolean, + isFullWidthDisplay: boolean, +) => { + if (hasBackgroundColor) return false; + if (isFullWidthDisplay) return true; + return index % 2 === 0; +}; + +const shouldDisplayVerticalSeparator = ( + index: number, + isFullWidthDisplay: boolean, + isNextSubsectionFullWidth: boolean, +) => { + if (isFullWidthDisplay || isNextSubsectionFullWidth) return false; + return index % 2 === 0; +}; diff --git a/libs/react-design-system/src/immersionFacile/components/convention-summary/index.ts b/libs/react-design-system/src/immersionFacile/components/convention-summary/index.ts new file mode 100644 index 0000000000..fa7a609a24 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/convention-summary/index.ts @@ -0,0 +1,2 @@ +export * from "./ConventionSummary"; +export * from "./ConventionSummary.styles"; diff --git a/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.scss b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.scss new file mode 100644 index 0000000000..25a8b34403 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.scss @@ -0,0 +1,5 @@ +.im-copy-button { + &--border { + border: 1px solid var(--grey-900-175); + } +} diff --git a/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.stories.tsx b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.stories.tsx new file mode 100644 index 0000000000..d89845ba55 --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.stories.tsx @@ -0,0 +1,51 @@ +import type { ArgTypes, Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { CopyButton, CopyButtonProperties } from "./CopyButton"; + +const Component = CopyButton; +type Story = StoryObj; +const argTypes: Partial> | undefined = {}; + +const componentDescription = ` +Affiche un bouton qui met un texte dans le presse-papier. + +\`\`\`tsx +import { CopyButton } from "react-design-system"; +\`\`\` +`; + +export default { + title: "CopyButton", + component: Component, + argTypes, + parameters: { + docs: { + description: { + component: componentDescription, + }, + }, + }, +} as Meta; + +export const WithLabel: Story = { + args: { + label: "Copier", + textToCopy: "texte copié", + withIcon: false, + }, +}; + +export const WithIconButNoLabel: Story = { + args: { + textToCopy: "texte copié", + withIcon: true, + }, +}; + +export const WithBorder: Story = { + args: { + textToCopy: "texte copié", + withIcon: true, + withBorder: true, + }, +}; diff --git a/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.styles.ts b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.styles.ts new file mode 100644 index 0000000000..c621d8026c --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.styles.ts @@ -0,0 +1,5 @@ +import "./CopyButton.scss"; + +export default { + copyButtonBorder: "im-copy-button--border", +}; diff --git a/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.tsx b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.tsx new file mode 100644 index 0000000000..b9d212209f --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/copy-button/CopyButton.tsx @@ -0,0 +1,53 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import React, { useState } from "react"; +import { useStyles } from "tss-react/dsfr"; +import Styles from "./CopyButton.styles"; + +export type CopyButtonProperties = { + textToCopy: string; + withBorder?: boolean; +} & ({ label: string; withIcon: boolean } | { withIcon: true }); + +export const CopyButton = (props: CopyButtonProperties) => { + const { cx } = useStyles(); + const [isCopied, setIsCopied] = useState(false); + + const onCopyButtonClick = (stringToCopy: string) => { + navigator.clipboard + .writeText(stringToCopy) + .then(() => { + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 3_000); + }) + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); + }; + + const copyButtonIsDisabled = isCopied; + + const defaultLabel = "label" in props ? props.label : ""; + const copyButtonLabel = isCopied ? "Copié !" : defaultLabel; + + return ( + + ); +}; diff --git a/libs/react-design-system/src/immersionFacile/components/copy-button/index.ts b/libs/react-design-system/src/immersionFacile/components/copy-button/index.ts new file mode 100644 index 0000000000..cca879074a --- /dev/null +++ b/libs/react-design-system/src/immersionFacile/components/copy-button/index.ts @@ -0,0 +1 @@ +export * from "./CopyButton"; diff --git a/libs/react-design-system/src/immersionFacile/components/index.ts b/libs/react-design-system/src/immersionFacile/components/index.ts index 7f400d9b95..446ffae6e6 100644 --- a/libs/react-design-system/src/immersionFacile/components/index.ts +++ b/libs/react-design-system/src/immersionFacile/components/index.ts @@ -2,7 +2,9 @@ export * from "./button-with-submenu"; export * from "./convention-document"; export * from "./convention-form-layout"; export * from "./convention-form-sidebar"; +export * from "./convention-summary"; export * from "./convention-renewed-informations"; +export * from "./copy-button"; export * from "./crisp-chat"; export * from "./discussion-meta"; export * from "./error-notifications"; diff --git a/libs/react-design-system/src/immersionFacile/components/page-header/PageHeader.tsx b/libs/react-design-system/src/immersionFacile/components/page-header/PageHeader.tsx index f5df490596..104e72e550 100644 --- a/libs/react-design-system/src/immersionFacile/components/page-header/PageHeader.tsx +++ b/libs/react-design-system/src/immersionFacile/components/page-header/PageHeader.tsx @@ -9,6 +9,7 @@ export type PageHeaderProps = { children?: React.ReactNode; classes?: Partial>; breadcrumbs?: React.ReactNode; + badge?: React.ReactNode; }; export const PageHeader = ({ @@ -17,6 +18,7 @@ export const PageHeader = ({ children, classes = {}, breadcrumbs, + badge, }: PageHeaderProps) => { const { cx } = useStyles(); return ( @@ -34,6 +36,7 @@ export const PageHeader = ({
{breadcrumbs}
)}
+ {badge}

{title}

diff --git a/playwright/utils/convention.ts b/playwright/utils/convention.ts index 8d9904693b..a0a5b9e844 100644 --- a/playwright/utils/convention.ts +++ b/playwright/utils/convention.ts @@ -320,18 +320,19 @@ export const submitEditConventionForm = async ( .locator(`#${domElementIds.conventionImmersionRoute.submitFormButton}`) .click(); await expect(page.locator(".fr-alert--error")).not.toBeVisible(); - await expectElementToBeVisible(page, ".im-convention-summary"); - await expect( - await page.locator(".im-convention-summary__signatory-section").all(), - ).toHaveLength(4); + await expectElementToBeVisible(page, ".im-convention-summary__section"); await expect( await page - .locator(".im-convention-summary__signatory-section") - .getByText("Représentant du bénéficiaire"), + .locator(".im-convention-summary__section", { + has: page.getByText("Signataires de la convention"), + }) + .getByText("Employeur actuel du bénéficiaire"), ).toBeVisible(); await expect( await page - .locator(".im-convention-summary__signatory-section") + .locator(".im-convention-summary__section", { + has: page.getByText("Signataires de la convention"), + }) .getByText("Employeur actuel"), ).toBeVisible(); @@ -356,11 +357,21 @@ export const confirmCreateConventionFormSubmit = async (page: Page) => { await page.click( `#${domElementIds.conventionImmersionRoute.submitFormButton}`, ); - await expectElementToBeVisible(page, ".im-convention-summary"); - await expect(page.locator(".im-convention-summary")).toBeVisible(); + await expectElementToBeVisible(page, ".im-convention-summary__section"); + await expect( + await page + .locator(".im-convention-summary__section", { + has: page.getByText("Signataires de la convention"), + }) + .getByText("Bénéficiaire", { exact: true }), + ).toBeVisible(); await expect( - await page.locator(".im-convention-summary__signatory-section").all(), - ).toHaveLength(2); + await page + .locator(".im-convention-summary__section", { + has: page.getByText("Signataires de la convention"), + }) + .getByText("Représentant de l'entreprise"), + ).toBeVisible(); await page.click( `#${domElementIds.conventionImmersionRoute.confirmSubmitFormButton}`, ); diff --git a/playwright/utils/utils.ts b/playwright/utils/utils.ts index 6088ca093b..807a0b0f1a 100644 --- a/playwright/utils/utils.ts +++ b/playwright/utils/utils.ts @@ -7,7 +7,7 @@ export const expectElementToBeVisible = async ( page: Page, selector: string, ) => { - const confirmation = await page.locator(selector); + const confirmation = await page.locator(selector).first(); await confirmation.waitFor(); await expect(confirmation).toBeVisible(); }; diff --git a/shared/src/schedule/ScheduleUtils.ts b/shared/src/schedule/ScheduleUtils.ts index 0c86ed54b2..de2d921af9 100644 --- a/shared/src/schedule/ScheduleUtils.ts +++ b/shared/src/schedule/ScheduleUtils.ts @@ -58,6 +58,12 @@ type DatesOfImmersion = { dateEnd: string; }; +type WeeklySchedule = { + schedule: string[]; + period: DateIntervalDto; + weeklyHours: number; +}[]; + export const calculateTotalImmersionHoursFromComplexSchedule = ( complexSchedule: DailyScheduleDto[], ): number => { @@ -81,6 +87,32 @@ export const prettyPrintSchedule = ( .flatMap((week) => makeWeeklyPrettyPrint(week, displayFreeDays, interval)) .join("\n"); +export const makeWeeklySchedule = ( + schedule: ScheduleDto, + interval: DateIntervalDto, +): WeeklySchedule => + makeImmersionTimetable(schedule.complexSchedule, interval) + .filter((weekSchedule) => !!weekSchedule) + .map((weekSchedule) => { + const weekStart = weekSchedule.at(0)?.date; + const weekEnd = weekSchedule.at(weekSchedule.length - 1)?.date; + if (!weekStart || !weekEnd) + throw new Error("Error: schedule should have a weekStart and weekEnd"); + return { + weeklyHours: calculateWeeklyHours(weekSchedule), + period: { + start: new Date(weekStart), + end: new Date(weekEnd), + }, + schedule: weekSchedule.map( + (daySchedule) => + `${ + frenchDayMapping(daySchedule.date).frenchDayName + } : ${prettyPrintDaySchedule(daySchedule.timePeriods)}`, + ), + }; + }); + const reasonableTimePeriods: TimePeriodsDto = [ { start: "08:00", diff --git a/shared/src/utils.ts b/shared/src/utils.ts index ae92ec6e84..a7c4fa948f 100644 --- a/shared/src/utils.ts +++ b/shared/src/utils.ts @@ -57,9 +57,8 @@ export const keys = ( type Falsy = false | 0 | "" | null | undefined; export const filterNotFalsy = (arg: T | Falsy): arg is T => !!arg; -export const removeUndefinedElements = ( - unfilteredList: (T | undefined)[], -): T[] => unfilteredList.filter(filterNotFalsy); +export const removeEmptyValue = (unfilteredList: (T | null)[]): T[] => + unfilteredList.flatMap((element) => (element ? [element] : [])); export const replaceArrayElement = ( original: Array,