From beb51be9d030ce6ea78eff321bd37e97fa52a66d Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 10:36:28 +0200 Subject: [PATCH 01/15] ongoing --- dashboard/src/components/ActionModal.js | 84 ++++++++++++++++++++++--- dashboard/src/recoil/actions.js | 3 + 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/ActionModal.js b/dashboard/src/components/ActionModal.js index 33274fff5..c0831d0a2 100644 --- a/dashboard/src/components/ActionModal.js +++ b/dashboard/src/components/ActionModal.js @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import { toast } from 'react-toastify'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useHistory, useLocation } from 'react-router-dom'; -import { actionsState, CANCEL, DONE, mappedIdsToLabels, prepareActionForEncryption, TODO } from '../recoil/actions'; +import { actionsState, allowedActionFieldsInHistory, CANCEL, DONE, mappedIdsToLabels, prepareActionForEncryption, TODO } from '../recoil/actions'; import { currentTeamState, organisationState, teamsState, userState } from '../recoil/auth'; import { dayjsInstance, now, outOfBoundariesDate } from '../services/date'; import API from '../services/api'; @@ -80,7 +80,7 @@ export default function ActionModal() { ); } -const ActionContent = ({ onClose, action, personId = null, personIds = null, isMulti = false, completedAt = null, dueAt = null }) => { +function ActionContent({ onClose, action, personId = null, personIds = null, isMulti = false, completedAt = null, dueAt = null }) { const teams = useRecoilValue(teamsState); const user = useRecoilValue(userState); const organisation = useRecoilValue(organisationState); @@ -282,6 +282,18 @@ const ActionContent = ({ onClose, action, personId = null, personIds = null, isM if (!body.dueAt) body.dueAt = data.completedAt || new Date(); delete body.team; + + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in body) { + if (!allowedActionFieldsInHistory.includes(key)) continue; + if (body[key] !== action[key]) historyEntry.data[key] = { oldValue: action[key], newValue: body[key] }; + } + if (!!Object.keys(historyEntry.data).length) body.history = [...(action.history || []), historyEntry]; + const actionResponse = await API.put({ path: `/action/${action._id}`, body: prepareActionForEncryption({ ...body, user: data.user || user._id }), @@ -371,12 +383,13 @@ const ActionContent = ({ onClose, action, personId = null, personIds = null, isM {!['restricted-access'].includes(user.role) && data?._id && ( { - if (index === 0) setActiveTab('Informations'); - if (index === 1) setActiveTab('Commentaires'); + if (tab.includes('Informations')) setActiveTab('Informations'); + if (tab.includes('Commentaires')) setActiveTab('Commentaires'); + if (tab.includes('Historique')) setActiveTab('Historique'); }} - activeTabIndex={['Informations', 'Commentaires'].findIndex((tab) => tab === activeTab)} + activeTabIndex={['Informations', 'Commentaires', 'Historique'].findIndex((tab) => tab === activeTab)} /> )}
); -}; +} + +function ActionHistory({ action }) { + const history = useMemo(() => (action.history || []).reverse(), [action.history]); + + return ( +
+
+

Historique

+
+ + + + + + + + + + {history.map((h) => { + return ( + + + + + + ); + })} + +
DateUtilisateurDonnée
{dayjsInstance(h.date).format('DD/MM/YYYY HH:mm')} + + + {Object.entries(h.data).map(([key, value]) => { + const personField = personFieldsIncludingCustomFields.find((f) => f.name === key); + if (key === 'teams') { + return ( +

+ {personField?.label} : + "{(value.oldValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" + + "{(value.newValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" +

+ ); + } + + return ( +

+ {personField?.label} :
+ {JSON.stringify(value.oldValue || '')}{JSON.stringify(value.newValue)} +

+ ); + })} +
+
+ ); +} diff --git a/dashboard/src/recoil/actions.js b/dashboard/src/recoil/actions.js index 6809fa547..802cb385e 100644 --- a/dashboard/src/recoil/actions.js +++ b/dashboard/src/recoil/actions.js @@ -42,8 +42,11 @@ const encryptedFields = [ 'teams', 'user', 'urgent', + 'history', ]; +export const allowedActionFieldsInHistory = encryptedFields.filter((field) => !['category', 'team', 'history'].includes(field)); + export const prepareActionForEncryption = (action, { checkRequiredFields = true } = {}) => { if (!!checkRequiredFields) { try { From 3b04628f09dececa76590c7fa5f6ffcc3fb7cf82 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 11:56:28 +0200 Subject: [PATCH 02/15] feat: historique pour les actions --- app/src/recoil/actions.js | 13 +++ app/src/scenes/Actions/Action.js | 25 ++--- dashboard/src/components/ActionModal.js | 136 ++++++++++++++---------- dashboard/src/recoil/actions.js | 13 ++- 4 files changed, 115 insertions(+), 72 deletions(-) diff --git a/app/src/recoil/actions.js b/app/src/recoil/actions.js index d9381d19d..c8bd50f14 100644 --- a/app/src/recoil/actions.js +++ b/app/src/recoil/actions.js @@ -44,6 +44,19 @@ const encryptedFields = [ 'history', ]; +export const allowedActionFieldsInHistory = [ + { name: 'categories', label: 'Catégorie(s)' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'group', label: 'Action familiale' }, + { name: 'name', label: "Nom de l'action" }, + { name: 'description', label: 'Description' }, + { name: 'teams', label: 'Équipe(s) en charge' }, + { name: 'urgent', label: 'Action urgente' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, +]; + export const prepareActionForEncryption = (action) => { try { if (!looseUuidRegex.test(action.person)) { diff --git a/app/src/scenes/Actions/Action.js b/app/src/scenes/Actions/Action.js index 9df21c7f1..d82bd5756 100644 --- a/app/src/scenes/Actions/Action.js +++ b/app/src/scenes/Actions/Action.js @@ -21,7 +21,7 @@ import ActionCategoriesModalSelect from '../../components/ActionCategoriesModalS import Label from '../../components/Label'; import Tags from '../../components/Tags'; import { MyText } from '../../components/MyText'; -import { actionsState, DONE, CANCEL, TODO, prepareActionForEncryption, mappedIdsToLabels } from '../../recoil/actions'; +import { actionsState, DONE, CANCEL, TODO, prepareActionForEncryption, allowedActionFieldsInHistory } from '../../recoil/actions'; import { useRecoilState, useRecoilValue } from 'recoil'; import { commentsState, prepareCommentForEncryption } from '../../recoil/comments'; import API from '../../services/api'; @@ -205,6 +205,18 @@ const Action = ({ navigation, route }) => { } } delete action.team; + + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in action) { + if (!allowedActionFieldsInHistory.map((field) => field.name).includes(key)) continue; + if (action[key] !== oldAction[key]) historyEntry.data[key] = { oldValue: oldAction[key], newValue: action[key] }; + } + if (!!Object.keys(historyEntry.data).length) action.history = [...(action.history || []), historyEntry]; + const response = await API.put({ path: `/action/${oldAction._id}`, body: prepareActionForEncryption(action), @@ -218,17 +230,6 @@ const Action = ({ navigation, route }) => { }) ); if (!!newAction.completedAt) await createReportAtDateIfNotExist(newAction.completedAt); - if (!statusChanged) return response; - const comment = { - comment: `${user.name} a changé le status de l'action: ${mappedIdsToLabels.find((status) => status._id === newAction.status)?.name}`, - action: actionDB?._id, - team: currentTeam._id, - user: user._id, - organisation: organisation._id, - date: new Date().toISOString(), - }; - const commentResponse = await API.post({ path: '/comment', body: prepareCommentForEncryption(comment) }); - if (commentResponse.ok) setComments((comments) => [commentResponse.decryptedData, ...comments]); return response; } catch (error) { capture(error, { extra: { message: 'error in updating action', action } }); diff --git a/dashboard/src/components/ActionModal.js b/dashboard/src/components/ActionModal.js index c0831d0a2..d3b8fc876 100644 --- a/dashboard/src/components/ActionModal.js +++ b/dashboard/src/components/ActionModal.js @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import { toast } from 'react-toastify'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useHistory, useLocation } from 'react-router-dom'; -import { actionsState, allowedActionFieldsInHistory, CANCEL, DONE, mappedIdsToLabels, prepareActionForEncryption, TODO } from '../recoil/actions'; +import { actionsState, allowedActionFieldsInHistory, CANCEL, DONE, prepareActionForEncryption, TODO } from '../recoil/actions'; import { currentTeamState, organisationState, teamsState, userState } from '../recoil/auth'; import { dayjsInstance, now, outOfBoundariesDate } from '../services/date'; import API from '../services/api'; @@ -289,7 +289,7 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM data: {}, }; for (const key in body) { - if (!allowedActionFieldsInHistory.includes(key)) continue; + if (!allowedActionFieldsInHistory.map((field) => field.name).includes(key)) continue; if (body[key] !== action[key]) historyEntry.data[key] = { oldValue: action[key], newValue: body[key] }; } if (!!Object.keys(historyEntry.data).length) body.history = [...(action.history || []), historyEntry]; @@ -312,17 +312,6 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM await createReportAtDateIfNotExist(newAction.completedAt); } } - if (statusChanged) { - const comment = { - comment: `${user.name} a changé le status de l'action: ${mappedIdsToLabels.find((status) => status._id === newAction.status)?.name}`, - action: action._id, - team: currentTeam._id, - user: user._id, - organisation: organisation._id, - }; - const commentResponse = await API.post({ path: '/comment', body: prepareCommentForEncryption(comment) }); - if (commentResponse.ok) setComments((comments) => [commentResponse.decryptedData, ...comments]); - } toast.success('Mise à jour !'); if (location.pathname !== '/stats') refresh(); // if we refresh when we're on stats page, it will remove the view we're on const actionCancelled = action.status !== CANCEL && body.status === CANCEL; @@ -658,6 +647,12 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM />
)} +
+ +
@@ -692,58 +687,81 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM } function ActionHistory({ action }) { - const history = useMemo(() => (action.history || []).reverse(), [action.history]); + const history = useMemo(() => [...(action?.history || [])].reverse(), [action?.history]); + const teams = useRecoilValue(teamsState); return (
-
-

Historique

-
- - - - - - - - - - {history.map((h) => { - return ( - - - - + + ); + })} + +
DateUtilisateurDonnée
{dayjsInstance(h.date).format('DD/MM/YYYY HH:mm')} - - - {Object.entries(h.data).map(([key, value]) => { - const personField = personFieldsIncludingCustomFields.find((f) => f.name === key); - if (key === 'teams') { + {!history?.length ? ( +
+

Cette action n'a pas encore d'historique.

+

+ Lorsqu'une action est modifiée, les changements sont enregistrés dans un historique, +
+ que vous pourrez ainsi retrouver sur cette page. +

+
+ ) : ( + + + + + + + + + + {history.map((h) => { + return ( + + + + - - ); - })} - -
DateUtilisateurDonnée
{dayjsInstance(h.date).format('DD/MM/YYYY HH:mm')} + + + {Object.entries(h.data).map(([key, value]) => { + const actionField = allowedActionFieldsInHistory.find((f) => f.name === key); + if (key === 'teams') { + return ( +

+ {actionField?.label} : + "{(value.oldValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" + + "{(value.newValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" +

+ ); + } + if (key === 'person') { + return ( +

+ {actionField?.label} :
+ + + {' '} + ➔{' '} + + + +

+ ); + } + return ( -

- {personField?.label} : - "{(value.oldValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" - - "{(value.newValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" +

+ {actionField?.label} :
+ {JSON.stringify(value.oldValue || '')}{JSON.stringify(value.newValue)}

); - } - - return ( -

- {personField?.label} :
- {JSON.stringify(value.oldValue || '')}{JSON.stringify(value.newValue)} -

- ); - })} -
+ })} +
+ )}
); } diff --git a/dashboard/src/recoil/actions.js b/dashboard/src/recoil/actions.js index 802cb385e..5be0c19ac 100644 --- a/dashboard/src/recoil/actions.js +++ b/dashboard/src/recoil/actions.js @@ -45,7 +45,18 @@ const encryptedFields = [ 'history', ]; -export const allowedActionFieldsInHistory = encryptedFields.filter((field) => !['category', 'team', 'history'].includes(field)); +export const allowedActionFieldsInHistory = [ + { name: 'categories', label: 'Catégorie(s)' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'group', label: 'Action familiale' }, + { name: 'name', label: "Nom de l'action" }, + { name: 'description', label: 'Description' }, + { name: 'teams', label: 'Équipe(s) en charge' }, + { name: 'urgent', label: 'Action urgente' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, +]; export const prepareActionForEncryption = (action, { checkRequiredFields = true } = {}) => { if (!!checkRequiredFields) { From 271c26496e00371572caf8abf3496ea967de1617 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 15:07:33 +0200 Subject: [PATCH 03/15] migration --- api/src/controllers/migration.js | 23 ++++++++++ dashboard/src/components/DataMigrator.js | 55 +++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/migration.js b/api/src/controllers/migration.js index 0106ff198..0bba2014c 100644 --- a/api/src/controllers/migration.js +++ b/api/src/controllers/migration.js @@ -344,6 +344,29 @@ router.put( } } + if (req.params.migrationName === "integrate-comments-in-actions-history") { + try { + z.array(z.string().regex(looseUuidRegex)).parse(req.body.commentIdsToDelete); + z.array( + z.object({ + _id: z.string().regex(looseUuidRegex), + encrypted: z.string(), + encryptedEntityKey: z.string(), + }) + ).parse(req.body.actionsToUpdate); + } catch (e) { + const error = new Error(`Invalid request in reports-from-real-date-to-date-id migration: ${e}`); + error.status = 400; + throw error; + } + for (const _id of req.body.commentIdsToDelete) { + await Comment.destroy({ where: { _id, organisation: req.user.organisation }, transaction: tx }); + } + for (const { _id, encrypted, encryptedEntityKey } of req.body.actionsToUpdate) { + await Action.update({ encrypted, encryptedEntityKey }, { where: { _id }, transaction: tx, paranoid: false }); + } + } + organisation.set({ migrations: [...(organisation.migrations || []), req.params.migrationName], migrating: false, diff --git a/dashboard/src/components/DataMigrator.js b/dashboard/src/components/DataMigrator.js index ba6b9149e..d901b40de 100644 --- a/dashboard/src/components/DataMigrator.js +++ b/dashboard/src/components/DataMigrator.js @@ -1,5 +1,5 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { prepareActionForEncryption } from '../recoil/actions'; +import { mappedIdsToLabels, prepareActionForEncryption } from '../recoil/actions'; import { organisationState, userState } from '../recoil/auth'; import { usePreparePersonForEncryption } from '../recoil/persons'; import { prepareReportForEncryption } from '../recoil/reports'; @@ -403,6 +403,59 @@ export default function useDataMigrator() { migrationLastUpdateAt = response.organisation.migrationLastUpdateAt; } } + + if (!organisation.migrations?.includes('integrate-comments-in-actions-history')) { + setLoadingText(LOADING_TEXT); + const comments = await API.get({ + path: '/comment', + query: { organisation: organisationId, after: 0, withDeleted: false }, + }).then((res) => res.decryptedData || []); + const actions = await API.get({ + path: '/action', + query: { organisation: organisationId, after: 0, withDeleted: false }, + }).then((res) => res.decryptedData || []); + + const actionsPerId = {}; + for (const action of actions) { + actionsPerId[action._id] = action; + } + const actionsToUpdate = {}; + const commentIdsToDelete = []; + + for (const comment of comments) { + if (!comment.action) continue; + if (!comment.comment.includes("a changé le status de l'action: ")) continue; + const action = actionsToUpdate[comment.action] ?? actionsPerId[comment.action]; + if (!action) { + commentIdsToDelete.push(comment._id); + continue; + } + if (!action.history) action.history = []; + const statusName = comment.comment.split("a changé le status de l'action: ")[1]; + const statusId = mappedIdsToLabels.find((e) => e.name === statusName)?._id; + action.history.push({ + user: comment.user, + date: comment.createdAt, + data: { + status: { oldValue: '', newValue: statusId }, + }, + }); + actionsToUpdate[comment.action] = action; + commentIdsToDelete.push(comment._id); + } + + const encryptedActionsToUpdate = await Promise.all(Object.values(actionsToUpdate).map(prepareActionForEncryption).map(encryptItem)); + + const response = await API.put({ + path: `/migration/integrate-comments-in-actions-history`, + body: { commentIdsToDelete, actionsToUpdate: encryptedActionsToUpdate }, + query: { migrationLastUpdateAt }, + }); + if (response.ok) { + setOrganisation(response.organisation); + migrationLastUpdateAt = response.organisation.migrationLastUpdateAt; + } + } }, }; } From e0b962fd63278d5558406470b901fbf3d17bf654 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 15:54:29 +0200 Subject: [PATCH 04/15] force app update --- README.md | 2 +- api/package.json | 2 +- api/src/middleware/versionCheck.js | 5 ++--- app/android/app/build.gradle | 4 ++-- app/app.json | 4 ++-- app/package.json | 2 +- website/package.json | 2 +- 7 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 42e905a28..d2d4b41ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mano -![Mobile version](https://img.shields.io/badge/mobile%20app%20version-2.36.2-blue) +![Mobile version](https://img.shields.io/badge/mobile%20app%20version-2.37.0-blue) [![Maintainability](https://api.codeclimate.com/v1/badges/223e4185a3e13f1ef5d0/maintainability)](https://codeclimate.com/github/SocialGouv/mano/maintainability) Code source de [Mano](https://mano-app.fabrique.social.gouv.fr/), organisé en plusieurs services : diff --git a/api/package.json b/api/package.json index a98514011..393ac79d3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "api_mano", "version": "1.283.8", - "mobileAppVersion": "2.36.2", + "mobileAppVersion": "2.37.0", "description": "", "main": "index.js", "scripts": { diff --git a/api/src/middleware/versionCheck.js b/api/src/middleware/versionCheck.js index 5625edbed..3f0b224fa 100644 --- a/api/src/middleware/versionCheck.js +++ b/api/src/middleware/versionCheck.js @@ -1,6 +1,6 @@ const { VERSION, MINIMUM_DASHBOARD_VERSION } = require("../config"); -const MINIMUM_MOBILE_APP_VERSION = [2, 35, 0]; +const MINIMUM_MOBILE_APP_VERSION = [2, 37, 0]; module.exports = ({ headers: { version, platform } }, res, next) => { if (platform === "website") return next(); @@ -28,8 +28,7 @@ module.exports = ({ headers: { version, platform } }, res, next) => { inAppMessage: [ `Veuillez mettre à jour votre application\u00A0!`, `Les fonctionnalités de cette nouvelle version sont\u00A0: -- Compatibilité avec les consultations par équipe -- Possibilité de rajouter des commentaires dans les consultations, les traitements et les dossiers médicaux`, +- Compatibilité de l 'historique des actions (seulement sur navigateur)`, [{ text: "Télécharger la dernière version", link: `https://mano-app.fabrique.social.gouv.fr/download?ts=${Date.now()}` }], ], }); diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index bf4eb3c5b..52bcf5e20 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -120,8 +120,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled true - versionCode 16 - versionName "2.36.2" + versionCode 17 + versionName "2.37.0" testBuildType System.getProperty('testBuildType', 'debug') // This will later be used to control the test apk build type testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/app/app.json b/app/app.json index dd9d604fb..24b4efcc6 100644 --- a/app/app.json +++ b/app/app.json @@ -2,8 +2,8 @@ "name": "mano", "displayName": "Mano", "version": { - "buildNumber": 16, - "buildName": "2.36.2" + "buildNumber": 17, + "buildName": "2.37.0" }, "bundle": { "android": "com.mano", diff --git a/app/package.json b/app/package.json index c04e68d53..d06f8a2c6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "mano", - "version": "2.36.2", + "version": "2.37.0", "private": true, "scripts": { "get-ip": "./get-ip.sh", diff --git a/website/package.json b/website/package.json index 8eeb0843b..4aa4a2ada 100644 --- a/website/package.json +++ b/website/package.json @@ -1,7 +1,7 @@ { "name": "website", "version": "1.283.8", - "mobileAppVersion": "2.36.2", + "mobileAppVersion": "2.37.0", "private": true, "engines": { "npm": "please-use-yarn", From 84c6663f247b8a75f3f25c3ac74b776753940f88 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 15:59:43 +0200 Subject: [PATCH 05/15] clean comments --- app/src/scenes/Actions/Action.js | 2 +- app/src/scenes/Reports/selectors.js | 1 - dashboard/src/components/ActionModal.js | 2 +- dashboard/src/recoil/selectors.js | 2 +- dashboard/src/scenes/report/view.js | 1 - 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/scenes/Actions/Action.js b/app/src/scenes/Actions/Action.js index d82bd5756..de3a0a556 100644 --- a/app/src/scenes/Actions/Action.js +++ b/app/src/scenes/Actions/Action.js @@ -314,7 +314,7 @@ const Action = ({ navigation, route }) => { setActions((actions) => [response.decryptedData, ...actions]); await createReportAtDateIfNotExist(response.decryptedData.createdAt); - for (let c of comments.filter((c) => c.action === actionDB._id).filter((c) => !c.comment.includes('a changé le status'))) { + for (let c of comments.filter((c) => c.action === actionDB._id)) { const body = { comment: c.comment, action: response.decryptedData._id, diff --git a/app/src/scenes/Reports/selectors.js b/app/src/scenes/Reports/selectors.js index 847c18ec7..66c2494ea 100644 --- a/app/src/scenes/Reports/selectors.js +++ b/app/src/scenes/Reports/selectors.js @@ -64,7 +64,6 @@ export const commentsForReport = selectorFamily({ const currentTeam = get(currentTeamState); const filteredComments = comments .filter((c) => c.team === currentTeam._id) - .filter((c) => !c.comment.includes('a changé le status')) .filter((c) => getIsDayWithinHoursOffsetOfDay(c.date || c.createdAt, date, currentTeam?.nightSession ? 12 : 0)) .map((comment) => { const commentPopulated = { ...comment }; diff --git a/dashboard/src/components/ActionModal.js b/dashboard/src/components/ActionModal.js index d3b8fc876..2bf1bbae7 100644 --- a/dashboard/src/components/ActionModal.js +++ b/dashboard/src/components/ActionModal.js @@ -227,7 +227,7 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM return; } setActions((actions) => [response.decryptedData, ...actions]); - for (let c of action.comments.filter((c) => c.action === action._id).filter((c) => !c.comment.includes('a changé le status'))) { + for (let c of action.comments.filter((c) => c.action === action._id)) { const body = { comment: c.comment, action: response.decryptedData._id, diff --git a/dashboard/src/recoil/selectors.js b/dashboard/src/recoil/selectors.js index 7ce6aa313..ee5d87143 100644 --- a/dashboard/src/recoil/selectors.js +++ b/dashboard/src/recoil/selectors.js @@ -182,7 +182,7 @@ export const itemsGroupedByPersonSelector = selector({ } } } - for (const comment of comments.filter((c) => !c.comment.includes('a changé le status'))) { + for (const comment of comments) { if (comment.action) { const person = personPerAction[comment.action]; if (!person) continue; diff --git a/dashboard/src/scenes/report/view.js b/dashboard/src/scenes/report/view.js index d9f2d023d..eaa5c47b2 100644 --- a/dashboard/src/scenes/report/view.js +++ b/dashboard/src/scenes/report/view.js @@ -269,7 +269,6 @@ const View = () => { const comments = useMemo( () => allComments - ?.filter((c) => !c.comment.includes('a changé le status')) .filter((c) => !!selectedTeamsObject[c.team]) .filter((c) => { const currentTeam = selectedTeamsObject[c.team]; From 35d74cbdc4cac83ef9a544e9a80ee74828fab8e9 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Fri, 8 Sep 2023 16:05:59 +0200 Subject: [PATCH 06/15] test --- e2e/actions_crud.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/e2e/actions_crud.spec.ts b/e2e/actions_crud.spec.ts index 7630e2879..1cebc4759 100644 --- a/e2e/actions_crud.spec.ts +++ b/e2e/actions_crud.spec.ts @@ -50,6 +50,16 @@ test("Actions", async ({ page }) => { await page.getByText("Mise à jour !").click(); await changeReactSelectValue(page, "action-select-status-filter", "FAITE"); + + await page.getByRole("cell", { name: action2Name }).click(); + await page.getByRole("button", { name: "Historique" }).click(); + await page.locator(`[data-test-id="Nom de l'action\\: \\"${action1Name}\\" ➔ \\"${action2Name}\\""]`).click(); + await page.locator(`[data-test-id="Description\\: \\"\\" ➔ \\"plouf\\""]`).click(); + await page.locator('[data-test-id="Action urgente\\: \\"\\" ➔ true"]').click(); + await page.locator('[data-test-id="Status\\: \\"A FAIRE\\" ➔ \\"FAIT\\""]').click(); + + await page.getByText("Fermer").click(); + await page.getByRole("cell", { name: action2Name }).click(); await page.getByRole("button", { name: "Modifier" }).click(); page.once("dialog", (dialog) => { From 6d6e4df1b4802e1ed2f906d1c093f68b4d297db5 Mon Sep 17 00:00:00 2001 From: Arnaud Ambroselli <31724752+arnaudambro@users.noreply.github.com> Date: Wed, 13 Sep 2023 06:44:11 +0200 Subject: [PATCH 07/15] feat: historique pour les traitements (#1651) * feat: historique pour les traitements * test --- app/src/recoil/treatments.js | 10 ++ app/src/scenes/Persons/Treatment.js | 28 ++++- dashboard/src/recoil/treatments.ts | 11 ++ .../person/components/MedicalFilePrint.js | 2 + .../person/components/TreatmentModal.js | 104 +++++++++++++++++- dashboard/src/types/treatment.ts | 1 + e2e/treatment_crud.spec.ts | 70 ++++++++++++ 7 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 e2e/treatment_crud.spec.ts diff --git a/app/src/recoil/treatments.js b/app/src/recoil/treatments.js index 3507fede9..8fdadd289 100644 --- a/app/src/recoil/treatments.js +++ b/app/src/recoil/treatments.js @@ -10,6 +10,16 @@ export const treatmentsState = atom({ const encryptedFields = ['person', 'user', 'startDate', 'endDate', 'name', 'dosage', 'frequency', 'indication', 'documents', 'comments', 'history']; +export const allowedTreatmentFieldsInHistory = [ + { name: 'person', label: 'Personne suivie' }, + { name: 'name', label: "Nom de l'action" }, + { name: 'startDate', label: 'Faite le' }, + { name: 'endDate', label: 'Faite le' }, + { name: 'dosage', label: 'Faite le' }, + { name: 'frequency', label: 'Faite le' }, + { name: 'indication', label: 'Faite le' }, +]; + export const prepareTreatmentForEncryption = (treatment) => { try { if (!looseUuidRegex.test(treatment.person)) { diff --git a/app/src/scenes/Persons/Treatment.js b/app/src/scenes/Persons/Treatment.js index 61fbde295..ae7b564f0 100644 --- a/app/src/scenes/Persons/Treatment.js +++ b/app/src/scenes/Persons/Treatment.js @@ -8,7 +8,7 @@ import ScreenTitle from '../../components/ScreenTitle'; import InputLabelled from '../../components/InputLabelled'; import Button from '../../components/Button'; import API from '../../services/api'; -import { prepareTreatmentForEncryption, treatmentsState } from '../../recoil/treatments'; +import { allowedTreatmentFieldsInHistory, prepareTreatmentForEncryption, treatmentsState } from '../../recoil/treatments'; import DateAndTimeInput from '../../components/DateAndTimeInput'; import DocumentsManager from '../../components/DocumentsManager'; import Spacer from '../../components/Spacer'; @@ -63,7 +63,7 @@ const Treatment = ({ navigation, route }) => { if (!startDate) return Alert.alert('Veuillez indiquer une date de début'); Keyboard.dismiss(); setPosting(true); - const body = prepareTreatmentForEncryption({ + const body = { name, dosage, frequency, @@ -74,9 +74,27 @@ const Treatment = ({ navigation, route }) => { documents, comments, user: treatmentDB?.user ?? user._id, - history: treatmentDB?.history ?? user._id, - }); - const treatmentResponse = isNew ? await API.post({ path: '/treatment', body }) : await API.put({ path: `/treatment/${treatmentDB._id}`, body }); + }; + + if (!isNew) { + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in body) { + if (!allowedTreatmentFieldsInHistory.map((field) => field.name).includes(key)) continue; + if (body[key] !== treatmentDB[key]) historyEntry.data[key] = { oldValue: treatmentDB[key], newValue: body[key] }; + } + if (!!Object.keys(historyEntry.data).length) { + const prevHistory = Array.isArray(treatmentDB.history) ? treatmentDB.history : []; + body.history = [...prevHistory, historyEntry]; + } + } + + const treatmentResponse = isNew + ? await API.post({ path: '/treatment', body: prepareTreatmentForEncryption(body) }) + : await API.put({ path: `/treatment/${treatmentDB._id}`, body: prepareTreatmentForEncryption(body) }); if (!treatmentResponse.ok) return false; if (isNew) { setAllTreatments((all) => [...all, treatmentResponse.decryptedData].sort((a, b) => new Date(b.startDate) - new Date(a.startDate))); diff --git a/dashboard/src/recoil/treatments.ts b/dashboard/src/recoil/treatments.ts index b3f2920ae..8a059f005 100644 --- a/dashboard/src/recoil/treatments.ts +++ b/dashboard/src/recoil/treatments.ts @@ -21,6 +21,17 @@ const encryptedFields: Array = [ 'indication', 'documents', 'comments', + 'history', +]; + +export const allowedTreatmentFieldsInHistory = [ + { name: 'person', label: 'Personne suivie' }, + { name: 'name', label: 'Nom du traitement' }, + { name: 'startDate', label: 'Date de début' }, + { name: 'endDate', label: 'Date de fin' }, + { name: 'dosage', label: 'Dosage' }, + { name: 'frequency', label: 'Fréquence' }, + { name: 'indication', label: 'Indication' }, ]; export const prepareTreatmentForEncryption = (treatment: TreatmentInstance, { checkRequiredFields = true } = {}) => { diff --git a/dashboard/src/scenes/person/components/MedicalFilePrint.js b/dashboard/src/scenes/person/components/MedicalFilePrint.js index 26993cb5e..1237b8e0f 100644 --- a/dashboard/src/scenes/person/components/MedicalFilePrint.js +++ b/dashboard/src/scenes/person/components/MedicalFilePrint.js @@ -117,6 +117,7 @@ export function MedicalFilePrint({ person }) { 'createdAt', 'person', 'organisation', + 'history', ]; return (
@@ -170,6 +171,7 @@ export function MedicalFilePrint({ person }) { 'withTime', 'personPopulated', 'userPopulated', + 'history', ]; return (
diff --git a/dashboard/src/scenes/person/components/TreatmentModal.js b/dashboard/src/scenes/person/components/TreatmentModal.js index f5cb7c8e4..96432a760 100644 --- a/dashboard/src/scenes/person/components/TreatmentModal.js +++ b/dashboard/src/scenes/person/components/TreatmentModal.js @@ -4,9 +4,9 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useHistory, useLocation } from 'react-router-dom'; import { v4 as uuidv4 } from 'uuid'; import { organisationState, userState } from '../../../recoil/auth'; -import { outOfBoundariesDate } from '../../../services/date'; +import { dayjsInstance, outOfBoundariesDate } from '../../../services/date'; import API from '../../../services/api'; -import { prepareTreatmentForEncryption, treatmentsState } from '../../../recoil/treatments'; +import { allowedTreatmentFieldsInHistory, prepareTreatmentForEncryption, treatmentsState } from '../../../recoil/treatments'; import DatePicker from '../../../components/DatePicker'; import { CommentsModule } from '../../../components/CommentsGeneric'; import { ModalContainer, ModalBody, ModalFooter, ModalHeader } from '../../../components/tailwind/Modal'; @@ -16,6 +16,7 @@ import CustomFieldDisplay from '../../../components/CustomFieldDisplay'; import UserName from '../../../components/UserName'; import { DocumentsModule } from '../../../components/DocumentsGeneric'; import TabsNav from '../../../components/tailwind/TabsNav'; +import PersonName from '../../../components/PersonName'; export default function TreatmentModal() { const treatmentsObjects = useRecoilValue(itemsGroupedByTreatmentSelector); @@ -84,6 +85,7 @@ function TreatmentContent({ onClose, treatment, personId }) { return { documents: [], comments: [], + history: [], ...treatment, }; } @@ -100,6 +102,7 @@ function TreatmentContent({ onClose, treatment, personId }) { organisation: organisation._id, documents: [], comments: [], + history: [], }; }, [treatment, user, personId, organisation]); const [activeTab, setActiveTab] = useState('Informations'); @@ -138,6 +141,23 @@ function TreatmentContent({ onClose, treatment, personId }) { toast.error('La date de fin de traitement est hors limites (entre 1900 et 2100)'); return false; } + + if (!isNewTreatment && !!treatment) { + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in body) { + if (!allowedTreatmentFieldsInHistory.map((field) => field.name).includes(key)) continue; + if (body[key] !== treatment[key]) historyEntry.data[key] = { oldValue: treatment[key], newValue: body[key] }; + } + if (!!Object.keys(historyEntry.data).length) { + const prevHistory = Array.isArray(treatment.history) ? treatment.history : []; + body.history = [...prevHistory, historyEntry]; + } + } + const treatmentResponse = isNewTreatment ? await API.post({ path: '/treatment', @@ -215,13 +235,15 @@ function TreatmentContent({ onClose, treatment, personId }) { 'Informations', `Documents ${data?.documents?.length ? `(${data.documents.length})` : ''}`, `Commentaires ${data?.comments?.length ? `(${data.comments.length})` : ''}`, + 'Historique', ]} onClick={(tab) => { if (tab.includes('Informations')) setActiveTab('Informations'); if (tab.includes('Documents')) setActiveTab('Documents'); if (tab.includes('Commentaires')) setActiveTab('Commentaires'); + if (tab.includes('Historique')) setActiveTab('Historique'); }} - activeTabIndex={['Informations', 'Documents', 'Commentaires'].findIndex((tab) => tab === activeTab)} + activeTabIndex={['Informations', 'Documents', 'Commentaires', 'Historique'].findIndex((tab) => tab === activeTab)} />
+
+ +
@@ -429,3 +457,73 @@ function TreatmentContent({ onClose, treatment, personId }) { ); } + +function TreatmentHistory({ treatment }) { + const history = useMemo(() => [...(treatment?.history || [])].reverse(), [treatment?.history]); + + return ( +
+ {!history?.length ? ( +
+

Ce traitement n'a pas encore d'historique.

+

+ Lorsqu'un traitement est modifié, les changements sont enregistrés dans un historique, +
+ que vous pourrez ainsi retrouver sur cette page. +

+
+ ) : ( + + + + + + + + + + {history.map((h) => { + return ( + + + + + + ); + })} + +
DateUtilisateurDonnée
{dayjsInstance(h.date).format('DD/MM/YYYY HH:mm')} + + + {Object.entries(h.data).map(([key, value]) => { + const treatmentField = allowedTreatmentFieldsInHistory.find((f) => f.name === key); + + if (key === 'person') { + return ( +

+ {treatmentField?.label} :
+ + + {' '} + ➔{' '} + + + +

+ ); + } + + return ( +

+ {treatmentField?.label} :
+ {JSON.stringify(value.oldValue || '')}{JSON.stringify(value.newValue)} +

+ ); + })} +
+ )} +
+ ); +} diff --git a/dashboard/src/types/treatment.ts b/dashboard/src/types/treatment.ts index d3e398601..2b2edc32c 100644 --- a/dashboard/src/types/treatment.ts +++ b/dashboard/src/types/treatment.ts @@ -17,4 +17,5 @@ export interface TreatmentInstance { comments: any[]; createdAt: Date; updatedAt: Date; + history: any[]; } diff --git a/e2e/treatment_crud.spec.ts b/e2e/treatment_crud.spec.ts new file mode 100644 index 000000000..d3ddfcb17 --- /dev/null +++ b/e2e/treatment_crud.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "@playwright/test"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import "dayjs/locale/fr"; +import { nanoid } from "nanoid"; +import { populate } from "./scripts/populate-db"; +import { changeReactSelectValue, clickOnEmptyReactSelect, loginWith, logOut } from "./utils"; + +dayjs.extend(utc); +dayjs.locale("fr"); + +test.beforeAll(async () => { + await populate(); +}); + +test("Traitement", async ({ page }) => { + const personName = "Manu Chao"; + + await loginWith(page, "admin1@example.org"); + await page.getByRole("link", { name: "Personnes suivies" }).click(); + await page.getByRole("button", { name: "Créer une nouvelle personne" }).click(); + await page.getByLabel("Nom").click(); + await page.getByLabel("Nom").fill(personName); + await page.getByRole("button", { name: "Sauvegarder" }).click(); + await page.getByText("Création réussie !").click(); + await page.getByRole("button", { name: "Dossier Médical" }).click(); + + await page.getByRole("button", { name: "Ajouter un traitement" }).click(); + await page.getByPlaceholder("Amoxicilline").click(); + await page.getByPlaceholder("Amoxicilline").fill("Paracétamol"); + await page.getByRole("button", { name: "Informations" }).click(); + await page.getByRole("button", { name: "Sauvegarder" }).click(); + await page.getByText("Traitement créé !").click(); + await page.getByText("Paracétamol").click(); + await page + .getByRole("dialog", { name: "Traitement: Paracétamol (créée par User Admin Test - 1)" }) + .getByRole("button", { name: "Historique" }) + .click(); + await page.getByText("Ce traitement n'a pas encore d'historique.Lorsqu'un traitement est modifié, les ").click(); + await page.getByRole("button", { name: "Informations" }).click(); + await page.getByTitle("Modifier ce traitement - seul le créateur peut modifier un traitement").click(); + await page.getByPlaceholder("1mg").click(); + await page.getByPlaceholder("1mg").fill("3mg"); + await page.getByPlaceholder("1 fois par jour").click(); + await page.getByPlaceholder("1 fois par jour").fill("2 fois par jour"); + await page.getByPlaceholder("Angine").click(); + await page.getByPlaceholder("Angine").fill("Grosse toux"); + await page.getByRole("button", { name: "Sauvegarder" }).click(); + await page.getByText("Traitement mis à jour !").click(); + await page.getByText("Paracétamol - Grosse toux - 3mg - 2 fois par jour").click(); + await page + .getByRole("dialog", { name: "Traitement: Paracétamol (créée par User Admin Test - 1)" }) + .getByRole("button", { name: "Historique" }) + .click(); + await page.locator('[data-test-id="Dosage\\: \\"\\" ➔ \\"3mg\\""]').click(); + await page.locator('[data-test-id="Fréquence\\: \\"\\" ➔ \\"2 fois par jour\\""]').click(); + await page.locator('[data-test-id="Indication\\: \\"\\" ➔ \\"Grosse toux\\""]').click(); + await page.getByRole("button", { name: "Informations" }).click(); + await page.getByTitle("Modifier ce traitement - seul le créateur peut modifier un traitement").click(); + + page.once("dialog", (dialog) => { + expect(dialog.message()).toBe("Voulez-vous supprimer ce traitement ?"); + dialog.accept(); + }); + await page + .getByRole("dialog", { name: "Modifier le traitement: Paracétamol (créée par User Admin Test - 1)" }) + .getByRole("button", { name: "Supprimer" }) + .click(); + await page.getByText("Traitement supprimé !").click(); +}); From 96e7dbfda2cd8ffd176a7a94cc0e27e54fc44e93 Mon Sep 17 00:00:00 2001 From: Arnaud Ambroselli <31724752+arnaudambro@users.noreply.github.com> Date: Wed, 13 Sep 2023 06:45:17 +0200 Subject: [PATCH 08/15] feat: historique pour les consultations (#1652) * feat: historique pour les consultations * test * fix: check github connection --- app/src/recoil/consultations.js | 50 ++++++- app/src/scenes/Persons/Consultation.js | 26 +++- dashboard/src/components/ConsultationModal.js | 126 +++++++++++++++++- dashboard/src/recoil/consultations.ts | 52 +++++++- dashboard/src/types/consultation.ts | 1 + ...ns_create-consultation-from-agenda.spec.ts | 9 +- 6 files changed, 256 insertions(+), 8 deletions(-) diff --git a/app/src/recoil/consultations.js b/app/src/recoil/consultations.js index 4f19f8c63..84a77115e 100644 --- a/app/src/recoil/consultations.js +++ b/app/src/recoil/consultations.js @@ -1,7 +1,8 @@ -import { atom } from 'recoil'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils/regex'; import { capture } from '../services/sentry'; import { Alert } from 'react-native'; +import { organisationState } from './auth'; export const consultationsState = atom({ key: 'consultationsState', @@ -10,6 +11,53 @@ export const consultationsState = atom({ export const encryptedFields = ['name', 'type', 'person', 'user', 'teams', 'documents', 'comments', 'history']; +export const consultationFieldsSelector = selector({ + key: 'consultationFieldsSelector', + get: ({ get }) => { + const organisation = get(organisationState); + return organisation?.consultations || []; + }, +}); + +export const flattenedCustomFieldsConsultationsSelector = selector({ + key: 'flattenedCustomFieldsConsultationsSelector', + get: ({ get }) => { + const customFieldsConsultationsSections = get(consultationFieldsSelector); + const customFieldsConsultations = []; + for (const section of customFieldsConsultationsSections) { + for (const field of section.fields) { + customFieldsConsultations.push(field); + } + } + return customFieldsConsultations; + }, +}); + +/* Other utils selector */ + +export const consultationsFieldsIncludingCustomFieldsSelector = selector({ + key: 'consultationsFieldsIncludingCustomFieldsSelector', + get: ({ get }) => { + const flattenedCustomFieldsConsultations = get(flattenedCustomFieldsConsultationsSelector); + return [ + { name: 'name', label: 'Nom' }, + { name: 'type', label: 'Type' }, + { name: 'onlyVisibleBy', label: 'Seulement visible par moi' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'teams', label: ':Equipe(s) en charge' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, + ...flattenedCustomFieldsConsultations.map((f) => { + return { + name: f.name, + label: f.label, + }; + }), + ]; + }, +}); + export const prepareConsultationForEncryption = (customFieldsConsultations) => (consultation) => { try { if (!looseUuidRegex.test(consultation.person)) { diff --git a/app/src/scenes/Persons/Consultation.js b/app/src/scenes/Persons/Consultation.js index 8b7cebf1c..317e286a0 100644 --- a/app/src/scenes/Persons/Consultation.js +++ b/app/src/scenes/Persons/Consultation.js @@ -14,7 +14,12 @@ import DocumentsManager from '../../components/DocumentsManager'; import Spacer from '../../components/Spacer'; import Label from '../../components/Label'; import ActionStatusSelect from '../../components/Selects/ActionStatusSelect'; -import { consultationsState, encryptedFields, prepareConsultationForEncryption } from '../../recoil/consultations'; +import { + consultationsFieldsIncludingCustomFieldsSelector, + consultationsState, + encryptedFields, + prepareConsultationForEncryption, +} from '../../recoil/consultations'; import ConsultationTypeSelect from '../../components/Selects/ConsultationTypeSelect'; import CustomFieldInput from '../../components/CustomFieldInput'; import { currentTeamState, organisationState, userState } from '../../recoil/auth'; @@ -40,6 +45,7 @@ const Consultation = ({ navigation, route }) => { const user = useRecoilValue(userState); const currentTeam = useRecoilValue(currentTeamState); const person = route?.params?.personDB || route?.params?.person; + const consultationsFieldsIncludingCustomFields = useRecoilValue(consultationsFieldsIncludingCustomFieldsSelector); const consultationDB = useMemo(() => { if (route?.params?.consultationDB?._id) { @@ -137,12 +143,28 @@ const Consultation = ({ navigation, route }) => { } else { consultationToSave.completedAt = null; } + + if (!isNew) { + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in consultationToSave) { + if (!consultationsFieldsIncludingCustomFields.map((field) => field.name).includes(key)) continue; + if (consultationToSave[key] !== consultationDB[key]) { + historyEntry.data[key] = { oldValue: consultationDB[key], newValue: consultationToSave[key] }; + } + } + if (!!Object.keys(historyEntry.data).length) consultationToSave.history = [...(consultationDB.history || []), historyEntry]; + } + const body = prepareConsultationForEncryption(organisation.consultations)({ ...consultationToSave, teams: isNew ? [currentTeam._id] : consultationToSave.teams, _id: consultationDB?._id, }); - console.log('body', body); + const consultationResponse = isNew ? await API.post({ path: '/consultation', body }) : await API.put({ path: `/consultation/${consultationDB._id}`, body }); diff --git a/dashboard/src/components/ConsultationModal.js b/dashboard/src/components/ConsultationModal.js index 057241a8f..580e7ac8c 100644 --- a/dashboard/src/components/ConsultationModal.js +++ b/dashboard/src/components/ConsultationModal.js @@ -6,7 +6,12 @@ import { toast } from 'react-toastify'; import { useLocation, useHistory } from 'react-router-dom'; import { CANCEL, DONE, TODO } from '../recoil/actions'; import { currentTeamState, organisationState, teamsState, userState } from '../recoil/auth'; -import { consultationsState, defaultConsultationFields, prepareConsultationForEncryption } from '../recoil/consultations'; +import { + consultationsFieldsIncludingCustomFieldsSelector, + consultationsState, + defaultConsultationFields, + prepareConsultationForEncryption, +} from '../recoil/consultations'; import API from '../services/api'; import { dayjsInstance } from '../services/date'; import useCreateReportAtDateIfNotExist from '../services/useCreateReportAtDateIfNotExist'; @@ -84,6 +89,7 @@ function ConsultationContent({ personId, consultation, date, onClose }) { const setModalConfirmState = useSetRecoilState(modalConfirmState); const setAllConsultations = useSetRecoilState(consultationsState); const createReportAtDateIfNotExist = useCreateReportAtDateIfNotExist(); + const consultationsFieldsIncludingCustomFields = useRecoilValue(consultationsFieldsIncludingCustomFieldsSelector); const [isEditing, setIsEditing] = useState(!consultation); @@ -92,6 +98,7 @@ function ConsultationContent({ personId, consultation, date, onClose }) { return { documents: [], comments: [], + history: [], teams: consultation.teams ?? teams.length === 1 ? [teams[0]._id] : [], ...consultation, }; @@ -110,6 +117,7 @@ function ConsultationContent({ personId, consultation, date, onClose }) { onlyVisibleBy: [], documents: [], comments: [], + history: [], createdAt: new Date(), }; }, [organisation._id, personId, user._id, consultation, date, teams]); @@ -147,6 +155,20 @@ function ConsultationContent({ personId, consultation, date, onClose }) { } else { body.completedAt = null; } + + if (!isNewConsultation && !!consultation) { + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in body) { + if (!consultationsFieldsIncludingCustomFields.map((field) => field.name).includes(key)) continue; + if (body[key] !== consultation[key]) historyEntry.data[key] = { oldValue: consultation[key], newValue: body[key] }; + } + if (!!Object.keys(historyEntry.data).length) body.history = [...(consultation.history || []), historyEntry]; + } + const consultationResponse = isNewConsultation ? await API.post({ path: '/consultation', @@ -246,13 +268,15 @@ function ConsultationContent({ personId, consultation, date, onClose }) { 'Informations', `Documents ${data?.documents?.length ? `(${data.documents.length})` : ''}`, `Commentaires ${data?.comments?.length ? `(${data.comments.length})` : ''}`, + 'Historique', ]} onClick={(tab) => { if (tab.includes('Informations')) setActiveTab('Informations'); if (tab.includes('Documents')) setActiveTab('Documents'); if (tab.includes('Commentaires')) setActiveTab('Commentaires'); + if (tab.includes('Historique')) setActiveTab('Historique'); }} - activeTabIndex={['Informations', 'Documents', 'Commentaires'].findIndex((tab) => tab === activeTab)} + activeTabIndex={['Informations', 'Documents', 'Commentaires', 'Historique'].findIndex((tab) => tab === activeTab)} /> +
+ +
@@ -540,3 +570,95 @@ function ConsultationContent({ personId, consultation, date, onClose }) { ); } + +function ConsultationHistory({ consultation }) { + const history = useMemo(() => [...(consultation?.history || [])].reverse(), [consultation?.history]); + const teams = useRecoilValue(teamsState); + const consultationsFieldsIncludingCustomFields = useRecoilValue(consultationsFieldsIncludingCustomFieldsSelector); + + return ( +
+ {!history?.length ? ( +
+

Cette consultation n'a pas encore d'historique.

+

+ Lorsqu'une consultation est modifiée, les changements sont enregistrés dans un historique, +
+ que vous pourrez ainsi retrouver sur cette page. +

+
+ ) : ( + + + + + + + + + + {history.map((h) => { + return ( + + + + + + ); + })} + +
DateUtilisateurDonnée
{dayjsInstance(h.date).format('DD/MM/YYYY HH:mm')} + + + {Object.entries(h.data).map(([key, value]) => { + const consultationField = consultationsFieldsIncludingCustomFields.find((f) => f.name === key); + + if (key === 'teams') { + return ( +

+ {consultationField?.label} : + "{(value.oldValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" + + "{(value.newValue || []).map((teamId) => teams.find((t) => t._id === teamId)?.name).join(', ')}" +

+ ); + } + + if (key === 'onlyVisibleBy') { + return ( +

+ {consultationField?.label} :
+ {value.oldValue.length ? 'Oui' : 'Non'}{value.newValue.length ? 'Oui' : 'Non'} +

+ ); + } + + if (key === 'person') { + return ( +

+ {consultationField?.label} :
+ + + {' '} + ➔{' '} + + + +

+ ); + } + + return ( +

+ {consultationField?.label} :
+ {JSON.stringify(value.oldValue || '')}{JSON.stringify(value.newValue)} +

+ ); + })} +
+ )} +
+ ); +} diff --git a/dashboard/src/recoil/consultations.ts b/dashboard/src/recoil/consultations.ts index 6aaded5b6..f6380c91b 100644 --- a/dashboard/src/recoil/consultations.ts +++ b/dashboard/src/recoil/consultations.ts @@ -1,10 +1,11 @@ -import { atom } from 'recoil'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; import type { ConsultationInstance } from '../types/consultation'; import type { CustomFieldsGroup } from '../types/field'; import type { UserInstance } from '../types/user'; +import { organisationState } from './auth'; const collectionName = 'consultation'; export const consultationsState = atom({ @@ -12,7 +13,54 @@ export const consultationsState = atom({ default: [], }); -const encryptedFields: Array = ['name', 'type', 'person', 'user', 'teams', 'documents', 'comments']; +const encryptedFields: Array = ['name', 'type', 'person', 'user', 'teams', 'documents', 'comments', 'history']; + +export const consultationFieldsSelector = selector({ + key: 'consultationFieldsSelector', + get: ({ get }) => { + const organisation = get(organisationState); + return organisation?.consultations || []; + }, +}); + +export const flattenedCustomFieldsConsultationsSelector = selector({ + key: 'flattenedCustomFieldsConsultationsSelector', + get: ({ get }) => { + const customFieldsConsultationsSections = get(consultationFieldsSelector); + const customFieldsConsultations = []; + for (const section of customFieldsConsultationsSections) { + for (const field of section.fields) { + customFieldsConsultations.push(field); + } + } + return customFieldsConsultations; + }, +}); + +/* Other utils selector */ + +export const consultationsFieldsIncludingCustomFieldsSelector = selector({ + key: 'consultationsFieldsIncludingCustomFieldsSelector', + get: ({ get }) => { + const flattenedCustomFieldsConsultations = get(flattenedCustomFieldsConsultationsSelector); + return [ + { name: 'name', label: 'Nom' }, + { name: 'type', label: 'Type' }, + { name: 'onlyVisibleBy', label: 'Seulement visible par moi' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'teams', label: ':Equipe(s) en charge' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, + ...flattenedCustomFieldsConsultations.map((f) => { + return { + name: f.name, + label: f.label, + }; + }), + ]; + }, +}); export const prepareConsultationForEncryption = (customFieldsConsultations: CustomFieldsGroup[]) => diff --git a/dashboard/src/types/consultation.ts b/dashboard/src/types/consultation.ts index 3554fe109..e3029d8f1 100644 --- a/dashboard/src/types/consultation.ts +++ b/dashboard/src/types/consultation.ts @@ -13,6 +13,7 @@ export interface ConsultationInstance { indication: string; documents: Array; comments: any[]; + history: any[]; createdAt: Date; updatedAt: Date; [key: string]: any; diff --git a/e2e/consultations_create-consultation-from-agenda.spec.ts b/e2e/consultations_create-consultation-from-agenda.spec.ts index 8b2de1e50..ea78b1dc9 100644 --- a/e2e/consultations_create-consultation-from-agenda.spec.ts +++ b/e2e/consultations_create-consultation-from-agenda.spec.ts @@ -20,13 +20,20 @@ test("test", async ({ page }) => { await page.getByLabel("Nom").click(); await page.getByLabel("Nom").fill("test"); await page.getByRole("button", { name: "Sauvegarder" }).click(); + await page.getByText("Création réussie !").click(); await page.getByRole("link", { name: "Agenda" }).click(); await page.getByRole("button", { name: "Créer une nouvelle consultation" }).click(); await changeReactSelectValue(page, "create-consultation-person-select", "test"); await changeReactSelectValue(page, "consultation-modal-type", "Médicale"); await page.getByRole("button", { name: "Sauvegarder" }).click(); - await page.getByText("Création réussie !").click(); await page.getByText("Consultation Médicale", { exact: true }).click(); await page.getByRole("button", { name: "Fermer" }).first().click(); + await page.getByText("Consultation Médicale").click(); + await page.getByRole("button", { name: "Modifier" }).click(); + await page.getByLabel("Nom (facultatif)").fill("Avec un nom"); + await page.getByRole("button", { name: "Sauvegarder" }).click(); + await page.getByText("Consultation Médicale").click(); + await page.getByRole("button", { name: "Historique" }).click(); + await page.locator('[data-test-id="Nom\\: \\"\\" ➔ \\"Avec un nom\\""]').click(); }); From c3e0ebbc97481e7cddd06ccb909322f5be521ad6 Mon Sep 17 00:00:00 2001 From: Arnaud Ambroselli <31724752+arnaudambro@users.noreply.github.com> Date: Wed, 13 Sep 2023 06:46:29 +0200 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20historique=20pour=20dossier=20m?= =?UTF-8?q?=C3=A9dical=20(#1654)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/scenes/Persons/MedicalFile.js | 16 ++++++- app/src/scenes/Persons/Person.js | 5 ++ dashboard/src/recoil/medicalFiles.ts | 2 +- .../src/scenes/person/components/EditModal.js | 39 +++++++++++++-- .../scenes/person/components/PersonHistory.js | 47 ++++++++++++++----- ...erson-full-test-migrated-from-jest.spec.ts | 3 +- 6 files changed, 93 insertions(+), 19 deletions(-) diff --git a/app/src/scenes/Persons/MedicalFile.js b/app/src/scenes/Persons/MedicalFile.js index 62f32cab5..6df333827 100644 --- a/app/src/scenes/Persons/MedicalFile.js +++ b/app/src/scenes/Persons/MedicalFile.js @@ -190,6 +190,20 @@ const MedicalFile = ({ navigation, person, personDB, onUpdatePerson, updating, e const onUpdateRequest = async (latestMedicalFile) => { if (!latestMedicalFile) latestMedicalFile = medicalFile; + + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in latestMedicalFile) { + if (!customFieldsMedicalFile.map((field) => field.name).includes(key)) continue; + if (latestMedicalFile[key] !== medicalFileDB[key]) { + historyEntry.data[key] = { oldValue: medicalFileDB[key], newValue: latestMedicalFile[key] }; + } + } + if (!!Object.keys(historyEntry.data).length) latestMedicalFile.history = [...(medicalFileDB.history || []), historyEntry]; + const response = await API.put({ path: `/medical-file/${medicalFileDB._id}`, body: prepareMedicalFileForEncryption(customFieldsMedicalFile)({ ...medicalFileDB, ...latestMedicalFile }), @@ -309,7 +323,7 @@ const MedicalFile = ({ navigation, person, personDB, onUpdatePerson, updating, e