@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.
Files changed (146) hide show
  1. package/README.md +4 -0
  2. package/dist/117.js +1 -0
  3. package/dist/117.js.map +1 -0
  4. package/dist/131.js +1 -0
  5. package/dist/131.js.map +1 -0
  6. package/dist/132.js +1 -0
  7. package/dist/132.js.map +1 -0
  8. package/dist/152.js +1 -0
  9. package/dist/152.js.map +1 -0
  10. package/dist/209.js +1 -0
  11. package/dist/209.js.map +1 -0
  12. package/dist/247.js +1 -0
  13. package/dist/247.js.map +1 -0
  14. package/dist/28.js +1 -0
  15. package/dist/28.js.map +1 -0
  16. package/dist/317.js +1 -0
  17. package/dist/317.js.map +1 -0
  18. package/dist/349.js +1 -0
  19. package/dist/349.js.map +1 -0
  20. package/dist/371.js +1 -0
  21. package/dist/371.js.map +1 -0
  22. package/dist/378.js +1 -0
  23. package/dist/378.js.map +1 -0
  24. package/dist/442.js +1 -0
  25. package/dist/442.js.map +1 -0
  26. package/dist/45.js +1 -0
  27. package/dist/45.js.map +1 -0
  28. package/dist/459.js +12 -0
  29. package/dist/459.js.map +1 -0
  30. package/dist/466.js +1 -0
  31. package/dist/466.js.map +1 -0
  32. package/dist/508.js +1 -0
  33. package/dist/508.js.map +1 -0
  34. package/dist/61.js +1 -0
  35. package/dist/61.js.map +1 -0
  36. package/dist/640.js +1 -0
  37. package/dist/640.js.map +1 -0
  38. package/dist/689.js +1 -0
  39. package/dist/689.js.map +1 -0
  40. package/dist/709.js +1 -0
  41. package/dist/709.js.map +1 -0
  42. package/dist/712.js +1 -0
  43. package/dist/712.js.map +1 -0
  44. package/dist/720.js +1 -0
  45. package/dist/720.js.map +1 -0
  46. package/dist/742.js +1 -0
  47. package/dist/742.js.map +1 -0
  48. package/dist/771.js +1 -0
  49. package/dist/771.js.map +1 -0
  50. package/dist/806.js +1 -0
  51. package/dist/806.js.map +1 -0
  52. package/dist/912.js +1 -0
  53. package/dist/912.js.map +1 -0
  54. package/dist/913.js +1 -0
  55. package/dist/913.js.map +1 -0
  56. package/dist/940.js +1 -0
  57. package/dist/940.js.map +1 -0
  58. package/dist/957.js +15 -0
  59. package/dist/957.js.map +1 -0
  60. package/dist/digihmis-esm-patient-registration-app.js +6 -0
  61. package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
  62. package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
  63. package/dist/main.js +17 -0
  64. package/dist/main.js.map +1 -0
  65. package/dist/routes.json +1 -0
  66. package/jest.config.js +26 -0
  67. package/package.json +51 -0
  68. package/rspack.config.js +1 -0
  69. package/src/active-visit-table/active-visit-table.component.tsx +240 -0
  70. package/src/active-visit-table/active-visits.resource.ts +338 -0
  71. package/src/active-visit-table/active-visits.scss +211 -0
  72. package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
  73. package/src/checkin-form/base-visit-type.component.tsx +102 -0
  74. package/src/checkin-form/base-visit-type.scss +75 -0
  75. package/src/checkin-form/checkin-form.scss +167 -0
  76. package/src/checkin-form/checkin-form.workspace.tsx +703 -0
  77. package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
  78. package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
  79. package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
  80. package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
  81. package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
  82. package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
  83. package/src/checkin-form/location-selector.component.tsx +86 -0
  84. package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
  85. package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
  86. package/src/checkin-form/visit-attribute-type.scss +5 -0
  87. package/src/checkin-form/visit-date-time.component.tsx +201 -0
  88. package/src/checkin-form/visit-form.resource.ts +308 -0
  89. package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
  90. package/src/components/custom-card/compact-patient-banner.scss +34 -0
  91. package/src/components/custom-card/custom-loader.component.tsx +22 -0
  92. package/src/components/custom-card/patient-search-context.ts +30 -0
  93. package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
  94. package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
  95. package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
  96. package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
  97. package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
  98. package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
  99. package/src/config-schema.ts +511 -0
  100. package/src/constant.ts +6 -0
  101. package/src/constants/index.ts +1 -0
  102. package/src/dashboard.meta.ts +8 -0
  103. package/src/declarations.d.ts +6 -0
  104. package/src/empty-svg/empty-svg.component.tsx +565 -0
  105. package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
  106. package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
  107. package/src/identifier-workspace/identifier-configure.scss +106 -0
  108. package/src/identifier-workspace/identifier.resource.ts +90 -0
  109. package/src/index.ts +33 -0
  110. package/src/left-panel/left-panel.component.tsx +54 -0
  111. package/src/left-panel/left-panel.scss +13 -0
  112. package/src/page-header/page-header.component.tsx +35 -0
  113. package/src/page-header/page-header.scss +76 -0
  114. package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
  115. package/src/patient-tags/deceased-patient-tag.scss +15 -0
  116. package/src/patient-tags/patient-tags.resource.ts +30 -0
  117. package/src/patient-tags/visit-tag.extension.tsx +43 -0
  118. package/src/patient-tags/visit-tag.scss +19 -0
  119. package/src/registration-form/form-manager.ts +449 -0
  120. package/src/registration-form/registration-form.component.tsx +1565 -0
  121. package/src/registration-form/registration-form.resource.ts +264 -0
  122. package/src/registration-form/registration-form.scss +282 -0
  123. package/src/routes.json +62 -0
  124. package/src/search-bar/search-bar.component.tsx +285 -0
  125. package/src/search-bar/search-bar.resource.ts +274 -0
  126. package/src/search-bar/search-bar.scss +427 -0
  127. package/src/search-dashboard/search-dashboard.component.tsx +17 -0
  128. package/src/search-dashboard/search-dashboard.scss +13 -0
  129. package/src/setup-tests.ts +1 -0
  130. package/src/type/index.ts +463 -0
  131. package/src/utils/fhir-mapper.ts +73 -0
  132. package/src/visit-summary/visit-summary.component.tsx +32 -0
  133. package/src/visit-summary/visit-summary.resource.ts +56 -0
  134. package/src/visit-summary/visit-summary.scss +74 -0
  135. package/translations/am.json +112 -0
  136. package/translations/ar.json +120 -0
  137. package/translations/en.json +112 -0
  138. package/translations/es.json +114 -0
  139. package/translations/fr.json +114 -0
  140. package/translations/he.json +114 -0
  141. package/translations/km.json +111 -0
  142. package/translations/pt.json +114 -0
  143. package/translations/sw.json +112 -0
  144. package/translations/vi.json +111 -0
  145. package/translations/zh.json +111 -0
  146. 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}>&middot;</span> <SkeletonIcon />{' '}
15
+ <span className={styles.middot}>&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);