Skip to content

Commit

Permalink
feat(candidate): add candidacy contestation caducite functionality
Browse files Browse the repository at this point in the history
- Introduced new contestation hooks and page for candidates to submit contestations regarding their candidacy status.
- Added CandidacyBanner component to manage and display different messages based on candidacy status (actualisation warning, caduque warning, and welcome message).
- Created ActualisationWarning and CaduqueWarning components to provide specific alerts to users.
- Updated candidacy context to include isCaduque field.
  • Loading branch information
ThomasDos committed Dec 11, 2024
1 parent 5cf5024 commit 19d407a
Show file tree
Hide file tree
Showing 10 changed files with 369 additions and 63 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 };
};
202 changes: 202 additions & 0 deletions packages/reva-candidate/src/app/(candidate)/contestation/page.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

const HasContestedComponent = () => {
return (
<div className="flex justify-between w-full">
<div className="flex flex-col justify-center">
<h1>Votre contestation est enregistrée</h1>
<p className="text-xl">
Elle a été envoyée à votre certificateur qui y répondra dans les
meilleurs délais.
</p>
<div>
<Link href="/">
<Button data-test="contestation-continue-button">
Retour à l'accueil
</Button>
</Link>
</div>
</div>
<Image
src="/candidat/images/letter-with-sent-icon.png"
alt="Contestation réussie"
width={282}
height={319}
/>
</div>
);
};

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<ContestationForm>({
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 ? (
<HasContestedComponent />
) : (
<div className="flex flex-col">
<h1 className="mb-0">Faire une contestation</h1>
<FormOptionalFieldsDisclaimer />
<p className="text-xl mb-12">
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.
</p>

<form
onSubmit={handleSubmit(handleFormSubmit)}
onReset={(e) => {
e.preventDefault();
resetForm();
}}
>
<h2 className="mb-2">Raison de la non-actualisation</h2>
<p className="text-lg">
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.
</p>
<Input
label="Raison de la non-actualisation :"
nativeTextAreaProps={register("contestationReason")}
textArea
state={errors.contestationReason ? "error" : "default"}
stateRelatedMessage={errors.contestationReason?.message}
className="mb-10"
data-test="contestation-candidate-confirmation-checkbox"
/>
<h2 className="mb-2">
Date prévisionnelle de dépôt du dossier de validation
</h2>
<p className="text-lg">
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.
</p>
<Input
className="max-w-xs mt-4"
label="Date prévisionnelle"
hintText="Si un accompagnateur a renseigné une date, vous la retrouverez ci-dessous."
nativeInputProps={{
type: "date",
...register("readyForJuryEstimatedAt"),
}}
state={errors.readyForJuryEstimatedAt ? "error" : "default"}
stateRelatedMessage={errors.readyForJuryEstimatedAt?.message}
data-test="contestation-date-input"
/>
<FormButtons
backUrl="/"
formState={{
isDirty,
isSubmitting,
}}
/>
</form>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="mt-12 flex flex-col gap-4"
data-test="actualisation-warning"
>
<div className="static w-full border-b-[4px] border-b-[#FFA180] px-8 py-8 shadow-[0px_6px_18px_0px_rgba(0,0,18,0.16)] flex flex-col items-center text-start lg:relative lg:h-[85px] lg:flex-row">
<Image
src="/candidat/images/image-home-character-young-man-glasses.png"
width={132}
height={153}
alt="Homme portant des lunettes"
className="relative hidden -top-28 lg:block lg:top-0 lg:-left-9"
/>
<div className="flex flex-col justify-center px-4 text-justify lg:mt-0 lg:p-0">
<p className="my-0">
<strong>
Actualisez-vous dès maintenant pour que votre recevabilité reste
valable !
</strong>{" "}
Sans actualisation de votre part d'ici le {thresholdDate}, vous ne
pourrez plus continuer votre parcours.
</p>
</div>
</div>
<Link href="/actualisation" className="self-end">
<Button data-test="actualisation-warning-button">S'actualiser</Button>
</Link>
</div>
);
};
28 changes: 28 additions & 0 deletions packages/reva-candidate/src/app/_components/CaduqueWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Button from "@codegouvfr/react-dsfr/Button";
import Image from "next/image";
import Link from "next/link";

export const CaduqueWarning = () => (
<div className="mt-12 flex flex-col gap-4" data-test="caduque-warning">
<div className="static w-full border-b-[4px] border-b-[#FFA180] px-8 py-8 shadow-[0px_6px_18px_0px_rgba(0,0,18,0.16)] flex flex-col items-center text-start lg:relative lg:h-[85px] lg:flex-row">
<Image
src="/candidat/images/image-warning-hand.png"
width={132}
height={153}
alt="Main levée en signe d'avertissement"
className="relative hidden -top-28 lg:block lg:top-0 lg:-left-9"
/>
<div className="flex flex-col justify-center px-4 text-justify lg:mt-0 lg:p-0">
<p className="my-0">
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”.
</p>
</div>
</div>
<Link href="/contestation" className="self-end">
<Button data-test="caduque-warning-button">Contester</Button>
</Link>
</div>
);
23 changes: 23 additions & 0 deletions packages/reva-candidate/src/app/_components/CandidacyBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 <CaduqueWarning />;
}

if (displayActualisationWarning) {
return <ActualisationWarning lastActivityDate={lastActivityDate} />;
}

return <WelcomeMessage />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const WelcomeMessage = () => (
<p
className="max-w-xl my-4 pr-6 text-dsfrGray-500 text-base"
data-test="welcome-message"
>
Bienvenue sur votre espace ! Toutes les étapes et informations relatives à
votre parcours VAE se trouvent ici.
</p>
);
Loading

0 comments on commit 19d407a

Please sign in to comment.