Skip to content

Commit

Permalink
KHP3-4780: (feat) add mch clinical views for anc, labour and delivery…
Browse files Browse the repository at this point in the history
…, and postn… (#97)

* (feat) add mch clinical views for anc, labour and delivery, and postnatal care

* fix lint issues

* (fix)add content switcher for MCH mother services. Add expandable table renderer for more encounter information

* (fix) format fixes

* remove unused code
ojwanganto authored Dec 14, 2023
1 parent 4a61147 commit 84a8efa
Showing 21 changed files with 1,418 additions and 4 deletions.
26 changes: 25 additions & 1 deletion packages/esm-patient-clinical-view-app/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
export const configSchema = {};
import { Type } from '@openmrs/esm-framework';

export const configSchema = {
encounterTypes: {
_type: Type.Object,
_description: 'List of encounter type UUIDs',
_default: {
mchMotherConsultation: 'c6d09e05-1f25-4164-8860-9f32c5a02df0',
},
},
formsList: {
_type: Type.Object,
_description: 'List of form UUIDs',
_default: {
antenatal: 'e8f98494-af35-4bb8-9fc7-c409c8fed843',
postNatal: '72aa78e0-ee4b-47c3-9073-26f3b9ecc4a7',
labourAndDelivery: '496c7cc3-0eea-4e84-a04c-2292949e2f7f',
},
},
};

export interface ConfigObject {
encounterTypes: { mchMotherConsultation: string };
formsList: { labourAndDelivery: string; antenatal: string; postnatal: string };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { useEffect, useState } from 'react';
import {
DataTable,
Table,
TableCell,
TableContainer,
TableBody,
TableHead,
TableHeader,
TableRow,
TableExpandHeader,
TableExpandRow,
TableExpandedRow,
} from '@carbon/react';
import styles from './o-table.scss';
import EncounterObservations from '../encounter-observation/encounter-observation.component';

interface TableProps {
tableHeaders: any;
tableRows: any;
}

export const OTable: React.FC<TableProps> = ({ tableHeaders, tableRows }) => {
return (
<TableContainer>
<DataTable rows={tableRows} headers={tableHeaders} isSortable={true} size="short">
{({ rows, headers, getHeaderProps, getTableProps, getRowProps }) => (
<Table {...getTableProps()}>
<TableHead>
<TableRow>
<TableExpandHeader enableToggle={false} />
{headers.map((header) => (
<TableHeader
className={`${styles.productiveHeading01} ${styles.text02}`}
{...getHeaderProps({
header,
isSortable: header.isSortable,
})}>
{header.header?.content ?? header.header}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => {
return (
<React.Fragment key={row.id}>
<TableExpandRow {...getRowProps({ row })}>
{row.cells.map((cell) => (
<TableCell key={cell.id}>{cell.value}</TableCell>
))}
</TableExpandRow>
<TableExpandedRow className={styles.hiddenRow} colSpan={headers.length + 2}>
<EncounterObservations observations={tableRows?.[index]?.obs ?? []} />
</TableExpandedRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
</DataTable>
</TableContainer>
);
};
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { formatDate, parseDate } from '@openmrs/esm-framework';

export function getEncounterValues(encounter, param: string, isDate?: Boolean) {
if (isDate) {
return formatDate(parseDate(encounter[param]));
} else {
return encounter[param] ? encounter[param] : '--';
}
}

export function formatDateTime(dateString: string): any {
const format = 'YYYY-MM-DDTHH:mm:ss';
if (dateString.includes('.')) {
dateString = dateString.split('.')[0];
}
return formatDate(parseDate(dateString));
}

export function obsArrayDateComparator(left, right) {
return formatDateTime(right.obsDatetime) - formatDateTime(left.obsDatetime);
}

export function findObs(encounter, obsConcept): Record<string, any> {
const allObs = encounter?.obs?.filter((observation) => observation.concept.uuid === obsConcept) || [];
return allObs?.length == 1 ? allObs[0] : allObs?.sort(obsArrayDateComparator)[0];
}

export function getObsFromEncounters(encounters, obsConcept) {
const filteredEnc = encounters?.find((enc) => enc.obs.find((obs) => obs.concept.uuid === obsConcept));
return getObsFromEncounter(filteredEnc, obsConcept);
}

export function getMultipleObsFromEncounter(encounter, obsConcepts: Array<string>) {
let observations = [];
obsConcepts.map((concept) => {
const obs = getObsFromEncounter(encounter, concept);
if (obs !== '--') {
observations.push(obs);
}
});

return observations.length ? observations.join(', ') : '--';
}

export function getObsFromEncounter(encounter, obsConcept, isDate?: Boolean, isTrueFalseConcept?: Boolean) {
const obs = findObs(encounter, obsConcept);

if (isTrueFalseConcept) {
if (obs.value.uuid == 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3') {
return 'Yes';
} else {
return 'No';
}
}
if (!obs) {
return '--';
}
if (isDate) {
return formatDate(parseDate(obs.value), { mode: 'wide' });
}
if (typeof obs.value === 'object' && obs.value?.names) {
return (
obs.value?.names?.find((conceptName) => conceptName.conceptNameType === 'SHORT')?.name || obs.value.name.name
);
}
return obs.value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { navigate } from '@openmrs/esm-framework';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './encounter-list.scss';
import { OTable } from '../data-table/o-table.component';
import {
Button,
Link,
OverflowMenu,
OverflowMenuItem,
Pagination,
DataTableSkeleton,
Layer,
Tile,
} from '@carbon/react';
import { Add } from '@carbon/react/icons';
import { useEncounterRows } from '../../src/hooks/useEncounterRows';
import { OpenmrsEncounter } from '../type/types';
import { EmptyDataIllustration, EmptyState } from '@openmrs/esm-patient-common-lib';

export interface O3FormSchema {
name: string;
pages: Array<any>;
processor: string;
uuid: string;
referencedForms: [];
encounterType: string;
encounter?: string | OpenmrsEncounter;
allowUnspecifiedAll?: boolean;
defaultPage?: string;
readonly?: string | boolean;
inlineRendering?: 'single-line' | 'multiline' | 'automatic';
markdown?: any;
postSubmissionActions?: Array<{ actionId: string; config?: Record<string, any> }>;
formOptions?: {
usePreviousValueDisabled: boolean;
};
version?: string;
}
export interface EncounterListColumn {
key: string;
header: string;
getValue: (encounter: any) => string;
link?: any;
}

export interface EncounterListProps {
patientUuid: string;
encounterType: string;
columns: Array<any>;
headerTitle: string;
description: string;
formList?: Array<{
name: string;
excludedIntents?: Array<string>;
fixedIntent?: string;
isDefault?: boolean;
}>;
launchOptions: {
moduleName: string;
hideFormLauncher?: boolean;
displayText?: string;
workspaceWindowSize?: 'minimized' | 'maximized';
};
filter?: (encounter: any) => boolean;
}

export const EncounterList: React.FC<EncounterListProps> = ({
patientUuid,
encounterType,
columns,
headerTitle,
description,
formList,
filter,
launchOptions,
}) => {
const { t } = useTranslation();
const [paginatedRows, setPaginatedRows] = useState([]);
const [forms, setForms] = useState<O3FormSchema[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const formNames = useMemo(() => formList.map((form) => form.name), []);
const {
encounters,
isLoading: isLoadingEncounters,
onFormSave,
} = useEncounterRows(patientUuid, encounterType, filter);
const { moduleName, workspaceWindowSize, displayText, hideFormLauncher } = launchOptions;

const defaultActions = useMemo(
() => [
{
label: t('viewEncounter', 'View'),
form: {
name: forms[0]?.name,
},
mode: 'view',
intent: '*',
},
{
label: t('editEncounter', 'Edit'),
form: {
name: forms[0]?.name,
},
mode: 'view',
intent: '*',
},
],
[forms, t],
);

const headers = useMemo(() => {
if (columns) {
return columns.map((column) => {
return { key: column.key, header: column.header };
});
}
return [];
}, [columns]);

const expandedHeaderProps = useMemo(() => {
if (columns) {
return columns.map((column) => {
return { key: column.key, header: column.header };
});
}
return [];
}, [columns]);

const constructPaginatedTableRows = useCallback(
(encounters: OpenmrsEncounter[], currentPage: number, pageSize: number) => {
const startIndex = (currentPage - 1) * pageSize;
const paginatedEncounters = [];
for (let i = startIndex; i < startIndex + pageSize; i++) {
if (i < encounters.length) {
paginatedEncounters.push(encounters[i]);
}
}
const rows = paginatedEncounters.map((encounter) => {
const tableRow: { id: string; actions: any; obs: any } = {
id: encounter.uuid,
actions: null,
obs: encounter.obs,
};
// inject launch actions
encounter['launchFormActions'] = {
editEncounter: () => {},
viewEncounter: () => {},
};
// process columns
columns.forEach((column) => {
let val = column.getValue(encounter);
if (column.link) {
val = (
<Link
onClick={(e) => {
e.preventDefault();
if (column.link.handleNavigate) {
column.link.handleNavigate(encounter);
} else {
column.link?.getUrl && navigate({ to: column.link.getUrl() });
}
}}>
{val}
</Link>
);
}
tableRow[column.key] = val;
});
// If custom config is available, generate actions accordingly; otherwise, fallback to the default actions.
const actions = tableRow.actions?.length ? tableRow.actions : defaultActions;
tableRow['actions'] = (
<OverflowMenu flipped className={styles.flippedOverflowMenu}>
{actions.map((actionItem, index) => (
<OverflowMenuItem
itemText={actionItem.label}
onClick={(e) => {
e.preventDefault();
}}
/>
))}
</OverflowMenu>
);
return tableRow;
});
setPaginatedRows(rows);
},
[columns, defaultActions, forms, moduleName, workspaceWindowSize],
);

useEffect(() => {
if (encounters?.length) {
constructPaginatedTableRows(encounters, currentPage, pageSize);
}
}, [encounters, pageSize, constructPaginatedTableRows, currentPage]);

return (
<>
{isLoadingEncounters ? (
<DataTableSkeleton rowCount={5} />
) : encounters.length > 0 ? (
<>
<div className={styles.widgetContainer}>
<div className={styles.widgetHeaderContainer}>
{!hideFormLauncher && <div className={styles.toggleButtons}>{}</div>}
</div>
<OTable tableHeaders={headers} tableRows={paginatedRows} />
<Pagination
page={currentPage}
pageSize={pageSize}
pageSizes={[10, 20, 30, 40, 50]}
totalItems={encounters.length}
onChange={({ page, pageSize }) => {
setCurrentPage(page);
setPageSize(pageSize);
}}
/>
</div>
</>
) : (
<div className={styles.widgetContainer}>
<Layer className={styles.emptyStateContainer}>
<Tile className={styles.tile}>
<div>
<EmptyDataIllustration />
</div>
<p className={styles.content}>There are no encounters to display</p>
</Tile>
</Layer>
</div>
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
@use '@carbon/styles/scss/spacing';
@use '@carbon/styles/scss/type';
@import "../../src/root.scss";


.widgetContainer {
background-color: $ui-background;
border: 1px solid #e0e0e0;
margin-bottom: 1rem;
}

.widgetHeaderContainer {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$spacing-04 0 spacing.$spacing-04 spacing.$spacing-05;
}

.widgetHeaderContainer > h4:after {
content: "";
display: block;
width: 2rem;
padding-top: 0.188rem;
border-bottom: 0.375rem solid var(--brand-03);
}

.newServiceEnrolmentBtn {
margin-bottom: 0.5em;
text-align: right;
}

.newServiceEnrolmentBtn > button {
background-color: $ui-background;
}

.widgetContainer :global(.cds--data-table) thead th button span {
height: unset !important;
}

.tile {
text-align: center;
}

.emptyStateContainer {
margin: 2rem 0;
}

.content {
@extend .productiveHeading01;
color: $text-02;
margin-top: spacing.$spacing-05;
margin-bottom: spacing.$spacing-03;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export function mapEncounters(encounters: Array<any>): MappedEncounter[] {
return encounters?.map((enc) => ({
id: enc?.uuid,
datetime: enc?.encounterDatetime,
obs: enc?.obs,
provider: enc?.encounterProviders?.length > 0 ? enc?.encounterProviders[0].provider?.person?.display : '--',
}));
}

export interface MappedEncounter {
id: string;
datetime: string;
obs: Array<Observation>;
provider: string;
}

export interface Encounter {
uuid: string;
encounterDatetime: string;
encounterProviders: Array<EncounterProvider>;
obs: Array<Observation>;
}

export interface EncounterProvider {
uuid: string;
display: string;
encounterRole: {
uuid: string;
display: string;
};
provider: {
uuid: string;
person: {
uuid: string;
display: string;
};
};
}

export interface Observation {
uuid: string;
concept: {
uuid: string;
display: string;
conceptClass: {
uuid: string;
display: string;
};
};
display: string;
groupMembers: null | Array<{
uuid: string;
concept: {
uuid: string;
display: string;
};
value: {
uuid: string;
display: string;
};
display: string;
}>;
value: any;
obsDatetime?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@use '@carbon/styles/scss/spacing';

.observation {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: spacing.$spacing-03;
margin: spacing.$spacing-05 spacing.$spacing-05 spacing.$spacing-05 0;
}

.observation > span {
align-self: center;
}

.parentConcept {
font-weight: bold;
}

.childConcept {
padding-left: 0.8rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SkeletonText } from '@carbon/react';
import { useConfig } from '@openmrs/esm-framework';
import { Observation } from '../../src/type/types';
import styles from './encounter-observation-table.scss';

interface EncounterObservationsProps {
observations: Array<Observation>;
}

const EncounterObservations: React.FC<EncounterObservationsProps> = ({ observations }) => {
const { t } = useTranslation();
const { obsConceptUuidsToHide = [] } = useConfig();
const conceptMap = new Map([
['1658AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Adherence rating'],
['164848AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Patient received viral load result'],
[
'163310AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
'Was the Viral load result suppressed (less than 1000) or unsuppressed (greater than 1000) ',
],
['160632AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Way forward'],
['160110AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Have any dosses been missed?'],
['1898AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', "Patient's condition improved since last visit"],
['95f73a05-7c52-4a8d-b3b1-f632a41d065d', 'Will the patient benefit from a home visit?'],
['164891AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Date of first session'],
['162846AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Pill count adherence % (from pill count)'],
['1272AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Has the patient been referred to other services?'],
]);

function getAnswerFromDisplay(display: string): string {
if (display == undefined) {
return '';
}
const colonIndex = display.indexOf(':');
if (colonIndex === -1) {
return '';
} else {
return display.substring(colonIndex + 1).trim();
}
}

function matchFormDisplay(conceptUuid: string): string {
let theDisplay = conceptMap.get(conceptUuid) ? conceptMap.get(conceptUuid) : '';
return theDisplay;
}

if (!observations) {
return <SkeletonText />;
}

if (observations) {
const filteredObservations = !!obsConceptUuidsToHide.length
? observations?.filter((obs) => {
return !obsConceptUuidsToHide.includes(obs?.concept?.uuid);
})
: observations;
return (
<div className={styles.observation}>
{filteredObservations?.map((obs, index) => {
if (obs.groupMembers) {
return (
<React.Fragment key={index}>
<span className={styles.parentConcept}>{obs.concept.display}</span>
<span />
{obs.groupMembers.map((member) => (
<React.Fragment key={index}>
<span className={styles.childConcept}>{member.concept.display}</span>
<span>{getAnswerFromDisplay(member.display)}</span>
</React.Fragment>
))}
</React.Fragment>
);
} else {
return (
<React.Fragment key={index}>
<span>
{matchFormDisplay(obs.concept.uuid) ? matchFormDisplay(obs.concept.uuid) : obs.concept.display}
</span>
<span>{getAnswerFromDisplay(obs.display)}</span>
</React.Fragment>
);
}
})}
</div>
);
}

return (
<div className={styles.observation}>
<p>{t('noObservationsFound', 'No observations found')}</p>
</div>
);
};

export default EncounterObservations;
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export const vLResultsConcept = '1305AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

export const ancVisitNumberConcept = '1425AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

//Labour & Delivery
export const infantStatusAtBirthConcept = '159917AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const deliveryOutcomeConcept = '159949AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const ancHivResultConcept = 'c5f74c86-62cd-4d22-9260-4238f1e45fe0';
export const hivStatusAtDeliveryConcept = 'c5f74c86-62cd-4d22-9260-4238f1e45fe0';
export const artInitiationConcept = '6e62bf7e-2107-4d09-b485-6e60cbbb2d08';
export const birthCountConcept = '1568AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const hivTestStatus = '164401AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const recenctViralLoad = '163310AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const motherStatusConcept = '1856AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const hivTestAtMaternityResults = '1396AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const placeOfDeliveryConcept = '1572AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

export const infantDeliveryGroupingConcept = '1c70c490-cafa-4c95-9fdd-a30b62bb78b8';
export const infantPTrackerIdConcept = '164803AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const infantDateOfBirth = '164802AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

//Infant Postnatal
export const infantPostnatalEncounterType = 'af1f1b24-d2e8-4282-b308-0bf79b365584';
export const nextVisitDate = '5096AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const ChildPTrackerId = '6c45421e-2566-47cb-bbb3-07586fffbfe2';
export const childDateOfBirth = '163260AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const artProphylaxisStatus = '1148AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const linkedToArt = 'a40d8bc4-56b8-4f28-a1dd-412da5cf20ed';
export const breastfeedingStatus = '1151AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const outcomeStatus = '160433AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const infantVisitDate = '159599AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const finalTestResults = '164460AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

//Antenatal
export const antenatalEncounterType = '2549af50-75c8-4aeb-87ca-4bb2cef6c69a';
export const visitDateConcept = '163260AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const hivTestResultConcept = '159427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const partnerHivStatus = '1436AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const artNoConcept = '164402AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const followUpDateConcept = '5096AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const eDDConcept = '5596AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const nextVisitDateConcept = '5096AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const artStartDate = '159599AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const ancVisitsConcept = '1425AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const testTypeConcept = 'ee8c0292-47f8-4c01-8b60-8ba13a560e1a';
export const infantExposureStatus = '6027869c-5d7e-4a82-b22f-6d9c57d61a4d';

//Mother Postnatal
export const motherPostnatalEncounterType = '269bcc7f-04f8-4ddc-883d-7a3a0d569aad';
export const MothervisitDate = '163260AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const MotherHivStatus = '159427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const MotherViralLoadDate = '163281AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const MotherViralLoadResult = '1305AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const MotherNextVisitDate = '5096AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const artUniqueNoConcept = '164402AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const motherGeneralConditionConcept = '1856AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const motherBreastConditionConcept = '159780AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const pphConditionConcept = '230AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const uterusConditionConcept = '163742AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

//Reports
export const ancVisitsReport = '5299521a-7fad-47bb-8280-14c99d04c790';
export const eddReport = 'aac635c9-0a77-4489-8b0d-866b3ad22f73';
export const motherHivStatusReport = 'ed50a889-dd5b-4759-861c-b54e3c686fe7';

//MCH Summary
export const mchEncounterType = '12de5bc5-352e-4faf-9961-a2125085a75c';
export const antenatalVisitType = '164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
export const mchVisitType = '140084BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
export const mchVisitsTypes = ['Antenatal', 'Labor and Delivery', 'Mother Postnatal'];

export const encounterRepresentation =
'custom:(uuid,encounterDatetime,encounterType,location:(uuid,name),' +
'patient:(uuid,display),encounterProviders:(uuid,provider:(uuid,name)),' +
'obs:(uuid,obsDatetime,voided,groupMembers,concept:(uuid,name:(uuid,name)),value:(uuid,name:(uuid,name),' +
'names:(uuid,conceptNameType,name))),form:(uuid,name))';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@use '@carbon/styles/scss/spacing';
@import "~@openmrs/esm-styleguide/src/vars";

.widgetContainer {
background-color: $ui-background;
}

.widgetHeaderContainer {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$spacing-04 0 spacing.$spacing-04 spacing.$spacing-05;
}

.widgetHeaderContainer > h4:after {
content: '';
display: block;
width: 2rem;
padding-top: 0.188rem;
border-bottom: 0.375rem solid var(--brand-01);
}

.toggleButtons {
width: fit-content;
margin: 0 spacing.$spacing-03;
}

.tabContainer div[role='tabpanel'] {
padding: 0 !important;
}

.tabContainer li button {
width: 100% !important;
}


.hivStatusTag {
min-width: 80px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export const mchFolderMeta = {
title: 'Maternal & Child Health',
slotName: 'mch-slot',
isExpanded: false,
patientExpression: 'calculateAge(patient.birthDate) <= 10 || patient.gender === "female"',
};

export const mchSummaryDashboardMeta = {
name: 'mch-summary',
slot: 'mch-summary-slot',
columns: 1,
title: 'MNCH Summary',
path: 'mnch-summary',
layoutMode: 'anchored',
};

export const maternalVisitsDashboardMeta = {
slot: 'maternal-visits-summary-slot',
columns: 1,
title: 'Maternal Visits',
path: 'maternal-visits',
layoutMode: 'anchored',
patientExpression: 'calculateAge(patient.birthDate) > 10',
};

export const childVisitsDashboardMeta = {
slot: 'child-visits-summary-slot',
columns: 1,
title: 'Child Visits',
path: 'child-visits',
layoutMode: 'anchored',
patientExpression: 'calculateAge(patient.birthDate) <= 10',
};

// Clinical Dashboard
export const motherChildDashboardMeta = {
name: 'mother-child-health',
slot: 'mother-child-health-dashboard-slot',
config: {
columns: 1,
type: 'grid',
programme: 'pmtct',
dashboardTitle: 'Mother Child Health Home Page',
icon: '',
},
isLink: true,
title: 'Maternal & Child Health',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Tabs, Tab, TabList, TabPanels, TabPanel, ContentSwitcher, Switch, SwitcherItem } from '@carbon/react';
import styles from '../../maternal-health-component.scss';
import { useTranslation } from 'react-i18next';
import AntenatalCareList from './tabs/antenatal-care.component';
import LabourDeliveryList from './tabs/labour-delivery.component';
import PostnatalCareList from './tabs/postnatal-care.component';
import { CardHeader } from '@openmrs/esm-patient-common-lib';

interface OverviewListProps {
patientUuid: string;
}

type SwitcherItem = {
index: number;
name?: string;
text?: string;
};
const MaternalHealthList: React.FC<OverviewListProps> = ({ patientUuid }) => {
const { t } = useTranslation();
const [switchItem, setSwitcherItem] = useState<SwitcherItem>({ index: 0 });

return (
<div className={styles.widgetCard}>
<CardHeader title={t('carePanel', 'MCH Clinical View')}>
<div className={styles.contextSwitcherContainer}>
<ContentSwitcher selectedIndex={switchItem?.index} onChange={setSwitcherItem}>
<Switch name={'antenatal'} text="Antenatal Care" />
<Switch name={'labourAndDelivery'} text="Labour and Delivery" />
<Switch name={'postnatal'} text="Postnatal Care" />
</ContentSwitcher>
</div>
</CardHeader>
<div style={{ width: '100%', minHeight: '20rem' }}>
{<>{switchItem.index == 0 && <AntenatalCareList patientUuid={patientUuid} />}</>}
{<>{switchItem.index == 1 && <LabourDeliveryList patientUuid={patientUuid} />}</>}
{<>{switchItem.index == 2 && <PostnatalCareList patientUuid={patientUuid} />}</>}
</div>
</div>
);
};

export default MaternalHealthList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EncounterList, EncounterListColumn } from '../../../../encounter-list/encounter-list.component';
import { getObsFromEncounter } from '../../../../encounter-list/encounter-list-utils';
import {
artInitiationConcept,
eDDConcept,
followUpDateConcept,
hivTestResultConcept,
ancVisitNumberConcept,
visitDateConcept,
vLResultsConcept,
partnerHivStatus,
} from '../../../constants';
import { useConfig, formatDate, parseDate } from '@openmrs/esm-framework';
import { ConfigObject } from '../../../../config-schema';

interface AntenatalCareListProps {
patientUuid: string;
}

const AntenatalCareList: React.FC<AntenatalCareListProps> = ({ patientUuid }) => {
const { t } = useTranslation();
const headerTitle = t('antenatalCare', 'Antenatal Care');
const {
encounterTypes: { mchMotherConsultation },
formsList: { antenatal },
} = useConfig<ConfigObject>();

const ANCEncounterTypeUUID = mchMotherConsultation;
const ANCEncounterFormUUID = antenatal;

const columns: EncounterListColumn[] = useMemo(
() => [
{
key: 'visitDate',
header: t('visitDate', 'Visit Date'),
getValue: (encounter) => {
return formatDate(parseDate(encounter.encounterDatetime));
},
},
{
key: 'ancVisitNumber',
header: t('ancVisitNumber', 'ANC Visit Number'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, ancVisitNumberConcept);
},
},
{
key: 'hivTestResults',
header: t('hivTestResults', 'HIV Test Results'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, hivTestResultConcept);
},
},
{
key: 'partnerStatus',
header: t('partnerStatus', 'HIV status of partner)'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, partnerHivStatus);
},
},
{
key: 'followUpDate',
header: t('followUpDate', 'Next follow-up date'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, followUpDateConcept, true);
},
},
{
key: 'facility',
header: t('facility', 'Facility'),
getValue: (encounter) => {
return encounter.location.name;
},
},
{
key: 'actions',
header: t('actions', 'Actions'),
getValue: (encounter) => [
{
form: { name: 'Antenatal Form', package: 'maternal_health' },
encounterUuid: encounter.uuid,
intent: '*',
label: t('editForm', 'Edit Form'),
mode: 'edit',
},
],
},
],
[t],
);

return (
<EncounterList
patientUuid={patientUuid}
encounterType={ANCEncounterTypeUUID}
formList={[{ name: 'Antenatal Form' }]}
columns={columns}
description={headerTitle}
headerTitle={headerTitle}
launchOptions={{
displayText: t('add', 'Add'),
moduleName: 'MCH Clinical View',
}}
filter={(encounter) => {
return encounter.form.uuid == ANCEncounterFormUUID;
}}
/>
);
};

export default AntenatalCareList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EncounterList, EncounterListColumn } from '../../../../encounter-list/encounter-list.component';
import { getObsFromEncounter } from '../../../../encounter-list/encounter-list-utils';
import {
ancHivResultConcept,
artInitiationConcept,
birthCountConcept,
deliveryOutcomeConcept,
hivTestAtMaternityResults,
hivTestResultConcept,
placeOfDeliveryConcept,
visitDateConcept,
} from '../../../constants';
import { useConfig, formatDate, parseDate } from '@openmrs/esm-framework';
import { ConfigObject } from '../../../../config-schema';

interface LabourDeliveryListProps {
patientUuid: string;
}

const LabourDeliveryList: React.FC<LabourDeliveryListProps> = ({ patientUuid }) => {
const { t } = useTranslation();
const headerTitle = t('labourAndDelivery', 'Labour and Delivery');

const {
encounterTypes: { mchMotherConsultation },
formsList: { labourAndDelivery },
} = useConfig<ConfigObject>();

const LNDEncounterTypeUUID = mchMotherConsultation;
const LNDEncounterFormUUID = labourAndDelivery;
const columns: EncounterListColumn[] = useMemo(
() => [
{
key: 'deliveryDate',
header: t('deliveryDate', 'Delivery Date'),
getValue: (encounter) => {
return formatDate(parseDate(encounter.encounterDatetime));
},
},
{
key: 'deliveryOutcome',
header: t('deliveryOutcome', 'Delivery Outcome'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, deliveryOutcomeConcept);
},
},
{
key: 'hivTestResults',
header: t('hivTestResults', 'HIV Test Results'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, hivTestResultConcept);
},
},
{
key: 'testingAtMaternity',
header: t('testingAtMaternity', 'HIV test at maternity'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, hivTestAtMaternityResults);
},
},
{
key: 'placeOfDelivery',
header: t('placeOfDelivery', 'Place of Delivery'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, placeOfDeliveryConcept);
},
},
{
key: 'actions',
header: t('actions', 'Actions'),
getValue: (encounter) => [
{
form: { name: 'Labour & Delivery Form', package: 'maternal_health' },
encounterUuid: encounter.uuid,
intent: '*',
label: t('editForm', 'Edit Form'),
mode: 'edit',
},
],
},
],
[t],
);

return (
<EncounterList
patientUuid={patientUuid}
encounterType={LNDEncounterTypeUUID}
formList={[{ name: 'Labour & Delivery Form' }]}
columns={columns}
description={headerTitle}
headerTitle={headerTitle}
launchOptions={{
displayText: t('add', 'Add'),
moduleName: 'MCH Clinical View',
}}
filter={(encounter) => {
return encounter.form.uuid == LNDEncounterFormUUID;
}}
/>
);
};

export default LabourDeliveryList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EncounterList, EncounterListColumn } from '../../../../encounter-list/encounter-list.component';
import { getObsFromEncounter } from '../../../../encounter-list/encounter-list-utils';
import {
hivTestResultConcept,
artUniqueNoConcept,
hivTestStatus,
MotherHivStatus,
MotherNextVisitDate,
motherPostnatalEncounterType,
MotherViralLoadDate,
MotherViralLoadResult,
MothervisitDate,
ancVisitNumberConcept,
recenctViralLoad,
motherGeneralConditionConcept,
pphConditionConcept,
} from '../../../constants';
import { useConfig, formatDate, parseDate } from '@openmrs/esm-framework';
import { ConfigObject } from '../../../../config-schema';

interface PostnatalCareListProps {
patientUuid: string;
}

const PostnatalCareList: React.FC<PostnatalCareListProps> = ({ patientUuid }) => {
const { t } = useTranslation();
const headerTitle = t('postnatalCare', 'Postnatal Care');

const {
encounterTypes: { mchMotherConsultation },
formsList: { postnatal },
} = useConfig<ConfigObject>();

const MotherPNCEncounterTypeUUID = mchMotherConsultation;
const MotherPNCEncounterFormUUID = postnatal;

const columns: EncounterListColumn[] = useMemo(
() => [
{
key: 'visitDate',
header: t('visitDate', 'Visit Date'),
getValue: (encounter) => {
return formatDate(parseDate(encounter.encounterDatetime));
},
},
{
key: 'hivTestResults',
header: t('hivTestResults', 'HIV Status'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, hivTestResultConcept);
},
},
{
key: 'motherGeneralCondition',
header: t('motherGeneralCondition', 'General condition'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, motherGeneralConditionConcept, true);
},
},
{
key: 'pphCondition',
header: t('pphCondition', 'PPH present'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, pphConditionConcept);
},
},
{
key: 'uterusCondition',
header: t('uterusCondition', 'PPH Condition of uterus'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, pphConditionConcept);
},
},
{
key: 'nextVisitDate',
header: t('nextVisitDate', 'Next visit date'),
getValue: (encounter) => {
return getObsFromEncounter(encounter, MotherNextVisitDate, true);
},
},
{
key: 'actions',
header: t('actions', 'Actions'),
getValue: (encounter) => [
{
form: { name: 'Mother - Postnatal Form', package: 'maternal_health' },
encounterUuid: encounter.uuid,
intent: '*',
label: t('editForm', 'Edit Form'),
mode: 'edit',
},
],
},
],
[t],
);

return (
<EncounterList
patientUuid={patientUuid}
encounterType={MotherPNCEncounterTypeUUID}
formList={[{ name: 'Mother - Postnatal Form' }]}
columns={columns}
description={headerTitle}
headerTitle={headerTitle}
launchOptions={{
displayText: t('add', 'Add'),
moduleName: 'MCH Clinical View',
}}
filter={(encounter) => {
return encounter.form.uuid == MotherPNCEncounterFormUUID;
}}
/>
);
};

export default PostnatalCareList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import useSWRImmutable, { mutate } from 'swr';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { openmrsFetch } from '@openmrs/esm-framework';
import { encounterRepresentation } from '../esm-mch-app/constants';

export interface OpenmrsResource {
uuid: string;
display?: string;
[anythingElse: string]: any;
}

export interface OpenmrsEncounter extends OpenmrsResource {
encounterDatetime: Date;
encounterType: string;
patient: string;
location: string;
encounterProviders?: Array<{ encounterRole: string; provider: string }>;
obs: Array<OpenmrsResource>;
form?: string;
visit?: string;
}

export function useEncounterRows(patientUuid: string, encounterType: string, encounterFilter: (encounter) => boolean) {
const [encounters, setEncounters] = useState([]);
const url = useMemo(
() => `/ws/rest/v1/encounter?encounterType=${encounterType}&patient=${patientUuid}&v=${encounterRepresentation}`,
[encounterType, patientUuid],
);

const {
data: response,
error,
isLoading,
} = useSWRImmutable<{ data: { results: OpenmrsEncounter[] } }, Error>(url, openmrsFetch);

useEffect(() => {
if (response) {
// sort the encounters
response.data.results.sort(
(a, b) => new Date(b.encounterDatetime).getTime() - new Date(a.encounterDatetime).getTime(),
);
// apply filter
if (encounterFilter) {
setEncounters(response.data.results.filter((encounter) => encounterFilter(encounter)));
} else {
setEncounters([...response.data.results]);
}
}
}, [encounterFilter, response]);

const onFormSave = useCallback(() => {
mutate(url);
}, [url]);

return {
encounters,
isLoading,
error,
onFormSave,
};
}
6 changes: 4 additions & 2 deletions packages/esm-patient-clinical-view-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfigSchema } from '@openmrs/esm-framework';
import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
import { configSchema } from './config-schema';
import { createDashboardLink, registerWorkspace } from '@openmrs/esm-patient-common-lib';
import MaternalHealthList from './esm-mch-app/views/maternal-health/maternal-health.component';

const moduleName = '@kenyaemr/esm-patient-clinical-view-app';

@@ -9,7 +11,7 @@ const options = {
};

export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');

export const mchClinicalView = getSyncLifecycle(MaternalHealthList, options);
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
}
120 changes: 120 additions & 0 deletions packages/esm-patient-clinical-view-app/src/root.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
@use '@carbon/styles/scss/spacing';
@use '@carbon/styles/scss/type';
@import "~@openmrs/esm-styleguide/src/vars";

$ui-01: #f4f4f4;
$ui-02: #ffffff;
$ui-03: #e0e0e0;
$ui-05: #161616;
$ui-background: #ffffff;
$color-gray-70: #525252;
$color-blue-60-2: #0f62fe;
$color-yellow-50: #feecae;
$inverse-support-03: #f1c21b;
$warning-background: #fff8e1;
$openmrs-background-grey: #f4f4f4;
$danger: #da1e28;
$interactive-01: #0f62fe;
$brand-teal-01: #3197D9;
$ohri-input-width: 22.313rem;
$ohri-home-background: #ededed;
$button-primary: #0078A6;

.productiveHeading01 {
@include type.type-style("heading-compact-01");
}

.productiveHeading02 {
@include type.type-style("heading-compact-02");
}

.productiveHeading03 {
@include type.type-style("heading-03");
}

.productiveHeading04 {
@include type.type-style("heading-04");
}

.productiveHeading05 {
@include type.type-style("heading-05");
}

.productiveHeading06 {
@include type.type-style("heading-06");
}

.bodyShort01 {
@include type.type-style("body-compact-01");
}

.helperText01 {
@include type.type-style("helper-text-01");
}

.bodyShort02 {
@include type.type-style("body-compact-02");
}

.bodyLong01 {
@include type.type-style("body-01");
}

.bodyLong02 {
@include type.type-style("body-02");
}

.label01 {
@include type.type-style("label-01");
}

.text02 {
color: $text-02;
}

aside {
background-color: $ui-02 !important;
}

// Login Overrides

div[class*='-esm-login__styles__center'] > img {
width: 140px; // design has 120px
}

:global(.tab-12rem) > button {
width: 12rem !important;
}

:global(.tab-14rem) > button {
width: 14rem !important;
}

:global(.tab-16rem) > button {
width: 16rem !important;
}

:global(.cds--overflow-menu) > div {
width: 15rem !important;
}

nav :global(.cds--accordion__title) {
color: #525252;
font-weight: 600 !important;
}

nav :global(.cds--accordion__content) {
padding-bottom: 0 !important;
padding-top: 0 !important;
}

nav :global(.cds--accordion__content)>a {
background-color: #cecece !important;
color: #161616 !important;
border-left-color: var(--brand-01) !important;
font: bolder;
}

:global(.cds--tab--list) button {
max-width: 16rem !important;
}
9 changes: 8 additions & 1 deletion packages/esm-patient-clinical-view-app/src/routes.json
Original file line number Diff line number Diff line change
@@ -5,6 +5,13 @@
},
"pages": [],
"extensions": [

{
"name": "mch-clinical-view",
"slot": "top-of-all-patient-dashboards-slot",
"component": "mchClinicalView",
"order": 0,
"online": true,
"offline": false
}
]
}
55 changes: 55 additions & 0 deletions packages/esm-patient-clinical-view-app/src/type/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { OpenmrsResource } from '@openmrs/esm-framework';

export interface LocationData {
display: string;
uuid: string;
}

type Links = Array<{
rel: string;
uri: string;
}>;

export interface OpenmrsEncounter extends OpenmrsResource {
encounterDatetime: Date;
encounterType: string;
patient: string;
location: string;
encounterProviders?: Array<{ encounterRole: string; provider: string }>;
obs: Array<OpenmrsResource>;
form?: string;
visit?: string;
}

export interface Concept {
uuid: string;
display: string;
answers?: Concept[];
}

export interface Observation {
uuid: string;
concept: {
uuid: string;
display: string;
conceptClass: {
uuid: string;
display: string;
};
};
display: string;
groupMembers: null | Array<{
uuid: string;
concept: {
uuid: string;
display: string;
};
value: {
uuid: string;
display: string;
};
display: string;
}>;
value: any;
obsDatetime?: string;
}

0 comments on commit 84a8efa

Please sign in to comment.