Skip to content

Commit b2635e3

Browse files
Merge pull request #16 from beda-software/vaccine-analytics
Vaccine analytics
2 parents cc9f835 + f31fed9 commit b2635e3

9 files changed

Lines changed: 470 additions & 29 deletions

File tree

contrib/fhir-emr

resources/seeds/AllergyIntolerance/allergy-single-ex.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ clinicalStatus:
1919
display: Active
2020
patient:
2121
reference: Patient/patient-single-ex
22+
reaction:
23+
- manifestation:
24+
- coding:
25+
- code: "4386001"
26+
system: "http://snomed.info/sct"
27+
display: "Bronchospasm"
28+
severity: mild

resources/seeds/Mapping/curated-ips.yaml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ body:
184184
- problemVerificationStatus: "{{ %problemItem.item.where(linkId.startsWith('condition-verification-status')).answer.valueCoding }}"
185185
- problemCategory: "{[ %problemItem.item.where(linkId.startsWith('condition-category')).answer.valueCoding ]}"
186186
"{% if %problemCode.exists() %}":
187-
fullUrl: "urn:uuid:Condition-{{ %problemId | %index }}"
187+
fullUrl: "{{'urn:uuid:Condition-' + %problemId.toString() | %index.toString()}}"
188188
request:
189189
"{% if %problemId.exists() %}":
190190
method: PUT
@@ -226,8 +226,9 @@ body:
226226
- allergyClinicalStatus: "{{ %allergyItem.item.where(linkId.startsWith('allergy-clinical-status')).answer.valueCoding }}"
227227
- allergyVerificationStatus: "{{ %allergyItem.item.where(linkId.startsWith('allergy-verification-status')).answer.valueCoding }}"
228228
- allergyCategory: "{[ %allergyItem.item.where(linkId.startsWith('allergy-category')).answer.valueCoding.code ]}"
229+
- allergyReactionItems: "{[ %allergyItem.item.where(linkId.startsWith('allergy-reaction-group')) ]}"
229230
"{% if %allergyCode.exists() %}":
230-
fullUrl: "urn:uuid:AllergyIntolerance-{{ %index }}"
231+
fullUrl: "{{'urn:uuid:AllergyIntolerance-' + %index.toString()}}"
231232
request:
232233
"{% if %allergyId.exists() %}":
233234
method: PUT
@@ -258,6 +259,20 @@ body:
258259
text:
259260
status: generated
260261
div: "<div xmlns='http://www.w3.org/1999/xhtml'><p><b>Generated Narrative</b></p><p><b>identifier</b>: id: urn:uuid:AllergyIntolerance-{{%index}}</p><p><b>clinicalStatus</b>: <span title='Codes: {http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical {{%allergyClinicalStatus.code}}}'>{{%allergyClinicalStatus.display}}</span></p><p><b>code</b>: <span title='Allergy or Intolerance}'>{{%allergyCode.display | %allergyCode}}</span></p><p><b>subject</b>: <a href='#Patient_{{%patientId}}'>See above (Patient/{{%patientId}})</a></p></div>"
262+
reaction:
263+
- "{% for allergyReactionItem in %allergyReactionItems %}":
264+
"{% assign %}":
265+
- allergyReactionManifestationAnswer: "{{ %allergyReactionItem.item.where(linkId='allergy-manifestation').answer }}"
266+
- allergyReactionManifestation: "{{ %allergyReactionManifestationAnswer.valueCoding | %allergyReactionManifestationAnswer.valueString }}"
267+
- allergyReactionSeverity: "{{ %allergyReactionItem.item.where(linkId='allergy-severity').answer.valueCoding.code }}"
268+
manifestation:
269+
- "{% if %allergyReactionManifestation is String %}":
270+
text: "{{ %allergyReactionManifestation }}"
271+
"{% else %}":
272+
coding:
273+
- "{{ %allergyReactionManifestation }}"
274+
severity: "{{ %allergyReactionSeverity }}"
275+
261276
- "{% for index, medicationItem in %medicationsItems %}":
262277
"{% assign %}":
263278
- medicationStatementId: "{{ %medicationItem.item.where(linkId='medication-statement-id').answer.valueString }}"
@@ -266,7 +281,7 @@ body:
266281
- medicationStatusReason: "{[ %medicationItem.item.where(linkId.startsWith('medication-status-reason')).answer.valueCoding.code ]}"
267282
- medicationEffectiveDateTime: "{{ %medicationItem.item.where(linkId.startsWith('medication-effective-datetime')).answer.valueDateTime }}"
268283
"{% if %medicationReference.exists() %}":
269-
fullUrl: "urn:uuid:MedicationStatement-{{ %index }}"
284+
fullUrl: "{{'urn:uuid:MedicationStatement-' + %index.toString()}}"
270285
request:
271286
"{% if %medicationStatementId.exists() %}":
272287
method: PUT
@@ -302,7 +317,7 @@ body:
302317
- immunizationStatus: "{{ %immunizationItem.item.where(linkId.startsWith('immunization-status')).answer.valueCoding.code }}"
303318
- immunizationDate: "{{ %immunizationItem.item.where(linkId.startsWith('immunization-occurrence-datetime')).answer.valueDateTime }}"
304319
"{% if %immunizationVaccineCode.exists() %}":
305-
fullUrl: "urn:uuid:Immunization-{{ %index }}"
320+
fullUrl: "{{'urn:uuid:Immunization-' + %index.toString()}}"
306321
request:
307322
"{% if %immunizationId.exists() %}":
308323
method: PUT
@@ -337,7 +352,7 @@ body:
337352
- procedureStatus: "{{ %procedureItem.item.where(linkId.startsWith('procedure-status')).answer.valueCoding.code }}"
338353
- procedurePerformedDateTime: "{{ %procedureItem.item.where(linkId.startsWith('procedure-performed-datetime')).answer.valueDateTime }}"
339354
"{% if %procedureCode.exists() %}":
340-
fullUrl: "urn:uuid:Procedure-{{ %index }}"
355+
fullUrl: "{{'urn:uuid:Procedure-' + %index.toString()}}"
341356
request:
342357
"{% if %procedureId.exists() %}":
343358
method: PUT

resources/seeds/Questionnaire/curated-ips.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,40 @@ item:
240240
initialExpression:
241241
language: text/fhirpath
242242
expression: "%context.category.coding"
243+
- linkId: allergy-reaction-group
244+
text: Reaction
245+
type: group
246+
repeats: true
247+
itemPopulationContext:
248+
language: text/fhirpath
249+
expression: "%context.reaction"
250+
item:
251+
- linkId: allergy-manifestation
252+
text: Manifestation
253+
type: text
254+
required: true
255+
initialExpression:
256+
language: text/fhirpath
257+
expression: "(%context.manifestation.text | %context.manifestation.coding.display)"
258+
- linkId: allergy-severity
259+
text: Severity
260+
type: choice
261+
initialExpression:
262+
language: text/fhirpath
263+
expression: "%Questionnaire.repeat(item).where(linkId='allergy-severity').answerOption.valueCoding.where(code=%context.severity)"
264+
answerOption:
265+
- valueCoding:
266+
system: http://hl7.org/fhir/reaction-event-severity
267+
code: mild
268+
display: Mild
269+
- valueCoding:
270+
system: http://hl7.org/fhir/reaction-event-severity
271+
code: moderate
272+
display: Moderate
273+
- valueCoding:
274+
system: http://hl7.org/fhir/reaction-event-severity
275+
code: severe
276+
display: Severe
243277
- linkId: allergies-summary
244278
text: Summary
245279
type: text
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Button, Flex, Table, Typography } from 'antd';
2+
import styled from 'styled-components';
3+
4+
export const S = {
5+
HeaderContainer: styled(Flex)`
6+
gap: 16px;
7+
`,
8+
FieldWrapper: styled(Flex)`
9+
flex-direction: column;
10+
gap: 8px;
11+
`,
12+
Label: styled(Typography.Text)`
13+
font-weight: 500;
14+
font-size: 14px;
15+
color: ${({ theme }) => theme.neutralPalette?.gray_13 || '#000'};
16+
`,
17+
ChartContainer: styled(Flex)`
18+
flex: 1 1 300px; /* grow on large screens, shrink on small */
19+
min-width: 260px; /* ensures chart fits on small screens */
20+
max-width: 400px; /* don't get excessively large */
21+
height: 350px; /* fixed height for Pie chart */
22+
`,
23+
MainContainer: styled(Flex)`
24+
width: 100%;
25+
flex-wrap: wrap;
26+
justify-content: flex-start;
27+
align-items: center;
28+
gap: 32px;
29+
`,
30+
LegendContainer: styled(Flex)`
31+
flex: 1; /* shrinkable, but wide enough for labels */
32+
min-width: 200px;
33+
flex-direction: column;
34+
gap: 12px;
35+
`,
36+
LegendItem: styled(Flex)`
37+
align-items: center;
38+
gap: 8px;
39+
`,
40+
ColorIndicator: styled.div<{ $color: string }>`
41+
width: 12px;
42+
height: 12px;
43+
background-color: ${({ $color }) => $color};
44+
border-radius: 2px;
45+
`,
46+
LegendText: styled.span`
47+
word-break: break-word;
48+
color: ${({ theme }) => theme.primary};
49+
`,
50+
ActiveDataContainer: styled(Flex)`
51+
flex-direction: column;
52+
gap: 8px;
53+
background-color: ${({ theme }) => theme.neutralPalette?.gray_1 || '#fff'};
54+
`,
55+
ActiveDataTitle: styled(Typography.Text)`
56+
font-weight: 500;
57+
font-size: 14px;
58+
color: ${({ theme }) => theme.primary};
59+
`,
60+
ActiveDataCount: styled(Typography.Text)`
61+
font-weight: 500;
62+
font-size: 14px;
63+
color: ${({ theme }) => theme.neutralPalette?.gray_13 || '#000'};
64+
`,
65+
ActiveDataDetailsContainer: styled(Flex)`
66+
padding: 16px;
67+
border: 1px solid #e0e0e0;
68+
border-radius: 8px;
69+
flex-direction: column;
70+
align-items: flex-start;
71+
`,
72+
CloseButton: styled(Button)`
73+
align-self: flex-end;
74+
`,
75+
DetailsTable: styled(Table)`
76+
width: 100%;
77+
`,
78+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Coding } from 'fhir/r4b';
2+
3+
export const GENDER_OPTIONS: Coding[] = [
4+
{
5+
code: 'male',
6+
system: 'http://hl7.org/fhir/administrative-gender',
7+
display: 'Male',
8+
},
9+
{
10+
code: 'female',
11+
system: 'http://hl7.org/fhir/administrative-gender',
12+
display: 'Female',
13+
},
14+
{
15+
code: 'other',
16+
system: 'http://hl7.org/fhir/administrative-gender',
17+
display: 'Other',
18+
},
19+
{
20+
code: 'unknown',
21+
system: 'http://hl7.org/fhir/administrative-gender',
22+
display: 'Unknown',
23+
},
24+
];
25+
26+
export const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D'];

src/containers/Analytics/hooks.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Coding } from 'fhir/r4b';
2+
import { Moment } from 'moment';
3+
import { useCallback, useState } from 'react';
4+
import { SingleValue } from 'react-select';
5+
6+
import { service } from '@beda.software/emr/services';
7+
import { formatFHIRDate, useService } from '@beda.software/fhir-react';
8+
import { mapSuccess } from '@beda.software/remote-data';
9+
10+
export interface ChartData {
11+
title: string;
12+
code: string;
13+
count: number;
14+
}
15+
export interface AnalyticsFilters {
16+
selectedGender: SingleValue<Coding> | null;
17+
birthDateRange: [Moment | null, Moment | null] | null;
18+
}
19+
20+
export function useAnalytics() {
21+
const [activeData, setActiveData] = useState<ChartData | null>(null);
22+
const [filters, setFilters] = useState<AnalyticsFilters>({
23+
selectedGender: null,
24+
birthDateRange: null,
25+
});
26+
const [response] = useService(async () => {
27+
const conditions: string[] = [];
28+
const params: (string | null)[] = [];
29+
30+
if (filters.birthDateRange?.[0] && filters.birthDateRange?.[1]) {
31+
conditions.push("(p.resource#>>'{birthDate}')::date >= ? and (p.resource#>>'{birthDate}')::date <= ?");
32+
params.push(formatFHIRDate(filters.birthDateRange[0]), formatFHIRDate(filters.birthDateRange[1]));
33+
}
34+
35+
if (filters.selectedGender?.code) {
36+
conditions.push("p.resource#>>'{gender}' = ?");
37+
params.push(filters.selectedGender.code);
38+
}
39+
40+
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
41+
const sqlQuery = `select i.resource#>>'{vaccineCode,coding,0,display}' as title, i.resource#>>'{vaccineCode,coding,0,code}' as code, count(i.id) from immunization i join patient p on i.resource#>>'{patient,id}' = p.id${whereClause} group by i.resource#>>'{vaccineCode,coding,0,display}', i.resource#>>'{vaccineCode,coding,0,code}'`;
42+
43+
const sqlResponse = await service<ChartData[]>({
44+
url: '/$sql',
45+
method: 'POST',
46+
data: [sqlQuery, ...params],
47+
});
48+
return sqlResponse;
49+
}, [filters]);
50+
51+
const handleGenderChange = useCallback((newValue: SingleValue<Coding>) => {
52+
setFilters((prev) => ({
53+
...prev,
54+
selectedGender: newValue,
55+
}));
56+
}, []);
57+
58+
const handleDateRangeChange = useCallback((dates: [Moment | null, Moment | null] | null) => {
59+
setFilters((prev) => ({
60+
...prev,
61+
birthDateRange: dates,
62+
}));
63+
}, []);
64+
65+
return { response, filters, handleGenderChange, handleDateRangeChange, activeData, setActiveData };
66+
}
67+
68+
export interface ActiveDataDetailsProps {
69+
activeData: ChartData;
70+
onClose: () => void;
71+
filters: AnalyticsFilters;
72+
}
73+
74+
export function useActiveDataDetails(props: ActiveDataDetailsProps) {
75+
const { activeData, filters } = props;
76+
77+
const [response] = useService(async () => {
78+
const patientConditions: string[] = [];
79+
const params: (string | null)[] = [];
80+
81+
if (filters.birthDateRange?.[0] && filters.birthDateRange?.[1]) {
82+
patientConditions.push("(resource#>>'{birthDate}')::date >= ? and (resource#>>'{birthDate}')::date <= ?");
83+
params.push(formatFHIRDate(filters.birthDateRange[0]), formatFHIRDate(filters.birthDateRange[1]));
84+
}
85+
86+
if (filters.selectedGender?.code) {
87+
patientConditions.push("resource#>>'{gender}' = ?");
88+
params.push(filters.selectedGender.code);
89+
}
90+
const immunizationConditions = ["i.resource#>>'{vaccineCode,coding,0,code}' = ?"];
91+
params.push(activeData.code);
92+
const patientWhereClause = patientConditions.length > 0 ? `WHERE ${patientConditions.join(' AND ')}` : '';
93+
const immunizationWhereClause =
94+
immunizationConditions.length > 0 ? `WHERE ${immunizationConditions.join(' AND ')}` : '';
95+
96+
const sqlQuery = `with filtered_patients as (select id from patient ${patientWhereClause}) select (select count(*) from filtered_patients) as total_patients, (select count(distinct i.resource#>>'{patient,id}') from immunization i join filtered_patients fp on i.resource#>>'{patient,id}' = fp.id ${immunizationWhereClause}) as vaccinated_patients`;
97+
const sqlResponse = mapSuccess(
98+
await service<Array<{ total_patients: number; vaccinated_patients: number }>>({
99+
url: '/$sql',
100+
method: 'POST',
101+
data: [sqlQuery, ...params],
102+
}),
103+
(data) => data[0],
104+
);
105+
return sqlResponse;
106+
}, [activeData.code, filters]);
107+
return { response };
108+
}

0 commit comments

Comments
 (0)