diff --git a/dashboard/src/components/Card.js b/dashboard/src/components/Card.js index 60f4868f1b..9dc13af47e 100644 --- a/dashboard/src/components/Card.js +++ b/dashboard/src/components/Card.js @@ -1,7 +1,7 @@ import React from 'react'; import HelpButtonAndModal from './HelpButtonAndModal'; -const Card = ({ title, count, unit, children, countId, dataTestId, help, onClick = null }) => { +const Card = ({ title, count, unit, children, countId, dataTestId, help = null, onClick = null }) => { const Component = !!onClick ? 'button' : 'div'; const props = !!onClick ? { onClick, type: 'button', name: 'card', className: 'button-cancel' } : {}; return ( diff --git a/dashboard/src/components/EvolutiveStatsViewer.tsx b/dashboard/src/components/EvolutiveStatsViewer.tsx new file mode 100644 index 0000000000..ff3c99a1ad --- /dev/null +++ b/dashboard/src/components/EvolutiveStatsViewer.tsx @@ -0,0 +1,30 @@ +export default function EvolutiveStatsViewer() { + return ( +
+
+
Au 31/12/2023
+
+

45

+

des personnes SDF

+
+
+
+
+

45%

+

+ des “Sans” Couverture Médicale au 31/01/2022 +
+ ont évolué vers “AME” au 01/02/2023 +

+
+
+
+
Au 31/12/2023
+
+

45

+

des personnes SDF

+
+
+
+ ); +} diff --git a/dashboard/src/recoil/persons.ts b/dashboard/src/recoil/persons.ts index 5fc470e7c2..9207b2ba9e 100644 --- a/dashboard/src/recoil/persons.ts +++ b/dashboard/src/recoil/persons.ts @@ -4,7 +4,9 @@ import { organisationState } from './auth'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; import type { PersonInstance } from '../types/person'; -import type { PredefinedField, CustomField } from '../types/field'; +import type { PredefinedField, CustomField, CustomOrPredefinedField } from '../types/field'; +import type { EvolutiveStatsPersonFields, EvolutiveStatOption, EvolutiveStatDateYYYYMMDD } from '../types/evolutivesStats'; +import { dayjsInstance } from '../services/date'; const collectionName = 'person'; export const personsState = atom({ @@ -76,14 +78,16 @@ export const personFieldsIncludingCustomFieldsSelector = selector({ return [ ...personFields, ...[...fieldsPersonsCustomizableOptions, ...flattenedCustomFieldsPersons].map((f) => { - return { + const field: CustomOrPredefinedField = { name: f.name, type: f.type, label: f.label, encrypted: true, importable: true, - options: f.options || null, + options: f.options || undefined, + filterable: true, }; + return field; }), ]; }, @@ -97,6 +101,107 @@ export const allowedPersonFieldsInHistorySelector = selector({ }, }); +export const evolutiveStatsPersonSelector = selector({ + key: 'evolutiveStatsPersonSelector', + get: ({ get }) => { + const allFields = get(personFieldsIncludingCustomFieldsSelector); + const fields = allFields.filter((f) => { + if (f.name === 'history') return false; + if (['text', 'textarea', 'number', 'date', 'date-with-time'].includes(f.type)) return false; + // remains 'yes-no' | 'enum' | 'multi-choice' | 'boolean' + return f.filterable; + }); + const personsFieldsInHistoryObject: EvolutiveStatsPersonFields = {}; + const persons = get(personsState); + + function getValuesOptionsByField(field: CustomOrPredefinedField): Array { + if (!field) return []; + const current = fields.find((_field) => _field.name === field.name); + if (!current) return []; + if (['yes-no'].includes(current.type)) return ['Oui', 'Non', 'Non renseigné']; + if (['boolean'].includes(current.type)) return ['Oui', 'Non']; + if (current?.name === 'outOfActiveList') return current.options ?? ['Oui', 'Non']; + if (current?.options?.length) return [...current?.options, 'Non renseigné']; + return ['Non renseigné']; + } + + // we take the years since the history began, let's say early 2023 + const dates: Record = {}; + let date = dayjsInstance('2023-01-01').format('YYYYMMDD'); + const today = dayjsInstance().format('YYYYMMDD'); + while (date !== today) { + dates[date] = 0; + date = dayjsInstance(date).add(1, 'day').format('YYYYMMDD'); + } + + for (const field of fields) { + const options = getValuesOptionsByField(field); + personsFieldsInHistoryObject[field.name] = {}; + for (const option of options) { + personsFieldsInHistoryObject[field.name][option] = { + ...dates, + }; + } + } + + for (const person of persons) { + const minimumDate = dayjsInstance(person.followedSince || person.createdAt).format('YYYYMMDD'); + let currentDate = today; + let currentPerson = structuredClone(person); + for (const field of fields) { + const value = currentPerson[field.name]; + if (value == null || value === '') { + // we cover the case of undefined, null, empty string + continue; + } + personsFieldsInHistoryObject[field.name][value][currentDate]++; + } + const history = person.history; + if (!!history?.length) { + const reversedHistory = [...history].reverse(); + for (const historyItem of reversedHistory) { + let historyDate = dayjsInstance(historyItem.date).format('YYYYMMDD'); + while (currentDate !== historyDate) { + currentDate = dayjsInstance(currentDate).subtract(1, 'day').format('YYYYMMDD'); + for (const field of fields) { + const value = currentPerson[field.name]; + if (value == null || value === '') { + // we cover the case of undefined, null, empty string + continue; + } + personsFieldsInHistoryObject[field.name][value][currentDate]++; + } + } + for (const historyChangeField of Object.keys(historyItem.data)) { + const oldValue = historyItem.data[historyChangeField].oldValue; + if (historyItem.data[historyChangeField].newValue !== currentPerson[historyChangeField]) { + capture(new Error('Incoherent history'), { + extra: { + person, + historyItem, + historyChangeField, + }, + }); + } + currentPerson[historyChangeField] = oldValue; + } + } + } + while (currentDate !== minimumDate) { + currentDate = dayjsInstance(currentDate).subtract(1, 'day').format('YYYYMMDD'); + for (const field of fields) { + const value = currentPerson[field.name]; + if (value == null || value === '') { + // we cover the case of undefined, null, empty string + continue; + } + personsFieldsInHistoryObject[field.name][value][currentDate]++; + } + } + } + }, +}); + export const filterPersonsBaseSelector = selector({ key: 'filterPersonsBaseSelector', get: ({ get }) => { diff --git a/dashboard/src/recoil/selectors.js b/dashboard/src/recoil/selectors.js index 3d6d581645..f281403d75 100644 --- a/dashboard/src/recoil/selectors.js +++ b/dashboard/src/recoil/selectors.js @@ -1,5 +1,5 @@ import { currentTeamState, userState, usersState } from './auth'; -import { personsState } from './persons'; +import { allowedPersonFieldsForEvolutiveStatsSelector, personsState } from './persons'; import { placesState } from './places'; import { relsPersonPlaceState } from './relPersonPlace'; import { reportsState } from './reports'; @@ -98,9 +98,19 @@ export const itemsGroupedByPersonSelector = selector({ const personsObject = {}; const user = get(userState); const usersObject = get(usersObjectSelector); + + // const allPersonFieldsInHistory = get(allowedPersonFieldsForEvolutiveStatsSelector); + // const personsFieldsInHistoryObject = {}; + // for (const field of allPersonFieldsInHistory) { + // const options = get(customFieldsObsSelector); + // personsFieldsInHistoryObject[field] = { + // __init: [], + // }; + // } + for (const person of persons) { originalPersonsObject[person._id] = { name: person.name, _id: person._id }; - personsObject[person._id] = { + const formattedPerson = { ...person, followedSince: person.followedSince || person.createdAt, userPopulated: usersObject[person.user], @@ -114,9 +124,25 @@ export const itemsGroupedByPersonSelector = selector({ // This was causing a bug in the "person suivies" stats, where people who were not out of active list were counted as out of active list. outOfActiveListDate: person.outOfActiveList ? person.outOfActiveListDate : null, }; + personsObject[person._id] = formattedPerson; if (!person.history?.length) continue; + const updatedFields = {}; for (const historyEntry of person.history) { + // we suppose history is sorted by date ascending (oldest first) + // if history date is same as followedSince, we don't add it to interactions + if (dayjsInstance(historyEntry.date).format('YYYY-MM-DD') === dayjsInstance(formattedPerson.followedSince).format('YYYY-MM-DD')) continue; personsObject[person._id].interactions.push(historyEntry.date); + // for (const field in Object.keys(historyEntry.data)) { + // const oldValue = historyEntry.data[field].oldValue; + // const newValue = historyEntry.data[field].newValue; + // if (!personsFieldsInHistoryObject[field]) continue; + // if (!updatedFields[field]) { + // personsFieldsInHistoryObject[field].__init.push(oldValue); + // updatedFields[field] = true; + // } else { + // updatedFields[field] = []; + // } + // } } } const actions = Object.values(get(actionsWithCommentsSelector)); diff --git a/dashboard/src/scenes/stats/Blocks.js b/dashboard/src/scenes/stats/Blocks.js index 663e0f50a6..bc12889c9f 100644 --- a/dashboard/src/scenes/stats/Blocks.js +++ b/dashboard/src/scenes/stats/Blocks.js @@ -1,9 +1,8 @@ -import React from 'react'; import { getDuration } from './utils'; import { capture } from '../../services/sentry'; import Card from '../../components/Card'; -export const Block = ({ data, title = 'Nombre de personnes suivies', help }) => ( +export const Block = ({ data, title = 'Nombre de personnes suivies', help = null }) => (
diff --git a/dashboard/src/scenes/stats/PersonsStats.js b/dashboard/src/scenes/stats/PersonsStats.js index 8527f602f3..adef32fa17 100644 --- a/dashboard/src/scenes/stats/PersonsStats.js +++ b/dashboard/src/scenes/stats/PersonsStats.js @@ -19,6 +19,7 @@ import { dayjsInstance, formatDateWithFullMonth } from '../../services/date'; import CustomFieldDisplay from '../../components/CustomFieldDisplay'; import { groupsState } from '../../recoil/groups'; import EvolutiveStatsSelector from '../../components/EvolutiveStatsSelector'; +import EvolutiveStatsViewer from '../../components/EvolutiveStatsViewer'; export default function PersonStats({ title, @@ -82,6 +83,7 @@ export default function PersonStats({ selection={evolutiveStatsIndicators} onChange={setEvolutiveStatsIndicators} /> + ) : ( <> diff --git a/dashboard/src/types/evolutivesStats.ts b/dashboard/src/types/evolutivesStats.ts new file mode 100644 index 0000000000..9280ca8cd9 --- /dev/null +++ b/dashboard/src/types/evolutivesStats.ts @@ -0,0 +1,8 @@ +import type { CustomOrPredefinedField } from '../types/field'; + +export type EvolutiveStatOption = string; +export type EvolutiveStatDateYYYYMMDD = string; +export type EvolutiveStatsPersonFields = Record< + CustomOrPredefinedField['name'], + Record> +>; diff --git a/dashboard/src/types/field.ts b/dashboard/src/types/field.ts index 264d8b749b..b8ff01a109 100644 --- a/dashboard/src/types/field.ts +++ b/dashboard/src/types/field.ts @@ -26,6 +26,8 @@ export interface PredefinedField { filterable?: boolean; } +export interface CustomOrPredefinedField extends PredefinedField {} + export interface CustomFieldsGroup { name: string; fields: CustomField[]; diff --git a/dashboard/src/types/history.ts b/dashboard/src/types/history.ts deleted file mode 100644 index adcb96006a..0000000000 --- a/dashboard/src/types/history.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { UUIDV4 } from './uuid'; - -export interface ElementHistory { - date: Date; - user: UUIDV4; - data: HistoryData; -} - -interface HistoryData { - oldValue: T; - newValue: T; -} diff --git a/dashboard/src/types/person.ts b/dashboard/src/types/person.ts index d51d598fd5..6285fd2cb3 100644 --- a/dashboard/src/types/person.ts +++ b/dashboard/src/types/person.ts @@ -1,5 +1,4 @@ import type { UUIDV4 } from './uuid'; -import type { ElementHistory } from './history'; import type { Document, DocumentWithLinkedItem, Folder } from './document'; import type { UserInstance } from './user'; import type { GroupInstance } from './group'; @@ -7,12 +6,7 @@ import type { TreatmentInstance } from './treatment'; import type { ConsultationInstance } from './consultation'; import type { MedicalFileInstance } from './medicalFile'; -export interface PersonInstance { - _id: UUIDV4; - organisation: UUIDV4; - createdAt: Date; - updatedAt: Date; - deletedAt?: Date; +interface PersonInstanceBase { outOfActiveList: boolean; user: UUIDV4; name: string; @@ -23,13 +17,33 @@ export interface PersonInstance { alertness?: boolean; wanderingAt?: Date; phone?: string; - assignedTeams?: UUIDV4[]; // You might need to adjust this type based on what the actual values are. + assignedTeams?: UUIDV4[]; followedSince?: Date; outOfActiveListDate?: Date; outOfActiveListReasons?: string[]; documents?: Array; - history?: ElementHistory[]; - [key: string]: any; // This allows for additional properties + [key: string]: any; +} + +type PersonField = keyof PersonInstanceBase | string; + +export interface PersonInstance extends PersonInstanceBase { + _id: UUIDV4; + organisation: UUIDV4; + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; + history?: Array<{ + date: Date; + user: UUIDV4; + data: Record< + PersonField, + { + oldValue: any; + newValue: any; + } + >; + }>; } export interface PersonPopulated extends PersonInstance {