@digihmis/esm-patient-registration-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/131.js +1 -0
- package/dist/131.js.map +1 -0
- package/dist/132.js +1 -0
- package/dist/132.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/247.js +1 -0
- package/dist/247.js.map +1 -0
- package/dist/28.js +1 -0
- package/dist/28.js.map +1 -0
- package/dist/317.js +1 -0
- package/dist/317.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/371.js +1 -0
- package/dist/371.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/45.js +1 -0
- package/dist/45.js.map +1 -0
- package/dist/459.js +12 -0
- package/dist/459.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/640.js +1 -0
- package/dist/640.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/709.js +1 -0
- package/dist/709.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/742.js +1 -0
- package/dist/742.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/806.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/940.js +1 -0
- package/dist/940.js.map +1 -0
- package/dist/957.js +15 -0
- package/dist/957.js.map +1 -0
- package/dist/digihmis-esm-patient-registration-app.js +6 -0
- package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
- package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/active-visit-table/active-visit-table.component.tsx +240 -0
- package/src/active-visit-table/active-visits.resource.ts +338 -0
- package/src/active-visit-table/active-visits.scss +211 -0
- package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
- package/src/checkin-form/base-visit-type.component.tsx +102 -0
- package/src/checkin-form/base-visit-type.scss +75 -0
- package/src/checkin-form/checkin-form.scss +167 -0
- package/src/checkin-form/checkin-form.workspace.tsx +703 -0
- package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
- package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
- package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
- package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
- package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
- package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
- package/src/checkin-form/location-selector.component.tsx +86 -0
- package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
- package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
- package/src/checkin-form/visit-attribute-type.scss +5 -0
- package/src/checkin-form/visit-date-time.component.tsx +201 -0
- package/src/checkin-form/visit-form.resource.ts +308 -0
- package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
- package/src/components/custom-card/compact-patient-banner.scss +34 -0
- package/src/components/custom-card/custom-loader.component.tsx +22 -0
- package/src/components/custom-card/patient-search-context.ts +30 -0
- package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
- package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
- package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
- package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
- package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
- package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
- package/src/config-schema.ts +511 -0
- package/src/constant.ts +6 -0
- package/src/constants/index.ts +1 -0
- package/src/dashboard.meta.ts +8 -0
- package/src/declarations.d.ts +6 -0
- package/src/empty-svg/empty-svg.component.tsx +565 -0
- package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
- package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
- package/src/identifier-workspace/identifier-configure.scss +106 -0
- package/src/identifier-workspace/identifier.resource.ts +90 -0
- package/src/index.ts +33 -0
- package/src/left-panel/left-panel.component.tsx +54 -0
- package/src/left-panel/left-panel.scss +13 -0
- package/src/page-header/page-header.component.tsx +35 -0
- package/src/page-header/page-header.scss +76 -0
- package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
- package/src/patient-tags/deceased-patient-tag.scss +15 -0
- package/src/patient-tags/patient-tags.resource.ts +30 -0
- package/src/patient-tags/visit-tag.extension.tsx +43 -0
- package/src/patient-tags/visit-tag.scss +19 -0
- package/src/registration-form/form-manager.ts +449 -0
- package/src/registration-form/registration-form.component.tsx +1565 -0
- package/src/registration-form/registration-form.resource.ts +264 -0
- package/src/registration-form/registration-form.scss +282 -0
- package/src/routes.json +62 -0
- package/src/search-bar/search-bar.component.tsx +285 -0
- package/src/search-bar/search-bar.resource.ts +274 -0
- package/src/search-bar/search-bar.scss +427 -0
- package/src/search-dashboard/search-dashboard.component.tsx +17 -0
- package/src/search-dashboard/search-dashboard.scss +13 -0
- package/src/setup-tests.ts +1 -0
- package/src/type/index.ts +463 -0
- package/src/utils/fhir-mapper.ts +73 -0
- package/src/visit-summary/visit-summary.component.tsx +32 -0
- package/src/visit-summary/visit-summary.resource.ts +56 -0
- package/src/visit-summary/visit-summary.scss +74 -0
- package/translations/am.json +112 -0
- package/translations/ar.json +120 -0
- package/translations/en.json +112 -0
- package/translations/es.json +114 -0
- package/translations/fr.json +114 -0
- package/translations/he.json +114 -0
- package/translations/km.json +111 -0
- package/translations/pt.json +114 -0
- package/translations/sw.json +112 -0
- package/translations/vi.json +111 -0
- package/translations/zh.json +111 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import { type Control, Controller, type FieldPath, useFormContext, useWatch } from 'react-hook-form';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { SelectItem, TimePicker, TimePickerSelect } from '@carbon/react';
|
|
7
|
+
import { type amPm } from '@openmrs/esm-patient-common-lib';
|
|
8
|
+
import { OpenmrsDatePicker, ResponsiveWrapper } from '@openmrs/esm-framework';
|
|
9
|
+
import { convertToDate, type VisitFormData } from './visit-form.resource';
|
|
10
|
+
import styles from './checkin-form.scss';
|
|
11
|
+
|
|
12
|
+
// Helpers to safely compute min/max across optional values
|
|
13
|
+
const minOf = (...values: Array<number | undefined | null>) => {
|
|
14
|
+
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
15
|
+
return nums.length ? Math.min(...nums) : undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const maxOf = (...values: Array<number | undefined | null>) => {
|
|
19
|
+
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
20
|
+
return nums.length ? Math.max(...nums) : undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface VisitDateTimeSectionProps {
|
|
24
|
+
control: Control<VisitFormData, any>;
|
|
25
|
+
firstEncounterDateTime: number;
|
|
26
|
+
lastEncounterDateTime: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The component conditionally renders the Visit start and end
|
|
31
|
+
* date / time fields based on the visit status (new / ongoing / past)
|
|
32
|
+
*/
|
|
33
|
+
const VisitDateTimeSection: React.FC<VisitDateTimeSectionProps> = ({
|
|
34
|
+
control,
|
|
35
|
+
firstEncounterDateTime,
|
|
36
|
+
lastEncounterDateTime,
|
|
37
|
+
}) => {
|
|
38
|
+
const { t } = useTranslation();
|
|
39
|
+
const [
|
|
40
|
+
visitStatus,
|
|
41
|
+
visitStartDate,
|
|
42
|
+
visitStartTime,
|
|
43
|
+
visitStartTimeFormat,
|
|
44
|
+
visitStopDate,
|
|
45
|
+
visitStopTime,
|
|
46
|
+
visitStopTimeFormat,
|
|
47
|
+
] = useWatch({
|
|
48
|
+
control,
|
|
49
|
+
name: [
|
|
50
|
+
'visitStatus',
|
|
51
|
+
'visitStartDate',
|
|
52
|
+
'visitStartTime',
|
|
53
|
+
'visitStartTimeFormat',
|
|
54
|
+
'visitStopDate',
|
|
55
|
+
'visitStopTime',
|
|
56
|
+
'visitStopTimeFormat',
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const hasStopTime = 'past' === visitStatus;
|
|
61
|
+
const selectedVisitStartDateTime = convertToDate(visitStartDate, visitStartTime, visitStartTimeFormat);
|
|
62
|
+
const selectedVisitStopDateTime = convertToDate(visitStopDate, visitStopTime, visitStopTimeFormat);
|
|
63
|
+
|
|
64
|
+
if (visitStatus === 'new') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section>
|
|
70
|
+
<div className={styles.sectionTitle}>
|
|
71
|
+
{visitStatus === 'ongoing'
|
|
72
|
+
? t('visitStartDate', 'Visit start date')
|
|
73
|
+
: t('visitStartAndEndDate', 'Visit start and end date')}
|
|
74
|
+
</div>
|
|
75
|
+
<VisitDateTimeField
|
|
76
|
+
dateField={{ name: 'visitStartDate', label: t('startDate', 'Start date') }}
|
|
77
|
+
timeField={{ name: 'visitStartTime', label: t('startTime', 'Start time') }}
|
|
78
|
+
timeFormatField={{ name: 'visitStartTimeFormat', label: t('startTimeFormat', 'Start time format') }}
|
|
79
|
+
maxDate={minOf(Date.now(), firstEncounterDateTime, selectedVisitStopDateTime?.getTime())}
|
|
80
|
+
/>
|
|
81
|
+
{hasStopTime && (
|
|
82
|
+
<VisitDateTimeField
|
|
83
|
+
dateField={{ name: 'visitStopDate', label: t('endDate', 'End date') }}
|
|
84
|
+
timeField={{ name: 'visitStopTime', label: t('endTime', 'End time') }}
|
|
85
|
+
timeFormatField={{ name: 'visitStopTimeFormat', label: t('endTimeFormat', 'End time format') }}
|
|
86
|
+
minDate={maxOf(lastEncounterDateTime, selectedVisitStartDateTime?.getTime())}
|
|
87
|
+
maxDate={Date.now()}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
interface VisitDateTimeFieldProps {
|
|
95
|
+
dateField: Field;
|
|
96
|
+
timeField: Field;
|
|
97
|
+
timeFormatField: Field;
|
|
98
|
+
minDate?: dayjs.ConfigType;
|
|
99
|
+
maxDate?: dayjs.ConfigType;
|
|
100
|
+
disabled?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Field {
|
|
104
|
+
name: FieldPath<VisitFormData>;
|
|
105
|
+
label: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This components renders a DatePicker, TimePicker and AM / PM dropdown
|
|
110
|
+
* used to input a Date.
|
|
111
|
+
* It is used by the visit form for the start and end time inputs.
|
|
112
|
+
*/
|
|
113
|
+
const VisitDateTimeField: React.FC<VisitDateTimeFieldProps> = ({
|
|
114
|
+
dateField,
|
|
115
|
+
timeField,
|
|
116
|
+
timeFormatField,
|
|
117
|
+
minDate,
|
|
118
|
+
maxDate,
|
|
119
|
+
disabled,
|
|
120
|
+
}) => {
|
|
121
|
+
const {
|
|
122
|
+
control,
|
|
123
|
+
formState: { errors },
|
|
124
|
+
} = useFormContext<VisitFormData>();
|
|
125
|
+
const { t } = useTranslation();
|
|
126
|
+
|
|
127
|
+
// Since we have the separate date and time fields, the full validation is done by zod.
|
|
128
|
+
// We are just using minDateObj and maxDateObj to restrict the bounds of the DatePicker.
|
|
129
|
+
const minDateObj = minDate ? dayjs(minDate).startOf('day') : null;
|
|
130
|
+
const maxDateObj = maxDate ? dayjs(maxDate).endOf('day') : null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className={classNames(styles.dateTimeSection, styles.sectionField)}>
|
|
134
|
+
<Controller
|
|
135
|
+
name={dateField.name}
|
|
136
|
+
control={control}
|
|
137
|
+
render={({ field, fieldState }) => (
|
|
138
|
+
<ResponsiveWrapper>
|
|
139
|
+
<OpenmrsDatePicker
|
|
140
|
+
{...field}
|
|
141
|
+
value={field.value as Date}
|
|
142
|
+
className={styles.datePicker}
|
|
143
|
+
id={`${dateField.name}Input`}
|
|
144
|
+
data-testid={`${dateField.name}Input`}
|
|
145
|
+
maxDate={maxDateObj}
|
|
146
|
+
minDate={minDateObj}
|
|
147
|
+
labelText={dateField.label}
|
|
148
|
+
invalid={Boolean(fieldState?.error?.message)}
|
|
149
|
+
invalidText={fieldState?.error?.message}
|
|
150
|
+
/>
|
|
151
|
+
</ResponsiveWrapper>
|
|
152
|
+
)}
|
|
153
|
+
/>
|
|
154
|
+
<ResponsiveWrapper>
|
|
155
|
+
<Controller
|
|
156
|
+
name={timeField.name}
|
|
157
|
+
control={control}
|
|
158
|
+
render={({ field: { onBlur, onChange, value } }) => (
|
|
159
|
+
<div className={styles.timePickerContainer}>
|
|
160
|
+
<TimePicker
|
|
161
|
+
className={styles.timePicker}
|
|
162
|
+
disabled={disabled}
|
|
163
|
+
id={timeField.name}
|
|
164
|
+
invalid={Boolean(errors[timeField.name])}
|
|
165
|
+
invalidText={errors[timeField.name]?.message}
|
|
166
|
+
labelText={timeField.label}
|
|
167
|
+
onBlur={onBlur}
|
|
168
|
+
onChange={(event) => onChange(event.target.value)}
|
|
169
|
+
pattern="^(0[1-9]|1[0-2]):([0-5][0-9])$"
|
|
170
|
+
value={value as string}>
|
|
171
|
+
<Controller
|
|
172
|
+
name={timeFormatField.name}
|
|
173
|
+
control={control}
|
|
174
|
+
render={({ field: { onChange, value } }) => (
|
|
175
|
+
<TimePickerSelect
|
|
176
|
+
aria-label={timeFormatField.label}
|
|
177
|
+
className={classNames({
|
|
178
|
+
[styles.timePickerSelectError]: errors[timeFormatField.name],
|
|
179
|
+
})}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
id={`${timeFormatField.name}Input`}
|
|
182
|
+
onChange={(event) => onChange(event.target.value as amPm)}
|
|
183
|
+
value={value as amPm}>
|
|
184
|
+
<SelectItem value="AM" text={t('AM', 'AM')} />
|
|
185
|
+
<SelectItem value="PM" text={t('PM', 'PM')} />
|
|
186
|
+
</TimePickerSelect>
|
|
187
|
+
)}
|
|
188
|
+
/>
|
|
189
|
+
</TimePicker>
|
|
190
|
+
{errors[timeFormatField.name] && (
|
|
191
|
+
<div className={styles.timerPickerError}>{errors[timeFormatField.name]?.message}</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
/>
|
|
196
|
+
</ResponsiveWrapper>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export default VisitDateTimeSection;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import type { TFunction } from 'i18next';
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import {
|
|
7
|
+
openmrsFetch,
|
|
8
|
+
restBaseUrl,
|
|
9
|
+
useConfig,
|
|
10
|
+
useConnectivity,
|
|
11
|
+
useEmrConfiguration,
|
|
12
|
+
useFeatureFlag,
|
|
13
|
+
useSession,
|
|
14
|
+
useVisitTypes,
|
|
15
|
+
type Visit,
|
|
16
|
+
} from '@openmrs/esm-framework';
|
|
17
|
+
import { time12HourFormatRegex, type amPm } from '@openmrs/esm-patient-common-lib';
|
|
18
|
+
import { useDefaultVisitLocation } from './hooks/useDefaultVisitLocation';
|
|
19
|
+
import { ChartConfig } from '../config-schema';
|
|
20
|
+
import { useOfflineVisitType } from './hooks/useOfflineVisitType';
|
|
21
|
+
|
|
22
|
+
export const visitStatuses = ['new', 'ongoing', 'past'] as const;
|
|
23
|
+
export type VisitStatus = (typeof visitStatuses)[number];
|
|
24
|
+
|
|
25
|
+
export type VisitFormData = {
|
|
26
|
+
visitStatus: VisitStatus;
|
|
27
|
+
visitStartDate: Date; // Date object that only contains info for year, month, day
|
|
28
|
+
visitStartTime: string; // hh:mm (note that hh is from 01 to 12, NOT 00 to 23)
|
|
29
|
+
visitStartTimeFormat: amPm;
|
|
30
|
+
visitStopDate: Date; // Date object that only contains info for year, month, day
|
|
31
|
+
visitStopTime: string; // hh:mm (note that hh is from 01 to 12, NOT 00 to 23)
|
|
32
|
+
visitStopTimeFormat: amPm;
|
|
33
|
+
programType: string;
|
|
34
|
+
visitType: string;
|
|
35
|
+
patientVisitType?: string; // UUID of the patient visit type (new/revisit) - optional field
|
|
36
|
+
visitLocation: {
|
|
37
|
+
display?: string;
|
|
38
|
+
uuid?: string;
|
|
39
|
+
};
|
|
40
|
+
visitAttributes: {
|
|
41
|
+
[x: string]: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// *****************
|
|
46
|
+
// Copied from form-submission.service.ts
|
|
47
|
+
// TODO: consolidate logic for parsing errors from REST API calls
|
|
48
|
+
export type FieldError = {
|
|
49
|
+
[key: string]: Array<{ code: string; message: string }>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ErrorObject = {
|
|
53
|
+
error: {
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
detail: string;
|
|
57
|
+
fieldErrors?: FieldError;
|
|
58
|
+
globalErrors?: FieldError;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function extractErrorMessagesFromResponse(errorObject: ErrorObject, t: TFunction) {
|
|
63
|
+
const {
|
|
64
|
+
error: { fieldErrors, globalErrors, message, code },
|
|
65
|
+
} = errorObject ?? {};
|
|
66
|
+
|
|
67
|
+
if (fieldErrors) {
|
|
68
|
+
return Object.values(fieldErrors)
|
|
69
|
+
.flatMap((errors) => errors.map((error) => error.message))
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (globalErrors) {
|
|
74
|
+
return Object.values(globalErrors)
|
|
75
|
+
.flatMap((errors) => errors.map((error) => error.message))
|
|
76
|
+
.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return message ?? code ?? t('unknownError', 'Unknown error');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useConditionalVisitTypes() {
|
|
83
|
+
const isOnline = useConnectivity();
|
|
84
|
+
|
|
85
|
+
const visitTypesHook = isOnline ? useVisitTypes : useOfflineVisitType;
|
|
86
|
+
|
|
87
|
+
return visitTypesHook();
|
|
88
|
+
}
|
|
89
|
+
export interface VisitFormCallbacks {
|
|
90
|
+
onVisitCreatedOrUpdated: (visit: Visit) => Promise<any>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function useVisitFormCallbacks() {
|
|
94
|
+
return useState<Map<string, VisitFormCallbacks>>(new Map());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createVisitAttribute(visitUuid: string, attributeType: string, value: string) {
|
|
98
|
+
return openmrsFetch(`${restBaseUrl}/visit/${visitUuid}/attribute`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-type': 'application/json' },
|
|
101
|
+
body: { attributeType, value },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function updateVisitAttribute(visitUuid: string, visitAttributeUuid: string, value: string) {
|
|
106
|
+
return openmrsFetch(`${restBaseUrl}/visit/${visitUuid}/attribute/${visitAttributeUuid}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-type': 'application/json' },
|
|
109
|
+
body: { value },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function deleteVisitAttribute(visitUuid: string, visitAttributeUuid: string) {
|
|
114
|
+
return openmrsFetch(`${restBaseUrl}/visit/${visitUuid}/attribute/${visitAttributeUuid}`, {
|
|
115
|
+
method: 'DELETE',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useVisitFormSchemaAndDefaultValues(visitToEdit: Visit) {
|
|
120
|
+
const { t } = useTranslation();
|
|
121
|
+
const { visitAttributeTypes, restrictByVisitLocationTag } = useConfig<ChartConfig>();
|
|
122
|
+
const isEmrApiModuleInstalled = useFeatureFlag('emrapi-module');
|
|
123
|
+
const sessionUser = useSession();
|
|
124
|
+
const sessionLocation = sessionUser?.sessionLocation;
|
|
125
|
+
const defaultVisitLocation = useDefaultVisitLocation(
|
|
126
|
+
sessionLocation,
|
|
127
|
+
restrictByVisitLocationTag && isEmrApiModuleInstalled,
|
|
128
|
+
);
|
|
129
|
+
const { emrConfiguration } = useEmrConfiguration();
|
|
130
|
+
|
|
131
|
+
return useMemo(() => {
|
|
132
|
+
const now = new Date();
|
|
133
|
+
|
|
134
|
+
const allEncounterDateTimes = (visitToEdit?.encounters ?? []).map(({ encounterDatetime }) =>
|
|
135
|
+
Date.parse(encounterDatetime),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const firstEncounterDateTime: number = Math.min(...allEncounterDateTimes);
|
|
139
|
+
const lastEncounterDateTime: number = Math.max(...allEncounterDateTimes);
|
|
140
|
+
|
|
141
|
+
const startDateTime = convertToDateTimeFields(visitToEdit?.startDatetime ?? now);
|
|
142
|
+
const stopDateTime = convertToDateTimeFields(visitToEdit?.stopDatetime ?? now);
|
|
143
|
+
|
|
144
|
+
const visitStatus: VisitStatus =
|
|
145
|
+
visitToEdit == null ? 'new' : visitToEdit.stopDatetime === null ? 'ongoing' : 'past';
|
|
146
|
+
|
|
147
|
+
const defaultValues: Partial<VisitFormData> = {
|
|
148
|
+
visitStatus,
|
|
149
|
+
visitStartDate: startDateTime.date,
|
|
150
|
+
visitStartTime: startDateTime.time,
|
|
151
|
+
visitStartTimeFormat: startDateTime.timeFormat,
|
|
152
|
+
visitStopDate: stopDateTime.date,
|
|
153
|
+
visitStopTime: stopDateTime.time,
|
|
154
|
+
visitStopTimeFormat: stopDateTime.timeFormat,
|
|
155
|
+
visitType: visitToEdit?.visitType?.uuid ?? emrConfiguration?.atFacilityVisitType?.uuid,
|
|
156
|
+
visitLocation: visitToEdit?.location ?? defaultVisitLocation ?? {},
|
|
157
|
+
visitAttributes:
|
|
158
|
+
visitToEdit?.attributes.reduce(
|
|
159
|
+
(acc, curr) => ({
|
|
160
|
+
...acc,
|
|
161
|
+
[curr.attributeType.uuid]: typeof curr.value === 'object' ? curr?.value?.uuid : `${curr.value ?? ''}`,
|
|
162
|
+
}),
|
|
163
|
+
{},
|
|
164
|
+
) ?? {},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const visitAttributes = (visitAttributeTypes ?? [])?.reduce(
|
|
168
|
+
(acc, { uuid, required }) => ({
|
|
169
|
+
...acc,
|
|
170
|
+
[uuid]: required
|
|
171
|
+
? z.string({ required_error: t('fieldRequired', 'This field is required') })
|
|
172
|
+
: z.string().optional(),
|
|
173
|
+
}),
|
|
174
|
+
{},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const visitStatusEnum = z.enum(visitStatuses);
|
|
178
|
+
const visitFormSchema = z
|
|
179
|
+
.object({
|
|
180
|
+
visitStatus: visitToEdit ? visitStatusEnum.exclude(['new']) : visitStatusEnum,
|
|
181
|
+
visitStartDate: z.date().optional(),
|
|
182
|
+
visitStartTime: z.string().regex(time12HourFormatRegex).optional(),
|
|
183
|
+
visitStartTimeFormat: z.enum(['PM', 'AM']).optional(),
|
|
184
|
+
visitStopDate: z.date().optional(),
|
|
185
|
+
visitStopTime: z.string().regex(time12HourFormatRegex).optional(),
|
|
186
|
+
visitStopTimeFormat: z.enum(['PM', 'AM']).optional(),
|
|
187
|
+
programType: z.string().optional(),
|
|
188
|
+
visitType: z.string({ required_error: t('visitTypeRequired', 'Visit type is required') }),
|
|
189
|
+
patientVisitType: z.string().optional(), // Optional patient visit type field
|
|
190
|
+
visitLocation: z.object({
|
|
191
|
+
display: z.string(),
|
|
192
|
+
uuid: z.string({ required_error: t('visitLocationRequired', 'Visit location is required') }),
|
|
193
|
+
}),
|
|
194
|
+
visitAttributes: z.object(visitAttributes),
|
|
195
|
+
})
|
|
196
|
+
.superRefine((data, ctx) => {
|
|
197
|
+
const {
|
|
198
|
+
visitStatus,
|
|
199
|
+
visitStartDate,
|
|
200
|
+
visitStartTime,
|
|
201
|
+
visitStartTimeFormat,
|
|
202
|
+
visitStopDate,
|
|
203
|
+
visitStopTime,
|
|
204
|
+
visitStopTimeFormat,
|
|
205
|
+
} = data;
|
|
206
|
+
|
|
207
|
+
const visitStartDateTime = convertToDate(visitStartDate, visitStartTime, visitStartTimeFormat);
|
|
208
|
+
const visitStopDateTime = convertToDate(visitStopDate, visitStopTime, visitStopTimeFormat);
|
|
209
|
+
|
|
210
|
+
if (visitStatus === 'ongoing' || visitStatus === 'past') {
|
|
211
|
+
if (visitStartDateTime === null) {
|
|
212
|
+
ctx.addIssue({
|
|
213
|
+
code: z.ZodIssueCode.custom,
|
|
214
|
+
message: t('visitStartDateTimeRequired', 'Start date and time are required'),
|
|
215
|
+
path: ['visitStartDate'],
|
|
216
|
+
});
|
|
217
|
+
} else if (visitStartDateTime > now) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: z.ZodIssueCode.custom,
|
|
220
|
+
message: t('futureStartTime', 'Start time cannot be in the future'),
|
|
221
|
+
path: ['visitStartTime'],
|
|
222
|
+
});
|
|
223
|
+
} else if (visitStartDateTime.getTime() > firstEncounterDateTime) {
|
|
224
|
+
ctx.addIssue({
|
|
225
|
+
code: z.ZodIssueCode.custom,
|
|
226
|
+
message: t(
|
|
227
|
+
'visitStartDateMustBeBeforeEarliestEncounter',
|
|
228
|
+
'Start time must be on or before {{firstEncounterDatetime}}',
|
|
229
|
+
{
|
|
230
|
+
firstEncounterDatetime: new Date(firstEncounterDateTime).toLocaleString(),
|
|
231
|
+
interpolation: {
|
|
232
|
+
escapeValue: false,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
),
|
|
236
|
+
path: ['visitStartTime'],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (visitStatus === 'past') {
|
|
242
|
+
if (visitStopDateTime === null) {
|
|
243
|
+
ctx.addIssue({
|
|
244
|
+
code: z.ZodIssueCode.custom,
|
|
245
|
+
message: t('endDateTimeRequired', 'End date and time are required'),
|
|
246
|
+
path: ['visitStopDate'],
|
|
247
|
+
});
|
|
248
|
+
} else if (visitStopDateTime > now) {
|
|
249
|
+
ctx.addIssue({
|
|
250
|
+
code: z.ZodIssueCode.custom,
|
|
251
|
+
message: t('futureEndTime', 'End time cannot be in the future'),
|
|
252
|
+
path: ['visitStopTime'],
|
|
253
|
+
});
|
|
254
|
+
} else if (visitStopDateTime < visitStartDateTime) {
|
|
255
|
+
ctx.addIssue({
|
|
256
|
+
code: z.ZodIssueCode.custom,
|
|
257
|
+
message: t('endTimeMustBeAfterStartTime', 'End time must be after start time'),
|
|
258
|
+
path: ['visitStopDate'],
|
|
259
|
+
});
|
|
260
|
+
} else if (visitStopDateTime.getTime() < lastEncounterDateTime) {
|
|
261
|
+
ctx.addIssue({
|
|
262
|
+
code: z.ZodIssueCode.custom,
|
|
263
|
+
message: t(
|
|
264
|
+
'endTimeMustBeAfterMostRecentEncounter',
|
|
265
|
+
'End time must be on or after {{lastEncounterDatetime}}',
|
|
266
|
+
{
|
|
267
|
+
lastEncounterDatetime: new Date(lastEncounterDateTime).toLocaleString(),
|
|
268
|
+
interpolation: {
|
|
269
|
+
escapeValue: false,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
),
|
|
273
|
+
path: ['visitStopTime'],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { visitFormSchema, defaultValues, firstEncounterDateTime, lastEncounterDateTime };
|
|
280
|
+
}, [t, visitAttributeTypes, visitToEdit, defaultVisitLocation, emrConfiguration]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Returns a Date object based on date, time and am/pm inputs from user.
|
|
284
|
+
// Note that the inputs are expected to be in local time.
|
|
285
|
+
// Returns a non-null Date only is the inputs are valid
|
|
286
|
+
export const convertToDate = (
|
|
287
|
+
date: Date, // Date object that only contains info for year, month, day
|
|
288
|
+
time12h: string, // hh:mm, where hh is 01 to 12
|
|
289
|
+
timeFormat: amPm, // AM / PM
|
|
290
|
+
): Date | null => {
|
|
291
|
+
if (!date || !time12h || !timeFormat) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const dateStr = dayjs(date).format('YYYY-MM-DD');
|
|
295
|
+
const ret = dayjs(`${dateStr} ${time12h} ${timeFormat}`, 'YYYY-MM-DD hh:mm A');
|
|
296
|
+
return ret.isValid() ? ret.toDate() : null;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// The inverse of `convertToDate`. Takes a Date-like value and returns
|
|
300
|
+
// the date (with hour / minute / seconds truncated), time12h and timeFormat
|
|
301
|
+
export const convertToDateTimeFields = (dateTime: dayjs.ConfigType) => {
|
|
302
|
+
const dateTimeDayjs = dayjs(dateTime);
|
|
303
|
+
return {
|
|
304
|
+
date: dateTimeDayjs.startOf('day').toDate(),
|
|
305
|
+
time: dateTimeDayjs.format('hh:mm'),
|
|
306
|
+
timeFormat: dateTimeDayjs.format('A') as amPm,
|
|
307
|
+
};
|
|
308
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import { getPatientName, PatientBannerPatientInfo, PatientPhoto, launchWorkspace } from '@openmrs/esm-framework';
|
|
4
|
+
import styles from './compact-patient-banner.scss';
|
|
5
|
+
import { SearchedPatient } from '../../type';
|
|
6
|
+
import { mapToFhirPatient } from '../../utils/fhir-mapper';
|
|
7
|
+
import { usePatientSearchContext } from './patient-search-context';
|
|
8
|
+
|
|
9
|
+
interface ClickablePatientContainerProps {
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
patient: SearchedPatient;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CompactPatientBannerProps {
|
|
15
|
+
patients: Array<SearchedPatient>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CompactPatientBanner = forwardRef<HTMLDivElement, CompactPatientBannerProps>(({ patients }, ref) => {
|
|
19
|
+
const fhirMappedPatients: Array<fhir.Patient> = useMemo(() => {
|
|
20
|
+
return patients.map(mapToFhirPatient);
|
|
21
|
+
}, [patients]);
|
|
22
|
+
|
|
23
|
+
const renderPatient = useCallback(
|
|
24
|
+
(patient: fhir.Patient, index: number) => {
|
|
25
|
+
const patientName = getPatientName(patient);
|
|
26
|
+
const searchedPatient = patients[index];
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<ClickablePatientContainer key={searchedPatient.uuid} patient={searchedPatient}>
|
|
30
|
+
<div className={styles.patientAvatar} role="img">
|
|
31
|
+
<PatientPhoto patientUuid={searchedPatient.uuid} patientName={patientName} />
|
|
32
|
+
</div>
|
|
33
|
+
<PatientBannerPatientInfo patient={patient} />
|
|
34
|
+
</ClickablePatientContainer>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
[patients],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return <div ref={ref}>{fhirMappedPatients.map(renderPatient)}</div>;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const ClickablePatientContainer = ({ patient, children }: ClickablePatientContainerProps) => {
|
|
44
|
+
const context = usePatientSearchContext();
|
|
45
|
+
const patientClickSideEffect = context?.patientClickSideEffect;
|
|
46
|
+
const isDeceased = Boolean(patient?.person?.dead);
|
|
47
|
+
|
|
48
|
+
const handlePatientClick = () => {
|
|
49
|
+
launchWorkspace('check-in-form', {
|
|
50
|
+
patientUuid: patient.uuid,
|
|
51
|
+
patient: patient,
|
|
52
|
+
openedFrom: 'registration',
|
|
53
|
+
showPatientHeader: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
patientClickSideEffect?.(patient.uuid);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
className={classNames(styles.patientSearchResult, styles.patientSearchResultButton, {
|
|
62
|
+
[styles.deceased]: isDeceased,
|
|
63
|
+
})}
|
|
64
|
+
onClick={handlePatientClick}>
|
|
65
|
+
{children}
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default CompactPatientBanner;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
3
|
+
|
|
4
|
+
.patientSearchResult,
|
|
5
|
+
.deceased.patientSearchResult {
|
|
6
|
+
text-decoration: none;
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
border: none;
|
|
10
|
+
border-bottom: 1px solid $ui-03;
|
|
11
|
+
|
|
12
|
+
&:hover,
|
|
13
|
+
&:focus {
|
|
14
|
+
background-color: $ui-01;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.patientSearchResultButton {
|
|
19
|
+
background: none;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
width: 100%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.patientAvatar {
|
|
25
|
+
width: 3.5rem;
|
|
26
|
+
height: 3.5rem;
|
|
27
|
+
margin: layout.$spacing-05;
|
|
28
|
+
border-radius: 1px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.skeletonIcon {
|
|
32
|
+
height: 3.5rem;
|
|
33
|
+
width: 3.5rem;
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SkeletonIcon, SkeletonText } from '@carbon/react';
|
|
3
|
+
import styles from './compact-patient-banner.scss';
|
|
4
|
+
|
|
5
|
+
export const Loader = () => {
|
|
6
|
+
return (
|
|
7
|
+
<div className={styles.patientSearchResult} data-testid="search-skeleton">
|
|
8
|
+
<div className={styles.patientAvatar} role="img">
|
|
9
|
+
<SkeletonIcon className={styles.skeletonIcon} />
|
|
10
|
+
</div>
|
|
11
|
+
<div>
|
|
12
|
+
<SkeletonText />
|
|
13
|
+
<span className={styles.demographics}>
|
|
14
|
+
<SkeletonIcon /> <span className={styles.middot}>·</span> <SkeletonIcon />{' '}
|
|
15
|
+
<span className={styles.middot}>·</span> <SkeletonIcon />
|
|
16
|
+
</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default Loader;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface PatientSearchContextProps {
|
|
4
|
+
/**
|
|
5
|
+
* A function to execute instead of navigating the user to the patient
|
|
6
|
+
* dashboard. If null/undefined, patient results will be links to the
|
|
7
|
+
* patient dashboard.
|
|
8
|
+
*/
|
|
9
|
+
nonNavigationSelectPatientAction?: (patientUuid: string) => void;
|
|
10
|
+
/**
|
|
11
|
+
* A function to execute when the user clicks on a patient result. Will
|
|
12
|
+
* be executed whether or not nonNavigationSelectPatientAction is defined,
|
|
13
|
+
* just before navigation (or after nonNavigationSelectPatientAction is called).
|
|
14
|
+
*/
|
|
15
|
+
patientClickSideEffect?: ((patientUuid: string) => void) | (() => void);
|
|
16
|
+
handleReturnToSearchList?: () => void;
|
|
17
|
+
showPatientSearch?: () => void;
|
|
18
|
+
hidePatientSearch?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const PatientSearchContext = createContext<PatientSearchContextProps>({
|
|
22
|
+
nonNavigationSelectPatientAction: undefined,
|
|
23
|
+
patientClickSideEffect: undefined,
|
|
24
|
+
handleReturnToSearchList: undefined,
|
|
25
|
+
showPatientSearch: undefined,
|
|
26
|
+
hidePatientSearch: undefined,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const PatientSearchContextProvider = PatientSearchContext.Provider;
|
|
30
|
+
export const usePatientSearchContext = () => useContext(PatientSearchContext);
|