From 96fd5baf9aba8ed8c83eafca9e71861524768203 Mon Sep 17 00:00:00 2001 From: Artur Rucinski Date: Tue, 12 Dec 2023 12:13:01 +0100 Subject: [PATCH] feat: add declarations page --- .../src/app/(default)/mon-espace/actions.ts | 47 +++++++ .../src/app/(default)/mon-espace/layout.tsx | 4 +- .../mon-espace/mes-declarations/IndexList.tsx | 131 +++++++++++++++++- .../mon-espace/mes-declarations/RepeqList.tsx | 61 ++++++++ .../mes-declarations/SelectSiren.tsx | 80 +++++++++++ .../mon-espace/mes-declarations/page.tsx | 88 ++++++++++-- packages/app/src/app/AuthHeaderItems.tsx | 2 +- 7 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 packages/app/src/app/(default)/mon-espace/actions.ts create mode 100644 packages/app/src/app/(default)/mon-espace/mes-declarations/RepeqList.tsx create mode 100644 packages/app/src/app/(default)/mon-espace/mes-declarations/SelectSiren.tsx diff --git a/packages/app/src/app/(default)/mon-espace/actions.ts b/packages/app/src/app/(default)/mon-espace/actions.ts new file mode 100644 index 0000000000..918ebff12f --- /dev/null +++ b/packages/app/src/app/(default)/mon-espace/actions.ts @@ -0,0 +1,47 @@ +import { declarationRepo, representationEquilibreeRepo } from "@api/core-domain/repo"; +import { GetAllDeclarationsBySiren } from "@api/core-domain/useCases/GetAllDeclarationsBySiren"; +import { GetDeclarationOpmcBySirenAndYear } from "@api/core-domain/useCases/GetDeclarationOpmcBySirenAndYear"; +import { GetRepresentationEquilibreeBySiren } from "@api/core-domain/useCases/GetRepresentationEquilibreeBySiren"; +import { assertServerSession } from "@api/utils/auth"; + +export async function getAllDeclarationsBySiren(siren: string) { + await assertServerSession({ + owner: { + check: siren, + message: "Not authorized to fetch declarations for this siren.", + }, + staff: true, + }); + + // handle default errors + const useCase = new GetAllDeclarationsBySiren(declarationRepo); + return await useCase.execute({ siren }); +} + +export async function getAllRepresentationEquilibreeBySiren(siren: string) { + await assertServerSession({ + owner: { + check: siren, + message: "Not authorized to fetch representation equilibree for this siren.", + }, + staff: true, + }); + + // handle default errors + const useCase = new GetRepresentationEquilibreeBySiren(representationEquilibreeRepo); + return await useCase.execute({ siren }); +} + +export async function getAllDeclarationOpmcSirenAndYear(siren: string, year: number) { + await assertServerSession({ + owner: { + check: siren, + message: "Not authorized to fetch representation equilibree for this siren.", + }, + staff: true, + }); + + // handle default errors + const useCase = new GetDeclarationOpmcBySirenAndYear(declarationRepo); + return await useCase.execute({ siren, year }); +} diff --git a/packages/app/src/app/(default)/mon-espace/layout.tsx b/packages/app/src/app/(default)/mon-espace/layout.tsx index bf02f0477e..f5ded216ad 100644 --- a/packages/app/src/app/(default)/mon-espace/layout.tsx +++ b/packages/app/src/app/(default)/mon-espace/layout.tsx @@ -1,5 +1,5 @@ import { fr } from "@codegouvfr/react-dsfr"; -import { Box, CenteredContainer } from "@design-system"; +import { Box, Container } from "@design-system"; import { type PropsWithChildren } from "react"; const DEFAULT_TITLE = "Mon Espace"; @@ -7,7 +7,7 @@ const DEFAULT_TITLE = "Mon Espace"; const MonEspaceLayout = ({ children }: PropsWithChildren) => { return ( - {children} + {children} ); }; diff --git a/packages/app/src/app/(default)/mon-espace/mes-declarations/IndexList.tsx b/packages/app/src/app/(default)/mon-espace/mes-declarations/IndexList.tsx index 49dfcb7993..2e5d0db796 100644 --- a/packages/app/src/app/(default)/mon-espace/mes-declarations/IndexList.tsx +++ b/packages/app/src/app/(default)/mon-espace/mes-declarations/IndexList.tsx @@ -1,7 +1,134 @@ -export const IndexList = () => { +"use client"; + +import Table from "@codegouvfr/react-dsfr/Table"; +import { CompanyWorkforceRange } from "@common/core-domain/domain/valueObjects/declaration/CompanyWorkforceRange"; +import { type DeclarationDTO } from "@common/core-domain/dtos/DeclarationDTO"; +import { type DeclarationOpmcDTO } from "@common/core-domain/dtos/DeclarationOpmcDTO"; +import { formatIsoToFr } from "@common/utils/date"; +import { Heading, Link } from "@design-system"; +import { isBefore, sub } from "date-fns"; +import { capitalize, upperCase } from "lodash"; + +import { buildHelpersObjectifsMesures } from "../../index-egapro/objectifs-mesures/[siren]/[year]/ObjectifsMesuresForm"; + +//Note: For 2022, first year of OPMC, we consider that the duration to be frozen is 2 years, but for next years, it will be 1 year like isFrozenDeclaration. +const OPMC_FROZEN_DURATION = { years: 2 }; + +const isFrozenDeclarationForOPMC = (declaration: DeclarationOpmcDTO) => + declaration?.["declaration-existante"]?.date + ? isBefore(new Date(declaration?.["declaration-existante"]?.date), sub(new Date(), OPMC_FROZEN_DURATION)) + : false; + +enum declarationOpmcStatus { + ALREADY_FILLED = "Déjà renseignés", + COMPLETED = "Renseignés", + INDEX_OVER_85 = "Index supérieur à 85", + NOT_APPLICABLE = "Non applicable", + NOT_MODIFIABLE = "Déclaration non modifiable", + NOT_MODIFIABLE_CORRECT = "Déclaration non modifiable sur données correctes", + NOT_MODIFIABLE_INCORRECT = "Déclaration non modifiable sur données incorrectes", + TO_COMPLETE = "À renseigner", + YEAR_NOT_APPLICABLE = "Année non applicable", +} + +const getDeclarationOpmcStatus = (declaration?: DeclarationOpmcDTO) => { + if (!declaration) return declarationOpmcStatus.NOT_APPLICABLE; + const { after2021, index, initialValuesObjectifsMesures, objectifsMesuresSchema } = + buildHelpersObjectifsMesures(declaration); + + if (!declaration["resultat-global"] || index === undefined) return declarationOpmcStatus.NOT_APPLICABLE; + if (!after2021) return declarationOpmcStatus.YEAR_NOT_APPLICABLE; + if (index >= 85) return declarationOpmcStatus.INDEX_OVER_85; + + try { + objectifsMesuresSchema.parse(initialValuesObjectifsMesures); + if (isFrozenDeclarationForOPMC(declaration)) return declarationOpmcStatus.NOT_MODIFIABLE_CORRECT; + } catch (e) { + if (isFrozenDeclarationForOPMC(declaration)) return declarationOpmcStatus.NOT_MODIFIABLE_INCORRECT; + return declarationOpmcStatus.TO_COMPLETE; + } + return declarationOpmcStatus.COMPLETED; +}; + +const formatDeclarationOpmcStatus = (status: declarationOpmcStatus, siren: string, year: number) => { + const withLink = (text: string) => ( + + {text} + + ); + + switch (status) { + case declarationOpmcStatus.COMPLETED: + return withLink(declarationOpmcStatus.COMPLETED); + case declarationOpmcStatus.INDEX_OVER_85: + return declarationOpmcStatus.INDEX_OVER_85; + case declarationOpmcStatus.NOT_APPLICABLE: + return declarationOpmcStatus.NOT_APPLICABLE; + case declarationOpmcStatus.NOT_MODIFIABLE_CORRECT: + return withLink(declarationOpmcStatus.ALREADY_FILLED); + case declarationOpmcStatus.NOT_MODIFIABLE_INCORRECT: + return withLink(declarationOpmcStatus.NOT_MODIFIABLE); + case declarationOpmcStatus.TO_COMPLETE: + return withLink(declarationOpmcStatus.TO_COMPLETE); + case declarationOpmcStatus.YEAR_NOT_APPLICABLE: + return declarationOpmcStatus.YEAR_NOT_APPLICABLE; + default: + return declarationOpmcStatus.NOT_APPLICABLE; + } +}; + +export const IndexList = ({ + declarations, + declarationOpmcList, +}: { + declarationOpmcList: DeclarationOpmcDTO[]; + declarations: DeclarationDTO[]; +}) => { + const headers = [ + "SIREN", + "ANNÉE INDICATEUR", + "STRUCTURE", + "TRANCHE D'EFFECTIF", + "DATE DE DÉCLARATION", + "INDEX", + "OBJECTIF ET MESURES", + "RÉCAPITULATIF", + ]; + + const data = declarations.map(declaration => { + const rowYear = declaration.commencer?.annéeIndicateurs; + const rowSiren = declaration.commencer?.siren; + return [ + + {declaration.commencer?.siren} + , + rowYear, + declaration.entreprise?.type === "ues" + ? upperCase(declaration.entreprise?.type) + : capitalize(declaration.entreprise?.type), + declaration.entreprise?.tranche ? CompanyWorkforceRange.Label[declaration.entreprise.tranche] : undefined, + formatIsoToFr(declaration["declaration-existante"].date || ""), + declaration["resultat-global"]?.index || NC, + formatDeclarationOpmcStatus( + getDeclarationOpmcStatus( + declarationOpmcList.find(declarationOpmc => declarationOpmc.commencer?.annéeIndicateurs === rowYear), + ), + rowSiren || "", + rowYear || 0, + ), + + Télécharger + , + ]; + }); return (
-

IndexList

+ +
); }; diff --git a/packages/app/src/app/(default)/mon-espace/mes-declarations/RepeqList.tsx b/packages/app/src/app/(default)/mon-espace/mes-declarations/RepeqList.tsx new file mode 100644 index 0000000000..3803241f2c --- /dev/null +++ b/packages/app/src/app/(default)/mon-espace/mes-declarations/RepeqList.tsx @@ -0,0 +1,61 @@ +import Table from "@codegouvfr/react-dsfr/Table"; +import { type RepresentationEquilibreeDTO } from "@common/core-domain/dtos/RepresentationEquilibreeDTO"; +import { formatIsoToFr } from "@common/utils/date"; +import { Heading, Link } from "@design-system"; + +export const RepeqList = ({ + representationEquilibrees, +}: { + representationEquilibrees: RepresentationEquilibreeDTO[]; +}) => { + const headers = [ + "SIREN", + "ANNÉE ÉCARTS", + "DATE DE DÉCLARATION", + "% FEMMES CADRES", + "% HOMMES CADRES", + "% FEMMES MEMBRES", + "% HOMMES MEMBRES", + "RÉCAPITULATIF", + ]; + + function getPercent( + filterReasonKey: string, + percentKey: string, + representationEquilibree: RepresentationEquilibreeDTO, + ) { + return ( + (!(filterReasonKey in representationEquilibree) && + representationEquilibree[percentKey as keyof RepresentationEquilibreeDTO]?.toString()) || + "NC" + ); + } + + const data = representationEquilibrees.map(representationEquilibree => [ + + {representationEquilibree.siren} + , + representationEquilibree.year, + formatIsoToFr(representationEquilibree.declaredAt), + getPercent("notComputableReasonExecutives", "executiveWomenPercent", representationEquilibree), + getPercent("notComputableReasonExecutives", "executiveMenPercent", representationEquilibree), + getPercent("notComputableReasonMembers", "memberWomenPercent", representationEquilibree), + getPercent("notComputableReasonMembers", "memberMenPercent", representationEquilibree), + + Télécharger + , + ]); + return ( +
+ +
+
+ ); +}; diff --git a/packages/app/src/app/(default)/mon-espace/mes-declarations/SelectSiren.tsx b/packages/app/src/app/(default)/mon-espace/mes-declarations/SelectSiren.tsx new file mode 100644 index 0000000000..ee740fccc2 --- /dev/null +++ b/packages/app/src/app/(default)/mon-espace/mes-declarations/SelectSiren.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { fr } from "@codegouvfr/react-dsfr"; +import SelectNext from "@codegouvfr/react-dsfr/SelectNext"; +import { Grid, GridCol } from "@design-system"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { first } from "lodash"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +export const SelectSiren = ({ + sirenListWithCompanyName, + currentSiren, +}: { + currentSiren?: string; + sirenListWithCompanyName: Array<{ companyName: string; siren: string }>; +}) => { + const router = useRouter(); + const sirenSchema = z.object({ + siren: z + .string() + .length(9) + .regex(/^\d+$/) + .refine(siren => sirenList.includes(siren), { + message: "Vous n'avez pas les droits sur ce Siren.", + }), + }); + + type sirenFormType = z.infer; + + const { + register, + handleSubmit, + watch, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(sirenSchema), + }); + + const sirenValue = watch("siren"); + const sirenList = sirenListWithCompanyName.map(({ siren }) => siren); + + const onSubmit = (data: sirenFormType) => { + router.push(`/mon-espace/mes-declarations?siren=${data.siren}`); + }; + + useEffect(() => { + if (isValid && sirenValue !== currentSiren) { + handleSubmit(onSubmit)(); + } + }, [sirenValue, isValid, handleSubmit, onSubmit]); + + return ( +
+ + + ({ + value, + label: value, + selected: currentSiren ? value === currentSiren : value === first(sirenList), + }))} + /> + + +
+ + {sirenListWithCompanyName.find(data => data.siren === sirenValue)?.companyName || ""} +
+
+
+
+ ); +}; diff --git a/packages/app/src/app/(default)/mon-espace/mes-declarations/page.tsx b/packages/app/src/app/(default)/mon-espace/mes-declarations/page.tsx index 9de5e70d24..c3bc52e873 100644 --- a/packages/app/src/app/(default)/mon-espace/mes-declarations/page.tsx +++ b/packages/app/src/app/(default)/mon-espace/mes-declarations/page.tsx @@ -1,20 +1,84 @@ +import { authConfig } from "@api/core-domain/infra/auth/config"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import { type DeclarationOpmcDTO } from "@common/core-domain/dtos/DeclarationOpmcDTO"; +import { type NextServerPageProps } from "@common/utils/next"; +import { Box, Heading } from "@design-system"; import { MessageProvider } from "@design-system/client"; -import { type Session } from "next-auth"; +import { getServerSession } from "next-auth"; +import { + getAllDeclarationOpmcSirenAndYear, + getAllDeclarationsBySiren, + getAllRepresentationEquilibreeBySiren, +} from "../actions"; import { IndexList } from "./IndexList"; +import { RepeqList } from "./RepeqList"; +import { SelectSiren } from "./SelectSiren"; -const canEditSiren = (user?: Session["user"]) => (siren?: string) => { - if (!siren || !user) return undefined; - return user.staff || user.companies.some(company => company.siren === siren); -}; +const InfoText = () => ( + <> +

+ Dans ce menu, vous avez accès à la liste des déclarations de l’index de l’égalité professionnelle et, si vous êtes + assujetti, de la représentation équilibrée qui ont été transmises à l’administration, en sélectionnant au + préalable dans la liste déroulante le numéro Siren de l'entreprise (ou de l’entreprise ayant déclaré l'index pour + le compte de l’unité économique et sociale) concernée si vous gérez plusieurs entreprises. +

+
+

+ Vous pouvez ainsi télécharger le récapitulatif de la déclaration à la colonne « RÉCAPITULATIF », et en + cliquant sur le Siren, vous accédez à la déclaration transmise. A la colonne « OBJECTIFS ET MESURES », vous + avez accès à la déclaration des mesures de correction lorsque l’index est inférieur à 75 points et des objectifs + de progression lorsque l’index est inférieur à 85 points. +

+ +); + +const MesDeclarationsPage = async ({ searchParams }: NextServerPageProps) => { + const session = await getServerSession(authConfig); + const sirenList = (session?.user.companies || []).map(company => company.siren); + if (!sirenList.length) return null; + const selectedSiren = typeof searchParams.siren === "string" ? searchParams.siren : sirenList[0]; + + try { + const declarations = await getAllDeclarationsBySiren(selectedSiren); + const repEq = await getAllRepresentationEquilibreeBySiren(selectedSiren); + const sirenWithCompanyName = sirenList.map(siren => ({ + siren: siren, + companyName: + declarations.find(company => company.commencer?.siren === siren)?.entreprise?.entrepriseDéclarante + ?.raisonSociale || "", + })); + const declarationOpmcList: DeclarationOpmcDTO[] = []; + for (const declaration of declarations) { + if (declaration.commencer?.annéeIndicateurs) { + const result = await getAllDeclarationOpmcSirenAndYear( + declaration.commencer?.siren || "", + declaration.commencer?.annéeIndicateurs || 0, + ); + if (result) { + declarationOpmcList.push(result); + } + } + } -const MesDeclarationsPage = async () => { - return ( - - - {/* */} - - ); + return ( + + + } /> + + + + + + + + + + + ); + } catch { + return null; + } }; export default MesDeclarationsPage; diff --git a/packages/app/src/app/AuthHeaderItems.tsx b/packages/app/src/app/AuthHeaderItems.tsx index dfac05d0c7..278041fdc7 100644 --- a/packages/app/src/app/AuthHeaderItems.tsx +++ b/packages/app/src/app/AuthHeaderItems.tsx @@ -18,7 +18,7 @@ export const UserHeaderItem = () => { quickAccessItem={{ iconId: isStaff ? "fr-icon-github-line" : "fr-icon-account-fill", text: `${session.data.user.email}${isStaff ? " (staff)" : ""}`, - linkProps: { href: "/index-egapro/tableauDeBord/mon-profil" }, + linkProps: { href: "/mon-espace/mon-profil" }, }} /> );