From 9bd3f4d7671f3d8b74c57c5e3ed65c069e035ba1 Mon Sep 17 00:00:00 2001 From: Fabien Mercier Date: Tue, 7 May 2024 14:48:55 +0200 Subject: [PATCH] =?UTF-8?q?Un=20utilisateur=20peut=20cr=C3=A9er=20une=20si?= =?UTF-8?q?mulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc | 1 + .../(connecte)/creer-une-simulation/page.tsx | 40 +++++ src/app/(connecte)/inventaire/page.tsx | 14 +- .../CreerUneSimulation.module.css | 3 + .../CreerUneSimulation.test.tsx | 162 ++++++++++++++++++ .../CreerUneSimulation/CreerUneSimulation.tsx | 149 ++++++++++++++++ src/components/CreerUneSimulation/action.ts | 16 ++ .../useCreerUneSimulation.ts | 108 ++++++++++++ .../IndicateursCles/IndicateursCles.tsx | 69 +++++++- src/presenters/inventairePresenter.ts | 55 +++++- vitest.config.ts | 2 + vitest.setup.ts | 15 +- 12 files changed, 619 insertions(+), 15 deletions(-) create mode 100644 src/app/(connecte)/creer-une-simulation/page.tsx create mode 100644 src/components/CreerUneSimulation/CreerUneSimulation.module.css create mode 100644 src/components/CreerUneSimulation/CreerUneSimulation.test.tsx create mode 100644 src/components/CreerUneSimulation/CreerUneSimulation.tsx create mode 100644 src/components/CreerUneSimulation/action.ts create mode 100644 src/components/CreerUneSimulation/useCreerUneSimulation.ts diff --git a/.eslintrc b/.eslintrc index 0e22046..797f415 100644 --- a/.eslintrc +++ b/.eslintrc @@ -106,6 +106,7 @@ "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/consistent-type-imports": "off", "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-member-accessibility": "off", "@typescript-eslint/naming-convention": "off", "@typescript-eslint/max-params": "off", "@typescript-eslint/no-magic-numbers": "off", diff --git a/src/app/(connecte)/creer-une-simulation/page.tsx b/src/app/(connecte)/creer-une-simulation/page.tsx new file mode 100644 index 0000000..79a9e5a --- /dev/null +++ b/src/app/(connecte)/creer-une-simulation/page.tsx @@ -0,0 +1,40 @@ +import { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { ReactElement } from 'react' + +import { getProfilAtih } from '../../../authentification' +import Breadcrumb from '../../../components/commun/Breadcrumb' +import CreerUneSimulation from '../../../components/CreerUneSimulation/CreerUneSimulation' + +const title = 'Créer une simulation' +export const metadata: Metadata = { + title, +} + +type PageProps = Readonly<{ + searchParams?: Readonly<{ + nomInventaire?: string + }> +}> + +export default async function PageCreerUneSimulation({ searchParams }: PageProps): Promise { + if (searchParams?.nomInventaire === undefined) { + notFound() + } + + const profil = await getProfilAtih() + + if (profil.isAdmin) { + notFound() + } + + return ( + <> + + + + ) +} diff --git a/src/app/(connecte)/inventaire/page.tsx b/src/app/(connecte)/inventaire/page.tsx index e14b13a..e576f36 100644 --- a/src/app/(connecte)/inventaire/page.tsx +++ b/src/app/(connecte)/inventaire/page.tsx @@ -15,11 +15,15 @@ export const metadata: Metadata = { title, } -type PageProps = Readonly<{ +export type PageProps = Readonly<{ searchParams: Readonly<{ + dureeDeVie?: string + heureUtilisation?: string + nombreEquipement?: string nomEtablissement?: string nomInventaire?: string - statut?: string + nouveauNomInventaire?: string + statut?: StatutsInventaire }> }> @@ -38,15 +42,13 @@ export default async function Page({ searchParams }: PageProps): Promise ) diff --git a/src/components/CreerUneSimulation/CreerUneSimulation.module.css b/src/components/CreerUneSimulation/CreerUneSimulation.module.css new file mode 100644 index 0000000..ddbe375 --- /dev/null +++ b/src/components/CreerUneSimulation/CreerUneSimulation.module.css @@ -0,0 +1,3 @@ +.astuce { + background-color: #EFF4FD; +} diff --git a/src/components/CreerUneSimulation/CreerUneSimulation.test.tsx b/src/components/CreerUneSimulation/CreerUneSimulation.test.tsx new file mode 100644 index 0000000..54fbfe9 --- /dev/null +++ b/src/components/CreerUneSimulation/CreerUneSimulation.test.tsx @@ -0,0 +1,162 @@ +import { inventaireModel } from '@prisma/client' +import { fireEvent, screen } from '@testing-library/react' +import * as navigation from 'next/navigation' + +import PageCreerUneSimulation from '../../app/(connecte)/creer-une-simulation/page' +import * as repository from '../../gateways/inventairesRepository' +import { jeSuisUnAdmin, jeSuisUnUtilisateur, renderComponent } from '../../testShared' + +describe('page créer une simulation', () => { + describe('en tant qu’utilisateur', () => { + it('quand j’affiche la page alors j’ai le nom de l’inventaire prérempli avec comme suffixe le mot simulation et la date', async () => { + // GIVEN + jeSuisUnUtilisateur() + + // WHEN + renderComponent(await PageCreerUneSimulation(queryParams())) + + // THEN + const champNomInventaire = screen.getByLabelText('Nom de l’inventaire * (minimum 4 caractères)') + expect(champNomInventaire).toHaveValue('Centre hospitalier - simulation 17/12/1995 03:24:00') + + const boutonContinuer = screen.getByRole('button', { name: 'Continuer' }) + expect(boutonContinuer).toBeEnabled() + }) + + it('quand j’affiche la page alors je ne peux pas dépasser -100 et 100 dans le champ nombre d’équipement en %', async () => { + // GIVEN + jeSuisUnUtilisateur() + + // WHEN + renderComponent(await PageCreerUneSimulation(queryParams())) + + // THEN + const champNombreEquipement = screen.getByLabelText('Nombre d’équipements en %') + expect(champNombreEquipement).toHaveAttribute('max', '100') + expect(champNombreEquipement).toHaveAttribute('min', '-100') + expect(champNombreEquipement).toHaveAttribute('type', 'number') + expect(champNombreEquipement).toHaveValue(0) + }) + + it('quand j’affiche la page alors je ne peux pas dépasser -20 et 20 dans le champ Durée de vie en années', async () => { + // GIVEN + jeSuisUnUtilisateur() + + // WHEN + renderComponent(await PageCreerUneSimulation(queryParams())) + + // THEN + const champDureeDeVie = screen.getByLabelText('Durée de vie en années') + expect(champDureeDeVie).toHaveAttribute('max', '20') + expect(champDureeDeVie).toHaveAttribute('min', '-20') + expect(champDureeDeVie).toHaveAttribute('type', 'number') + expect(champDureeDeVie).toHaveValue(0) + }) + + it('quand j’affiche la page alors je ne peux pas dépasser -24 et 24 dans le champ Heures d’utilisation par jour', async () => { + // GIVEN + jeSuisUnUtilisateur() + + // WHEN + renderComponent(await PageCreerUneSimulation(queryParams())) + + // THEN + const champHeuresUtilisation = screen.getByLabelText('Heures d’utilisation par jour') + expect(champHeuresUtilisation).toHaveAttribute('max', '24') + expect(champHeuresUtilisation).toHaveAttribute('min', '-24') + expect(champHeuresUtilisation).toHaveAttribute('type', 'number') + expect(champHeuresUtilisation).toHaveValue(0) + }) + + it('quand j’écris un nom d’inventaire inférieur à 4 caractères alors je ne peux pas créer la simulation', async () => { + // GIVEN + jeSuisUnUtilisateur() + renderComponent(await PageCreerUneSimulation(queryParams())) + const champNomInventaire = screen.getByLabelText('Nom de l’inventaire * (minimum 4 caractères)') + + // WHEN + fireEvent.change(champNomInventaire, { target: { value: '???' } }) + + // THEN + const boutonContinuer = screen.getByRole('button', { name: 'Continuer' }) + expect(boutonContinuer).toBeDisabled() + }) + + it('quand je valide le formulaire avec un nom d’inventaire qui existe déjà alors j’ai un message d’erreur', async () => { + // GIVEN + vi.spyOn(repository, 'recupererUnInventaireRepository').mockResolvedValueOnce({} as inventaireModel) + jeSuisUnUtilisateur() + renderComponent(await PageCreerUneSimulation(queryParams())) + const champNomInventaire = screen.getByLabelText('Nom de l’inventaire * (minimum 4 caractères)') + fireEvent.change(champNomInventaire, { target: { value: 'nom inventaire dejà exitant' } }) + const boutonContinuer = screen.getByRole('button', { name: 'Continuer' }) + + // WHEN + fireEvent.click(boutonContinuer) + + // THEN + const champNomInventaireMaj = await screen.findByLabelText('Nom de l’inventaire * (minimum 4 caractères)') + expect(champNomInventaireMaj).toHaveAttribute('aria-describedby', 'formInputError-error') + expect(champNomInventaireMaj).toHaveAttribute('aria-invalid', 'true') + + const textErreur = await screen.findByText('Cet inventaire existe déjà. Modifiez le nom de l’inventaire pour continuer.', { selector: 'p' }) + expect(textErreur).toBeInTheDocument() + }) + + it('quand je valide le formulaire alors je vais à la suite', async () => { + // GIVEN + vi.spyOn(repository, 'recupererUnInventaireRepository').mockResolvedValue(null) + jeSuisUnUtilisateur() + renderComponent(await PageCreerUneSimulation(queryParams())) + + const champNomInventaire = screen.getByLabelText('Nom de l’inventaire * (minimum 4 caractères)') + fireEvent.change(champNomInventaire, { target: { value: 'nom inventaire correct' } }) + const boutonContinuer = screen.getByRole('button', { name: 'Continuer' }) + const champNombreEquipement = screen.getByLabelText('Nombre d’équipements en %') + fireEvent.change(champNombreEquipement, { target: { value: 10 } }) + const champDureeDeVie = screen.getByLabelText('Durée de vie en années') + fireEvent.change(champDureeDeVie, { target: { value: 10 } }) + const champHeuresUtilisation = screen.getByLabelText('Heures d’utilisation par jour') + fireEvent.change(champHeuresUtilisation, { target: { value: 10 } }) + + // WHEN + fireEvent.click(boutonContinuer) + + // THEN + expect(navigation.useRouter).toHaveBeenCalledWith() + // expect(navigation.useRouter.push).toHaveBeenCalledWith('') + }) + }) + + describe('en tant qu’admin', () => { + it('quand j’affiche la page alors je n’y ai pas accès', async () => { + // GIVEN + jeSuisUnAdmin() + + // WHEN + const page = async () => renderComponent(await PageCreerUneSimulation(queryParams())) + + // THEN + await expect(page).rejects.toThrow('NEXT_NOT_FOUND') + }) + }) + + it('quand il n’y a pas de nom d’inventaire dans l’url alors je n’y ai pas accès', async () => { + // GIVEN + const queryParams = {} + + // WHEN + const page = async () => renderComponent(await PageCreerUneSimulation(queryParams)) + + // THEN + await expect(page).rejects.toThrow('NEXT_NOT_FOUND') + }) +}) + +function queryParams() { + return { + searchParams: { + nomInventaire: 'Centre hospitalier', + }, + } +} diff --git a/src/components/CreerUneSimulation/CreerUneSimulation.tsx b/src/components/CreerUneSimulation/CreerUneSimulation.tsx new file mode 100644 index 0000000..cb66672 --- /dev/null +++ b/src/components/CreerUneSimulation/CreerUneSimulation.tsx @@ -0,0 +1,149 @@ +'use client' + +import { ReactElement } from 'react' + +import styles from './CreerUneSimulation.module.css' +import { useCreerUneSimulation } from './useCreerUneSimulation' +import { formaterLeNomEtablissement } from '../../presenters/sharedPresenter' +import InfoBulle from '../commun/Infobulle' + +type CreerUneSimulationProps = Readonly<{ + ancienNomInventaire: string + nomEtablissement: string +}> + +export default function CreerUneSimulation({ ancienNomInventaire, nomEtablissement }: CreerUneSimulationProps): ReactElement { + const { + creerUneSimulation, + dureeDeVie, + heureUtilisation, + isDisabled, + isInvalid, + modifierDureeDeVie, + modifierHeureUtilisation, + modifierNombreEquipement, + modifierNouveauNomInventaire, + nombreEquipement, + nouveauNomInventaire, + } = useCreerUneSimulation(ancienNomInventaire) + + return ( +
+
+

+ Créer une simulation +

+

+ pour l’établissement + {' '} + {formaterLeNomEtablissement(nomEtablissement)} +

+

+ Explorez une stratégie de réduction de votre empreinte environnementale, en faisant varier certaines données de votre inventaire. +
+ * champs obligatoires +

+
+
+ + + + { + isInvalid ? ( +

+ Cet inventaire existe déjà. Modifiez le nom de l’inventaire pour continuer. +

+ ) : null + } + + +
+
+

+ Variation d’opportunités de réduction +

+

+ Ces variations, positives ou négatives, seront appliquées à l’ensemble de votre inventaire initial. +

+ + + + + + + + + +
+ +
+
+
+ ) +} diff --git a/src/components/CreerUneSimulation/action.ts b/src/components/CreerUneSimulation/action.ts new file mode 100644 index 0000000..4f1aecb --- /dev/null +++ b/src/components/CreerUneSimulation/action.ts @@ -0,0 +1,16 @@ +'use server' + +import { getProfilAtih } from '../../authentification' +import { recupererUnInventaireRepository } from '../../gateways/inventairesRepository' + +export async function estCeQueLeNomInventaireExisteAction(nomInventaire: string): Promise { + const profil = await getProfilAtih() + + const inventaire = await recupererUnInventaireRepository(profil.nomEtablissement, nomInventaire) + + if (inventaire) { + return true + } + + return false +} diff --git a/src/components/CreerUneSimulation/useCreerUneSimulation.ts b/src/components/CreerUneSimulation/useCreerUneSimulation.ts new file mode 100644 index 0000000..de0eb93 --- /dev/null +++ b/src/components/CreerUneSimulation/useCreerUneSimulation.ts @@ -0,0 +1,108 @@ +import { useRouter } from 'next/navigation' +import { FormEvent, useState } from 'react' + +import { estCeQueLeNomInventaireExisteAction } from './action' +import { StatutsInventaire } from '../../presenters/sharedPresenter' + +type State = Readonly<{ + isDisabled: boolean + isInvalid: boolean + nouveauNomInventaire: string +}> + +type UseCreerUneSimulation = Readonly<{ + creerUneSimulation: (event: FormEvent) => Promise + dureeDeVie: string + heureUtilisation: string + isDisabled: boolean + isInvalid: boolean + modifierDureeDeVie: (event: FormEvent) => void + modifierHeureUtilisation: (event: FormEvent) => void + modifierNombreEquipement: (event: FormEvent) => void + modifierNouveauNomInventaire: (event: FormEvent) => void + nombreEquipement: string + nouveauNomInventaire: string +}> + +export function useCreerUneSimulation(ancienNomInventaire: string): UseCreerUneSimulation { + const date = new Date() + const router = useRouter() + const [state, setState] = useState({ + isDisabled: false, + isInvalid: false, + nouveauNomInventaire: `${ancienNomInventaire} - simulation ${date.toLocaleString()}`, + }) + const [nombreEquipement, setNombreEquipement] = useState('0') + const [dureeDeVie, setDureeDeVie] = useState('0') + const [heureUtilisation, setHeureUtilisation] = useState('0') + + const modifierNombreEquipement = (event: FormEvent) => { + setNombreEquipement(event.currentTarget.value) + } + + const modifierDureeDeVie = (event: FormEvent) => { + setDureeDeVie(event.currentTarget.value) + } + + const modifierHeureUtilisation = (event: FormEvent) => { + setHeureUtilisation(event.currentTarget.value) + } + + const creerUneSimulation = async (event: FormEvent) => { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const nouveauNomInventaire = formData.get('nouveauNomInventaire') as string + const nomEtablissement = formData.get('nomEtablissement') as string + const nombreEquipement = formData.get('nombreEquipement') as string + const dureeDeVie = formData.get('dureeDeVie') as string + const heureUtilisation = formData.get('heureUtilisation') as string + + const nomInventaireExiste = await estCeQueLeNomInventaireExisteAction(nouveauNomInventaire) + + if (!nomInventaireExiste) { + const url = new URL('/inventaire', document.location.href) + url.searchParams.append('nomEtablissement', nomEtablissement) + url.searchParams.append('nomInventaire', ancienNomInventaire) + url.searchParams.append('nouveauNomInventaire', nouveauNomInventaire) + url.searchParams.append('nombreEquipement', nombreEquipement) + url.searchParams.append('dureeDeVie', dureeDeVie) + url.searchParams.append('heureUtilisation', heureUtilisation) + url.searchParams.append('statut', StatutsInventaire.TRAITE) + + router.push(url.toString()) + } else { + setState({ + isDisabled: true, + isInvalid: true, + nouveauNomInventaire: state.nouveauNomInventaire, + }) + } + } + + const modifierNouveauNomInventaire = (event: FormEvent) => { + const caracteresMinimumPourUnNomInventaire = 4 + + setState({ + isDisabled: event.currentTarget.value.length >= caracteresMinimumPourUnNomInventaire ? false : true, + isInvalid: false, + nouveauNomInventaire: event.currentTarget.value, + }) + } + + return { + creerUneSimulation, + dureeDeVie, + heureUtilisation, + isDisabled: state.isDisabled, + isInvalid: state.isInvalid, + modifierDureeDeVie, + modifierHeureUtilisation, + modifierNombreEquipement, + modifierNouveauNomInventaire, + nombreEquipement, + nouveauNomInventaire: state.nouveauNomInventaire, + } +} + + diff --git a/src/components/IndicateursCles/IndicateursCles.tsx b/src/components/IndicateursCles/IndicateursCles.tsx index e0305d0..bab8144 100644 --- a/src/components/IndicateursCles/IndicateursCles.tsx +++ b/src/components/IndicateursCles/IndicateursCles.tsx @@ -1,5 +1,6 @@ 'use client' +import Link from 'next/link' import { ReactElement } from 'react' import { Bar, Pie } from 'react-chartjs-2' @@ -36,7 +37,7 @@ export default function IndicateursCles({ nomEtablissement={nomEtablissement} nomInventaire={nomInventaire} /> -
+

@@ -239,6 +240,72 @@ export default function IndicateursCles({

+
+
+

+ Simulez vos réductions d’empreinte +

+

+ Découvrez comment réduire votre empreinte environnementale en simulant différentes opportunités grâce à notre fonction de simulation. + Créez une simulation de cet inventaire et saisissez vos hypothèses de réduction en jouant sur trois données : +

+
    +
  • + la durée de vie ; +
  • +
  • + la quantité d’équipements ; +
  • +
  • + le nombre d’heures d’utilisation. +
  • +
+ + Créer une simulation + +
+
+ + + + + + + + + +
+
, modelesModel: ReadonlyArray, - statut: StatutsInventaire + searchParams: PageProps['searchParams'] ): InventairePresenter { + const dateInventaire = modelesModel.length === 0 || searchParams.nouveauNomInventaire !== undefined ? new Date() : modelesModel[0].dateInventaire + + const isNonCalcule = (searchParams.statut ?? StatutsInventaire.EN_ATTENTE) === StatutsInventaire.TRAITE + const equipementsAvecSesModeles = referentielsTypesEquipementsModel.map((referentielTypeEquipementModel): EquipementAvecSesModelesPresenter => { return { modeles: referentielTypeEquipementModel.modeles @@ -30,9 +35,12 @@ export function inventairePresenter( const equipementsModelFiltre = modelesModel.filter((modeleModel): boolean => modeleModel.nom === modele.relationModeles.nom) if (equipementsModelFiltre.length > 0) { - quantite = equipementsModelFiltre[0].quantite - dureeDeVie = calculerLaDureeDeVie(equipementsModelFiltre[0].dateAchat) - heureUtilisation = convertirLeTauxUtilisationEnHeureUtilisation(equipementsModelFiltre[0].tauxUtilisation) + quantite = modifierQuantite(equipementsModelFiltre[0].quantite, searchParams.nombreEquipement) + dureeDeVie = modifierDureeDeVie(calculerLaDureeDeVie(equipementsModelFiltre[0].dateAchat), searchParams.dureeDeVie) + heureUtilisation = modifierHeureUtilisation( + convertirLeTauxUtilisationEnHeureUtilisation(equipementsModelFiltre[0].tauxUtilisation), + searchParams.heureUtilisation + ) } return { @@ -47,11 +55,44 @@ export function inventairePresenter( } }) - const dateInventaire = modelesModel.length === 0 ? new Date() : modelesModel[0].dateInventaire - return { dateInventaire: formaterLaDateEnFrancais(dateInventaire), equipementsAvecSesModeles, - isNonCalcule: statut === StatutsInventaire.TRAITE, + isNonCalcule, + } +} + +function modifierQuantite(quantite: number, pourcentage?: string): number { + return pourcentage !== undefined ? Math.round(quantite + quantite * Number(pourcentage) / 100) : quantite +} + +function modifierDureeDeVie(dureeDeVie: number, ajout?: string): number { + const dureeDeVieMinimum = 1 + + if (ajout !== undefined) { + const resultat = dureeDeVie + Number(ajout) + + if (resultat < dureeDeVieMinimum) return dureeDeVieMinimum + + return resultat + } + + return dureeDeVie +} + +function modifierHeureUtilisation(heureUtilisation: number, ajout?: string): number { + const heureUtilisationMinimum = 1 + const heureUtilisationMaximum = 24 + + if (ajout !== undefined) { + const resultat = heureUtilisation + Number(ajout) + + if (resultat < heureUtilisationMinimum) return heureUtilisationMinimum + + if (resultat > heureUtilisationMaximum) return heureUtilisationMaximum + + return resultat } + + return heureUtilisation } diff --git a/vitest.config.ts b/vitest.config.ts index 615a5d3..7d31a91 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ include: [ 'src/app/(connecte)/modifier-un-referentiel/**/*', 'src/components/ModifierUnReferentiel/**/*', + 'src/app/(connecte)/creer-une-simulation/*', + 'src/components/CreerUneSimulation/**/*', 'src/app/(connecte)/page.tsx', 'src/components/Inventaires/**/*', 'src/app/(deconnecte)/connexion/**/*', diff --git a/vitest.setup.ts b/vitest.setup.ts index 78ae1e1..38cfa4f 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,14 @@ import 'vitest-dom/extend-expect' +// Date par défaut dans tous les tests +class StubedDate extends Date { + constructor() { + super('1995-12-17T03:24:00') + } +} +// @ts-expect-error +global.Date = StubedDate + vi.mock('next/navigation', () => { return { notFound: vi.fn(() => { @@ -9,7 +18,11 @@ vi.mock('next/navigation', () => { throw new Error('NEXT REDIRECT ' + destination) }), usePathname: vi.fn(), - useRouter: vi.fn(), + useRouter: vi.fn(() => { + return { + push: vi.fn(), + } + }), } })