Skip to content

Commit

Permalink
(feat) Revitalized Immunization Support (#1572)
Browse files Browse the repository at this point in the history
Co-authored-by: Dennis Kigen <kigen.work@gmail.com>
  • Loading branch information
samuelmale and denniskigen committed Jan 18, 2024
1 parent 2808c0f commit 100ce91
Show file tree
Hide file tree
Showing 31 changed files with 1,149 additions and 529 deletions.
6 changes: 4 additions & 2 deletions packages/esm-patient-immunizations-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@
},
"dependencies": {
"@carbon/react": "^1.12.0",
"@openmrs/esm-patient-common-lib": "^6.1.0",
"lodash-es": "^4.17.21"
"@hookform/resolvers": "^3.3.1",
"lodash-es": "^4.17.21",
"react-hook-form": "^7.46.2",
"zod": "^3.22.2"
},
"peerDependencies": {
"@openmrs/esm-framework": "5.x",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import immunizationWidgetSchema from './immunizations/immunization-widget-config-schema';
import { type ImmunizationWidgetConfigObject } from './immunizations/immunization-domain';
import { type ImmunizationWidgetConfigObject } from './types/fhir-immunization-domain';

export const configSchema = {
immunizationsConfig: immunizationWidgetSchema,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { fhirBaseUrl, openmrsFetch } from '@openmrs/esm-framework';
import useSWR from 'swr';
import { type FHIRImmunizationBundle } from '../types/fhir-immunization-domain';
import { mapFromFHIRImmunizationBundle } from '../immunizations/immunization-mapper';

export function useImmunizations(patientUuid: string) {
const immunizationsUrl = `${fhirBaseUrl}/Immunization?patient=${patientUuid}`;

const { data, error, isLoading, isValidating, mutate } = useSWR<{ data: FHIRImmunizationBundle }, Error>(
immunizationsUrl,
openmrsFetch,
);
const existingImmunizations = data ? mapFromFHIRImmunizationBundle(data.data) : null;

return {
data: existingImmunizations,
error,
isLoading,
isValidating,
mutate,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { openmrsFetch } from '@openmrs/esm-framework';
import { type ImmunizationWidgetConfigObject, type OpenmrsConcept } from '../types/fhir-immunization-domain';
import useSWR from 'swr';

export function useImmunizationsConceptSet(config: ImmunizationWidgetConfigObject) {
const conceptRepresentation =
'custom:(uuid,display,answers:(uuid,display),conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))';

const { data, error, isLoading } = useSWR<{ data: { results: Array<OpenmrsConcept> } }, Error>(
`/ws/rest/v1/concept?references=${config.immunizationConceptSet}&v=${conceptRepresentation}`,
openmrsFetch,
);
return {
immunizationsConceptSet: data && data.data.results[0],
isLoading,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { useMemo } from 'react';
import { type ImmunizationSequenceDefinition } from '../../types/fhir-immunization-domain';
import { useController, type Control } from 'react-hook-form';
import { Dropdown, NumberInput } from '@carbon/react';
import styles from './../immunizations-form.scss';
import { useTranslation } from 'react-i18next';

export const DoseInput: React.FC<{
vaccine: string;
sequences: ImmunizationSequenceDefinition[];
control: Control;
}> = ({ vaccine, sequences, control }) => {
const { t } = useTranslation();
const { field } = useController({ name: 'doseNumber', control });

const vaccineSequences = useMemo(
() => sequences?.find((sequence) => sequence.vaccineConceptUuid === vaccine)?.sequences || [],
[sequences, vaccine],
);

return (
<div className={styles.row}>
{vaccineSequences.length ? (
<Dropdown
id="sequence"
label={t('pleaseSelect', 'Please select')}
titleText={t('sequence', 'Sequence')}
items={vaccineSequences?.map((sequence) => sequence.sequenceNumber) || []}
itemToString={(item) => vaccineSequences.find((s) => s.sequenceNumber === item)?.sequenceLabel}
onChange={(val) => field.onChange(parseInt(val.selectedItem || 0))}
selectedItem={field.value}
/>
) : (
<NumberInput
id="doseNumber"
label={t('doseNumber', 'Dose number within series')}
min={0}
onChange={(event) => field.onChange(parseInt(event.target.value || 0))}
value={field.value}
hideSteppers={true}
/>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import isEmpty from 'lodash-es/isEmpty';
import {
Button,
DataTable,
TableContainer,
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from '@carbon/react';
import { Edit } from '@carbon/react/icons';
import { type ImmunizationGrouped } from '../../types';
import { formatDate, parseDate } from '@openmrs/esm-framework';
import { immunizationFormSub } from '../utils';
import styles from './immunizations-sequence-table.scss';

interface SequenceTableProps {
immunizationsByVaccine: ImmunizationGrouped;
launchPatientImmunizationForm: () => void;
}

const SequenceTable: React.FC<SequenceTableProps> = ({ immunizationsByVaccine, launchPatientImmunizationForm }) => {
const { t } = useTranslation();
const { existingDoses, sequences, vaccineUuid } = immunizationsByVaccine;

const tableHeader = useMemo(
() => [
{ key: 'sequence', header: sequences.length ? t('sequence', 'Sequence') : t('doseNumber', 'Dose Number') },
{ key: 'vaccinationDate', header: t('vaccinationDate', 'Vaccination Date') },
{ key: 'expirationDate', header: t('expirationDate', 'Expiration Date') },
{ key: 'edit', header: '' },
],
[t],
);

const tableRows = existingDoses?.map((dose) => {
return {
id: dose?.immunizationObsUuid,
sequence: isEmpty(sequences)
? dose.doseNumber || 0
: sequences?.find((s) => s.sequenceNumber === dose.doseNumber).sequenceLabel || dose.doseNumber,
vaccinationDate: dose?.occurrenceDateTime && formatDate(new Date(dose.occurrenceDateTime)),
expirationDate: dose?.expirationDate && formatDate(new Date(dose.expirationDate), { noToday: true }),
edit: (
<Button
kind="ghost"
iconDescription="Edit"
renderIcon={(props) => <Edit size={16} {...props} />}
onClick={() => {
immunizationFormSub.next({
vaccineUuid: vaccineUuid,
immunizationId: dose.immunizationObsUuid,
vaccinationDate: dose.occurrenceDateTime && parseDate(dose.occurrenceDateTime),
doseNumber: dose.doseNumber,
expirationDate: dose.expirationDate && parseDate(dose.expirationDate),
lotNumber: dose.lotNumber,
manufacturer: dose.manufacturer,
visitId: dose.visitUuid,
});
launchPatientImmunizationForm();
}}
>
{t('edit', 'Edit')}
</Button>
),
};
});

return (
tableRows.length > 0 && (
<DataTable rows={tableRows} headers={tableHeader} useZebraStyles>
{({ rows, headers, getHeaderProps, getTableProps }) => (
<TableContainer className={styles.sequenceTable}>
<Table {...getTableProps()}>
<TableHead>
<TableRow>
{headers.map((header) => (
<TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
return (
<TableRow key={row.id}>
{row.cells.map((cell) => (
<TableCell key={cell?.id} className={styles.tableCell}>
{cell?.value}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</DataTable>
)
);
};

export default SequenceTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.tableCell {
padding: 0rem 1.5rem !important;
}

.sequenceTable {
margin: 0.5rem 0;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { formatDate, parseDate } from '@openmrs/esm-framework';
import find from 'lodash-es/find';
import get from 'lodash-es/get';
import groupBy from 'lodash-es/groupBy';
Expand All @@ -10,31 +9,27 @@ import {
type FHIRImmunizationBundle,
type FHIRImmunizationBundleEntry,
type FHIRImmunizationResource,
type ImmunizationData,
type ImmunizationDoseData,
type ImmunizationFormData,
type Reference,
} from './immunization-domain';
} from '../types/fhir-immunization-domain';
import { type ExistingDoses, type ImmunizationFormData, type ImmunizationGrouped } from '../types';

const mapToImmunizationDose = (immunizationBundleEntry: FHIRImmunizationBundleEntry): ImmunizationDoseData => {
const mapToImmunizationDose = (immunizationBundleEntry: FHIRImmunizationBundleEntry): ExistingDoses => {
const immunizationResource = immunizationBundleEntry?.resource;
const immunizationObsUuid = immunizationResource?.id;
const manufacturer = immunizationResource?.manufacturer?.display;
const lotNumber = immunizationResource?.lotNumber;
const protocolApplied = immunizationResource?.protocolApplied?.length > 0 && immunizationResource?.protocolApplied[0];
const sequenceLabel = protocolApplied?.series;
const sequenceNumber = protocolApplied?.doseNumberPositiveInt;
const occurrenceDateTime = formatDate(new Date(immunizationResource?.occurrenceDateTime));
const expirationDate = formatDate(new Date(immunizationResource?.expirationDate));

const doseNumber = protocolApplied?.doseNumberPositiveInt;
const occurrenceDateTime = immunizationResource?.occurrenceDateTime as any as string;
const expirationDate = immunizationResource?.expirationDate as any as string;
return {
immunizationObsUuid,
manufacturer,
lotNumber,
sequenceLabel,
sequenceNumber,
doseNumber,
occurrenceDateTime,
expirationDate,
visitUuid: fromReference(immunizationResource?.encounter),
};
};

Expand All @@ -45,12 +40,14 @@ const findCodeWithoutSystem = function (immunizationResource: FHIRImmunizationRe
});
};

export const mapFromFHIRImmunizationBundle = (immunizationBundle: FHIRImmunizationBundle): Array<ImmunizationData> => {
export const mapFromFHIRImmunizationBundle = (
immunizationBundle: FHIRImmunizationBundle,
): Array<ImmunizationGrouped> => {
const groupByImmunization = groupBy(immunizationBundle.entry, (immunizationResourceEntry) => {
return findCodeWithoutSystem(immunizationResourceEntry.resource)?.code;
});
return map(groupByImmunization, (immunizationsForOneVaccine: Array<FHIRImmunizationBundleEntry>) => {
const existingDoses: Array<ImmunizationDoseData> = map(immunizationsForOneVaccine, mapToImmunizationDose);
const existingDoses: Array<ExistingDoses> = map(immunizationsForOneVaccine, mapToImmunizationDose);
const codeWithoutSystem = findCodeWithoutSystem(immunizationsForOneVaccine[0]?.resource);

return {
Expand All @@ -66,16 +63,20 @@ function toReferenceOfType(type: string, referenceValue: string): Reference {
return { type, reference };
}

function fromReference(reference: Reference): string {
return reference.reference.split('/')[1];
}

export const mapToFHIRImmunizationResource = (
immunizationFormData: ImmunizationFormData,
visitUuid,
locationUuid,
providerUuid,
visitUuid: string,
locationUuid: string,
providerUuid: string,
): FHIRImmunizationResource => {
return {
resourceType: 'Immunization',
status: 'completed',
id: immunizationFormData.immunizationObsUuid,
id: immunizationFormData.immunizationId,
vaccineCode: {
coding: [
{
Expand All @@ -86,16 +87,16 @@ export const mapToFHIRImmunizationResource = (
},
patient: toReferenceOfType('Patient', immunizationFormData.patientUuid),
encounter: toReferenceOfType('Encounter', visitUuid), //Reference of visit instead of encounter
occurrenceDateTime: parseDate(immunizationFormData.vaccinationDate),
expirationDate: parseDate(immunizationFormData.expirationDate),
occurrenceDateTime: immunizationFormData.vaccinationDate,
expirationDate: immunizationFormData.expirationDate,
location: toReferenceOfType('Location', locationUuid),
performer: [{ actor: toReferenceOfType('Practitioner', providerUuid) }],
manufacturer: { display: immunizationFormData.manufacturer },
lotNumber: immunizationFormData.lotNumber,
protocolApplied: [
{
doseNumberPositiveInt: immunizationFormData.currentDose.sequenceNumber,
series: immunizationFormData.currentDose.sequenceLabel,
doseNumberPositiveInt: immunizationFormData.doseNumber,
series: null, // the backend currently does not support "series"
},
],
};
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Type } from '@openmrs/esm-framework';

export default {
vaccinesConceptSet: {
immunizationConceptSet: {
_type: Type.String,
_default: 'CIEL:984',
_description: 'A uuid or concept mapping which will have all the possible vaccines as set-members.',
Expand Down

0 comments on commit 100ce91

Please sign in to comment.