From b6e37d4698306334802f48ff4a1ac9911ff52c75 Mon Sep 17 00:00:00 2001 From: Benjamin Levesque <14175665+benjlevesque@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:22:34 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9A=A7=20WIP=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atoms/form/AutoFormField.stories.tsx | 73 +++++++++ .../components/atoms/form/AutoFormField.tsx | 151 ++++++++++++++++++ .../src/components/atoms/form/InputDate.tsx | 18 +-- .../applications/ssr/src/utils/formAction.ts | 25 ++- 4 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 packages/applications/ssr/src/components/atoms/form/AutoFormField.stories.tsx create mode 100644 packages/applications/ssr/src/components/atoms/form/AutoFormField.tsx diff --git a/packages/applications/ssr/src/components/atoms/form/AutoFormField.stories.tsx b/packages/applications/ssr/src/components/atoms/form/AutoFormField.stories.tsx new file mode 100644 index 0000000000..f5b14cfacf --- /dev/null +++ b/packages/applications/ssr/src/components/atoms/form/AutoFormField.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta } from '@storybook/react'; +import { z } from 'zod'; + +import { ValidationErrors } from '@/utils/formAction'; + +import { AutoFormFieldProps, makeAutoFormField } from './AutoFormField'; + +const optionalEnum = ( + enumSchema: z.ZodEnum, +) => + z + .union([enumSchema, z.literal(''), z.literal('N/A')]) + .transform((v) => (v === '' || v === 'N/A' ? undefined : v)) + .optional(); + +const schema = z.object({ + requiredStringField: z.string(), + requiredNumberField: z.number(), + requiredDateField: z.date(), + enumField: z.enum(['first', 'second']), + optionalEnumField: optionalEnum(z.enum(['first', 'second'])), + booleanField: z.boolean(), + optionalBooleanField: z.boolean().optional(), +}); + +const value: z.infer = { + requiredStringField: 'foo', + requiredNumberField: 1, + requiredDateField: new Date(), + enumField: 'second', + booleanField: false, +}; + +const errors: ValidationErrors> = { + requiredNumberField: 'bad value', + requiredStringField: 'invalid', + enumField: 'missing', +}; + +const AutoFormField = makeAutoFormField(schema, {}, {}); +const AutoFormFieldWithValue = makeAutoFormField(schema, value, {}); +const AutoFormFieldWithError = makeAutoFormField(schema, value, errors); + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: 'Atoms/Form/AutoFormField', + component: AutoFormField, + parameters: {}, + tags: ['autodocs'], + argTypes: {}, +} satisfies Meta>>; + +export default meta; + +export const RequiredString = () => ; +export const RequiredStringWithValue = () => ; +export const RequiredStringWithError = () => ; + +export const RequiredNumber = () => ; +export const RequiredNumberWithValue = () => ; +export const RequiredNumberWithError = () => ; + +export const RequiredDate = () => ; +export const RequiredDateWithValue = () => ; +export const RequiredDateWithError = () => ; + +export const Enum = () => ; +export const EnumWithValue = () => ; +export const OptionalEnum = () => ; + +export const Boolean = () => ; +export const BooleanWithValue = () => ; +export const OptionalBoolean = () => ; diff --git a/packages/applications/ssr/src/components/atoms/form/AutoFormField.tsx b/packages/applications/ssr/src/components/atoms/form/AutoFormField.tsx new file mode 100644 index 0000000000..06b203fc06 --- /dev/null +++ b/packages/applications/ssr/src/components/atoms/form/AutoFormField.tsx @@ -0,0 +1,151 @@ +import { z } from 'zod'; +import Input from '@codegouvfr/react-dsfr/Input'; +import Select, { SelectProps } from '@codegouvfr/react-dsfr/SelectNext'; +import { HTMLInputTypeAttribute } from 'react'; + +import { Iso8601DateTime } from '@potentiel-libraries/iso8601-datetime'; + +import type { ValidationErrors } from '@/utils/formAction'; + +import { InputDate } from './InputDate'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ZodSchema = z.ZodEffects | z.ZodObject; + +export type AutoFormFieldProps> = { + name: TField; + label?: string; + disabled?: boolean; + value?: string | undefined; +}; + +export const makeAutoFormField = ( + schema: TSchema, + values: Partial>, + errors: ValidationErrors>, +) => { + const AutoFormField = >({ + name, + label, + value, + disabled, + }: AutoFormFieldProps) => { + // TODO restrict TField to string only + if (typeof name !== 'string') { + throw new Error('field name must be a string'); + } + + const element = getZodElement(schema, name); + const isOptional = element.isOptional(); + const props = { + label: label ?? formatLabel(name), + id: name, + state: errors[name] ? ('error' as const) : ('default' as const), + stateRelatedMessage: errors[name], + disabled, + }; + const { type, options } = getProps(element, isOptional); + const defaultValue = value ?? values[name]; + const nativeProps = { + name: encodeURIComponent(name), // NB field name is URI encoded ! must be decoded to read + required: !isOptional, + 'aria-required': !isOptional, + defaultValue: defaultValue !== undefined ? String(defaultValue) : undefined, + type, + }; + + if (type === 'date') { + return ( + + ); + } + + if (options) { + return ; + }; + return AutoFormField; +}; + +function getZodElement(schema: TSchema, name: string) { + if (schema instanceof z.ZodEffects) { + return getZodElement(schema.sourceType(), name); + } + + if (schema instanceof z.ZodOptional) { + return getZodElement(schema.unwrap(), name); + } + + const shape = schema._def.shape() as z.ZodRawShape; + return shape[name]; +} + +const getProps = ( + element: z.ZodTypeAny, + isOptional: boolean, +): { options?: SelectProps.Option[]; type?: HTMLInputTypeAttribute } => { + const options = isOptional ? [{ value: '', label: '' }] : []; + + // ignore effects + if (element instanceof z.ZodEffects) { + return getProps(element.sourceType(), isOptional); + } + + // In case of an optional, get the underlying type + if (element instanceof z.ZodOptional) { + return getProps(element._def.innerType, isOptional); + } + + // In case of a union, get the first item of the union (hacky!) + if (element instanceof z.ZodUnion) { + return getProps((element as z.ZodUnion)._def.options[0], isOptional); + } + // In case of an enum, return its values + if (element instanceof z.ZodEnum) { + return { + options: options.concat( + (element._def.values as string[]).map((o) => ({ + value: o, + label: o + .replaceAll('-', ' ') + .split(' ') + .map((s) => s[0].toUpperCase() + s.slice(1)) + .join(' '), + })), + ), + }; + } + // In case of a boolean, return true, false + if (element instanceof z.ZodBoolean) { + return { + options: options.concat({ value: 'true', label: 'Oui' }, { value: 'false', label: 'Non' }), + }; + } + if (element instanceof z.ZodDate) { + return { + type: 'date', + }; + } + if (element instanceof z.ZodNumber) { + return { type: 'number' }; + } + if (element instanceof z.ZodString && element._def.checks.find((c) => c.kind === 'email')) { + return { type: 'email' }; + } + return {}; +}; + +// uppercase first letter, and split words at each capital +// eg: motifÉlimination => Motif Élimination +const formatLabel = (fieldName: string) => + fieldName[0].toUpperCase() + + fieldName.slice(1).replace(/([a-z\u00E0-\u00FC])?([A-Z\u00C0-\u00DC])/g, '$1 $2'); diff --git a/packages/applications/ssr/src/components/atoms/form/InputDate.tsx b/packages/applications/ssr/src/components/atoms/form/InputDate.tsx index b1d50a1f93..41613a4c49 100644 --- a/packages/applications/ssr/src/components/atoms/form/InputDate.tsx +++ b/packages/applications/ssr/src/components/atoms/form/InputDate.tsx @@ -16,17 +16,15 @@ type InputDateProps = InputProps.RegularInput & { export const InputDate: FC = (props) => { return ( + /> ); }; diff --git a/packages/applications/ssr/src/utils/formAction.ts b/packages/applications/ssr/src/utils/formAction.ts index 1b655f89aa..aaedf8d3b1 100644 --- a/packages/applications/ssr/src/utils/formAction.ts +++ b/packages/applications/ssr/src/utils/formAction.ts @@ -50,28 +50,27 @@ export type FormState = status: 'unknown-error'; }; -export type FormAction< - TState, - TSchema extends - | zod.AnyZodObject - | zod.ZodDiscriminatedUnion = zod.AnyZodObject, -> = (previousState: TState, data: zod.infer) => Promise; +export type FormAction = ( + previousState: TState, + data: zod.infer, +) => Promise; const TWO_SECONDS = 2000; export const formAction = - < - TSchema extends zod.AnyZodObject | zod.ZodDiscriminatedUnion, - TState extends FormState, - >( + ( action: FormAction, schema?: TSchema, ) => async (previousState: TState, formData: FormData) => { try { - const data = schema - ? await schema.parseAsync(Object.fromEntries(formData)) - : Object.fromEntries(formData); + // decode field names that have been uri encoded + const decodedFormData = Object.fromEntries( + Object.entries(Object.fromEntries(formData)).map( + ([key, value]) => [decodeURIComponent(key), value] as const, + ), + ); + const data = schema ? await schema.parseAsync(decodedFormData) : decodedFormData; const result = await action(previousState, data); From 40204df93a4a03a0204fa0f653dcba3ffe8caa4b Mon Sep 17 00:00:00 2001 From: Benjamin Levesque <14175665+benjlevesque@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:29:08 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Formulaire=20de=20modification?= =?UTF-8?q?=20d'une=20candidature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/organisms/UserNavigation.tsx | 3 - .../src/candidature/candidature.routes.ts | 16 +- .../[identifiant]/corriger/page.tsx | 66 +++++++ .../app/candidatures/[identifiant]/page.tsx | 68 +++++++ .../src/app/candidatures/corriger/page.tsx | 2 +- .../molecules/UserBasedRoleNavigation.tsx | 6 - .../candidature/NotificationBadge.tsx | 14 ++ .../projet/ProjectListItemHeading.tsx | 10 +- .../projet/ProjetBanner.template.tsx | 53 ++++++ .../molecules/projet/ProjetBanner.tsx | 36 ++-- .../CorrigerCandidatures.form.tsx | 0 .../CorrigerCandidatures.page.tsx | 0 .../corrigerCandidatures.action.ts | 44 ++--- .../corriger/CorrigerCandidature.form.tsx | 86 +++++++++ .../corriger/CorrigerCandidature.page.tsx | 58 ++++++ .../corriger/CorrigerCandidatures.stories.tsx | 43 +++++ .../corriger/corrigerCandidature.action.ts | 77 ++++++++ .../D\303\251tailsCandidature.page.tsx" | 166 ++++++++++++++++++ .../helpers/getLocalit\303\251.ts" | 11 +- .../importer/ImporterCandidatures.page.tsx | 37 +++- .../importer/candidature.schema.test.ts | 56 +++--- .../importer/candidature.schema.ts | 165 ++++++++++------- .../importer/importerCandidatures.action.ts | 44 ++--- .../lister/CandidatureListItemActions.tsx | 2 +- .../src/statutCandidature.valueType.ts | 2 +- .../src/typeTechnologie.valueType.ts | 2 +- .../valueTypes/identifiantProjet.valueType.ts | 2 +- 27 files changed, 879 insertions(+), 190 deletions(-) create mode 100644 packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx create mode 100644 packages/applications/ssr/src/app/candidatures/[identifiant]/page.tsx create mode 100644 packages/applications/ssr/src/components/molecules/candidature/NotificationBadge.tsx create mode 100644 packages/applications/ssr/src/components/molecules/projet/ProjetBanner.template.tsx rename packages/applications/ssr/src/components/pages/candidature/{corriger => corriger-en-masse}/CorrigerCandidatures.form.tsx (100%) rename packages/applications/ssr/src/components/pages/candidature/{corriger => corriger-en-masse}/CorrigerCandidatures.page.tsx (100%) rename packages/applications/ssr/src/components/pages/candidature/{corriger => corriger-en-masse}/corrigerCandidatures.action.ts (74%) create mode 100644 packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx create mode 100644 packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.page.tsx create mode 100644 packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.stories.tsx create mode 100644 packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts create mode 100644 "packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" diff --git a/packages/applications/legacy/src/views/components/UI/organisms/UserNavigation.tsx b/packages/applications/legacy/src/views/components/UI/organisms/UserNavigation.tsx index b6460d333c..e8d5f8627d 100644 --- a/packages/applications/legacy/src/views/components/UI/organisms/UserNavigation.tsx +++ b/packages/applications/legacy/src/views/components/UI/organisms/UserNavigation.tsx @@ -103,9 +103,6 @@ const MenuAdmin = (currentPage?: string) => ( > Notifier des candidats - - Corriger des candidats - Tous les candidats diff --git a/packages/applications/routes/src/candidature/candidature.routes.ts b/packages/applications/routes/src/candidature/candidature.routes.ts index 87bd83c76d..253c3ba212 100644 --- a/packages/applications/routes/src/candidature/candidature.routes.ts +++ b/packages/applications/routes/src/candidature/candidature.routes.ts @@ -1,7 +1,7 @@ import { encodeParameter } from '../encodeParameter'; export const importer = '/candidatures/importer'; -export const corriger = '/candidatures/corriger'; +export const corrigerEnMasse = '/candidatures/corriger'; type ListerFilters = { appelOffre?: string; @@ -31,9 +31,15 @@ export const lister = (filters?: ListerFilters) => { return `/candidatures${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; }; -export const prévisualiserAttestation = (identifiantProjet: string) => - `/candidatures/${encodeParameter(identifiantProjet)}/previsualiser-attestation`; +const _avecIdentifiant = + (path = '') => + (identifiantProjet: string) => + `/candidatures/${encodeParameter(identifiantProjet)}${path}`; + +export const détails = _avecIdentifiant(); +export const corriger = _avecIdentifiant('/corriger'); + +export const prévisualiserAttestation = _avecIdentifiant('/previsualiser-attestation'); // TODO: à supprimer pour utiliser directement Routes.Document.télécharger dans le front // une fois qu'on aura migré la page Projet -export const téléchargerAttestation = (identifiantProjet: string) => - `/candidatures/${encodeParameter(identifiantProjet)}/telecharger-attestation`; +export const téléchargerAttestation = _avecIdentifiant('/telecharger-attestation'); diff --git a/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx b/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx new file mode 100644 index 0000000000..01a9ad660f --- /dev/null +++ b/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx @@ -0,0 +1,66 @@ +import { Metadata, ResolvingMetadata } from 'next'; +import { ComponentProps } from 'react'; + +import { Candidature } from '@potentiel-domain/candidature'; + +import { IdentifiantParameter } from '@/utils/identifiantParameter'; +import { PageWithErrorHandling } from '@/utils/PageWithErrorHandling'; +import { decodeParameter } from '@/utils/decodeParameter'; +import { CorrigerCandidaturePage } from '@/components/pages/candidature/corriger/CorrigerCandidature.page'; + +import { getCandidature } from '../../_helpers/getCandidature'; + +type PageProps = IdentifiantParameter; + +export async function generateMetadata( + { params }: IdentifiantParameter, + _: ResolvingMetadata, +): Promise { + const identifiantProjet = decodeParameter(params.identifiant); + const candidature = await getCandidature(identifiantProjet); + + return { + title: `Candidature ${candidature.nomProjet} - Potentiel`, + description: 'Modifier la candidature', + }; +} + +export default async function Page({ params }: PageProps) { + return PageWithErrorHandling(async () => { + const identifiantProjet = decodeParameter(params.identifiant); + const candidature = await getCandidature(identifiantProjet); + return ; + }); +} + +const mapToProps = ( + candidature: Candidature.ConsulterCandidatureReadModel, +): ComponentProps => ({ + candidature: { + identifiantProjet: candidature.identifiantProjet.formatter(), + statut: candidature.statut.formatter(), + nomProjet: candidature.nomProjet, + typeGarantiesFinancières: candidature.typeGarantiesFinancières?.type, + historiqueAbandon: candidature.historiqueAbandon.formatter(), + nomCandidat: candidature.nomCandidat, + nomReprésentantLégal: candidature.nomReprésentantLégal, + emailContact: candidature.emailContact, + puissanceProductionAnnuelle: candidature.puissanceProductionAnnuelle, + prixRéference: candidature.prixReference, + technologie: candidature.technologie.formatter(), + sociétéMère: candidature.sociétéMère, + noteTotale: candidature.noteTotale, + motifÉlimination: candidature.motifÉlimination, + puissanceÀLaPointe: candidature.puissanceALaPointe, + evaluationCarboneSimplifiée: candidature.evaluationCarboneSimplifiée, + actionnariat: candidature.actionnariat?.formatter(), + dateÉchéanceGf: candidature.dateÉchéanceGf?.date, + localité: { + adresse1: candidature.localité.adresse1, + adresse2: candidature.localité.adresse2, + codePostal: candidature.localité.codePostal, + commune: candidature.localité.commune, + }, + }, + estNotifiée: candidature.estNotifiée, +}); diff --git a/packages/applications/ssr/src/app/candidatures/[identifiant]/page.tsx b/packages/applications/ssr/src/app/candidatures/[identifiant]/page.tsx new file mode 100644 index 0000000000..c591441d26 --- /dev/null +++ b/packages/applications/ssr/src/app/candidatures/[identifiant]/page.tsx @@ -0,0 +1,68 @@ +import { Metadata, ResolvingMetadata } from 'next'; +import { match } from 'ts-pattern'; + +import { mapToPlainObject } from '@potentiel-domain/core'; +import { Candidature } from '@potentiel-domain/candidature'; +import { Role } from '@potentiel-domain/utilisateur'; + +import { IdentifiantParameter } from '@/utils/identifiantParameter'; +import { PageWithErrorHandling } from '@/utils/PageWithErrorHandling'; +import { decodeParameter } from '@/utils/decodeParameter'; +import { + DétailsCandidaturePage, + DétailsCandidaturePageProps, +} from '@/components/pages/candidature/détails/DétailsCandidature.page'; +import { withUtilisateur } from '@/utils/withUtilisateur'; + +import { getCandidature } from '../_helpers/getCandidature'; + +type PageProps = IdentifiantParameter; + +export async function generateMetadata( + { params }: IdentifiantParameter, + _: ResolvingMetadata, +): Promise { + const identifiantProjet = decodeParameter(params.identifiant); + const candidature = await getCandidature(identifiantProjet); + + return { + title: `Candidature ${candidature.nomProjet} - Potentiel`, + description: 'Détail de la candidature', + }; +} + +export default async function Page({ params }: PageProps) { + return PageWithErrorHandling(async () => + withUtilisateur(async (utilisateur) => { + const identifiantProjet = decodeParameter(params.identifiant); + const candidature = await getCandidature(identifiantProjet); + return ( + + ); + }), + ); +} + +const mapToActions = ( + props: Pick, + role: Role.ValueType, +) => { + const defaultActions = { + corriger: role.aLaPermission('candidature.corriger'), + }; + return match(props) + .returnType() + .with({ estNotifiée: true }, () => ({ + ...defaultActions, + prévisualiserAttestation: false, + téléchargerAttestation: true, + })) + .otherwise(() => ({ + ...defaultActions, + prévisualiserAttestation: role.aLaPermission('candidature.attestation.prévisualiser'), + téléchargerAttestation: false, + })); +}; diff --git a/packages/applications/ssr/src/app/candidatures/corriger/page.tsx b/packages/applications/ssr/src/app/candidatures/corriger/page.tsx index 7f30d3e2e4..86f6b3b165 100644 --- a/packages/applications/ssr/src/app/candidatures/corriger/page.tsx +++ b/packages/applications/ssr/src/app/candidatures/corriger/page.tsx @@ -1,4 +1,4 @@ -import { CorrigerCandidaturesPage } from '@/components/pages/candidature/corriger/CorrigerCandidatures.page'; +import { CorrigerCandidaturesPage } from '@/components/pages/candidature/corriger-en-masse/CorrigerCandidatures.page'; import { PageWithErrorHandling } from '@/utils/PageWithErrorHandling'; export default async function Page() { diff --git a/packages/applications/ssr/src/components/molecules/UserBasedRoleNavigation.tsx b/packages/applications/ssr/src/components/molecules/UserBasedRoleNavigation.tsx index 49e28076d4..7f0ef8fc9a 100644 --- a/packages/applications/ssr/src/components/molecules/UserBasedRoleNavigation.tsx +++ b/packages/applications/ssr/src/components/molecules/UserBasedRoleNavigation.tsx @@ -95,12 +95,6 @@ const getNavigationItemsBasedOnRole = ( }), }, }, - { - text: 'Corriger des candidats', - linkProps: { - href: Routes.Candidature.corriger, - }, - }, { text: 'Tous les candidats', linkProps: { diff --git a/packages/applications/ssr/src/components/molecules/candidature/NotificationBadge.tsx b/packages/applications/ssr/src/components/molecules/candidature/NotificationBadge.tsx new file mode 100644 index 0000000000..fcda5caa1f --- /dev/null +++ b/packages/applications/ssr/src/components/molecules/candidature/NotificationBadge.tsx @@ -0,0 +1,14 @@ +import Badge from '@codegouvfr/react-dsfr/Badge'; +import React from 'react'; + +type NotificationBadgeProps = { + estNotifié: boolean; +}; + +export const NotificationBadge: React.FC = ({ estNotifié }) => { + return ( + + {estNotifié ? 'Notifié' : 'À Notifier'} + + ); +}; diff --git a/packages/applications/ssr/src/components/molecules/projet/ProjectListItemHeading.tsx b/packages/applications/ssr/src/components/molecules/projet/ProjectListItemHeading.tsx index 6809d55528..66a5be37e9 100644 --- a/packages/applications/ssr/src/components/molecules/projet/ProjectListItemHeading.tsx +++ b/packages/applications/ssr/src/components/molecules/projet/ProjectListItemHeading.tsx @@ -1,11 +1,11 @@ import { FC } from 'react'; -import Badge from '@codegouvfr/react-dsfr/Badge'; import { IdentifiantProjet, StatutProjet } from '@potentiel-domain/common'; import { Iso8601DateTime } from '@potentiel-libraries/iso8601-datetime'; import { PlainType } from '@potentiel-domain/core'; import { FormattedDate } from '../../atoms/FormattedDate'; +import { NotificationBadge } from '../candidature/NotificationBadge'; import { StatutProjetBadge } from './StatutProjetBadge'; @@ -61,11 +61,3 @@ const FormattedIdentifiantProjet: FC<{ {famille ? `-F${famille}` : ''}-{numéroCRE} ); - -const NotificationBadge = ({ estNotifié }: Pick) => { - return ( - - {estNotifié ? 'Notifié' : 'À Notifier'} - - ); -}; diff --git a/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.template.tsx b/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.template.tsx new file mode 100644 index 0000000000..0e65bd7bdf --- /dev/null +++ b/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.template.tsx @@ -0,0 +1,53 @@ +'use server'; + +import React, { FC } from 'react'; + +import { IdentifiantProjet } from '@potentiel-domain/common'; +import { Iso8601DateTime } from '@potentiel-libraries/iso8601-datetime'; + +import { FormattedDate } from '@/components/atoms/FormattedDate'; + +export type ProjetBannerProps = { + href?: string; + nom: string; + badge: React.ReactNode; + localité?: { commune: string; département: string; région: string }; + dateDésignation?: Iso8601DateTime; + identifiantProjet: IdentifiantProjet.ValueType; +}; + +export const ProjetBannerTemplate: FC = ({ + href, + badge, + nom, + localité, + dateDésignation, + identifiantProjet, +}) => { + return ( + + ); +}; diff --git a/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.tsx b/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.tsx index 7543253403..5a769e1f6c 100644 --- a/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.tsx +++ b/packages/applications/ssr/src/components/molecules/projet/ProjetBanner.tsx @@ -7,45 +7,37 @@ import { notFound } from 'next/navigation'; import { Routes } from '@potentiel-applications/routes'; import { Candidature } from '@potentiel-domain/candidature'; import { Option } from '@potentiel-libraries/monads'; +import { IdentifiantProjet } from '@potentiel-domain/common'; import { StatutProjetBadge } from '@/components/molecules/projet/StatutProjetBadge'; -import { FormattedDate } from '@/components/atoms/FormattedDate'; + +import { ProjetBannerTemplate } from './ProjetBanner.template'; export type ProjetBannerProps = { identifiantProjet: string; }; export const ProjetBanner: FC = async ({ identifiantProjet }) => { - const candidature = await mediator.send({ + const projet = await mediator.send({ type: 'Candidature.Query.ConsulterProjet', data: { identifiantProjet, }, }); - if (Option.isNone(candidature)) { + if (Option.isNone(projet)) { return notFound(); } - const { nom, statut, localité, dateDésignation, appelOffre, famille, période } = candidature; + const { nom, statut, localité, dateDésignation } = projet; return ( - + } + localité={localité} + dateDésignation={dateDésignation} + href={Routes.Projet.details(identifiantProjet)} + identifiantProjet={IdentifiantProjet.convertirEnValueType(identifiantProjet)} + nom={nom} + /> ); }; diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.form.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/CorrigerCandidatures.form.tsx similarity index 100% rename from packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.form.tsx rename to packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/CorrigerCandidatures.form.tsx diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.page.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/CorrigerCandidatures.page.tsx similarity index 100% rename from packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.page.tsx rename to packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/CorrigerCandidatures.page.tsx diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidatures.action.ts b/packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/corrigerCandidatures.action.ts similarity index 74% rename from packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidatures.action.ts rename to packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/corrigerCandidatures.action.ts index 6a39191c04..bff6bf0c0c 100644 --- a/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidatures.action.ts +++ b/packages/applications/ssr/src/components/pages/candidature/corriger-en-masse/corrigerCandidatures.action.ts @@ -12,7 +12,7 @@ import { ActionResult, FormAction, formAction, FormState } from '@/utils/formAct import { withUtilisateur } from '@/utils/withUtilisateur'; import { document } from '@/utils/zod/documentTypes'; -import { candidatureSchema, CandidatureShape } from '../importer/candidature.schema'; +import { candidatureCsvSchema, CandidatureShape } from '../importer/candidature.schema'; import { getLocalité } from '../helpers'; const schema = zod.object({ @@ -25,7 +25,7 @@ const action: FormAction = async (_, { fichierCorrecti withUtilisateur(async (utilisateur) => { const { parsedData, rawData } = await parseCsv( fichierCorrectionCandidatures.stream(), - candidatureSchema, + candidatureCsvSchema, ); if (parsedData.length === 0) { @@ -40,7 +40,7 @@ const action: FormAction = async (_, { fichierCorrecti for (const line of parsedData) { try { - const projectRawLine = rawData.find((data) => data['Nom projet'] === line.nom_projet) ?? {}; + const projectRawLine = rawData.find((data) => data['Nom projet'] === line.nomProjet) ?? {}; await mediator.send({ type: 'Candidature.UseCase.CorrigerCandidature', @@ -55,13 +55,13 @@ const action: FormAction = async (_, { fichierCorrecti } catch (error) { if (error instanceof DomainError) { errors.push({ - key: line.nom_projet, + key: line.nomProjet, reason: error.message, }); continue; } errors.push({ - key: line.nom_projet, + key: line.nomProjet, reason: `Une erreur inconnue empêche la correction des candidatures`, }); } @@ -82,31 +82,31 @@ const mapLineToUseCaseData = ( ): Omit => ({ typeGarantiesFinancièresValue: line.type_gf, historiqueAbandonValue: line.historique_abandon, - appelOffreValue: line.appel_offre, + appelOffreValue: line.appelOffre, périodeValue: line.période, familleValue: line.famille, - numéroCREValue: line.num_cre, - nomProjetValue: line.nom_projet, - sociétéMèreValue: line.société_mère, - nomCandidatValue: line.nom_candidat, - puissanceProductionAnnuelleValue: line.puissance_production_annuelle, - prixReferenceValue: line.prix_reference, - noteTotaleValue: line.note_totale, - nomReprésentantLégalValue: line.nom_représentant_légal, - emailContactValue: line.email_contact, + numéroCREValue: line.numéroCRE, + nomProjetValue: line.nomProjet, + sociétéMèreValue: line.sociétéMère, + nomCandidatValue: line.nomCandidat, + puissanceProductionAnnuelleValue: line.puissanceProductionAnnuelle, + prixReferenceValue: line.prixRéférence, + noteTotaleValue: line.noteTotale, + nomReprésentantLégalValue: line.nomReprésentantLégal, + emailContactValue: line.emailContact, localitéValue: getLocalité(line), statutValue: line.statut, - motifÉliminationValue: line.motif_élimination, - puissanceALaPointeValue: line.puissance_a_la_pointe, - evaluationCarboneSimplifiéeValue: line.evaluation_carbone_simplifiée, + motifÉliminationValue: line.motifÉlimination, + puissanceALaPointeValue: line.puissanceÀLaPointe, + evaluationCarboneSimplifiéeValue: line.evaluationCarboneSimplifiée, technologieValue: line.technologie, - actionnariatValue: line.financement_collectif + actionnariatValue: line.financementCollectif ? Candidature.TypeActionnariat.financementCollectif.formatter() - : line.gouvernance_partagée + : line.gouvernancePartagée ? Candidature.TypeActionnariat.gouvernancePartagée.formatter() : undefined, - dateÉchéanceGfValue: line.date_échéance_gf?.toISOString(), - territoireProjetValue: line.territoire_projet, + dateÉchéanceGfValue: line.dateÉchéanceGf?.toISOString(), + territoireProjetValue: line.territoireProjet, détailsValue: rawLine, }); diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx new file mode 100644 index 0000000000..82a20c22bd --- /dev/null +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import Button from '@codegouvfr/react-dsfr/Button'; + +import { Routes } from '@potentiel-applications/routes'; + +import { Form } from '@/components/atoms/form/Form'; +import { makeAutoFormField } from '@/components/atoms/form/AutoFormField'; +import { SubmitButton } from '@/components/atoms/form/SubmitButton'; +import { ValidationErrors } from '@/utils/formAction'; + +import { candidatureSchema } from '../importer/candidature.schema'; + +import { + corrigerCandidatureAction, + CorrigerCandidatureFormEntries, +} from './corrigerCandidature.action'; + +export type CorrigerCandidatureFormProps = { + candidature: CorrigerCandidatureFormEntries; +}; + +export const CorrigerCandidatureForm: React.FC = ({ + candidature, +}) => { + const [validationErrors, setValidationErrors] = React.useState< + ValidationErrors + >({}); + + const AutoFormField = makeAutoFormField(candidatureSchema, candidature, validationErrors); + + return ( +
+ + Corriger + + } + > + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.page.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.page.tsx new file mode 100644 index 0000000000..c201e5ec63 --- /dev/null +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.page.tsx @@ -0,0 +1,58 @@ +import Alert from '@codegouvfr/react-dsfr/Alert'; +import Link from 'next/link'; + +import { IdentifiantProjet } from '@potentiel-domain/common'; +import { Routes } from '@potentiel-applications/routes'; + +import { NotificationBadge } from '@/components/molecules/candidature/NotificationBadge'; +import { ProjetBannerTemplate } from '@/components/molecules/projet/ProjetBanner.template'; +import { StatutProjetBadge } from '@/components/molecules/projet/StatutProjetBadge'; +import { ColumnPageTemplate } from '@/components/templates/ColumnPage.template'; + +import { CorrigerCandidatureForm, CorrigerCandidatureFormProps } from './CorrigerCandidature.form'; + +export type CorrigerCandidaturePageProps = CorrigerCandidatureFormProps & { estNotifiée: boolean }; + +export const CorrigerCandidaturePage: React.FC = ({ + candidature, + estNotifiée, +}) => { + const identifiantProjet = IdentifiantProjet.convertirEnValueType(candidature.identifiantProjet); + + return ( + + + + + } + /> + } + leftColumn={{ children: }} + rightColumn={{ + children: ( + +
Ce formulaire sert à corriger des erreurs lors de la candidature.
+
+ Pour un changement a posteriori, utiliser le formulaire dans la{' '} + + page projet + +
+ + } + /> + ), + }} + /> + ); +}; diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.stories.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.stories.tsx new file mode 100644 index 0000000000..894768cf20 --- /dev/null +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidatures.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { faker } from '@faker-js/faker'; + +import { CorrigerCandidaturePage } from './CorrigerCandidature.page'; + +const meta = { + title: 'Pages/Candidature/Corriger/CorrigerCandidaturePage', + component: CorrigerCandidaturePage, + parameters: {}, + tags: ['autodocs'], + argTypes: {}, +} satisfies Meta<{}>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + candidature: { + nomProjet: 'Boulodrome de marseille', + emailContact: 'porteur@test.test', + identifiantProjet: 'Eolien#2##test', + nomCandidat: faker.company.name(), + evaluationCarboneSimplifiée: 1.4, + historiqueAbandon: 'première-candidature', + localité: { + adresse1: faker.location.streetAddress(), + adresse2: faker.location.secondaryAddress(), + codePostal: faker.location.zipCode(), + commune: faker.location.city(), + }, + nomReprésentantLégal: faker.person.fullName(), + noteTotale: 8.6, + prixRéference: 1.6, + puissanceÀLaPointe: false, + puissanceProductionAnnuelle: 1, + sociétéMère: faker.company.name(), + statut: 'classé', + technologie: 'eolien', + }, + estNotifiée: false, + }, +}; diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts b/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts new file mode 100644 index 0000000000..4b4c3e5250 --- /dev/null +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts @@ -0,0 +1,77 @@ +'use server'; + +import * as zod from 'zod'; +import { mediator } from 'mediateur'; + +import { Candidature } from '@potentiel-domain/candidature'; +import { Routes } from '@potentiel-applications/routes'; +import { DateTime, IdentifiantProjet } from '@potentiel-domain/common'; + +import { FormAction, formAction, FormState } from '@/utils/formAction'; +import { withUtilisateur } from '@/utils/withUtilisateur'; + +import { candidatureSchema } from '../importer/candidature.schema'; +import { getLocalité } from '../helpers'; + +export type CorrigerCandidaturesState = FormState; + +const schema = candidatureSchema; +export type CorrigerCandidatureFormEntries = zod.infer; + +const action: FormAction = async (_, body) => + withUtilisateur(async (utilisateur) => { + await mediator.send({ + type: 'Candidature.UseCase.CorrigerCandidature', + data: { + ...mapBodyToUseCaseData(body), + corrigéLe: DateTime.now().formatter(), + corrigéPar: utilisateur.identifiantUtilisateur.formatter(), + }, + }); + + return { + status: 'success', + redirectUrl: Routes.Candidature.détails(body.identifiantProjet), + }; + }); + +export const corrigerCandidatureAction = formAction(action, schema); + +const mapBodyToUseCaseData = ( + data: zod.infer, +): Omit => { + const { appelOffre, période, famille, numéroCRE } = IdentifiantProjet.convertirEnValueType( + data.identifiantProjet, + ); + return { + appelOffreValue: appelOffre, + périodeValue: période, + familleValue: famille, + numéroCREValue: numéroCRE, + historiqueAbandonValue: data.historiqueAbandon, + nomProjetValue: data.nomProjet, + sociétéMèreValue: data.sociétéMère, + nomCandidatValue: data.nomCandidat, + puissanceProductionAnnuelleValue: data.puissanceProductionAnnuelle, + prixReferenceValue: data.prixRéference, + noteTotaleValue: data.noteTotale, + nomReprésentantLégalValue: data.nomReprésentantLégal, + emailContactValue: data.emailContact, + localitéValue: getLocalité({ + codePostaux: data.localité.codePostal.split('/').map((x) => x.trim()), + ...data.localité, + }), + statutValue: data.statut, + motifÉliminationValue: data.motifÉlimination, + puissanceALaPointeValue: data.puissanceÀLaPointe, + evaluationCarboneSimplifiéeValue: data.evaluationCarboneSimplifiée, + technologieValue: data.technologie, + typeGarantiesFinancièresValue: data.typeGarantiesFinancières, + dateÉchéanceGfValue: data.dateÉchéanceGf?.toISOString(), + actionnariatValue: data.actionnariat, + + // TODO + détailsValue: {}, + territoireProjetValue: '', + }; +}; diff --git "a/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" "b/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" new file mode 100644 index 0000000000..2464d6ddc7 --- /dev/null +++ "b/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" @@ -0,0 +1,166 @@ +import React, { FC } from 'react'; +import Button from '@codegouvfr/react-dsfr/Button'; + +import { Routes } from '@potentiel-applications/routes'; +import { DateTime, IdentifiantProjet } from '@potentiel-domain/common'; +import { Candidature } from '@potentiel-domain/candidature'; +import { PlainType } from '@potentiel-domain/core'; + +import { ColumnPageTemplate } from '@/components/templates/ColumnPage.template'; +import { Heading1, Heading2 } from '@/components/atoms/headings'; +import { ProjetBannerTemplate } from '@/components/molecules/projet/ProjetBanner.template'; +import { StatutProjetBadge } from '@/components/molecules/projet/StatutProjetBadge'; +import { NotificationBadge } from '@/components/molecules/candidature/NotificationBadge'; +import { FormattedDate } from '@/components/atoms/FormattedDate'; + +type AvailableActions = Record< + 'corriger' | 'prévisualiserAttestation' | 'téléchargerAttestation', + boolean +>; + +export type DétailsCandidaturePageProps = { + candidature: PlainType; + actions: AvailableActions; +}; + +export const DétailsCandidaturePage: FC = ({ + candidature, + actions, +}) => { + const identifiantProjet = IdentifiantProjet.bind(candidature.identifiantProjet); + return ( + + + + + } + /> + } + heading={Détail de la candidature} + leftColumn={{ + children: ( +
+ + + {candidature.localité.adresse1} + {candidature.localité.adresse2 && {candidature.localité.adresse2}} + + {candidature.localité.codePostal} {candidature.localité.commune} + + + {candidature.localité.département}, {candidature.localité.région} + + + + Puissance installée: {candidature.puissanceProductionAnnuelle} MW + + Evaluation carbone simplifiée: {candidature.evaluationCarboneSimplifiée} kg eq + CO2/kWc + + + {candidature.typeGarantiesFinancières && ( + + Type: {candidature.typeGarantiesFinancières.type} + {candidature.dateÉchéanceGf && ( + + Date d'échéance:{' '} + + + )} + + )} + + + {candidature.nomCandidat} + {candidature.nomReprésentantLégal} + + + {candidature.emailContact} + + + +
+ ), + }} + rightColumn={{ + children: mapToActionComponents({ + actions, + identifiantProjet, + }), + }} + /> + ); +}; + +type MapToActionsComponentsProps = { + actions: AvailableActions; + identifiantProjet: IdentifiantProjet.ValueType; +}; + +const mapToActionComponents = ({ identifiantProjet, actions }: MapToActionsComponentsProps) => ( +
+ Actions +
+ + {actions.téléchargerAttestation && ( + + )} + {actions.prévisualiserAttestation && ( + + )} +
+
+); + +type FieldProps = { name: string; children: React.ReactNode }; +const Field: React.FC = ({ name, children }) => ( +
+ {name} +
{children}
+
+); + +type FieldGroupProps = { name: string; children: React.ReactNode }; +const FieldGroup: React.FC = ({ name, children }) => ( +
+ {name} +
{children}
+
+); diff --git "a/packages/applications/ssr/src/components/pages/candidature/helpers/getLocalit\303\251.ts" "b/packages/applications/ssr/src/components/pages/candidature/helpers/getLocalit\303\251.ts" index 99de922293..3e175fd005 100644 --- "a/packages/applications/ssr/src/components/pages/candidature/helpers/getLocalit\303\251.ts" +++ "b/packages/applications/ssr/src/components/pages/candidature/helpers/getLocalit\303\251.ts" @@ -8,12 +8,15 @@ import { } from './getRégionAndDépartementFromCodePostal'; export const getLocalité = ({ - code_postaux, + codePostaux, adresse1, adresse2, commune, -}: CandidatureShape): Candidature.ImporterCandidatureUseCase['data']['localitéValue'] => { - const départementsRégions = code_postaux +}: Pick< + CandidatureShape, + 'codePostaux' | 'adresse1' | 'adresse2' | 'commune' +>): Candidature.ImporterCandidatureUseCase['data']['localitéValue'] => { + const départementsRégions = codePostaux .map(getRégionAndDépartementFromCodePostal) .filter((dptRegion): dptRegion is DépartementRégion => !!dptRegion); const departements = Array.from(new Set(départementsRégions.map((x) => x.département))); @@ -23,7 +26,7 @@ export const getLocalité = ({ adresse1, adresse2, commune, - codePostal: code_postaux.join(' / '), + codePostal: codePostaux.join(' / '), département: departements.join(' / '), région: régions.join(' / '), }; diff --git a/packages/applications/ssr/src/components/pages/candidature/importer/ImporterCandidatures.page.tsx b/packages/applications/ssr/src/components/pages/candidature/importer/ImporterCandidatures.page.tsx index cee27f08f4..236b77057c 100644 --- a/packages/applications/ssr/src/components/pages/candidature/importer/ImporterCandidatures.page.tsx +++ b/packages/applications/ssr/src/components/pages/candidature/importer/ImporterCandidatures.page.tsx @@ -1,12 +1,41 @@ import { FC } from 'react'; +import Alert from '@codegouvfr/react-dsfr/Alert'; +import Link from 'next/link'; + +import { Routes } from '@potentiel-applications/routes'; import { Heading1 } from '@/components/atoms/headings'; -import { PageTemplate } from '@/components/templates/Page.template'; +import { ColumnPageTemplate } from '@/components/templates/ColumnPage.template'; import { ImporterCandidaturesForm } from './ImporterCandidatures.form'; export const ImporterCandidaturesPage: FC = () => ( - Importer des candidats}> - - + Importer des candidats} + leftColumn={{ children: }} + rightColumn={{ + className: 'mt-20', + children: ( + + Il est possible de corriger des candidat existants: +
    +
  • + individuellement, via la{' '} + page des candidatures +
  • +
  • + en masse, par CSV, via la{' '} + page de correction +
  • +
+ + } + /> + ), + }} + /> ); diff --git a/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.test.ts b/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.test.ts index 71ca2f8c83..afbb2fd227 100644 --- a/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.test.ts +++ b/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.test.ts @@ -3,7 +3,7 @@ import { test, describe } from 'node:test'; import { expect, assert } from 'chai'; import { SafeParseReturnType, SafeParseSuccess } from 'zod'; -import { CandidatureCsvRowShape, candidatureSchema } from './candidature.schema'; +import { CandidatureCsvRowShape, candidatureCsvSchema } from './candidature.schema'; const minimumValues: Partial> = { "Appel d'offres": "appel d'offre", @@ -52,7 +52,7 @@ function assertNoError( describe('Schema candidature', () => { test('Cas nominal, éliminé', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, }); assertNoError(result); @@ -88,7 +88,7 @@ describe('Schema candidature', () => { }); test('Cas nominal, classé', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesClassé, 'Technologie\n(dispositif de production)': 'Eolien', 'Engagement de fourniture de puissance à la pointe\n(AO ZNI)': 'Oui', @@ -127,7 +127,7 @@ describe('Schema candidature', () => { describe('Erreurs courantes', () => { test('chaîne de caractères obligatoire sans valeur', () => { - const result = candidatureSchema.safeParse({}); + const result = candidatureCsvSchema.safeParse({}); assert(!result.success); expect(result.error.errors[0]).to.deep.eq({ code: 'invalid_type', @@ -139,7 +139,7 @@ describe('Schema candidature', () => { }); test('chaîne de caractères obligatoire avec valeur vide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ "Appel d'offres": '', }); assert(!result.success); @@ -155,7 +155,7 @@ describe('Schema candidature', () => { }); test('chaîne de caractères obligatoire avec espaces', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ "Appel d'offres": ' ', }); assert(!result.success); @@ -171,7 +171,7 @@ describe('Schema candidature', () => { }); test('nombre avec charactères', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, puissance_production_annuelle: 'abcd', }); @@ -186,7 +186,7 @@ describe('Schema candidature', () => { }); test('nombre strictement positif optionnel vide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Valeur de l’évaluation carbone des modules (kg eq CO2/kWc)': '', }); @@ -195,7 +195,7 @@ describe('Schema candidature', () => { }); test('nombre strictement positif requis vide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, puissance_production_annuelle: '', }); @@ -211,7 +211,7 @@ describe('Schema candidature', () => { }); test('nombre strictement positif vaut 0', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, puissance_production_annuelle: '0', }); @@ -228,7 +228,7 @@ describe('Schema candidature', () => { }); test('nombre strictement positif avec valeur négative', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, puissance_production_annuelle: 0, }); @@ -243,7 +243,7 @@ describe('Schema candidature', () => { }); test('oui/non valeur manquante', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Gouvernance partagée (Oui/Non)': '', }); @@ -258,7 +258,7 @@ describe('Schema candidature', () => { }); test('oui/non avec valeur invalide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Gouvernance partagée (Oui/Non)': 'peut-être', }); @@ -273,7 +273,7 @@ describe('Schema candidature', () => { }); test('Enum avec valeur invalide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Classé ?': 'wrong', }); @@ -288,7 +288,7 @@ describe('Schema candidature', () => { }); test('Enum avec valeur vide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, "1. Garantie financière jusqu'à 6 mois après la date d'achèvement\n2. Garantie financière avec date d'échéance et à renouveler\n3. Consignation": '', @@ -298,7 +298,7 @@ describe('Schema candidature', () => { }); test('Enum avec N/A', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, "1. Garantie financière jusqu'à 6 mois après la date d'achèvement\n2. Garantie financière avec date d'échéance et à renouveler\n3. Consignation": 'N/A', @@ -308,7 +308,7 @@ describe('Schema candidature', () => { }); test('Email non valide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Adresse électronique du contact': 'wrong', }); @@ -324,7 +324,7 @@ describe('Schema candidature', () => { describe('Règles métier', () => { test("Motif d'élimination n'est pas obligatoire si classé", () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Classé ?': 'Classé', "Motif d'élimination": undefined, @@ -335,7 +335,7 @@ describe('Schema candidature', () => { }); test("Motif d'élimination est obligatoire si éliminé", () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, "Motif d'élimination": undefined, }); @@ -350,7 +350,7 @@ describe('Schema candidature', () => { }); test("Date d'échéance est obligatoire si GF avec date d'échéance", () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Classé ?': 'Classé', "Motif d'élimination": undefined, @@ -368,7 +368,7 @@ describe('Schema candidature', () => { }); test('notifiedOn est interdit', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, notifiedOn: 'foo', }); @@ -383,7 +383,7 @@ describe('Schema candidature', () => { }); test('financement collectif et gouvernance partagée sont exclusifs', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Financement collectif (Oui/Non)': 'Oui', 'Gouvernance partagée (Oui/Non)': 'Oui', @@ -400,7 +400,7 @@ describe('Schema candidature', () => { describe('Cas particuliers', () => { describe('Evaluation carbone', () => { test('accepte N/A', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Evaluation carbone simplifiée indiquée au C. du formulaire de candidature et arrondie (kg eq CO2/kWc)': 'N/A', @@ -409,7 +409,7 @@ describe('Schema candidature', () => { }); test('accepte un nombre positif', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Evaluation carbone simplifiée indiquée au C. du formulaire de candidature et arrondie (kg eq CO2/kWc)': '1', @@ -418,7 +418,7 @@ describe('Schema candidature', () => { }); test(`n'accepte pas un nombre négatif`, () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Evaluation carbone simplifiée indiquée au C. du formulaire de candidature et arrondie (kg eq CO2/kWc)': '-1', @@ -427,7 +427,7 @@ describe('Schema candidature', () => { }); test(`n'accepte pas du texte`, () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesEliminé, 'Evaluation carbone simplifiée indiquée au C. du formulaire de candidature et arrondie (kg eq CO2/kWc)': 'abcd', @@ -438,7 +438,7 @@ describe('Schema candidature', () => { describe('Code postal', () => { test('accepte un code postal valide', () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesClassé, CP: '33100', }); @@ -448,7 +448,7 @@ describe('Schema candidature', () => { }); test("n'accepte pas un code postal invalide", () => { - const result = candidatureSchema.safeParse({ + const result = candidatureCsvSchema.safeParse({ ...minimumValuesClassé, CP: 'invalide', }); diff --git a/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.ts b/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.ts index d33b0602de..0f1f620346 100644 --- a/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.ts +++ b/packages/applications/ssr/src/components/pages/candidature/importer/candidature.schema.ts @@ -89,38 +89,39 @@ const requiredFieldIfReferenceFieldEquals = < }; }; +// Les colonnes du fichier CSV const colonnes = { - appel_offre: `Appel d'offres`, + appelOffre: `Appel d'offres`, période: 'Période', famille: 'Famille', - num_cre: 'N°CRE', - nom_projet: 'Nom projet', - société_mère: 'Société mère', - nom_candidat: 'Candidat', - puissance_production_annuelle: 'puissance_production_annuelle', - prix_reference: 'prix_reference', - note_totale: 'Note totale', - nom_représentant_légal: 'Nom et prénom du représentant légal', - email_contact: 'Adresse électronique du contact', + numéroCRE: 'N°CRE', + nomProjet: 'Nom projet', + sociétéMère: 'Société mère', + nomCandidat: 'Candidat', + puissanceProductionAnnuelle: 'puissance_production_annuelle', + prixRéférence: 'prix_reference', + noteTotale: 'Note totale', + nomReprésentantLégal: 'Nom et prénom du représentant légal', + emailContact: 'Adresse électronique du contact', adresse1: 'N°, voie, lieu-dit 1', adresse2: 'N°, voie, lieu-dit 2', - code_postaux: 'CP', + codePostaux: 'CP', commune: 'Commune', statut: 'Classé ?', - motif_élimination: "Motif d'élimination", - puissance_a_la_pointe: 'Engagement de fourniture de puissance à la pointe\n(AO ZNI)', - evaluation_carbone_simplifiée: + motifÉlimination: "Motif d'élimination", + puissanceÀLaPointe: 'Engagement de fourniture de puissance à la pointe\n(AO ZNI)', + evaluationCarboneSimplifiée: 'Evaluation carbone simplifiée indiquée au C. du formulaire de candidature et arrondie (kg eq CO2/kWc)', technologie: 'Technologie\n(dispositif de production)', - type_gf: + typeGf: "1. Garantie financière jusqu'à 6 mois après la date d'achèvement\n2. Garantie financière avec date d'échéance et à renouveler\n3. Consignation", - financement_collectif: 'Financement collectif (Oui/Non)', - gouvernance_partagée: 'Gouvernance partagée (Oui/Non)', - date_échéance_gf: "Date d'échéance au format JJ/MM/AAAA", + financementCollectif: 'Financement collectif (Oui/Non)', + gouvernancePartagée: 'Gouvernance partagée (Oui/Non)', + dateÉchéanceGf: "Date d'échéance au format JJ/MM/AAAA", // TODO quel est le bon nom pour cette colonne? - historique_abandon: + historiqueAbandon: "1. Lauréat d'aucun AO\n2. Abandon classique\n3. Abandon avec recandidature\n4. Lauréat d'un AO", - territoire_projet: 'Territoire\n(AO ZNI)', + territoireProjet: 'Territoire\n(AO ZNI)', } as const; // Order matters! the CSV uses "1"/"2"/"3" @@ -148,21 +149,21 @@ const technologie = { const candidatureCsvRowSchema = z .object({ - [colonnes.appel_offre]: requiredStringSchema, + [colonnes.appelOffre]: requiredStringSchema, [colonnes.période]: requiredStringSchema, [colonnes.famille]: optionalStringSchema, - [colonnes.num_cre]: requiredStringSchema, - [colonnes.nom_projet]: requiredStringSchema, - [colonnes.société_mère]: optionalStringSchema, - [colonnes.nom_candidat]: requiredStringSchema, - [colonnes.puissance_production_annuelle]: strictlyPositiveNumberSchema, - [colonnes.prix_reference]: strictlyPositiveNumberSchema, - [colonnes.note_totale]: numberSchema, - [colonnes.nom_représentant_légal]: requiredStringSchema, - [colonnes.email_contact]: requiredStringSchema.email(), + [colonnes.numéroCRE]: requiredStringSchema, + [colonnes.nomProjet]: requiredStringSchema, + [colonnes.sociétéMère]: optionalStringSchema, + [colonnes.nomCandidat]: requiredStringSchema, + [colonnes.puissanceProductionAnnuelle]: strictlyPositiveNumberSchema, + [colonnes.prixRéférence]: strictlyPositiveNumberSchema, + [colonnes.noteTotale]: numberSchema, + [colonnes.nomReprésentantLégal]: requiredStringSchema, + [colonnes.emailContact]: requiredStringSchema.email(), [colonnes.adresse1]: requiredStringSchema, [colonnes.adresse2]: optionalStringSchema, - [colonnes.code_postaux]: requiredStringSchema + [colonnes.codePostaux]: requiredStringSchema .transform((val) => val.split('/').map((str) => str.trim())) .refine( (val) => val.every(getRégionAndDépartementFromCodePostal), @@ -170,60 +171,54 @@ const candidatureCsvRowSchema = z ), [colonnes.commune]: requiredStringSchema, [colonnes.statut]: z.string().pipe(z.enum(['Eliminé', 'Classé'])), - [colonnes.puissance_a_la_pointe]: optionalOuiNonSchema, - [colonnes.evaluation_carbone_simplifiée]: z + [colonnes.puissanceÀLaPointe]: optionalOuiNonSchema, + [colonnes.evaluationCarboneSimplifiée]: z .union([z.enum(['N/A']), strictlyPositiveNumberSchema]) .transform((val) => (val === 'N/A' ? 0 : val)), [colonnes.technologie]: z .enum(['N/A', 'Eolien', 'Hydraulique', 'PV']) .optional() .transform((val) => val ?? 'N/A'), - [colonnes.financement_collectif]: ouiNonSchema, - [colonnes.gouvernance_partagée]: ouiNonSchema, - [colonnes.historique_abandon]: z.enum(['1', '2', '3', '4']), + [colonnes.financementCollectif]: ouiNonSchema, + [colonnes.gouvernancePartagée]: ouiNonSchema, + [colonnes.historiqueAbandon]: z.enum(['1', '2', '3', '4']), // columns with refines - [colonnes.motif_élimination]: optionalStringSchema.transform((val) => val || undefined), // see refine below - [colonnes.type_gf]: optionalEnum(z.enum(['1', '2', '3'])), // see refine below - [colonnes.date_échéance_gf]: dateSchema.optional(), // see refine below - [colonnes.territoire_projet]: optionalStringSchema, // see refines below + [colonnes.motifÉlimination]: optionalStringSchema.transform((val) => val || undefined), // see refine below + [colonnes.typeGf]: optionalEnum(z.enum(['1', '2', '3'])), // see refine below + [colonnes.dateÉchéanceGf]: dateSchema.optional(), // see refine below + [colonnes.territoireProjet]: optionalStringSchema, // see refines below notifiedOn: z.undefined({ invalid_type_error: 'Le champs notifiedOn ne peut pas être présent', }), }) // le motif d'élimination est obligatoire si la candidature est éliminée .superRefine( - requiredFieldIfReferenceFieldEquals(colonnes.motif_élimination, colonnes.statut, 'Eliminé'), + requiredFieldIfReferenceFieldEquals(colonnes.motifÉlimination, colonnes.statut, 'Eliminé'), ) // le type de GF est obligatoire si la candidature est classée - .superRefine(requiredFieldIfReferenceFieldEquals(colonnes.type_gf, colonnes.statut, 'Classé')) + .superRefine(requiredFieldIfReferenceFieldEquals(colonnes.typeGf, colonnes.statut, 'Classé')) // la date d'échéance est obligatoire si les GF sont de type "avec date d'échéance" + .superRefine(requiredFieldIfReferenceFieldEquals(colonnes.dateÉchéanceGf, colonnes.typeGf, '2')) .superRefine( requiredFieldIfReferenceFieldEquals( - "Date d'échéance au format JJ/MM/AAAA", - colonnes.type_gf, - '2', - ), - ) - .superRefine( - requiredFieldIfReferenceFieldEquals( - colonnes.territoire_projet, - colonnes.appel_offre, + colonnes.territoireProjet, + colonnes.appelOffre, 'CRE4 - ZNI', ), ) .superRefine( requiredFieldIfReferenceFieldEquals( - colonnes.territoire_projet, - colonnes.appel_offre, + colonnes.territoireProjet, + colonnes.appelOffre, 'CRE4 - ZNI 2017', ), ) - .refine((val) => !(val[colonnes.financement_collectif] && val[colonnes.gouvernance_partagée]), { - message: `Seule l'une des deux colonnes "${colonnes.financement_collectif}" et "${colonnes.gouvernance_partagée}" peut avoir la valeur "Oui"`, - path: [colonnes.financement_collectif, colonnes.gouvernance_partagée], + .refine((val) => !(val[colonnes.financementCollectif] && val[colonnes.gouvernancePartagée]), { + message: `Seule l'une des deux colonnes "${colonnes.financementCollectif}" et "${colonnes.gouvernancePartagée}" peut avoir la valeur "Oui"`, + path: [colonnes.financementCollectif, colonnes.gouvernancePartagée], }); -export const candidatureSchema = candidatureCsvRowSchema +export const candidatureCsvSchema = candidatureCsvRowSchema // Transforme les noms des clés de la ligne en valeurs plus simples à manipuler .transform((val) => { type CandidatureShape = { @@ -238,12 +233,62 @@ export const candidatureSchema = candidatureCsvRowSchema .transform((val) => { return { ...val, - type_gf: val.type_gf ? typeGf[Number(val.type_gf) - 1] : undefined, - historique_abandon: historiqueAbandon[Number(val.historique_abandon) - 1], + type_gf: val.typeGf ? typeGf[Number(val.typeGf) - 1] : undefined, + historique_abandon: historiqueAbandon[Number(val.historiqueAbandon) - 1], statut: statut[val.statut], technologie: technologie[val.technologie], }; }); export type CandidatureCsvRowShape = z.infer; -export type CandidatureShape = z.infer; +export type CandidatureShape = z.infer; + +/** Schema simplifié pour utilisation sans données CSV */ +export const candidatureSchema = z + .object({ + identifiantProjet: z.string(), + nomProjet: requiredStringSchema, + sociétéMère: optionalStringSchema, + nomCandidat: requiredStringSchema, + puissanceProductionAnnuelle: strictlyPositiveNumberSchema, + prixRéference: strictlyPositiveNumberSchema, + noteTotale: numberSchema, + nomReprésentantLégal: requiredStringSchema, + emailContact: requiredStringSchema.email(), + localité: z + .object({ + adresse1: requiredStringSchema, + adresse2: optionalStringSchema, + codePostal: requiredStringSchema + .transform((val) => val.split('/').map((str) => str.trim())) + .refine( + (val) => val.every(getRégionAndDépartementFromCodePostal), + 'Le code postal ne correspond à aucune région / département', + ) + .transform((val) => val.join(' / ')), + commune: requiredStringSchema, + }) + .optional(), // TODO not optional + statut: z.enum(Candidature.StatutCandidature.statuts), + puissanceÀLaPointe: z.coerce.boolean(), + evaluationCarboneSimplifiée: strictlyPositiveNumberSchema, + technologie: z.enum(Candidature.TypeTechnologie.types), + actionnariat: optionalEnum(z.enum(Candidature.TypeActionnariat.types)), + historiqueAbandon: z.enum(Candidature.HistoriqueAbandon.types), + // columns with refines + motifÉlimination: optionalStringSchema.transform((val) => val || undefined), // see refine below + typeGarantiesFinancières: optionalEnum(z.enum(Candidature.TypeGarantiesFinancières.types)), // see refine below + dateÉchéanceGf: z.date().optional(), // see refine below + }) + // le motif d'élimination est obligatoire si la candidature est éliminée + .superRefine(requiredFieldIfReferenceFieldEquals('motifÉlimination', 'statut', 'éliminé')) + // le type de GF est obligatoire si la candidature est classée + .superRefine(requiredFieldIfReferenceFieldEquals('typeGarantiesFinancières', 'statut', 'classé')) + // la date d'échéance est obligatoire si les GF sont de type "avec date d'échéance" + .superRefine( + requiredFieldIfReferenceFieldEquals( + 'dateÉchéanceGf', + 'typeGarantiesFinancières', + 'avec-date-échéance', + ), + ); diff --git a/packages/applications/ssr/src/components/pages/candidature/importer/importerCandidatures.action.ts b/packages/applications/ssr/src/components/pages/candidature/importer/importerCandidatures.action.ts index c03f4af544..4a6fb7de3d 100644 --- a/packages/applications/ssr/src/components/pages/candidature/importer/importerCandidatures.action.ts +++ b/packages/applications/ssr/src/components/pages/candidature/importer/importerCandidatures.action.ts @@ -14,7 +14,7 @@ import { document } from '@/utils/zod/documentTypes'; import { getLocalité } from '../helpers'; -import { candidatureSchema, CandidatureShape } from './candidature.schema'; +import { candidatureCsvSchema, CandidatureShape } from './candidature.schema'; const schema = zod.object({ fichierImportCandidature: document, @@ -26,7 +26,7 @@ const action: FormAction = async (_, { fichierImportCa return withUtilisateur(async (utilisateur) => { const { parsedData, rawData } = await parseCsv( fichierImportCandidature.stream(), - candidatureSchema, + candidatureCsvSchema, ); if (parsedData.length === 0) { @@ -41,7 +41,7 @@ const action: FormAction = async (_, { fichierImportCa for (const line of parsedData) { try { - const projectRawLine = rawData.find((data) => data['Nom projet'] === line.nom_projet) ?? {}; + const projectRawLine = rawData.find((data) => data['Nom projet'] === line.nomProjet) ?? {}; await mediator.send({ type: 'Candidature.UseCase.ImporterCandidature', data: { @@ -55,13 +55,13 @@ const action: FormAction = async (_, { fichierImportCa } catch (error) { if (error instanceof DomainError) { errors.push({ - key: line.nom_projet, + key: line.nomProjet, reason: error.message, }); continue; } errors.push({ - key: line.nom_projet, + key: line.nomProjet, reason: `Une erreur inconnue empêche l'import des candidatures`, }); } @@ -89,30 +89,30 @@ const mapLineToUseCaseData = ( ): Omit => ({ typeGarantiesFinancièresValue: line.type_gf, historiqueAbandonValue: line.historique_abandon, - appelOffreValue: line.appel_offre, + appelOffreValue: line.appelOffre, périodeValue: line.période, familleValue: line.famille, - numéroCREValue: line.num_cre, - nomProjetValue: line.nom_projet, - sociétéMèreValue: line.société_mère, - nomCandidatValue: line.nom_candidat, - puissanceProductionAnnuelleValue: line.puissance_production_annuelle, - prixReferenceValue: line.prix_reference, - noteTotaleValue: line.note_totale, - nomReprésentantLégalValue: line.nom_représentant_légal, - emailContactValue: line.email_contact, + numéroCREValue: line.numéroCRE, + nomProjetValue: line.nomProjet, + sociétéMèreValue: line.sociétéMère, + nomCandidatValue: line.nomCandidat, + puissanceProductionAnnuelleValue: line.puissanceProductionAnnuelle, + prixReferenceValue: line.prixRéférence, + noteTotaleValue: line.noteTotale, + nomReprésentantLégalValue: line.nomReprésentantLégal, + emailContactValue: line.emailContact, localitéValue: getLocalité(line), statutValue: line.statut, - motifÉliminationValue: line.motif_élimination, - puissanceALaPointeValue: line.puissance_a_la_pointe, - evaluationCarboneSimplifiéeValue: line.evaluation_carbone_simplifiée, + motifÉliminationValue: line.motifÉlimination, + puissanceALaPointeValue: line.puissanceÀLaPointe, + evaluationCarboneSimplifiéeValue: line.evaluationCarboneSimplifiée, technologieValue: line.technologie, - actionnariatValue: line.financement_collectif + actionnariatValue: line.financementCollectif ? Candidature.TypeActionnariat.financementCollectif.formatter() - : line.gouvernance_partagée + : line.gouvernancePartagée ? Candidature.TypeActionnariat.gouvernancePartagée.formatter() : undefined, - dateÉchéanceGfValue: line.date_échéance_gf?.toISOString(), - territoireProjetValue: line.territoire_projet, + dateÉchéanceGfValue: line.dateÉchéanceGf?.toISOString(), + territoireProjetValue: line.territoireProjet, détailsValue: removeEmptyValues(rawLine), }); diff --git a/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx b/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx index df204f6fcf..713bcd3082 100644 --- a/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx +++ b/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx @@ -25,7 +25,7 @@ export const CandidatureListItemActions: FC = ( - {actions.téléchargerAttestation && ( - - )} - {actions.prévisualiserAttestation && ( - - )} - - + )} + {actions.téléchargerAttestation && ( + + )} + {actions.prévisualiserAttestation && ( + + )} + ); type FieldProps = { name: string; children: React.ReactNode }; From 94026ef7cdb1f7468b047eb945e5852cbe5c7467 Mon Sep 17 00:00:00 2001 From: Benjamin Levesque <14175665+benjlevesque@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:09:56 +0200 Subject: [PATCH 4/5] =?UTF-8?q?la=20correction=20impacte=20les=20donn?= =?UTF-8?q?=C3=A9es=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attestation/buildCertificate.ts | 7 +- .../eventHandlers/project.eventHandlers.ts | 11 ++ .../getLegacyProjetByIdentifiantProjet.ts | 3 +- .../handleProjectRawDataCorrected.ts | 27 ++++ .../modules/project/eventHandlers/index.ts | 1 + .../project/events/ProjectRawDataCorrected.ts | 41 +++++++ .../src/modules/project/events/index.ts | 1 + .../legacy/src/sagas/candidature.saga.ts | 115 +++++++++++++----- .../[identifiant]/corriger/page.tsx | 10 +- .../corriger/CorrigerCandidature.form.tsx | 33 ++++- .../corriger/corrigerCandidature.action.ts | 15 ++- .../D\303\251tailsCandidature.page.tsx" | 1 + .../importer/candidature.schema.ts | 42 ++++--- .../lister/CandidatureListItemActions.tsx | 3 +- 14 files changed, 244 insertions(+), 66 deletions(-) create mode 100644 packages/applications/legacy/src/modules/project/eventHandlers/handleProjectRawDataCorrected.ts create mode 100644 packages/applications/legacy/src/modules/project/events/ProjectRawDataCorrected.ts diff --git a/packages/applications/document-builder/src/candidature/attestation/buildCertificate.ts b/packages/applications/document-builder/src/candidature/attestation/buildCertificate.ts index 7ab3f2a766..5a8904f97d 100644 --- a/packages/applications/document-builder/src/candidature/attestation/buildCertificate.ts +++ b/packages/applications/document-builder/src/candidature/attestation/buildCertificate.ts @@ -109,9 +109,12 @@ const mapToCertificateData = ({ potentielId, nomProjet: candidature.nomProjet, - adresseProjet: [candidature.localité.adresse1, candidature.localité.adresse2] + adresseProjet: [ + ...candidature.localité.adresse1.split('\n'), + ...(candidature.localité.adresse2?.split('\n') ?? []), + ] .filter(Boolean) - .join('\n'), + .join(', '), codePostalProjet: candidature.localité.codePostal, communeProjet: candidature.localité.commune, diff --git a/packages/applications/legacy/src/config/eventHandlers/project.eventHandlers.ts b/packages/applications/legacy/src/config/eventHandlers/project.eventHandlers.ts index 0d3de2f4a4..4d604899cf 100644 --- a/packages/applications/legacy/src/config/eventHandlers/project.eventHandlers.ts +++ b/packages/applications/legacy/src/config/eventHandlers/project.eventHandlers.ts @@ -4,6 +4,7 @@ import { LegacyModificationImported } from '../../modules/modificationRequest'; import { handleLegacyModificationImported, handleProjectRawDataImported, + handleProjectRawDataCorrected, makeOnDélaiAccordé, makeOnDélaiAccordéCorrigé, ProjectRawDataImported, @@ -13,12 +14,14 @@ import { makeOnDateMiseEnServiceTransmise, makeOnDemandeComplèteRaccordementTransmise, DemandeComplèteRaccordementTransmise, + ProjectRawDataCorrected, } from '../../modules/project'; import { subscribeToRedis } from '../eventBus.config'; import { eventStore } from '../eventStore.config'; import { findProjectByIdentifiers, récupérerDétailDossiersRaccordements } from '../queries.config'; import { getProjectAppelOffre } from '../queryProjectAO.config'; import { projectRepo } from '../repos.config'; +import { getUserById } from '../queries.config'; eventStore.subscribe( ProjectRawDataImported.type, @@ -29,6 +32,14 @@ eventStore.subscribe( }), ); +eventStore.subscribe( + ProjectRawDataCorrected.type, + handleProjectRawDataCorrected({ + projectRepo, + getUserById, + }), +); + eventStore.subscribe( LegacyModificationImported.type, handleLegacyModificationImported({ diff --git a/packages/applications/legacy/src/infra/sequelize/queries/project/getLegacyProjetByIdentifiantProjet.ts b/packages/applications/legacy/src/infra/sequelize/queries/project/getLegacyProjetByIdentifiantProjet.ts index 798e33032c..8477a4a02c 100644 --- a/packages/applications/legacy/src/infra/sequelize/queries/project/getLegacyProjetByIdentifiantProjet.ts +++ b/packages/applications/legacy/src/infra/sequelize/queries/project/getLegacyProjetByIdentifiantProjet.ts @@ -1,12 +1,13 @@ import { IdentifiantProjet } from '@potentiel-domain/common'; import { Project } from '../../projectionsNext'; +import { PlainType } from '@potentiel-domain/core'; export const getLegacyProjetByIdentifiantProjet = async ({ appelOffre, période, famille, numéroCRE, -}: Pick) => { +}: PlainType) => { try { const projet = await Project.findOne({ where: { diff --git a/packages/applications/legacy/src/modules/project/eventHandlers/handleProjectRawDataCorrected.ts b/packages/applications/legacy/src/modules/project/eventHandlers/handleProjectRawDataCorrected.ts new file mode 100644 index 0000000000..fd9efdf2ca --- /dev/null +++ b/packages/applications/legacy/src/modules/project/eventHandlers/handleProjectRawDataCorrected.ts @@ -0,0 +1,27 @@ +import { err } from 'neverthrow'; +import { TransactionalRepository, UniqueEntityID } from '../../../core/domain'; +import { ProjectRawDataCorrected } from '../events'; +import { Project } from '../Project'; +import { GetUserById } from '../../../config'; + +// This exists only to access Project repo from within the ssr=>legacy saga. +export const handleProjectRawDataCorrected = + (deps: { projectRepo: TransactionalRepository; getUserById: GetUserById }) => + async (event: ProjectRawDataCorrected) => { + const { projectRepo, getUserById } = deps; + + const { correctedBy, correctedData, projectId } = event.payload; + return projectRepo.transaction( + new UniqueEntityID(projectId || undefined), + (project) => + getUserById(correctedBy).map((user) => { + if (user) { + return project.correctData(user, correctedData); + } + return err('no user found'); + }), + { + acceptNew: true, + }, + ); + }; diff --git a/packages/applications/legacy/src/modules/project/eventHandlers/index.ts b/packages/applications/legacy/src/modules/project/eventHandlers/index.ts index aed1a54055..f06760c90e 100644 --- a/packages/applications/legacy/src/modules/project/eventHandlers/index.ts +++ b/packages/applications/legacy/src/modules/project/eventHandlers/index.ts @@ -1,5 +1,6 @@ export * from './handleLegacyModificationImported'; export * from './handleProjectRawDataImported'; +export * from './handleProjectRawDataCorrected'; export * from './onDélaiAccordé'; export * from './onCahierDesChargesChoisi'; export * from './onDélaiAccordéCorrigé'; diff --git a/packages/applications/legacy/src/modules/project/events/ProjectRawDataCorrected.ts b/packages/applications/legacy/src/modules/project/events/ProjectRawDataCorrected.ts new file mode 100644 index 0000000000..5b450d293b --- /dev/null +++ b/packages/applications/legacy/src/modules/project/events/ProjectRawDataCorrected.ts @@ -0,0 +1,41 @@ +import { BaseDomainEvent, DomainEvent } from '../../../core/domain'; + +import { Actionnariat } from '../types'; + +export interface ProjectRawDataCorrectedPayload { + projectId: string; + correctedBy: string; + correctedData: { + nomProjet: string; + territoireProjet: string; + puissance: number; + prixReference: number; + evaluationCarbone: number; + note: number; + nomCandidat: string; + nomRepresentantLegal: string; + email: string; + adresseProjet: string; + codePostalProjet: string; + communeProjet: string; + engagementFournitureDePuissanceAlaPointe: boolean; + isFinancementParticipatif: boolean; + isInvestissementParticipatif: boolean; + motifsElimination: string; + actionnariat?: Actionnariat; + }; +} + +// This exists only to access Project repo from within the ssr=>legacy saga. +export class ProjectRawDataCorrected + extends BaseDomainEvent + implements DomainEvent +{ + public static type: 'ProjectRawDataCorrected' = 'ProjectRawDataCorrected'; + public type = ProjectRawDataCorrected.type; + currentVersion = 1; + + aggregateIdFromPayload(payload: ProjectRawDataCorrectedPayload) { + return payload.projectId; + } +} diff --git a/packages/applications/legacy/src/modules/project/events/index.ts b/packages/applications/legacy/src/modules/project/events/index.ts index d6dfde518d..0beb8ccb9a 100644 --- a/packages/applications/legacy/src/modules/project/events/index.ts +++ b/packages/applications/legacy/src/modules/project/events/index.ts @@ -17,6 +17,7 @@ export * from './ProjectCompletionDueDateSet'; export * from './ProjectDCRDueDateCancelled'; export * from './ProjectDCRDueDateSet'; export * from './ProjectDataCorrected'; +export * from './ProjectRawDataCorrected'; export * from './ProjectFournisseursUpdated'; export * from './ProjectImported'; export * from './ProjectNotificationDateSet'; diff --git a/packages/applications/legacy/src/sagas/candidature.saga.ts b/packages/applications/legacy/src/sagas/candidature.saga.ts index 97d286f11e..16e34f16f2 100644 --- a/packages/applications/legacy/src/sagas/candidature.saga.ts +++ b/packages/applications/legacy/src/sagas/candidature.saga.ts @@ -2,7 +2,12 @@ import { Message, MessageHandler, mediator } from 'mediateur'; import { Event } from '@potentiel-infrastructure/pg-event-sourcing'; import { Candidature } from '@potentiel-domain/candidature'; import { eventStore } from '../config/eventStore.config'; -import { DésignationCatégorie, ProjectRawDataImported } from '../modules/project'; +import { + DésignationCatégorie, + ProjectClasseGranted, + ProjectRawDataCorrected, + ProjectRawDataImported, +} from '../modules/project'; import { v4 } from 'uuid'; import { AppelOffre } from '@potentiel-domain/appel-offre'; import { Option } from '@potentiel-libraries/monads'; @@ -11,6 +16,9 @@ import getDepartementRegionFromCodePostal, { } from '../helpers/getDepartementRegionFromCodePostal'; import { ConsulterDocumentProjetQuery, DocumentProjet } from '@potentiel-domain/document'; import { DateTime, IdentifiantProjet } from '@potentiel-domain/common'; +import { getLegacyProjetByIdentifiantProjet } from '../infra/sequelize/queries/project'; +import { getUserByEmail } from '../infra/sequelize/queries/users/getUserByEmail'; +import { ok } from 'neverthrow'; export type SubscriptionEvent = ( | Candidature.CandidatureImportéeEvent @@ -39,14 +47,54 @@ export const register = () => { switch (event.type) { case 'CandidatureImportée-V1': case 'CandidatureCorrigée-V1': - await eventStore.publish( - new ProjectRawDataImported({ - payload: { - importId: v4(), - data: { ...mapToLegacyEventPayload(identifiantProjet, payload, appelOffre), details }, - }, - }), - ); + const projet = await getLegacyProjetByIdentifiantProjet(identifiantProjet); + // Si le projet n'est pas notifié, on l'importe ou le réimporte + if (!projet?.notifiedOn) { + await eventStore.publish( + new ProjectRawDataImported({ + payload: { + importId: v4(), + data: { + ...mapToLegacyEventPayload(identifiantProjet, payload, appelOffre), + details, + }, + }, + }), + ); + } else if (event.type === 'CandidatureCorrigée-V1') { + const userId = await new Promise((r) => + getUserByEmail(event.payload.corrigéPar).map((user) => { + r(user?.id ?? ''); + return ok(user); + }), + ); + if (projet.classe === 'Eliminé' && event.payload.statut === 'classé') { + await eventStore.publish( + new ProjectClasseGranted({ + payload: { + projectId: projet.id, + grantedBy: userId, + }, + }), + ); + } else { + // si le projet est notifié, on corrige les données + eventStore.publish( + new ProjectRawDataCorrected({ + payload: { + correctedBy: userId, + projectId: projet.id, + correctedData: mapToCorrectedData(event.payload), + }, + }), + ); + } + // TODO + // if(newTechnologie !== oldTEchnologie){ + // publish ProjectCompletionDueDateSet + // } + } + return; } }; @@ -75,41 +123,48 @@ const mapToLegacyEventPayload = ( familleId: identifiantProjet.famille, numeroCRE: identifiantProjet.numéroCRE, classe: payload.statut === 'classé' ? 'Classé' : 'Eliminé', - nomProjet: payload.nomProjet, - nomCandidat: payload.nomCandidat, - nomRepresentantLegal: payload.nomReprésentantLégal, - email: payload.emailContact, - motifsElimination: payload.motifÉlimination ?? '', garantiesFinancièresDateEchéance: payload.dateÉchéanceGf, + garantiesFinancièresType: getTypeGarantiesFinancieresLabel(payload.typeGarantiesFinancières), technologie: payload.technologie, historiqueAbandon: payload.historiqueAbandon, - puissance: payload.puissanceProductionAnnuelle, - garantiesFinancièresType: getTypeGarantiesFinancieresLabel(payload.typeGarantiesFinancières), - engagementFournitureDePuissanceAlaPointe: payload.puissanceALaPointe, actionnaire: payload.sociétéMère, - prixReference: payload.prixReference, - note: payload.noteTotale, - evaluationCarbone: payload.evaluationCarboneSimplifiée, désignationCatégorie: getDésignationCatégorie({ puissance: payload.puissanceProductionAnnuelle, note: payload.noteTotale, periodeDetails: période, }), - - actionnariat: - payload.actionnariat === 'financement-collectif' || - payload.actionnariat === 'gouvernance-partagée' - ? payload.actionnariat - : undefined, - isFinancementParticipatif: payload.actionnariat === 'financement-participatif', - isInvestissementParticipatif: payload.actionnariat === 'investissement-participatif', - notifiedOn: 0, - territoireProjet: payload.territoireProjet, + ...mapToCorrectedData(payload), ...getLocalitéInfo(payload.localité), }; }; +const mapToCorrectedData = ( + payload: SubscriptionEvent['payload'], +): ProjectRawDataCorrected['payload']['correctedData'] => ({ + nomProjet: payload.nomProjet, + nomCandidat: payload.nomCandidat, + nomRepresentantLegal: payload.nomReprésentantLégal, + email: payload.emailContact, + motifsElimination: payload.motifÉlimination ?? '', + puissance: payload.puissanceProductionAnnuelle, + engagementFournitureDePuissanceAlaPointe: payload.puissanceALaPointe, + prixReference: payload.prixReference, + note: payload.noteTotale, + evaluationCarbone: payload.evaluationCarboneSimplifiée, + actionnariat: + payload.actionnariat === 'financement-collectif' + ? 'financement-collectif' + : payload.actionnariat === 'gouvernance-partagée' + ? 'gouvernance-partagee' + : undefined, + isFinancementParticipatif: payload.actionnariat === 'financement-participatif', + isInvestissementParticipatif: payload.actionnariat === 'investissement-participatif', + + territoireProjet: payload.territoireProjet, + ...getLocalitéInfo(payload.localité), +}); + const getLocalitéInfo = ({ codePostal, adresse1, diff --git a/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx b/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx index 6066d7f472..865bd5f4ee 100644 --- a/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx +++ b/packages/applications/ssr/src/app/candidatures/[identifiant]/corriger/page.tsx @@ -55,12 +55,10 @@ const mapToProps = ( evaluationCarboneSimplifiée: candidature.evaluationCarboneSimplifiée, actionnariat: candidature.actionnariat?.formatter(), dateÉchéanceGf: candidature.dateÉchéanceGf?.date, - localité: { - adresse1: candidature.localité.adresse1, - adresse2: candidature.localité.adresse2, - codePostal: candidature.localité.codePostal, - commune: candidature.localité.commune, - }, + adresse1: candidature.localité.adresse1, + adresse2: candidature.localité.adresse2, + codePostal: candidature.localité.codePostal, + commune: candidature.localité.commune, }, estNotifiée: !!candidature.notification, }); diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx index 82a20c22bd..f30e4aa7ef 100644 --- a/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/CorrigerCandidature.form.tsx @@ -2,6 +2,8 @@ import React from 'react'; import Button from '@codegouvfr/react-dsfr/Button'; +import Checkbox from '@codegouvfr/react-dsfr/Checkbox'; +import { z } from 'zod'; import { Routes } from '@potentiel-applications/routes'; @@ -29,6 +31,14 @@ export const CorrigerCandidatureForm: React.FC = ( >({}); const AutoFormField = makeAutoFormField(candidatureSchema, candidature, validationErrors); + const getStateProps = (field: keyof z.infer) => ({ + state: validationErrors[field] ? ('error' as const) : ('default' as const), + stateRelatedMessage: validationErrors[field], + }); + const getInputProps = (field: keyof z.infer) => ({ + name: encodeURIComponent(field), + value: candidature[field] !== undefined ? String(candidature[field]) : undefined, + }); return (
= ( } > - - + - - - + + + + - + ); diff --git a/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts b/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts index 4b4c3e5250..21157935b3 100644 --- a/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts +++ b/packages/applications/ssr/src/components/pages/candidature/corriger/corrigerCandidature.action.ts @@ -9,6 +9,7 @@ import { DateTime, IdentifiantProjet } from '@potentiel-domain/common'; import { FormAction, formAction, FormState } from '@/utils/formAction'; import { withUtilisateur } from '@/utils/withUtilisateur'; +import { getCandidature } from '@/app/candidatures/_helpers/getCandidature'; import { candidatureSchema } from '../importer/candidature.schema'; import { getLocalité } from '../helpers'; @@ -20,10 +21,12 @@ export type CorrigerCandidatureFormEntries = zod.infer; const action: FormAction = async (_, body) => withUtilisateur(async (utilisateur) => { + const candidature = await getCandidature(body.identifiantProjet); await mediator.send({ type: 'Candidature.UseCase.CorrigerCandidature', data: { ...mapBodyToUseCaseData(body), + statutValue: candidature.statut.formatter(), corrigéLe: DateTime.now().formatter(), corrigéPar: utilisateur.identifiantUtilisateur.formatter(), }, @@ -39,7 +42,10 @@ export const corrigerCandidatureAction = formAction(action, schema); const mapBodyToUseCaseData = ( data: zod.infer, -): Omit => { +): Omit< + Candidature.CorrigerCandidatureUseCase['data'], + 'corrigéLe' | 'corrigéPar' | 'statutValue' +> => { const { appelOffre, période, famille, numéroCRE } = IdentifiantProjet.convertirEnValueType( data.identifiantProjet, ); @@ -58,10 +64,11 @@ const mapBodyToUseCaseData = ( nomReprésentantLégalValue: data.nomReprésentantLégal, emailContactValue: data.emailContact, localitéValue: getLocalité({ - codePostaux: data.localité.codePostal.split('/').map((x) => x.trim()), - ...data.localité, + codePostaux: data.codePostal.split('/').map((x) => x.trim()), + commune: data.commune, + adresse1: data.adresse1, + adresse2: data.adresse2, }), - statutValue: data.statut, motifÉliminationValue: data.motifÉlimination, puissanceALaPointeValue: data.puissanceÀLaPointe, evaluationCarboneSimplifiéeValue: data.evaluationCarboneSimplifiée, diff --git "a/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" "b/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" index fa681840e8..cbd79c2e3e 100644 --- "a/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" +++ "b/packages/applications/ssr/src/components/pages/candidature/d\303\251tails/D\303\251tailsCandidature.page.tsx" @@ -33,6 +33,7 @@ export const DétailsCandidaturePage: FC = ({ banner={ val.split('/').map((str) => str.trim())) - .refine( - (val) => val.every(getRégionAndDépartementFromCodePostal), - 'Le code postal ne correspond à aucune région / département', - ) - .transform((val) => val.join(' / ')), - commune: requiredStringSchema, - }) - .optional(), // TODO not optional + + adresse1: requiredStringSchema, + adresse2: optionalStringSchema, + codePostal: requiredStringSchema + .transform((val) => val.split('/').map((str) => str.trim())) + .refine( + (val) => val.every(getRégionAndDépartementFromCodePostal), + 'Le code postal ne correspond à aucune région / département', + ) + .transform((val) => val.join(' / ')), + commune: requiredStringSchema, + statut: z.enum(Candidature.StatutCandidature.statuts), - puissanceÀLaPointe: z.coerce.boolean(), + puissanceÀLaPointe: z + .string() + .toLowerCase() + .optional() + .default('false') + .transform((s) => JSON.parse(s)) + .pipe(z.boolean()), evaluationCarboneSimplifiée: strictlyPositiveNumberSchema, technologie: z.enum(Candidature.TypeTechnologie.types), actionnariat: optionalEnum(z.enum(Candidature.TypeActionnariat.types)), @@ -278,7 +282,13 @@ export const candidatureSchema = z // columns with refines motifÉlimination: optionalStringSchema.transform((val) => val || undefined), // see refine below typeGarantiesFinancières: optionalEnum(z.enum(Candidature.TypeGarantiesFinancières.types)), // see refine below - dateÉchéanceGf: z.date().optional(), // see refine below + dateÉchéanceGf: z + .string() + .transform((str) => { + console.log(str); + return str ? new Date(str) : undefined; + }) + .optional(), // see refine below }) // le motif d'élimination est obligatoire si la candidature est éliminée .superRefine(requiredFieldIfReferenceFieldEquals('motifÉlimination', 'statut', 'éliminé')) diff --git a/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx b/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx index 713bcd3082..3fd7022203 100644 --- a/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx +++ b/packages/applications/ssr/src/components/pages/candidature/lister/CandidatureListItemActions.tsx @@ -25,7 +25,8 @@ export const CandidatureListItemActions: FC = (