diff --git a/packages/esm-ward-app/src/beds/occupied-bed.scss b/packages/esm-ward-app/src/beds/occupied-bed.scss index 320fb4ac8..eda7a75c3 100644 --- a/packages/esm-ward-app/src/beds/occupied-bed.scss +++ b/packages/esm-ward-app/src/beds/occupied-bed.scss @@ -19,6 +19,6 @@ .bedDividerLine { height: 1px; - background-color: vars.$ui-03; + background-color: vars.$ui-05; width: 30%; } diff --git a/packages/esm-ward-app/src/config-schema.ts b/packages/esm-ward-app/src/config-schema.ts index ee9f2866f..aaca08bc4 100644 --- a/packages/esm-ward-app/src/config-schema.ts +++ b/packages/esm-ward-app/src/config-schema.ts @@ -19,6 +19,7 @@ export const defaultPatientCardElementConfig: PatientCardElementConfig = { addressFields: defaultPatientAddressFields, }, obs: null, + codedObsTags: null, }; export const builtInPatientCardElements: PatientCardElementType[] = [ @@ -57,7 +58,7 @@ export const configSchema: ConfigSchema = { _description: 'Config for the patientCardElementType "patient-obs"', conceptUuid: { _type: Type.UUID, - _description: 'defines which observation value to show', + _description: 'Required. Identifies the concept to use to identify the desired observations.', _default: null, }, label: { @@ -90,6 +91,48 @@ export const configSchema: ConfigSchema = { _default: false, }, }, + codedObsTags: { + _description: 'Config for the patientCardElementType "patient-coded-obs-tags"', + conceptUuid: { + _type: Type.UUID, + _description: 'Required. Identifies the concept to use to identify the desired observations.', + _default: null, + }, + summaryLabel: { + _type: Type.String, + _description: `Optional. The custom label or i18n key to the translated label to display for the summary tag. The summary tag shows the count of the number of answers that are present but not configured to show as their own tags. If not provided, defaults to the name of the concept.`, + _default: null, + }, + summaryLabelI18nModule: { + _type: Type.String, + _description: 'Optional. The custom module to use for translation of the summary label', + _default: null, + }, + summaryLabelColor: { + _type: Type.String, + _description: + 'The color of the summary tag. See https://react.carbondesignsystem.com/?path=/docs/components-tag--overview for a list of supported colors', + _default: null, + }, + tags: { + _description: `An array specifying concept sets and color. Observations with coded values that are members of the specified concept sets will be displayed as their own tags with the specified color. Any observation with coded values not belonging to any concept sets specified will be summarized as a count in the summary tag. If a concept set is listed multiple times, the first matching applied-to rule takes precedence.`, + _type: Type.Array, + _elements: { + color: { + _type: Type.String, + _description: + 'Color of the tag. See https://react.carbondesignsystem.com/?path=/docs/components-tag--overview for a list of supported colors.', + }, + appliedToConceptSets: { + _type: Type.Array, + _description: `The concept sets which the color applies to. Observations with coded values that are members of the specified concept sets will be displayed as their own tag with the specified color. If an observation's coded value belongs to multiple concept sets, the first matching applied-to rule takes precedence.`, + _elements: { + _type: Type.UUID, + }, + }, + }, + }, + }, }, }, }, @@ -177,7 +220,7 @@ export interface PatientAddressElementConfig { export interface PatientObsElementConfig { /** - * Required. Defines which observation value to show + * Required. Identifies the concept to use to identify the desired observations. */ conceptUuid: string; @@ -208,7 +251,52 @@ export interface PatientObsElementConfig { onlyWithinCurrentVisit?: boolean; } +export interface PatientCodedObsTagsElementConfig { + /** + * Required. Identifies the concept to use to identify the desired observations. + */ + conceptUuid: string; + + /** + * Optional. The custom label or i18n key to the translated label to display for the summary tag. The summary tag + * shows the count of the number of answers that are present but not configured to show as their own tags. If not + * provided, defaults to the name of the concept. + */ + summaryLabel?: string; + /** + * Optional. The custom module to use for translation of the summary label + */ + summaryLabelI18nModule?: string; + + /** + * The color of the summary tag. + * See https://react.carbondesignsystem.com/?path=/docs/components-tag--overview for a list of supported colors + */ + summaryLabelColor?: string; + + /** + * An array specifying concept sets and color. Observations with coded values that are members of the specified concept sets + * will be displayed as their own tags with the specified color. Any observation with coded values not belonging to + * any concept sets specified will be summarized as a count in the summary tag. If a concept set is listed multiple times, + * the first matching applied-to rule takes precedence. + */ + tags: Array<{ + /** + * Color of the tag. See https://react.carbondesignsystem.com/?path=/docs/components-tag--overview for a list of supported colors. + */ + color: string; + + /** + * The concept sets which the color applies to. Observations with coded values that are members of the specified concept sets + * will be displayed as their own tag with the specified color. + * If an observation's coded value belongs to multiple concept sets, the first matching applied-to rule takes precedence. + */ + appliedToConceptSets: Array; + }>; +} + export type PatientCardElementConfig = { address: PatientAddressElementConfig; obs: PatientObsElementConfig; + codedObsTags: PatientCodedObsTagsElementConfig; }; diff --git a/packages/esm-ward-app/src/hooks/useConcept.ts b/packages/esm-ward-app/src/hooks/useConcept.ts new file mode 100644 index 000000000..5ba7935f3 --- /dev/null +++ b/packages/esm-ward-app/src/hooks/useConcept.ts @@ -0,0 +1,11 @@ +import { type Concept, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; + +export function useConcepts(uuids: string[], rep = 'default') { + const apiUrl = `${restBaseUrl}/concept?references=${uuids.join()}&v=${rep}`; + const { data, ...rest } = useSWRImmutable<{ data: { results: Array } }, Error>(apiUrl, openmrsFetch); + return { + concepts: data?.data?.results, + ...rest, + }; +} diff --git a/packages/esm-ward-app/src/types/index.ts b/packages/esm-ward-app/src/types/index.ts index e9186ab1e..058cc7fe5 100644 --- a/packages/esm-ward-app/src/types/index.ts +++ b/packages/esm-ward-app/src/types/index.ts @@ -23,6 +23,7 @@ export const patientCardElementTypes = [ 'patient-age', 'patient-address', 'patient-obs', + 'patient-coded-obs-tags', 'admission-time', ] as const; export type PatientCardElementType = (typeof patientCardElementTypes)[number]; diff --git a/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-coded-obs-tags.tsx b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-coded-obs-tags.tsx new file mode 100644 index 000000000..601816f33 --- /dev/null +++ b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-coded-obs-tags.tsx @@ -0,0 +1,80 @@ +import { SkeletonText, Tag } from '@carbon/react'; +import { translateFrom, type OpenmrsResource } from '@openmrs/esm-framework'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { type PatientCodedObsTagsElementConfig } from '../../config-schema'; +import { moduleName } from '../../constant'; +import { useObs } from '../../hooks/useObs'; +import { type WardPatientCardElement } from '../../types'; +import styles from '../ward-patient-card.scss'; +import { obsCustomRepresentation, useConceptToTagColorMap } from './ward-patient-obs.resource'; + +/** + * The WardPatientCodedObsTags displays observations of coded values of a particular concept in the active visit as tags. + * Typically, these are taken from checkbox fields from a form. Each answer value can either be configured + * to show as its own tag, or collapsed into a summary tag show the number of these values present. + * + * This is a rather specialized element; + * for a more general display of obs value, use WardPatientObs instead. + * @param config + * @returns + */ +const wardPatientCodedObsTags = (config: PatientCodedObsTagsElementConfig) => { + const WardPatientCodedObsTags: WardPatientCardElement = ({ patient, visit }) => { + const { conceptUuid, summaryLabel, summaryLabelColor, summaryLabelI18nModule } = config; + const { data, isLoading } = useObs({ patient: patient.uuid, concept: conceptUuid }, obsCustomRepresentation); + const { t } = useTranslation(); + const { data: conceptToTagColorMap } = useConceptToTagColorMap(config); + + if (isLoading) { + return ; + } else { + const obsToDisplay = data?.data?.results?.filter((o) => { + const matchVisit = o.encounter.visit?.uuid == visit?.uuid; + return matchVisit || visit == null; // TODO: remove visit == null hack when server API supports returning visit + }); + + const summaryLabelToDisplay = + summaryLabel != null + ? translateFrom(summaryLabelI18nModule ?? moduleName, summaryLabel) + : obsToDisplay?.[0]?.concept?.display; + + const obsNodes = obsToDisplay?.map((o) => { + const { display, uuid } = o.value as OpenmrsResource; + + const color = conceptToTagColorMap?.get(uuid); + if (color) { + return ( + + {display} + + ); + } else { + return null; + } + }); + + const obsWithNoTagCount = obsNodes.filter((o) => o == null).length; + if (obsNodes?.length > 0 || obsWithNoTagCount > 0) { + return ( +
+ + {obsNodes} + {obsWithNoTagCount > 0 ? ( + + {t('countItems', '{{count}} {{item}}', { count: obsWithNoTagCount, item: summaryLabelToDisplay })} + + ) : null} + +
+ ); + } else { + return null; + } + } + }; + + return WardPatientCodedObsTags; +}; + +export default wardPatientCodedObsTags; diff --git a/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.resource.ts b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.resource.ts new file mode 100644 index 000000000..0a16ff285 --- /dev/null +++ b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.resource.ts @@ -0,0 +1,52 @@ +import { openmrsFetch, restBaseUrl, type Concept } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; +import { type PatientCodedObsTagsElementConfig } from '../../config-schema'; + +// prettier-ignore +export const obsCustomRepresentation = + 'custom:(uuid,display,obsDatetime,value,' + + 'concept:(uuid,display),' + + 'encounter:(uuid,display,' + + 'visit:(uuid,display)))'; + +// get the setMembers of a concept set +const conceptSetCustomRepresentation = 'custom:(uuid,setMembers:(uuid))'; + +export function useConceptToTagColorMap(codedObsTagsConfig: PatientCodedObsTagsElementConfig) { + // fetch the members of the concept sets and process the data + // to return conceptToTagColorMap (wrapped in a promise). + // Let swr cache the result of this function. + const fetchAndMap = (url: string) => { + const conceptSetToTagColorMap = new Map(); + for (const tag of codedObsTagsConfig.tags) { + const { color, appliedToConceptSets } = tag; + for (const answer of appliedToConceptSets ?? []) { + if (!conceptSetToTagColorMap.has(answer)) { + conceptSetToTagColorMap.set(answer, color); + } + } + } + + return openmrsFetch<{ results: Array }>(url).then((data) => { + const conceptSets = data.data.results; + const conceptToTagColorMap = new Map(); + if (conceptSets) { + for (const conceptSet of conceptSets) { + for (const concept of conceptSet.setMembers) { + if (!conceptToTagColorMap.has(concept.uuid)) { + conceptToTagColorMap.set(concept.uuid, conceptSetToTagColorMap.get(conceptSet.uuid)); + } + } + } + } + + return conceptToTagColorMap; + }); + }; + + const conceptSetUuids = codedObsTagsConfig.tags.flatMap((tag) => tag.appliedToConceptSets); + const apiUrl = `${restBaseUrl}/concept?references=${conceptSetUuids.join()}&v=${conceptSetCustomRepresentation}`; + const conceptToTagColorMap = useSWRImmutable(apiUrl, fetchAndMap); + + return conceptToTagColorMap; +} diff --git a/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.tsx b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.tsx index c388a56b4..51758327c 100644 --- a/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.tsx +++ b/packages/esm-ward-app/src/ward-patient-card/row-elements/ward-patient-obs.tsx @@ -7,13 +7,7 @@ import { useObs } from '../../hooks/useObs'; import { type WardPatientCardElement } from '../../types'; import styles from '../ward-patient-card.scss'; import { moduleName } from '../../constant'; - -// prettier-ignore -const obsCustomRepresentation = - 'custom:(uuid,display,obsDatetime,value,' + - 'concept:(uuid,display),' + - 'encounter:(uuid,display,' + - 'visit:(uuid,display)))'; +import { obsCustomRepresentation } from './ward-patient-obs.resource'; const wardPatientObs = (config: PatientObsElementConfig) => { const WardPatientObs: WardPatientCardElement = ({ patient, visit }) => { @@ -40,18 +34,21 @@ const wardPatientObs = (config: PatientObsElementConfig) => { const obsNodes = obsToDisplay?.map((o) => { const { value } = o; const display: any = (value as OpenmrsResource)?.display ?? o.value; - - return {display} ; + return {display} ; }); - return ( -
- - {labelToDisplay ? t('labelColon', '{{label}}:', { label: labelToDisplay }) : ''} - - {obsNodes} -
- ); + if (obsNodes?.length > 0) { + return ( +
+ + {labelToDisplay ? t('labelColon', '{{label}}:', { label: labelToDisplay }) : ''} + + {obsNodes} +
+ ); + } else { + return null; + } } }; diff --git a/packages/esm-ward-app/src/ward-patient-card/ward-patient-card-row.resources.tsx b/packages/esm-ward-app/src/ward-patient-card/ward-patient-card-row.resources.tsx index 6cb5fc17f..1697f5466 100644 --- a/packages/esm-ward-app/src/ward-patient-card/ward-patient-card-row.resources.tsx +++ b/packages/esm-ward-app/src/ward-patient-card/ward-patient-card-row.resources.tsx @@ -14,6 +14,7 @@ import WardPatientName from './row-elements/ward-patient-name'; import React from 'react'; import styles from './ward-patient-card.scss'; import wardPatientObs from './row-elements/ward-patient-obs'; +import wardPatientCodedObsTags from './row-elements/ward-patient-coded-obs-tags'; export function usePatientCardRows(location: string) { const { wardPatientCards } = useConfig(); @@ -84,5 +85,8 @@ function getPatientCardElementFromDefinition( case 'patient-obs': { return wardPatientObs(config.obs); } + case 'patient-coded-obs-tags': { + return wardPatientCodedObsTags(config.codedObsTags); + } } } diff --git a/packages/esm-ward-app/src/ward-patient-card/ward-patient-card.scss b/packages/esm-ward-app/src/ward-patient-card/ward-patient-card.scss index fa47f4f0d..5db8acdfa 100644 --- a/packages/esm-ward-app/src/ward-patient-card/ward-patient-card.scss +++ b/packages/esm-ward-app/src/ward-patient-card/ward-patient-card.scss @@ -23,6 +23,10 @@ padding: spacing.$spacing-04; } +.wardPatientCardRow:empty { + display: none; +} + .wardPatientCardHeader { @extend .dotSeparatedChildren; display: flex;