Skip to content

Commit 19d407a

Browse files
committed
feat(candidate): add candidacy contestation caducite functionality
- 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.
1 parent 5cf5024 commit 19d407a

File tree

10 files changed

+369
-63
lines changed

10 files changed

+369
-63
lines changed
27.7 KB
Loading
11.1 KB
Loading
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useGraphQlClient } from "@/components/graphql/graphql-client/GraphqlClient";
2+
import { graphql } from "@/graphql/generated";
3+
import { useMutation, useQueryClient } from "@tanstack/react-query";
4+
5+
const CREATE_CONTESTATION = graphql(`
6+
mutation createContestation(
7+
$candidacyId: ID!
8+
$contestationReason: String!
9+
$readyForJuryEstimatedAt: Timestamp!
10+
) {
11+
candidacy_contestation_caducite_create_contestation(
12+
candidacyId: $candidacyId
13+
contestationReason: $contestationReason
14+
readyForJuryEstimatedAt: $readyForJuryEstimatedAt
15+
) {
16+
id
17+
}
18+
}
19+
`);
20+
21+
export const useContestation = () => {
22+
const { graphqlClient } = useGraphQlClient();
23+
const queryClient = useQueryClient();
24+
25+
const { mutateAsync: createContestation } = useMutation({
26+
mutationKey: ["createContestation"],
27+
mutationFn: ({
28+
candidacyId,
29+
contestationReason,
30+
readyForJuryEstimatedAt,
31+
}: {
32+
candidacyId: string;
33+
contestationReason: string;
34+
readyForJuryEstimatedAt: number;
35+
}) =>
36+
graphqlClient.request(CREATE_CONTESTATION, {
37+
candidacyId,
38+
contestationReason,
39+
readyForJuryEstimatedAt,
40+
}),
41+
onSuccess: () => {
42+
queryClient.invalidateQueries({
43+
queryKey: ["candidate"],
44+
});
45+
},
46+
});
47+
48+
return { createContestation };
49+
};
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"use client";
2+
3+
import { useCandidacy } from "@/components/candidacy/candidacy.context";
4+
import { useFeatureFlipping } from "@/components/feature-flipping/featureFlipping";
5+
import { FormButtons } from "@/components/form/form-footer/FormButtons";
6+
import { FormOptionalFieldsDisclaimer } from "@/components/legacy/atoms/FormOptionalFieldsDisclaimer/FormOptionalFieldsDisclaimer";
7+
import { graphqlErrorToast, successToast } from "@/components/toast/toast";
8+
import Button from "@codegouvfr/react-dsfr/Button";
9+
import Input from "@codegouvfr/react-dsfr/Input";
10+
import { zodResolver } from "@hookform/resolvers/zod";
11+
import { format, isBefore } from "date-fns";
12+
import Image from "next/image";
13+
import Link from "next/link";
14+
import { useRouter } from "next/navigation";
15+
import { useCallback, useEffect, useMemo, useState } from "react";
16+
import { useForm } from "react-hook-form";
17+
import { z } from "zod";
18+
import { useContestation } from "./contestation.hooks";
19+
20+
const schema = z
21+
.object({
22+
contestationReason: z
23+
.string()
24+
.trim()
25+
.min(1, "Veuillez indiquer une raison"),
26+
readyForJuryEstimatedAt: z.string().nullable(),
27+
})
28+
.superRefine(({ readyForJuryEstimatedAt }, ctx) => {
29+
if (!readyForJuryEstimatedAt) {
30+
ctx.addIssue({
31+
code: z.ZodIssueCode.custom,
32+
message:
33+
"Veuillez sélectionner une date prévisionnelle de dépôt du dossier de validation",
34+
path: ["readyForJuryEstimatedAt"],
35+
});
36+
} else if (isBefore(new Date(readyForJuryEstimatedAt), new Date())) {
37+
ctx.addIssue({
38+
code: z.ZodIssueCode.custom,
39+
message: "Merci d'indiquer une date postérieure à la date du jour",
40+
path: ["readyForJuryEstimatedAt"],
41+
});
42+
}
43+
});
44+
45+
type ContestationForm = z.infer<typeof schema>;
46+
47+
const HasContestedComponent = () => {
48+
return (
49+
<div className="flex justify-between w-full">
50+
<div className="flex flex-col justify-center">
51+
<h1>Votre contestation est enregistrée</h1>
52+
<p className="text-xl">
53+
Elle a été envoyée à votre certificateur qui y répondra dans les
54+
meilleurs délais.
55+
</p>
56+
<div>
57+
<Link href="/">
58+
<Button data-test="contestation-continue-button">
59+
Retour à l'accueil
60+
</Button>
61+
</Link>
62+
</div>
63+
</div>
64+
<Image
65+
src="/candidat/images/letter-with-sent-icon.png"
66+
alt="Contestation réussie"
67+
width={282}
68+
height={319}
69+
/>
70+
</div>
71+
);
72+
};
73+
74+
export default function ContestationPage() {
75+
const [hasContested, setHasContested] = useState(false);
76+
const router = useRouter();
77+
const { isFeatureActive } = useFeatureFlipping();
78+
const candidacyActualisationFeatureIsActive = isFeatureActive(
79+
"candidacy_actualisation",
80+
);
81+
const { candidacy } = useCandidacy();
82+
const { createContestation } = useContestation();
83+
84+
const defaultValues = useMemo(
85+
() => ({
86+
contestationReason: "",
87+
readyForJuryEstimatedAt: candidacy?.readyForJuryEstimatedAt
88+
? format(new Date(candidacy.readyForJuryEstimatedAt), "yyyy-MM-dd")
89+
: null,
90+
}),
91+
[candidacy?.readyForJuryEstimatedAt],
92+
);
93+
94+
const {
95+
register,
96+
handleSubmit,
97+
reset,
98+
formState: { isDirty, isSubmitting, errors },
99+
} = useForm<ContestationForm>({
100+
resolver: zodResolver(schema),
101+
defaultValues,
102+
});
103+
104+
const handleFormSubmit = async ({
105+
contestationReason,
106+
readyForJuryEstimatedAt,
107+
}: ContestationForm) => {
108+
if (!candidacy?.id || !contestationReason || !readyForJuryEstimatedAt) {
109+
return;
110+
}
111+
try {
112+
await createContestation({
113+
candidacyId: candidacy?.id,
114+
contestationReason,
115+
readyForJuryEstimatedAt: new Date(readyForJuryEstimatedAt).getTime(),
116+
});
117+
successToast("Votre contestation est enregistrée");
118+
setHasContested(true);
119+
} catch (error) {
120+
graphqlErrorToast(error);
121+
}
122+
};
123+
124+
const resetForm = useCallback(
125+
() => reset(defaultValues),
126+
[reset, defaultValues],
127+
);
128+
129+
useEffect(resetForm, [resetForm]);
130+
131+
if (!candidacyActualisationFeatureIsActive) {
132+
router.push("/");
133+
return null;
134+
}
135+
136+
return hasContested ? (
137+
<HasContestedComponent />
138+
) : (
139+
<div className="flex flex-col">
140+
<h1 className="mb-0">Faire une contestation</h1>
141+
<FormOptionalFieldsDisclaimer />
142+
<p className="text-xl mb-12">
143+
Vous souhaitez continuer votre parcours VAE malgré la décision sur votre
144+
recevabilité ? Pour cela, expliquez la raison de votre non-actualisation
145+
puis complétez la date prévisionnelle de dépot de dossier de validation.
146+
</p>
147+
148+
<form
149+
onSubmit={handleSubmit(handleFormSubmit)}
150+
onReset={(e) => {
151+
e.preventDefault();
152+
resetForm();
153+
}}
154+
>
155+
<h2 className="mb-2">Raison de la non-actualisation</h2>
156+
<p className="text-lg">
157+
Une recevabilité n'est plus valable lorsque le candidat ne s'est pas
158+
actualisé. Vous devez expliquer au certificateur la raison qui vous a
159+
empêché de le faire (exemple : congé maternité ou arrêt maladie). Il
160+
pourra vous demander des pièces justificatives à envoyer par mail.
161+
</p>
162+
<Input
163+
label="Raison de la non-actualisation :"
164+
nativeTextAreaProps={register("contestationReason")}
165+
textArea
166+
state={errors.contestationReason ? "error" : "default"}
167+
stateRelatedMessage={errors.contestationReason?.message}
168+
className="mb-10"
169+
data-test="contestation-candidate-confirmation-checkbox"
170+
/>
171+
<h2 className="mb-2">
172+
Date prévisionnelle de dépôt du dossier de validation
173+
</h2>
174+
<p className="text-lg">
175+
La date prévisionnelle de dépôt du dossier de validation est une
176+
simple estimation, elle ne vous engage pas. Elle permet au
177+
certificateur d'avoir une idée du moment où vous aurez fini votre
178+
dossier et d'anticiper l'organisation de votre jury.
179+
</p>
180+
<Input
181+
className="max-w-xs mt-4"
182+
label="Date prévisionnelle"
183+
hintText="Si un accompagnateur a renseigné une date, vous la retrouverez ci-dessous."
184+
nativeInputProps={{
185+
type: "date",
186+
...register("readyForJuryEstimatedAt"),
187+
}}
188+
state={errors.readyForJuryEstimatedAt ? "error" : "default"}
189+
stateRelatedMessage={errors.readyForJuryEstimatedAt?.message}
190+
data-test="contestation-date-input"
191+
/>
192+
<FormButtons
193+
backUrl="/"
194+
formState={{
195+
isDirty,
196+
isSubmitting,
197+
}}
198+
/>
199+
</form>
200+
</div>
201+
);
202+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Button from "@codegouvfr/react-dsfr/Button";
2+
import { addMonths, format } from "date-fns";
3+
import Image from "next/image";
4+
import Link from "next/link";
5+
6+
export const ActualisationWarning = ({
7+
lastActivityDate,
8+
}: {
9+
lastActivityDate: number;
10+
}) => {
11+
// La candidature sera considérée comme caduque après cette date, 6 mois après la dernière actualisation
12+
const thresholdDate = format(addMonths(lastActivityDate, 6), "dd/MM/yyyy");
13+
14+
return (
15+
<div
16+
className="mt-12 flex flex-col gap-4"
17+
data-test="actualisation-warning"
18+
>
19+
<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">
20+
<Image
21+
src="/candidat/images/image-home-character-young-man-glasses.png"
22+
width={132}
23+
height={153}
24+
alt="Homme portant des lunettes"
25+
className="relative hidden -top-28 lg:block lg:top-0 lg:-left-9"
26+
/>
27+
<div className="flex flex-col justify-center px-4 text-justify lg:mt-0 lg:p-0">
28+
<p className="my-0">
29+
<strong>
30+
Actualisez-vous dès maintenant pour que votre recevabilité reste
31+
valable !
32+
</strong>{" "}
33+
Sans actualisation de votre part d'ici le {thresholdDate}, vous ne
34+
pourrez plus continuer votre parcours.
35+
</p>
36+
</div>
37+
</div>
38+
<Link href="/actualisation" className="self-end">
39+
<Button data-test="actualisation-warning-button">S'actualiser</Button>
40+
</Link>
41+
</div>
42+
);
43+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Button from "@codegouvfr/react-dsfr/Button";
2+
import Image from "next/image";
3+
import Link from "next/link";
4+
5+
export const CaduqueWarning = () => (
6+
<div className="mt-12 flex flex-col gap-4" data-test="caduque-warning">
7+
<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">
8+
<Image
9+
src="/candidat/images/image-warning-hand.png"
10+
width={132}
11+
height={153}
12+
alt="Main levée en signe d'avertissement"
13+
className="relative hidden -top-28 lg:block lg:top-0 lg:-left-9"
14+
/>
15+
<div className="flex flex-col justify-center px-4 text-justify lg:mt-0 lg:p-0">
16+
<p className="my-0">
17+
Parce que vous ne vous êtes pas actualisé à temps, votre recevabilité
18+
n'est plus valable. Cela signifie que votre parcours VAE s'arrête ici.
19+
Si vous souhaitez contester cette décision, cliquez sur le bouton
20+
“Contester”.
21+
</p>
22+
</div>
23+
</div>
24+
<Link href="/contestation" className="self-end">
25+
<Button data-test="caduque-warning-button">Contester</Button>
26+
</Link>
27+
</div>
28+
);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ActualisationWarning } from "./ActualisationWarning";
2+
import { CaduqueWarning } from "./CaduqueWarning";
3+
import { WelcomeMessage } from "./WelcomeMessage";
4+
5+
export const CandidacyBanner = ({
6+
displayCaduqueWarning,
7+
displayActualisationWarning,
8+
lastActivityDate,
9+
}: {
10+
displayCaduqueWarning: boolean;
11+
displayActualisationWarning: boolean;
12+
lastActivityDate: number;
13+
}) => {
14+
if (displayCaduqueWarning) {
15+
return <CaduqueWarning />;
16+
}
17+
18+
if (displayActualisationWarning) {
19+
return <ActualisationWarning lastActivityDate={lastActivityDate} />;
20+
}
21+
22+
return <WelcomeMessage />;
23+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const WelcomeMessage = () => (
2+
<p
3+
className="max-w-xl my-4 pr-6 text-dsfrGray-500 text-base"
4+
data-test="welcome-message"
5+
>
6+
Bienvenue sur votre espace ! Toutes les étapes et informations relatives à
7+
votre parcours VAE se trouvent ici.
8+
</p>
9+
);

0 commit comments

Comments
 (0)