diff --git a/packages/reva-candidate/public/images/image-warning-hand.png b/packages/reva-candidate/public/images/image-warning-hand.png new file mode 100644 index 000000000..d44027d65 Binary files /dev/null and b/packages/reva-candidate/public/images/image-warning-hand.png differ diff --git a/packages/reva-candidate/public/images/letter-with-sent-icon.png b/packages/reva-candidate/public/images/letter-with-sent-icon.png new file mode 100644 index 000000000..9bd2689e2 Binary files /dev/null and b/packages/reva-candidate/public/images/letter-with-sent-icon.png differ diff --git a/packages/reva-candidate/src/app/(candidate)/contestation/contestation.hooks.ts b/packages/reva-candidate/src/app/(candidate)/contestation/contestation.hooks.ts new file mode 100644 index 000000000..49e16aaf7 --- /dev/null +++ b/packages/reva-candidate/src/app/(candidate)/contestation/contestation.hooks.ts @@ -0,0 +1,49 @@ +import { useGraphQlClient } from "@/components/graphql/graphql-client/GraphqlClient"; +import { graphql } from "@/graphql/generated"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +const CREATE_CONTESTATION = graphql(` + mutation createContestation( + $candidacyId: ID! + $contestationReason: String! + $readyForJuryEstimatedAt: Timestamp! + ) { + candidacy_contestation_caducite_create_contestation( + candidacyId: $candidacyId + contestationReason: $contestationReason + readyForJuryEstimatedAt: $readyForJuryEstimatedAt + ) { + id + } + } +`); + +export const useContestation = () => { + const { graphqlClient } = useGraphQlClient(); + const queryClient = useQueryClient(); + + const { mutateAsync: createContestation } = useMutation({ + mutationKey: ["createContestation"], + mutationFn: ({ + candidacyId, + contestationReason, + readyForJuryEstimatedAt, + }: { + candidacyId: string; + contestationReason: string; + readyForJuryEstimatedAt: number; + }) => + graphqlClient.request(CREATE_CONTESTATION, { + candidacyId, + contestationReason, + readyForJuryEstimatedAt, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["candidate"], + }); + }, + }); + + return { createContestation }; +}; diff --git a/packages/reva-candidate/src/app/(candidate)/contestation/page.tsx b/packages/reva-candidate/src/app/(candidate)/contestation/page.tsx new file mode 100644 index 000000000..ce8ee428f --- /dev/null +++ b/packages/reva-candidate/src/app/(candidate)/contestation/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useCandidacy } from "@/components/candidacy/candidacy.context"; +import { useFeatureFlipping } from "@/components/feature-flipping/featureFlipping"; +import { FormButtons } from "@/components/form/form-footer/FormButtons"; +import { FormOptionalFieldsDisclaimer } from "@/components/legacy/atoms/FormOptionalFieldsDisclaimer/FormOptionalFieldsDisclaimer"; +import { graphqlErrorToast, successToast } from "@/components/toast/toast"; +import Button from "@codegouvfr/react-dsfr/Button"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, isBefore } from "date-fns"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useContestation } from "./contestation.hooks"; + +const schema = z + .object({ + contestationReason: z + .string() + .trim() + .min(1, "Veuillez indiquer une raison"), + readyForJuryEstimatedAt: z.string().nullable(), + }) + .superRefine(({ readyForJuryEstimatedAt }, ctx) => { + if (!readyForJuryEstimatedAt) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Veuillez sélectionner une date prévisionnelle de dépôt du dossier de validation", + path: ["readyForJuryEstimatedAt"], + }); + } else if (isBefore(new Date(readyForJuryEstimatedAt), new Date())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Merci d'indiquer une date postérieure à la date du jour", + path: ["readyForJuryEstimatedAt"], + }); + } + }); + +type ContestationForm = z.infer; + +const HasContestedComponent = () => { + return ( +
+
+

Votre contestation est enregistrée

+

+ Elle a été envoyée à votre certificateur qui y répondra dans les + meilleurs délais. +

+
+ + + +
+
+ Contestation réussie +
+ ); +}; + +export default function ContestationPage() { + const [hasContested, setHasContested] = useState(false); + const router = useRouter(); + const { isFeatureActive } = useFeatureFlipping(); + const candidacyActualisationFeatureIsActive = isFeatureActive( + "candidacy_actualisation", + ); + const { candidacy } = useCandidacy(); + const { createContestation } = useContestation(); + + const defaultValues = useMemo( + () => ({ + contestationReason: "", + readyForJuryEstimatedAt: candidacy?.readyForJuryEstimatedAt + ? format(new Date(candidacy.readyForJuryEstimatedAt), "yyyy-MM-dd") + : null, + }), + [candidacy?.readyForJuryEstimatedAt], + ); + + const { + register, + handleSubmit, + reset, + formState: { isDirty, isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues, + }); + + const handleFormSubmit = async ({ + contestationReason, + readyForJuryEstimatedAt, + }: ContestationForm) => { + if (!candidacy?.id || !contestationReason || !readyForJuryEstimatedAt) { + return; + } + try { + await createContestation({ + candidacyId: candidacy?.id, + contestationReason, + readyForJuryEstimatedAt: new Date(readyForJuryEstimatedAt).getTime(), + }); + successToast("Votre contestation est enregistrée"); + setHasContested(true); + } catch (error) { + graphqlErrorToast(error); + } + }; + + const resetForm = useCallback( + () => reset(defaultValues), + [reset, defaultValues], + ); + + useEffect(resetForm, [resetForm]); + + if (!candidacyActualisationFeatureIsActive) { + router.push("/"); + return null; + } + + return hasContested ? ( + + ) : ( +
+

Faire une contestation

+ +

+ Vous souhaitez continuer votre parcours VAE malgré la décision sur votre + recevabilité ? Pour cela, expliquez la raison de votre non-actualisation + puis complétez la date prévisionnelle de dépot de dossier de validation. +

+ +
{ + e.preventDefault(); + resetForm(); + }} + > +

Raison de la non-actualisation

+

+ Une recevabilité n'est plus valable lorsque le candidat ne s'est pas + actualisé. Vous devez expliquer au certificateur la raison qui vous a + empêché de le faire (exemple : congé maternité ou arrêt maladie). Il + pourra vous demander des pièces justificatives à envoyer par mail. +

+ +

+ Date prévisionnelle de dépôt du dossier de validation +

+

+ La date prévisionnelle de dépôt du dossier de validation est une + simple estimation, elle ne vous engage pas. Elle permet au + certificateur d'avoir une idée du moment où vous aurez fini votre + dossier et d'anticiper l'organisation de votre jury. +

+ + + +
+ ); +} diff --git a/packages/reva-candidate/src/app/_components/ActualisationWarning.tsx b/packages/reva-candidate/src/app/_components/ActualisationWarning.tsx new file mode 100644 index 000000000..00ed39a13 --- /dev/null +++ b/packages/reva-candidate/src/app/_components/ActualisationWarning.tsx @@ -0,0 +1,43 @@ +import Button from "@codegouvfr/react-dsfr/Button"; +import { addMonths, format } from "date-fns"; +import Image from "next/image"; +import Link from "next/link"; + +export const ActualisationWarning = ({ + lastActivityDate, +}: { + lastActivityDate: number; +}) => { + // La candidature sera considérée comme caduque après cette date, 6 mois après la dernière actualisation + const thresholdDate = format(addMonths(lastActivityDate, 6), "dd/MM/yyyy"); + + return ( +
+
+ Homme portant des lunettes +
+

+ + Actualisez-vous dès maintenant pour que votre recevabilité reste + valable ! + {" "} + Sans actualisation de votre part d'ici le {thresholdDate}, vous ne + pourrez plus continuer votre parcours. +

+
+
+ + + +
+ ); +}; diff --git a/packages/reva-candidate/src/app/_components/CaduqueWarning.tsx b/packages/reva-candidate/src/app/_components/CaduqueWarning.tsx new file mode 100644 index 000000000..17d207383 --- /dev/null +++ b/packages/reva-candidate/src/app/_components/CaduqueWarning.tsx @@ -0,0 +1,28 @@ +import Button from "@codegouvfr/react-dsfr/Button"; +import Image from "next/image"; +import Link from "next/link"; + +export const CaduqueWarning = () => ( +
+
+ Main levée en signe d'avertissement +
+

+ Parce que vous ne vous êtes pas actualisé à temps, votre recevabilité + n'est plus valable. Cela signifie que votre parcours VAE s'arrête ici. + Si vous souhaitez contester cette décision, cliquez sur le bouton + “Contester”. +

+
+
+ + + +
+); diff --git a/packages/reva-candidate/src/app/_components/CandidacyBanner.tsx b/packages/reva-candidate/src/app/_components/CandidacyBanner.tsx new file mode 100644 index 000000000..8c3773b1e --- /dev/null +++ b/packages/reva-candidate/src/app/_components/CandidacyBanner.tsx @@ -0,0 +1,23 @@ +import { ActualisationWarning } from "./ActualisationWarning"; +import { CaduqueWarning } from "./CaduqueWarning"; +import { WelcomeMessage } from "./WelcomeMessage"; + +export const CandidacyBanner = ({ + displayCaduqueWarning, + displayActualisationWarning, + lastActivityDate, +}: { + displayCaduqueWarning: boolean; + displayActualisationWarning: boolean; + lastActivityDate: number; +}) => { + if (displayCaduqueWarning) { + return ; + } + + if (displayActualisationWarning) { + return ; + } + + return ; +}; diff --git a/packages/reva-candidate/src/app/_components/WelcomeMessage.tsx b/packages/reva-candidate/src/app/_components/WelcomeMessage.tsx new file mode 100644 index 000000000..f10e4a43e --- /dev/null +++ b/packages/reva-candidate/src/app/_components/WelcomeMessage.tsx @@ -0,0 +1,9 @@ +export const WelcomeMessage = () => ( +

+ Bienvenue sur votre espace ! Toutes les étapes et informations relatives à + votre parcours VAE se trouvent ici. +

+); diff --git a/packages/reva-candidate/src/app/page.tsx b/packages/reva-candidate/src/app/page.tsx index 86c38913e..d968e4583 100644 --- a/packages/reva-candidate/src/app/page.tsx +++ b/packages/reva-candidate/src/app/page.tsx @@ -7,60 +7,9 @@ import { ProjectTimeline } from "@/components/legacy/organisms/ProjectTimeline/P import { useCandidacy } from "@/components/candidacy/candidacy.context"; import { useFeatureFlipping } from "@/components/feature-flipping/featureFlipping"; -import Button from "@codegouvfr/react-dsfr/Button"; -import { addMonths, format, isAfter, subWeeks } from "date-fns"; -import Image from "next/image"; -import Link from "next/link"; +import { addMonths, isAfter, subWeeks } from "date-fns"; import { useRouter } from "next/navigation"; - -const WelcomeMessage = () => ( -

- Bienvenue sur votre espace ! Toutes les étapes et informations relatives à - votre parcours VAE se trouvent ici. -

-); - -const ActualisationWarning = ({ - lastActivityDate, -}: { - lastActivityDate: number; -}) => { - // La candidature sera considérée comme caduque après cette date, 6 mois après la dernière actualisation - const thresholdDate = format(addMonths(lastActivityDate, 6), "dd/MM/yyyy"); - - return ( -
-
- Homme portant des lunettes -
-

- - Actualisez-vous dès maintenant pour que votre recevabilité reste - valable ! - {" "} - Sans actualisation de votre part d'ici le {thresholdDate}, vous ne - pourrez plus continuer votre parcours. -

-
-
- - - -
- ); -}; +import { CandidacyBanner } from "./_components/CandidacyBanner"; export default function Home() { const { candidate, candidacy } = useCandidacy(); @@ -86,12 +35,17 @@ export default function Home() { lastActiveStatus === "DOSSIER_FAISABILITE_RECEVABLE" || lastActiveStatus === "DOSSIER_DE_VALIDATION_SIGNALE"; - const displayActualisationWarning = + const displayActualisationWarning = !!( candidacy?.lastActivityDate && candidacy?.feasibility?.decision === "ADMISSIBLE" && candidacyActualisationFeatureIsActive && shouldDisplayActualisationWarning && - isLastActiveStatusValidForActualisationWarning; + isLastActiveStatusValidForActualisationWarning + ); + + const displayCaduqueWarning = !!( + candidacy?.isCaduque && candidacyActualisationFeatureIsActive + ); return ( @@ -102,14 +56,11 @@ export default function Home() { firstname={candidate.firstname} lastname={candidate.lastname} /> - {displayActualisationWarning ? ( - - ) : ( - - )} - + ); diff --git a/packages/reva-candidate/src/components/candidacy/candidacy.context.tsx b/packages/reva-candidate/src/components/candidacy/candidacy.context.tsx index c6ad65faf..eed326d0e 100644 --- a/packages/reva-candidate/src/components/candidacy/candidacy.context.tsx +++ b/packages/reva-candidate/src/components/candidacy/candidacy.context.tsx @@ -57,6 +57,7 @@ const GET_CANDIDATE_WITH_CANDIDACY = graphql(` firstAppointmentOccuredAt lastActivityDate readyForJuryEstimatedAt + isCaduque candidacyDropOut { createdAt }