From fd5d1fa604e1da27c1309e2c5f2add1163a16fa9 Mon Sep 17 00:00:00 2001 From: Arnaud AMBROSELLI Date: Mon, 6 Nov 2023 14:50:25 +0100 Subject: [PATCH] =?UTF-8?q?fix(api,dashboard):=20d=C3=A9ploiement=20du=20d?= =?UTF-8?q?ashboard=20avant=20l'api=20pour=20=C3=A9viter=20que=20la=20bann?= =?UTF-8?q?i=C3=A8re=20'Rafraichissez=20svp'=20ne=20soit=20inutile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kontinuous/values.yaml | 2 +- api/src/controllers/migration.js | 33 +- dashboard/src/components/DataMigrator.js | 16 +- dashboard/src/scenes/stats/PersonsStats.js | 573 +++++++++++++++++++++ 4 files changed, 618 insertions(+), 6 deletions(-) create mode 100644 dashboard/src/scenes/stats/PersonsStats.js diff --git a/.kontinuous/values.yaml b/.kontinuous/values.yaml index 010fd2b955..19ce10a83f 100644 --- a/.kontinuous/values.yaml +++ b/.kontinuous/values.yaml @@ -15,7 +15,7 @@ www: api: ~chart: app - ~needs: [build-api, pg] + ~needs: [build-api, pg, dashboard] host: "{{ .Values.global.host }}" enabled: true containerPort: 3000 diff --git a/api/src/controllers/migration.js b/api/src/controllers/migration.js index 24769be66e..63929448a3 100644 --- a/api/src/controllers/migration.js +++ b/api/src/controllers/migration.js @@ -10,6 +10,29 @@ const validateUser = require("../middleware/validateUser"); const { serializeOrganisation } = require("../utils/data-serializer"); const { Organisation, Person, Action, Comment, Report, Team, Service, sequelize, Group } = require("../db/sequelize"); +const migrationsAvailable = { + "integrate-comments-in-actions-history": true, +}; + +// why this route ? +// because we deplpy the dashboard BEFORE the backend +// so if the dashboard wants to do a migration and the backend is not deployed yet +// the dashboard would spend time and ressource to prepare a migration +// that the backend would ignore because it doesn't know it yet +router.get( + "/migrations-available", + passport.authenticate("user", { session: false }), + validateUser(["admin", "normal", "restricted-access"]), + catchErrors(async (req, res) => { + const organisation = await Organisation.findOne({ where: { _id: req.user.organisation } }); + if (!organisation) return res.status(404).send({ ok: false, error: "Not Found" }); + return res.status(200).send({ + ok: true, + data: migrationsAvailable, + }); + }) +); + router.put( "/:migrationName", passport.authenticate("user", { session: false }), @@ -36,6 +59,8 @@ router.put( organisation.set({ migrating: true }); await organisation.save(); + let migrationsChecked = [...migrationsAvailable]; + try { await sequelize.transaction(async (tx) => { /* @@ -86,12 +111,16 @@ router.put( 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, + migrationLastUpdateAt: new Date(), + }); + await organisation.save({ transaction: tx }); } organisation.set({ - migrations: [...(organisation.migrations || []), req.params.migrationName], migrating: false, - migrationLastUpdateAt: new Date(), }); await organisation.save({ transaction: tx }); }); diff --git a/dashboard/src/components/DataMigrator.js b/dashboard/src/components/DataMigrator.js index 36c7a6d843..1a945cc8ed 100644 --- a/dashboard/src/components/DataMigrator.js +++ b/dashboard/src/components/DataMigrator.js @@ -27,9 +27,16 @@ export default function useDataMigrator() { migrateData: async (organisation) => { const organisationId = organisation?._id; let migrationLastUpdateAt = organisation.migrationLastUpdateAt; + const migrationsAvailable = await API.get({ path: '/migration/migrations-available' }).then((res) => { + console.log({ res }); + return res.data || {}; + }); + + console.log({ migrationsAvailable }); + /* // Example of migration: - if (!organisation.migrations?.includes('migration-name')) { + if (!organisation.migrations?.includes('migration-name') && migrationsAvailable['migration-name']) { setLoadingText(LOADING_TEXT); const somethingRes = await API.get({ path: '/something-to-update', @@ -49,14 +56,17 @@ export default function useDataMigrator() { if (response.ok) { setOrganisation(response.organisation); migrationLastUpdateAt = response.organisation.migrationLastUpdateAt; - else { + } else { return false; } } // End of example of migration. */ - if (!organisation.migrations?.includes('integrate-comments-in-actions-history')) { + if ( + !organisation.migrations?.includes('integrate-comments-in-actions-history') && + migrationsAvailable['integrate-comments-in-actions-history'] + ) { setLoadingText(LOADING_TEXT); const comments = await API.get({ path: '/comment', diff --git a/dashboard/src/scenes/stats/PersonsStats.js b/dashboard/src/scenes/stats/PersonsStats.js new file mode 100644 index 0000000000..38e54a624e --- /dev/null +++ b/dashboard/src/scenes/stats/PersonsStats.js @@ -0,0 +1,573 @@ +import React, { useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { utils, writeFile } from 'xlsx'; +import { useLocalStorage } from '../../services/useLocalStorage'; +import { CustomResponsiveBar, CustomResponsivePie } from './charts'; +import Filters, { filterData } from '../../components/Filters'; +import { getDuration, getMultichoiceBarData, getPieData } from './utils'; +import Card from '../../components/Card'; +import { capture } from '../../services/sentry'; +import { Block } from './Blocks'; +import CustomFieldsStats from './CustomFieldsStats'; +import { ModalBody, ModalContainer, ModalFooter, ModalHeader } from '../../components/tailwind/Modal'; +import { organisationState, teamsState } from '../../recoil/auth'; +import { personFieldsIncludingCustomFieldsSelector, sortPersons } from '../../recoil/persons'; +import TagTeam from '../../components/TagTeam'; +import Table from '../../components/table'; +import { dayjsInstance, formatDateWithFullMonth } from '../../services/date'; +import CustomFieldDisplay from '../../components/CustomFieldDisplay'; +import { groupsState } from '../../recoil/groups'; + +export default function PersonStats({ + title, + firstBlockHelp, + filterBase, + filterPersons, + setFilterPersons, + personsForStats, + personFields, + flattenedCustomFieldsPersons, +}) { + const allGroups = useRecoilValue(groupsState); + + const [personsModalOpened, setPersonsModalOpened] = useState(false); + const [sliceField, setSliceField] = useState(null); + const [sliceValue, setSliceValue] = useState(null); + const [slicedData, setSlicedData] = useState([]); + + const groupsForPersons = useMemo(() => { + const groupIds = new Set(); + for (const person of personsForStats) { + if (person.group) { + groupIds.add(person.group._id); + } + } + return allGroups.filter((group) => groupIds.has(group._id)); + }, [personsForStats, allGroups]); + + const onSliceClick = (newSlice, fieldName, personConcerned = personsForStats) => { + const newSlicefield = filterBase.find((f) => f.field === fieldName); + setSliceField(newSlicefield); + setSliceValue(newSlice); + const slicedData = + newSlicefield.type === 'boolean' + ? personConcerned.filter((p) => (newSlice === 'Non' ? !p[newSlicefield.field] : !!p[newSlicefield.field])) + : filterData( + personConcerned, + [{ ...newSlicefield, value: newSlice, type: newSlicefield.field === 'outOfActiveList' ? 'boolean' : newSlicefield.field }], + true + ); + setSlicedData(slicedData); + setPersonsModalOpened(true); + }; + return ( + <> +

Statistiques des {title}

+ +
+ + + + +
+ { + onSliceClick(newSlice, 'gender'); + }} + data={getPieData(personsForStats, 'gender', { options: personFields.find((f) => f.name === 'gender').options })} + help={`Genre des ${title} dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + { + setSliceField(personFields.find((f) => f.name === 'birthdate')); + setSliceValue(newSlice); + setSlicedData(data); + setPersonsModalOpened(true); + }} + /> + { + setSliceField(personFields.find((f) => f.name === 'followedSince')); + setSliceValue(newSlice); + setSlicedData(data); + setPersonsModalOpened(true); + }} + /> + { + setSliceField(personFields.find((f) => f.name === 'wanderingAt')); + setSliceValue(newSlice); + setSlicedData(data); + setPersonsModalOpened(true); + }} + /> + { + onSliceClick(newSlice, 'alertness'); + }} + data={getPieData(personsForStats, 'alertness', { isBoolean: true })} + help={`${title.capitalize()} vulnérables dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + { + onSliceClick(newSlice, 'outOfActiveList'); + }} + data={getPieData(personsForStats, 'outOfActiveList', { isBoolean: true })} + help={`${title} dans la période définie, sorties de la file active. La date de sortie de la file active n'est pas nécessairement dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + { + onSliceClick( + newSlice, + 'outOfActiveListReasons', + personsForStats.filter((p) => !!p.outOfActiveList) + ); + }} + axisTitleY="File active" + axisTitleX="Raison de sortie de file active" + isMultiChoice + totalForMultiChoice={personsForStats.filter((p) => !!p.outOfActiveList).length} + totalTitleForMultiChoice={Nombre de personnes concernées} + data={getMultichoiceBarData( + personsForStats.filter((p) => !!p.outOfActiveList), + 'outOfActiveListReasons' + )} + /> + + `${label.capitalize()} des ${title} dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.` + } + totalTitleForMultiChoice={Nombre de personnes concernées} + /> + { + setPersonsModalOpened(false); + }} + persons={slicedData} + sliceField={sliceField} + onAfterLeave={() => { + setSliceField(null); + setSliceValue(null); + setSlicedData([]); + }} + title={`${sliceField?.label} : ${sliceValue} (${slicedData.length})`} + /> + + ); +} + +const BlockWanderingAt = ({ persons }) => { + persons = persons.filter((p) => Boolean(p.wanderingAt)); + if (!persons.length) { + return ( +
+ +
+ ); + } + const averageWanderingAt = persons.reduce((total, person) => total + Date.parse(person.wanderingAt), 0) / (persons.length || 1); + const durationFromNowToAverage = Date.now() - averageWanderingAt; + const [count, unit] = getDuration(durationFromNowToAverage); + + return ( +
+ +
+ ); +}; + +const BlockGroup = ({ title, groups }) => { + try { + if (!groups.length) { + return ( +
+ +
+ ); + } + + const avg = Math.round((groups.reduce((total, group) => total + group.relations.length, 0) / groups.length) * 100) / 100; + return ( +
+ + Taille moyenne des familles: {avg} + + } + /> +
+ ); + } catch (errorBlockTotal) { + capture('error block total', errorBlockTotal, { title, groups }); + } + return null; +}; + +const BlockCreatedAt = ({ persons }) => { + if (persons.length === 0) { + return ( +
+ +
+ ); + } + + const averageFollowedTime = + persons.reduce((total, person) => { + // On utilise followedSince si disponible, sinon createdAt + const startFollowedDate = Date.parse(person.followedSince || person.createdAt); + + // Si outOfActiveList est à true et que outOfActiveListDate est disponible, on utilise cette date, sinon on utilise la date actuelle + const endFollowedDate = person.outOfActiveList && person.outOfActiveListDate ? Date.parse(new Date(person.outOfActiveListDate)) : Date.now(); + + // On renvoie la différence entre les deux dates (et pour éviter les nombres négatifs, on renvoie 0 si la différence est négative) + return total + (endFollowedDate - startFollowedDate > 0 ? endFollowedDate - startFollowedDate : 0); + }, 0) / (persons.length || 1); // On divise par le nombre de personnes pour obtenir la moyenne + + const [count, unit] = getDuration(averageFollowedTime); + + return ( +
+ +
+ ); +}; + +const initCategories = (categories) => { + const objCategories = {}; + for (const cat of categories) { + objCategories[cat] = []; + } + return objCategories; +}; + +export const AgeRangeBar = ({ persons, onItemClick }) => { + const categories = ['0 - 2', '3 - 17', '18 - 24', '25 - 44', '45 - 59', '60+', 'Non renseigné']; + + const data = persons.reduce((newData, person) => { + if (!person.birthdate || !person.birthdate.length) { + newData['Non renseigné'].push(person); + return newData; + } + // now person has an `age` field + if (person.age < 2) { + newData['0 - 2'].push(person); + return newData; + } + if (person.age < 18) { + newData['3 - 17'].push(person); + return newData; + } + if (person.age < 25) { + newData['18 - 24'].push(person); + return newData; + } + if (person.age < 45) { + newData['25 - 44'].push(person); + return newData; + } + if (person.age < 60) { + newData['45 - 59'].push(person); + return newData; + } + newData['60+'].push(person); + return newData; + }, initCategories(categories)); + + const dataCount = Object.keys(data) + .filter((key) => data[key]?.length > 0) + .map((key) => ({ name: key, [key]: data[key]?.length })); + + return ( + c !== 'Non renseigné')} + onItemClick={ + onItemClick + ? (item) => { + onItemClick(item, data[item]); + } + : null + } + data={dataCount} + axisTitleX="Tranche d'âge" + axisTitleY="Nombre de personnes" + help={`Répartition des âges des personnes concernées, dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + ); +}; + +const StatsCreatedAtRangeBar = ({ persons, onItemClick }) => { + const categories = ['0-6 mois', '6-12 mois', '1-2 ans', '2-5 ans', '+ 5 ans']; + + let data = persons.reduce((newData, person) => { + if (!person.followedSince || !person.createdAt || !person.createdAt.length) { + return newData; + // newData["Non renseigné"].push(person); + } + const parsedDate = Date.parse(person.followedSince || person.createdAt); + const fromNowInMonths = (Date.now() - parsedDate) / 1000 / 60 / 60 / 24 / (365.25 / 12); + if (fromNowInMonths < 6) { + newData['0-6 mois'].push(person); + return newData; + } + if (fromNowInMonths < 12) { + newData['6-12 mois'].push(person); + return newData; + } + if (fromNowInMonths < 24) { + newData['1-2 ans'].push(person); + return newData; + } + if (fromNowInMonths < 60) { + newData['2-5 ans'].push(person); + return newData; + } + newData['+ 5 ans'].push(person); + return newData; + }, initCategories(categories)); + + const dataCount = Object.keys(data) + .filter((key) => data[key]?.length > 0) + .map((key) => ({ name: key, [key]: data[key]?.length })); + + return ( + { + onItemClick(item, data[item]); + } + : null + } + axisTitleX="Temps de suivi" + axisTitleY="Nombre de personnes" + help={`Répartition des temps de suivi des personnes concernées, dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + ); +}; + +const StatsWanderingAtRangeBar = ({ persons, onItemClick }) => { + const categories = ['0-6 mois', '6-12 mois', '1-2 ans', '2-5 ans', '5-10 ans', '+ 10 ans', 'Non renseigné']; + + let data = persons.reduce((newData, person) => { + if (!person.wanderingAt || !person.wanderingAt.length) { + newData['Non renseigné'].push(person); + return newData; + } + const parsedDate = Date.parse(person.wanderingAt); + const fromNowInMonths = (Date.now() - parsedDate) / 1000 / 60 / 60 / 24 / (365.25 / 12); + if (fromNowInMonths < 6) { + newData['0-6 mois'].push(person); + return newData; + } + if (fromNowInMonths < 12) { + newData['6-12 mois'].push(person); + return newData; + } + if (fromNowInMonths < 24) { + newData['1-2 ans'].push(person); + return newData; + } + if (fromNowInMonths < 60) { + newData['2-5 ans'].push(person); + return newData; + } + if (fromNowInMonths < 120) { + newData['5-10 ans'].push(person); + return newData; + } + newData['+ 10 ans'].push(person); + return newData; + }, initCategories(categories)); + + const dataCount = Object.keys(data) + .filter((key) => data[key]?.length > 0) + .map((key) => ({ name: key, [key]: data[key]?.length })); + + return ( + { + onItemClick(item, data[item]); + } + : null + } + axisTitleX="Temps d'errance" + axisTitleY="Nombre de personnes" + help={`Répartition des temps d'errance des personnes concernées, dans la période définie.\n\nSi aucune période n'est définie, on considère l'ensemble des personnes.`} + /> + ); +}; + +const Teams = ({ person: { _id, assignedTeams } }) => ( + + {assignedTeams?.map((teamId) => ( + + ))} + +); + +const SelectedPersonsModal = ({ open, onClose, persons, title, onAfterLeave, sliceField }) => { + const history = useHistory(); + const teams = useRecoilValue(teamsState); + const organisation = useRecoilValue(organisationState); + const personFieldsIncludingCustomFields = useRecoilValue(personFieldsIncludingCustomFieldsSelector); + + const [sortBy, setSortBy] = useLocalStorage('person-sortBy', 'name'); + const [sortOrder, setSortOrder] = useLocalStorage('person-sortOrder', 'ASC'); + const data = useMemo(() => { + return [...persons].sort(sortPersons(sortBy, sortOrder)); + }, [persons, sortBy, sortOrder]); + + if (!sliceField) return null; + + const exportXlsx = () => { + const wb = utils.book_new(); + const ws = utils.json_to_sheet( + persons.map((person) => { + return { + id: person._id, + ...personFieldsIncludingCustomFields + .filter((person) => !['_id', 'organisation', 'user', 'createdAt', 'updatedAt', 'documents', 'history'].includes(person.name)) + .reduce((fields, field) => { + if (field.name === 'assignedTeams') { + fields[field.label] = (person[field.name] || []).map((t) => teams.find((person) => person._id === t)?.name)?.join(', '); + } else if (['date', 'date-with-time'].includes(field.type)) + fields[field.label || field.name] = person[field.name] ? dayjsInstance(person[field.name]).format('YYYY-MM-DD') : ''; + else if (['boolean'].includes(field.type)) fields[field.label || field.name] = person[field.name] ? 'Oui' : 'Non'; + else if (['yes-no'].includes(field.type)) fields[field.label || field.name] = person[field.name]; + else if (Array.isArray(person[field.name])) fields[field.label || field.name] = person[field.name].join(', '); + else fields[field.label || field.name] = person[field.name]; + return fields; + }, {}), + 'Créé le': dayjsInstance(person.createdAt).format('YYYY-MM-DD'), + 'Mis à jour le': dayjsInstance(person.updatedAt).format('YYYY-MM-DD'), + }; + }) + ); + utils.book_append_sheet(wb, ws, 'Personnes suivies'); + writeFile(wb, `${title}.xlsx`); + }; + return ( + + + {title}{' '} + + + }> + +
+ history.push(`/person/${p._id}`)} + columns={[ + { + title: '', + dataKey: 'group', + onSortOrder: setSortOrder, + onSortBy: setSortBy, + sortOrder, + sortBy, + small: true, + render: (person) => { + if (!person.group) return null; + return ( +
+ + 👪 + +
+ ); + }, + }, + { + title: 'Nom', + dataKey: 'name', + onSortOrder: setSortOrder, + onSortBy: setSortBy, + sortOrder, + sortBy, + }, + { + title: sliceField.label, + dataKey: sliceField, + render: (person) => { + return ; + }, + }, + { title: 'Équipe(s) en charge', dataKey: 'assignedTeams', render: (person) => }, + { + title: 'Suivi(e) depuis le', + dataKey: 'followedSince', + onSortOrder: setSortOrder, + onSortBy: setSortBy, + sortOrder, + sortBy, + render: (p) => formatDateWithFullMonth(p.followedSince || p.createdAt || ''), + }, + ].filter((c) => organisation.groupsEnabled || c.dataKey !== 'group')} + /> + + + + + + + ); +};