@bathiran212/esm-patient-notes-app 2.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 (216) hide show
  1. package/README.md +4 -0
  2. package/dist/1076.js +1 -0
  3. package/dist/1076.js.map +1 -0
  4. package/dist/1339.js +1 -0
  5. package/dist/1339.js.map +1 -0
  6. package/dist/1480.js +1 -0
  7. package/dist/1480.js.map +1 -0
  8. package/dist/1646.js +1 -0
  9. package/dist/1646.js.map +1 -0
  10. package/dist/1789.js +1 -0
  11. package/dist/1789.js.map +1 -0
  12. package/dist/1869.js +1 -0
  13. package/dist/1869.js.map +1 -0
  14. package/dist/1871.js +1 -0
  15. package/dist/1871.js.map +1 -0
  16. package/dist/1877.js +1 -0
  17. package/dist/1877.js.map +1 -0
  18. package/dist/2153.js +1 -0
  19. package/dist/2153.js.map +1 -0
  20. package/dist/2317.js +1 -0
  21. package/dist/2317.js.map +1 -0
  22. package/dist/2416.js +1 -0
  23. package/dist/2416.js.map +1 -0
  24. package/dist/2544.js +27 -0
  25. package/dist/2544.js.map +1 -0
  26. package/dist/282.js +1 -0
  27. package/dist/282.js.map +1 -0
  28. package/dist/2824.js +1 -0
  29. package/dist/2824.js.map +1 -0
  30. package/dist/2842.js +1 -0
  31. package/dist/2842.js.map +1 -0
  32. package/dist/2881.js +1 -0
  33. package/dist/2881.js.map +1 -0
  34. package/dist/3378.js +1 -0
  35. package/dist/3378.js.map +1 -0
  36. package/dist/3720.js +1 -0
  37. package/dist/3720.js.map +1 -0
  38. package/dist/3963.js +1 -0
  39. package/dist/3963.js.map +1 -0
  40. package/dist/3989.js +1 -0
  41. package/dist/3989.js.map +1 -0
  42. package/dist/4106.js +1 -0
  43. package/dist/4106.js.map +1 -0
  44. package/dist/4111.js +1 -0
  45. package/dist/4111.js.map +1 -0
  46. package/dist/434.js +1 -0
  47. package/dist/434.js.map +1 -0
  48. package/dist/4348.js +1 -0
  49. package/dist/4348.js.map +1 -0
  50. package/dist/4383.js +1 -0
  51. package/dist/4383.js.map +1 -0
  52. package/dist/4658.js +1 -0
  53. package/dist/4658.js.map +1 -0
  54. package/dist/466.js +1 -0
  55. package/dist/466.js.map +1 -0
  56. package/dist/4928.js +1 -0
  57. package/dist/4928.js.map +1 -0
  58. package/dist/5117.js +1 -0
  59. package/dist/5117.js.map +1 -0
  60. package/dist/5132.js +1 -0
  61. package/dist/5132.js.map +1 -0
  62. package/dist/5145.js +1 -0
  63. package/dist/5145.js.map +1 -0
  64. package/dist/5503.js +1 -0
  65. package/dist/5503.js.map +1 -0
  66. package/dist/556.js +1 -0
  67. package/dist/556.js.map +1 -0
  68. package/dist/5644.js +1 -0
  69. package/dist/5644.js.map +1 -0
  70. package/dist/5697.js +1 -0
  71. package/dist/5697.js.map +1 -0
  72. package/dist/5861.js +1 -0
  73. package/dist/5861.js.map +1 -0
  74. package/dist/5940.js +1 -0
  75. package/dist/5940.js.map +1 -0
  76. package/dist/6047.js +1 -0
  77. package/dist/6047.js.map +1 -0
  78. package/dist/6371.js +1 -0
  79. package/dist/6371.js.map +1 -0
  80. package/dist/6377.js +1 -0
  81. package/dist/6377.js.map +1 -0
  82. package/dist/6444.js +1 -0
  83. package/dist/6444.js.map +1 -0
  84. package/dist/6508.js +1 -0
  85. package/dist/6508.js.map +1 -0
  86. package/dist/6724.js +1 -0
  87. package/dist/6724.js.map +1 -0
  88. package/dist/6904.js +1 -0
  89. package/dist/6904.js.map +1 -0
  90. package/dist/7045.js +1 -0
  91. package/dist/7045.js.map +1 -0
  92. package/dist/7103.js +1 -0
  93. package/dist/7103.js.map +1 -0
  94. package/dist/7175.js +1 -0
  95. package/dist/7175.js.map +1 -0
  96. package/dist/7182.js +1 -0
  97. package/dist/7182.js.map +1 -0
  98. package/dist/7205.js +11 -0
  99. package/dist/7205.js.map +1 -0
  100. package/dist/7646.js +17 -0
  101. package/dist/7646.js.map +1 -0
  102. package/dist/7742.js +1 -0
  103. package/dist/7742.js.map +1 -0
  104. package/dist/7912.js +1 -0
  105. package/dist/7912.js.map +1 -0
  106. package/dist/8358.js +1 -0
  107. package/dist/8358.js.map +1 -0
  108. package/dist/8359.js +1 -0
  109. package/dist/8359.js.map +1 -0
  110. package/dist/8369.js +1 -0
  111. package/dist/8369.js.map +1 -0
  112. package/dist/8695.js +1 -0
  113. package/dist/8695.js.map +1 -0
  114. package/dist/8722.js +1 -0
  115. package/dist/8722.js.map +1 -0
  116. package/dist/903.js +1 -0
  117. package/dist/903.js.map +1 -0
  118. package/dist/9061.js +1 -0
  119. package/dist/9061.js.map +1 -0
  120. package/dist/9072.js +1 -0
  121. package/dist/9072.js.map +1 -0
  122. package/dist/9105.js +1 -0
  123. package/dist/9105.js.map +1 -0
  124. package/dist/9712.js +1 -0
  125. package/dist/9712.js.map +1 -0
  126. package/dist/9771.js +1 -0
  127. package/dist/9771.js.map +1 -0
  128. package/dist/9806.js +1 -0
  129. package/dist/9806.js.map +1 -0
  130. package/dist/main.js +16 -0
  131. package/dist/main.js.map +1 -0
  132. package/dist/openmrs-esm-patient-notes-app.js +6 -0
  133. package/dist/openmrs-esm-patient-notes-app.js.buildmanifest.json +1760 -0
  134. package/dist/openmrs-esm-patient-notes-app.js.map +1 -0
  135. package/dist/routes.json +1 -0
  136. package/package.json +58 -0
  137. package/rspack.config.js +1 -0
  138. package/src/config-schema.ts +28 -0
  139. package/src/dashboard.meta.ts +7 -0
  140. package/src/declarations.d.ts +4 -0
  141. package/src/index.ts +45 -0
  142. package/src/notes/notes-overview.extension.tsx +74 -0
  143. package/src/notes/notes-overview.scss +40 -0
  144. package/src/notes/notes-overview.test.tsx +101 -0
  145. package/src/notes/paginated-notes.component.tsx +182 -0
  146. package/src/notes/visit-note-config-schema.ts +38 -0
  147. package/src/notes/visit-notes-form.scss +219 -0
  148. package/src/notes/visit-notes-form.test.tsx +523 -0
  149. package/src/notes/visit-notes-form.workspace.tsx +853 -0
  150. package/src/notes/visit-notes.resource.ts +113 -0
  151. package/src/routes.json +48 -0
  152. package/src/sticky-notes/delete-sticky-note-button.component.tsx +39 -0
  153. package/src/sticky-notes/delete-sticky-note-button.scss +13 -0
  154. package/src/sticky-notes/delete-sticky-note.modal.test.tsx +72 -0
  155. package/src/sticky-notes/delete-sticky-note.modal.tsx +62 -0
  156. package/src/sticky-notes/edit-sticky-note-button.component.tsx +20 -0
  157. package/src/sticky-notes/sticky-note-header-button.component.tsx +100 -0
  158. package/src/sticky-notes/sticky-note-header-button.scss +38 -0
  159. package/src/sticky-notes/sticky-note-header-button.test.tsx +182 -0
  160. package/src/sticky-notes/sticky-note-panel.component.tsx +88 -0
  161. package/src/sticky-notes/sticky-note-panel.scss +54 -0
  162. package/src/sticky-notes/sticky-note-panel.test.tsx +66 -0
  163. package/src/sticky-notes/sticky-note.modal.scss +3 -0
  164. package/src/sticky-notes/sticky-note.modal.test.tsx +115 -0
  165. package/src/sticky-notes/sticky-note.modal.tsx +93 -0
  166. package/src/sticky-notes/sticky-note.resource.test.ts +24 -0
  167. package/src/sticky-notes/sticky-note.resource.ts +82 -0
  168. package/src/sticky-notes/utils.test.ts +36 -0
  169. package/src/sticky-notes/utils.ts +9 -0
  170. package/src/types/index.ts +203 -0
  171. package/src/visit-note-action-button.extension.tsx +28 -0
  172. package/src/visit-note-action-button.test.tsx +42 -0
  173. package/translations/am.json +55 -0
  174. package/translations/ar.json +55 -0
  175. package/translations/ar_SY.json +55 -0
  176. package/translations/bn.json +55 -0
  177. package/translations/cs.json +55 -0
  178. package/translations/de.json +55 -0
  179. package/translations/en.json +55 -0
  180. package/translations/en_US.json +55 -0
  181. package/translations/es.json +55 -0
  182. package/translations/es_MX.json +55 -0
  183. package/translations/fr.json +55 -0
  184. package/translations/he.json +55 -0
  185. package/translations/hi.json +55 -0
  186. package/translations/hi_IN.json +55 -0
  187. package/translations/id.json +55 -0
  188. package/translations/it.json +55 -0
  189. package/translations/ka.json +55 -0
  190. package/translations/km.json +55 -0
  191. package/translations/ku.json +55 -0
  192. package/translations/ky.json +55 -0
  193. package/translations/lg.json +55 -0
  194. package/translations/ne.json +55 -0
  195. package/translations/pl.json +55 -0
  196. package/translations/pt.json +55 -0
  197. package/translations/pt_BR.json +55 -0
  198. package/translations/qu.json +55 -0
  199. package/translations/ro_RO.json +55 -0
  200. package/translations/ru_RU.json +55 -0
  201. package/translations/si.json +55 -0
  202. package/translations/sq.json +55 -0
  203. package/translations/sw.json +55 -0
  204. package/translations/sw_KE.json +55 -0
  205. package/translations/tr.json +55 -0
  206. package/translations/tr_TR.json +55 -0
  207. package/translations/uk.json +55 -0
  208. package/translations/uz.json +55 -0
  209. package/translations/uz@Latn.json +55 -0
  210. package/translations/uz_UZ.json +55 -0
  211. package/translations/vi.json +55 -0
  212. package/translations/zh.json +55 -0
  213. package/translations/zh_CN.json +55 -0
  214. package/translations/zh_TW.json +55 -0
  215. package/tsconfig.json +4 -0
  216. package/vitest.config.ts +4 -0
@@ -0,0 +1,853 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import classnames from 'classnames';
3
+ import dayjs from 'dayjs';
4
+ import { debounce } from 'lodash-es';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useSWRConfig } from 'swr';
7
+ import { z } from 'zod';
8
+ import { zodResolver } from '@hookform/resolvers/zod';
9
+ import { Controller, useForm, type Control } from 'react-hook-form';
10
+ import type { TFunction } from 'i18next';
11
+ import {
12
+ Button,
13
+ ButtonSet,
14
+ Column,
15
+ Form,
16
+ FormGroup,
17
+ InlineLoading,
18
+ InlineNotification,
19
+ Row,
20
+ Search,
21
+ SkeletonText,
22
+ Stack,
23
+ Tag,
24
+ TextArea,
25
+ Tile,
26
+ } from '@carbon/react';
27
+ import { Add, CloseFilled, WarningFilled } from '@carbon/react/icons';
28
+ import {
29
+ createAttachment,
30
+ createErrorHandler,
31
+ ExtensionSlot,
32
+ OpenmrsDatePicker,
33
+ ResponsiveWrapper,
34
+ restBaseUrl,
35
+ showModal,
36
+ showSnackbar,
37
+ useConfig,
38
+ useFeatureFlag,
39
+ useLayoutType,
40
+ useSession,
41
+ Workspace2,
42
+ type Encounter,
43
+ type UploadedFile,
44
+ } from '@openmrs/esm-framework';
45
+ import {
46
+ invalidateVisitAndEncounterData,
47
+ type PatientWorkspace2DefinitionProps,
48
+ useAllowedFileExtensions,
49
+ } from '@openmrs/esm-patient-common-lib';
50
+ import type { ConfigObject } from '../config-schema';
51
+ import type { Concept, Diagnosis, DiagnosisPayload, VisitNotePayload } from '../types';
52
+ import {
53
+ deletePatientDiagnosis,
54
+ fetchDiagnosisConceptsByName,
55
+ savePatientDiagnosis,
56
+ saveVisitNote,
57
+ updateVisitNote,
58
+ useVisitNotes,
59
+ } from './visit-notes.resource';
60
+ import styles from './visit-notes-form.scss';
61
+
62
+ type VisitNotesFormData = Omit<z.infer<ReturnType<typeof createSchema>>, 'images'> & {
63
+ images?: UploadedFile[];
64
+ };
65
+
66
+ interface DiagnosesDisplayProps {
67
+ fieldName: string;
68
+ isDiagnosisNotSelected: (diagnosis: Concept) => boolean;
69
+ isLoading: boolean;
70
+ isSearching: boolean;
71
+ onAddDiagnosis: (diagnosis: Concept, searchInputField: string) => void;
72
+ searchResults: Array<Concept>;
73
+ t: TFunction;
74
+ value: string;
75
+ }
76
+
77
+ interface DiagnosisSearchProps {
78
+ control: Control<VisitNotesFormData>;
79
+ error?: object;
80
+ handleSearch: (fieldName) => void;
81
+ labelText: string;
82
+ name: 'noteDate' | 'primaryDiagnosisSearch' | 'secondaryDiagnosisSearch' | 'clinicalNote';
83
+ placeholder: string;
84
+ setIsSearching: (isSearching: boolean) => void;
85
+ }
86
+
87
+ const createSchema = (t: TFunction, isRetrospectiveDataEntryEnabled: boolean) => {
88
+ return z.object({
89
+ noteDate: isRetrospectiveDataEntryEnabled ? z.date() : z.date().optional(),
90
+ primaryDiagnosisSearch: z.string(),
91
+ secondaryDiagnosisSearch: z.string().optional(),
92
+ clinicalNote: z.string().optional(),
93
+ images: z.array(z.any()).optional(),
94
+ });
95
+ };
96
+
97
+ const SEARCH_TIMEOUT_MS = 500;
98
+
99
+ export interface VisitNotesFormProps {
100
+ encounter?: Encounter;
101
+ formContext: 'creating' | 'editing';
102
+ }
103
+
104
+ const VisitNotesForm: React.FC<PatientWorkspace2DefinitionProps<VisitNotesFormProps, {}>> = ({
105
+ closeWorkspace,
106
+ workspaceProps: { formContext, encounter },
107
+ groupProps: { patientUuid, patient },
108
+ }) => {
109
+ const isEditing: boolean = Boolean(formContext === 'editing' && encounter?.id);
110
+ const { t } = useTranslation();
111
+ const isTablet = useLayoutType() === 'tablet';
112
+ const session = useSession();
113
+ const { isPrimaryDiagnosisRequired, ...config } = useConfig<ConfigObject>();
114
+ const memoizedState = useMemo(() => ({ patientUuid, patient }), [patientUuid, patient]);
115
+ const { clinicianEncounterRole, encounterNoteTextConceptUuid, encounterTypeUuid, formConceptUuid } =
116
+ config.visitNoteConfig;
117
+ const [isLoadingPrimaryDiagnoses, setIsLoadingPrimaryDiagnoses] = useState(false);
118
+ const [isLoadingSecondaryDiagnoses, setIsLoadingSecondaryDiagnoses] = useState(false);
119
+ const [isSearching, setIsSearching] = useState(false);
120
+ const [selectedPrimaryDiagnoses, setSelectedPrimaryDiagnoses] = useState<Array<Diagnosis>>([]);
121
+ const [selectedSecondaryDiagnoses, setSelectedSecondaryDiagnoses] = useState<Array<Diagnosis>>([]);
122
+ const [searchPrimaryResults, setSearchPrimaryResults] = useState<Array<Concept>>(null);
123
+ const [searchSecondaryResults, setSearchSecondaryResults] = useState<Array<Concept>>(null);
124
+ const [combinedDiagnoses, setCombinedDiagnoses] = useState<Array<Diagnosis>>([]);
125
+ const [rows, setRows] = useState<number>();
126
+ const [error, setError] = useState<Error>(null);
127
+ const { allowedFileExtensions } = useAllowedFileExtensions();
128
+ const isRetrospectiveDataEntryEnabled = useFeatureFlag('rde');
129
+
130
+ const visitNoteFormSchema = useMemo(
131
+ () => createSchema(t, isRetrospectiveDataEntryEnabled),
132
+ [t, isRetrospectiveDataEntryEnabled],
133
+ );
134
+
135
+ const customResolver = useCallback(
136
+ async (data, context, options) => {
137
+ const zodResult = await zodResolver(visitNoteFormSchema)(data, context, options);
138
+
139
+ if (isPrimaryDiagnosisRequired && selectedPrimaryDiagnoses.length === 0) {
140
+ return {
141
+ ...zodResult,
142
+ errors: {
143
+ ...zodResult.errors,
144
+ primaryDiagnosisSearch: {
145
+ type: 'custom',
146
+ message: t('primaryDiagnosisRequired', 'Choose at least one primary diagnosis'),
147
+ },
148
+ },
149
+ };
150
+ }
151
+
152
+ return zodResult;
153
+ },
154
+ [visitNoteFormSchema, isPrimaryDiagnosisRequired, selectedPrimaryDiagnoses, t],
155
+ );
156
+
157
+ const {
158
+ clearErrors,
159
+ control,
160
+ formState: { errors, dirtyFields, isSubmitting },
161
+ handleSubmit,
162
+ setValue,
163
+ watch,
164
+ } = useForm<VisitNotesFormData>({
165
+ mode: 'onSubmit',
166
+ resolver: customResolver,
167
+ defaultValues: {
168
+ primaryDiagnosisSearch: '',
169
+ noteDate: isEditing ? new Date(encounter.rawDatetime) : new Date(),
170
+ clinicalNote: isEditing
171
+ ? String(encounter?.obs?.find((obs) => obs.concept.uuid === encounterNoteTextConceptUuid)?.value || '')
172
+ : '',
173
+ },
174
+ });
175
+
176
+ useEffect(() => {
177
+ if (encounter?.diagnoses?.length) {
178
+ try {
179
+ const transformedDiagnoses = encounter.diagnoses.map((d) => ({
180
+ patient: patientUuid,
181
+ diagnosis: {
182
+ coded: d.diagnosis.coded?.uuid,
183
+ },
184
+ certainty: d.certainty,
185
+ rank: d.rank,
186
+ display: d.display,
187
+ }));
188
+
189
+ const primaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 1);
190
+ const secondaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 2);
191
+
192
+ setSelectedPrimaryDiagnoses(primaryDiagnoses);
193
+ setSelectedSecondaryDiagnoses(secondaryDiagnoses);
194
+ setCombinedDiagnoses([...primaryDiagnoses, ...secondaryDiagnoses]);
195
+ } catch (err) {
196
+ setError(new Error(t('errorTransformingDiagnoses', 'Error transforming diagnoses')));
197
+ createErrorHandler();
198
+ }
199
+ }
200
+ }, [encounter, patientUuid, t]);
201
+
202
+ const currentImages = watch('images');
203
+
204
+ const { mutateVisitNotes } = useVisitNotes(patientUuid);
205
+ const { mutate: globalMutate } = useSWRConfig();
206
+
207
+ const mutateAttachments = useCallback(
208
+ () => globalMutate((key) => typeof key === 'string' && key.startsWith(`${restBaseUrl}/attachment`)),
209
+ [globalMutate],
210
+ );
211
+
212
+ const locationUuid = session?.sessionLocation?.uuid;
213
+ const providerUuid = session?.currentProvider?.uuid;
214
+
215
+ const debouncedSearch = useMemo(
216
+ () =>
217
+ debounce((fieldQuery, fieldName) => {
218
+ clearErrors('primaryDiagnosisSearch');
219
+ if (fieldQuery) {
220
+ if (fieldName === 'primaryDiagnosisSearch') {
221
+ setIsLoadingPrimaryDiagnoses(true);
222
+ } else if (fieldName === 'secondaryDiagnosisSearch') {
223
+ setIsLoadingSecondaryDiagnoses(true);
224
+ }
225
+
226
+ fetchDiagnosisConceptsByName(fieldQuery, config.diagnosisConceptClass)
227
+ .then((matchingConceptDiagnoses: Array<Concept>) => {
228
+ if (fieldName === 'primaryDiagnosisSearch') {
229
+ setSearchPrimaryResults(matchingConceptDiagnoses);
230
+ setIsLoadingPrimaryDiagnoses(false);
231
+ } else if (fieldName === 'secondaryDiagnosisSearch') {
232
+ setSearchSecondaryResults(matchingConceptDiagnoses);
233
+ setIsLoadingSecondaryDiagnoses(false);
234
+ }
235
+ })
236
+ .catch((e) => {
237
+ setError(e);
238
+ createErrorHandler();
239
+ });
240
+ }
241
+ }, SEARCH_TIMEOUT_MS),
242
+ [config.diagnosisConceptClass, clearErrors],
243
+ );
244
+
245
+ const handleSearch = useCallback(
246
+ (fieldName) => {
247
+ const fieldQuery = watch(fieldName);
248
+ if (fieldQuery) {
249
+ debouncedSearch(fieldQuery, fieldName);
250
+ }
251
+ setIsSearching(false);
252
+ },
253
+ [debouncedSearch, watch],
254
+ );
255
+
256
+ const createDiagnosis = useCallback(
257
+ (concept: Concept) => ({
258
+ certainty: 'PROVISIONAL',
259
+ display: concept.display,
260
+ diagnosis: {
261
+ coded: concept.uuid,
262
+ },
263
+ patient: patientUuid,
264
+ rank: 2,
265
+ }),
266
+ [patientUuid],
267
+ );
268
+
269
+ const handleAddDiagnosis = useCallback(
270
+ (conceptDiagnosisToAdd: Concept, searchInputField: string) => {
271
+ const newDiagnosis = createDiagnosis(conceptDiagnosisToAdd);
272
+ if (searchInputField === 'primaryDiagnosisSearch') {
273
+ newDiagnosis.rank = 1;
274
+ setValue('primaryDiagnosisSearch', '');
275
+ setSearchPrimaryResults([]);
276
+ setSelectedPrimaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]);
277
+ clearErrors('primaryDiagnosisSearch');
278
+ } else if (searchInputField === 'secondaryDiagnosisSearch') {
279
+ setValue('secondaryDiagnosisSearch', '');
280
+ setSearchSecondaryResults([]);
281
+ setSelectedSecondaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]);
282
+ }
283
+ setCombinedDiagnoses((combinedDiagnoses) => [...combinedDiagnoses, newDiagnosis]);
284
+ },
285
+ [createDiagnosis, setValue, clearErrors],
286
+ );
287
+
288
+ const handleRemoveDiagnosis = useCallback(
289
+ (diagnosisToRemove: Diagnosis, searchInputField) => {
290
+ if (searchInputField === 'primaryInputSearch') {
291
+ setSelectedPrimaryDiagnoses(
292
+ selectedPrimaryDiagnoses.filter(
293
+ (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded,
294
+ ),
295
+ );
296
+ } else if (searchInputField === 'secondaryInputSearch') {
297
+ setSelectedSecondaryDiagnoses(
298
+ selectedSecondaryDiagnoses.filter(
299
+ (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded,
300
+ ),
301
+ );
302
+ }
303
+ setCombinedDiagnoses(
304
+ combinedDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded),
305
+ );
306
+ },
307
+ [combinedDiagnoses, selectedPrimaryDiagnoses, selectedSecondaryDiagnoses],
308
+ );
309
+
310
+ const isDiagnosisNotSelected = (diagnosis: Concept) => {
311
+ const isPrimaryDiagnosisSelected = selectedPrimaryDiagnoses.some(
312
+ (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded,
313
+ );
314
+ const isSecondaryDiagnosisSelected = selectedSecondaryDiagnoses.some(
315
+ (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded,
316
+ );
317
+
318
+ return !isPrimaryDiagnosisSelected && !isSecondaryDiagnosisSelected;
319
+ };
320
+
321
+ const showImageCaptureModal = useCallback(() => {
322
+ const close = showModal('capture-photo-modal', {
323
+ saveFile: (file: UploadedFile) => {
324
+ if (file.capturedFromWebcam && !file.fileName.includes('.')) {
325
+ file.fileName = `${file.fileName}.png`;
326
+ }
327
+
328
+ setValue('images', currentImages ? [...currentImages, file] : [file]);
329
+ close();
330
+ return Promise.resolve();
331
+ },
332
+ closeModal: () => {
333
+ close();
334
+ },
335
+ allowedExtensions:
336
+ allowedFileExtensions && Array.isArray(allowedFileExtensions)
337
+ ? allowedFileExtensions.filter((ext) => !/pdf/i.test(ext))
338
+ : [],
339
+ collectDescription: true,
340
+ multipleFiles: true,
341
+ });
342
+ }, [allowedFileExtensions, currentImages, setValue]);
343
+
344
+ const handleRemoveImage = (index: number) => {
345
+ const updatedImages = [...currentImages];
346
+ updatedImages.splice(index, 1);
347
+ setValue('images', updatedImages);
348
+
349
+ showSnackbar({
350
+ title: t('imageRemoved', 'Image removed'),
351
+ kind: 'success',
352
+ isLowContrast: true,
353
+ });
354
+ };
355
+
356
+ const onSubmit = useCallback(
357
+ (data: VisitNotesFormData) => {
358
+ const { noteDate, clinicalNote, images } = data;
359
+
360
+ if (isPrimaryDiagnosisRequired && !selectedPrimaryDiagnoses.length) {
361
+ return;
362
+ }
363
+
364
+ let finalNoteDate = dayjs(noteDate);
365
+ const now = new Date();
366
+
367
+ // When RDE is off, the datepicker is hidden and noteDate defaults to new Date().
368
+ // This always falls within the 30-minute window, so encounterDatetime is intentionally
369
+ // omitted from the payload -> letting the server attach the correct timestamp.
370
+ if (finalNoteDate.diff(now, 'minute') <= 30) {
371
+ finalNoteDate = null;
372
+ }
373
+
374
+ const existingClinicalNoteObs = encounter?.obs?.find((obs) => obs.concept.uuid === encounterNoteTextConceptUuid);
375
+
376
+ const visitNotePayload: VisitNotePayload = {
377
+ encounterDatetime: finalNoteDate?.format(),
378
+ form: formConceptUuid,
379
+ patient: patientUuid,
380
+ location: locationUuid,
381
+ encounterProviders: [
382
+ {
383
+ encounterRole: clinicianEncounterRole,
384
+ provider: providerUuid,
385
+ },
386
+ ],
387
+ encounterType: encounterTypeUuid,
388
+ obs: clinicalNote
389
+ ? [
390
+ {
391
+ concept: { uuid: encounterNoteTextConceptUuid, display: '' },
392
+ value: clinicalNote,
393
+ ...(existingClinicalNoteObs && { uuid: existingClinicalNoteObs.uuid }),
394
+ },
395
+ ]
396
+ : [],
397
+ };
398
+
399
+ const abortController = new AbortController();
400
+
401
+ const savePromise = isEditing
402
+ ? updateVisitNote(abortController, encounter.id, visitNotePayload)
403
+ : saveVisitNote(abortController, visitNotePayload);
404
+
405
+ return savePromise
406
+ .then((response) => {
407
+ if (response.status === 201 || response.status === 200) {
408
+ const encounterUuid = encounter?.id || response.data.uuid;
409
+
410
+ // If editing, first delete existing diagnoses
411
+ if (isEditing && encounter?.diagnoses?.length) {
412
+ return Promise.all(
413
+ encounter.diagnoses.map((diagnosis) => deletePatientDiagnosis(abortController, diagnosis.uuid)),
414
+ ).then(() => encounterUuid);
415
+ }
416
+
417
+ return encounterUuid;
418
+ }
419
+ })
420
+ .then((encounterUuid) => {
421
+ return Promise.all(
422
+ combinedDiagnoses.map((diagnosis) => {
423
+ const diagnosesPayload: DiagnosisPayload = {
424
+ encounter: encounterUuid,
425
+ patient: patientUuid,
426
+ condition: null,
427
+ diagnosis: {
428
+ coded: diagnosis.diagnosis.coded,
429
+ },
430
+ certainty: diagnosis.certainty,
431
+ rank: diagnosis.rank,
432
+ };
433
+ return savePatientDiagnosis(abortController, diagnosesPayload);
434
+ }),
435
+ );
436
+ })
437
+ .then(() => {
438
+ if (images?.length) {
439
+ return Promise.all(
440
+ images.map((image) => {
441
+ const imageToUpload: UploadedFile = {
442
+ base64Content: image.base64Content,
443
+ file: image.file,
444
+ fileName: image.fileName,
445
+ fileType: image.fileType,
446
+ fileDescription: image.fileDescription || '',
447
+ };
448
+ return createAttachment(patientUuid, imageToUpload);
449
+ }),
450
+ );
451
+ } else {
452
+ return Promise.resolve([]);
453
+ }
454
+ })
455
+ .then(() => {
456
+ // Invalidate encounter and notes data since we created a new encounter with notes
457
+ // Also invalidate visit history table since the visit now has new encounters
458
+ invalidateVisitAndEncounterData(globalMutate, patientUuid);
459
+ mutateVisitNotes();
460
+
461
+ if (images?.length) {
462
+ mutateAttachments();
463
+ }
464
+
465
+ closeWorkspace({ discardUnsavedChanges: true });
466
+
467
+ showSnackbar({
468
+ isLowContrast: true,
469
+ subtitle: t('visitNoteNowVisible', 'It is now visible on the Visits page'),
470
+ kind: 'success',
471
+ title: t('visitNoteSaved', 'Visit note saved'),
472
+ });
473
+ })
474
+ .catch((err) => {
475
+ createErrorHandler();
476
+
477
+ showSnackbar({
478
+ title: t('visitNoteSaveError', 'Error saving visit note'),
479
+ kind: 'error',
480
+ isLowContrast: false,
481
+ subtitle: err?.responseBody?.error?.message ?? err.message,
482
+ });
483
+ });
484
+ },
485
+ [
486
+ clinicianEncounterRole,
487
+ closeWorkspace,
488
+ combinedDiagnoses,
489
+ encounter?.diagnoses,
490
+ encounter?.id,
491
+ encounter?.obs,
492
+ encounterNoteTextConceptUuid,
493
+ encounterTypeUuid,
494
+ formConceptUuid,
495
+ globalMutate,
496
+ isEditing,
497
+ isPrimaryDiagnosisRequired,
498
+ locationUuid,
499
+ mutateAttachments,
500
+ mutateVisitNotes,
501
+ patientUuid,
502
+ providerUuid,
503
+ selectedPrimaryDiagnoses.length,
504
+ t,
505
+ ],
506
+ );
507
+
508
+ const onError = (errors) => console.error(errors);
509
+
510
+ const hasUserUnsavedChanges = Object.keys(dirtyFields).length > 0;
511
+
512
+ return (
513
+ <Workspace2 title={t('visitNoteWorkspaceTitle', 'Visit note')} hasUnsavedChanges={hasUserUnsavedChanges}>
514
+ <Form className={styles.form} onSubmit={handleSubmit(onSubmit, onError)}>
515
+ <ExtensionSlot name="visit-context-header-slot" state={{ patientUuid }} />
516
+
517
+ {isTablet && (
518
+ <Row className={styles.headerGridRow}>
519
+ <ExtensionSlot name="visit-form-header-slot" className={styles.dataGridRow} state={memoizedState} />
520
+ </Row>
521
+ )}
522
+
523
+ <div className={styles.formContainer}>
524
+ <Stack gap={2}>
525
+ {isTablet ? <h2 className={styles.heading}>{t('addVisitNote', 'Add a visit note')}</h2> : null}
526
+ {isRetrospectiveDataEntryEnabled && (
527
+ <Row className={styles.row}>
528
+ <Column sm={1}>
529
+ <span className={styles.columnLabel}>{t('date', 'Date')}</span>
530
+ </Column>
531
+ <Column sm={3}>
532
+ <Controller
533
+ name="noteDate"
534
+ control={control}
535
+ render={({ field, fieldState }) => (
536
+ <ResponsiveWrapper>
537
+ <OpenmrsDatePicker
538
+ {...field}
539
+ data-testid="visitDateTimePicker"
540
+ id="visitDateTimePicker"
541
+ invalid={Boolean(fieldState?.error?.message)}
542
+ invalidText={fieldState?.error?.message}
543
+ isDisabled={isEditing}
544
+ labelText={t('visitDate', 'Visit date')}
545
+ maxDate={new Date()}
546
+ />
547
+ </ResponsiveWrapper>
548
+ )}
549
+ />
550
+ </Column>
551
+ </Row>
552
+ )}
553
+ <div className={styles.diagnosesText}>
554
+ {selectedPrimaryDiagnoses.map((diagnosis) => (
555
+ <Tag
556
+ className={styles.tag}
557
+ filter
558
+ key={diagnosis.diagnosis.coded}
559
+ onClose={() => handleRemoveDiagnosis(diagnosis, 'primaryInputSearch')}
560
+ type="red"
561
+ >
562
+ {diagnosis.display}
563
+ </Tag>
564
+ ))}
565
+ {selectedSecondaryDiagnoses.map((diagnosis) => (
566
+ <Tag
567
+ className={styles.tag}
568
+ filter
569
+ key={diagnosis.diagnosis.coded}
570
+ onClose={() => handleRemoveDiagnosis(diagnosis, 'secondaryInputSearch')}
571
+ type="blue"
572
+ >
573
+ {diagnosis.display}
574
+ </Tag>
575
+ ))}
576
+ {!selectedPrimaryDiagnoses.length && !selectedSecondaryDiagnoses.length && (
577
+ <span>{t('emptyDiagnosisText', 'No diagnosis selected — Enter a diagnosis below')}</span>
578
+ )}
579
+ </div>
580
+ <Row className={styles.row}>
581
+ <Column sm={1}>
582
+ <span className={styles.columnLabel}>{t('primaryDiagnosis', 'Primary diagnosis')}</span>
583
+ </Column>
584
+ <Column sm={3}>
585
+ <FormGroup legendText={t('searchForPrimaryDiagnosis', 'Search for a primary diagnosis')}>
586
+ <DiagnosisSearch
587
+ name="primaryDiagnosisSearch"
588
+ control={control}
589
+ labelText={t('enterPrimaryDiagnoses', 'Enter Primary diagnoses')}
590
+ placeholder={t('primaryDiagnosisInputPlaceholder', 'Choose a primary diagnosis')}
591
+ handleSearch={handleSearch}
592
+ error={errors?.primaryDiagnosisSearch}
593
+ setIsSearching={setIsSearching}
594
+ />
595
+ {error ? (
596
+ <InlineNotification
597
+ className={styles.errorNotification}
598
+ lowContrast
599
+ title={t('error', 'Error')}
600
+ subtitle={t('errorFetchingConcepts', 'There was a problem fetching concepts') + '.'}
601
+ onClose={() => setError(null)}
602
+ />
603
+ ) : null}
604
+ <DiagnosesDisplay
605
+ fieldName={'primaryDiagnosisSearch'}
606
+ isDiagnosisNotSelected={isDiagnosisNotSelected}
607
+ isLoading={isLoadingPrimaryDiagnoses}
608
+ isSearching={isSearching}
609
+ onAddDiagnosis={handleAddDiagnosis}
610
+ searchResults={searchPrimaryResults}
611
+ t={t}
612
+ value={watch('primaryDiagnosisSearch')}
613
+ />
614
+ </FormGroup>
615
+ </Column>
616
+ </Row>
617
+ <Row className={styles.row}>
618
+ <Column sm={1}>
619
+ <span className={styles.columnLabel}>{t('secondaryDiagnosis', 'Secondary diagnosis')}</span>
620
+ </Column>
621
+ <Column sm={3}>
622
+ <FormGroup legendText={t('searchForSecondaryDiagnosis', 'Search for a secondary diagnosis')}>
623
+ <DiagnosisSearch
624
+ name="secondaryDiagnosisSearch"
625
+ control={control}
626
+ labelText={t('enterSecondaryDiagnoses', 'Enter Secondary diagnoses')}
627
+ placeholder={t('secondaryDiagnosisInputPlaceholder', 'Choose a secondary diagnosis')}
628
+ handleSearch={handleSearch}
629
+ setIsSearching={setIsSearching}
630
+ />
631
+ {error ? (
632
+ <InlineNotification
633
+ className={styles.errorNotification}
634
+ lowContrast
635
+ title={t('error', 'Error')}
636
+ subtitle={t('errorFetchingConcepts', 'There was a problem fetching concepts') + '.'}
637
+ onClose={() => setError(null)}
638
+ />
639
+ ) : null}
640
+ <DiagnosesDisplay
641
+ fieldName={'secondaryDiagnosisSearch'}
642
+ isDiagnosisNotSelected={isDiagnosisNotSelected}
643
+ isLoading={isLoadingSecondaryDiagnoses}
644
+ isSearching={isSearching}
645
+ onAddDiagnosis={handleAddDiagnosis}
646
+ searchResults={searchSecondaryResults}
647
+ t={t}
648
+ value={watch('secondaryDiagnosisSearch')}
649
+ />
650
+ </FormGroup>
651
+ </Column>
652
+ </Row>
653
+ <Row className={styles.row}>
654
+ <Column sm={1}>
655
+ <span className={styles.columnLabel}>{t('note', 'Note')}</span>
656
+ </Column>
657
+ <Column sm={3}>
658
+ <Controller
659
+ name="clinicalNote"
660
+ control={control}
661
+ render={({ field: { onChange, onBlur, value } }) => (
662
+ <ResponsiveWrapper>
663
+ <TextArea
664
+ id="additionalNote"
665
+ rows={rows}
666
+ labelText={t('clinicalNoteLabel', 'Write your notes')}
667
+ placeholder={t('clinicalNotePlaceholder', 'Write any notes here')}
668
+ value={value}
669
+ onBlur={onBlur}
670
+ onChange={(event) => {
671
+ onChange(event);
672
+ const textareaLineHeight = 24; // This is the default line height for Carbon's TextArea component
673
+ const newRows = Math.ceil(event.target.scrollHeight / textareaLineHeight);
674
+ setRows(newRows);
675
+ }}
676
+ />
677
+ </ResponsiveWrapper>
678
+ )}
679
+ />
680
+ </Column>
681
+ </Row>
682
+ <Row className={styles.row}>
683
+ <Column sm={1}>
684
+ <span className={styles.columnLabel}>{t('image', 'Image')}</span>
685
+ </Column>
686
+ <Column sm={3}>
687
+ <FormGroup legendText="">
688
+ <p className={styles.imgUploadHelperText}>
689
+ {t('imageUploadHelperText', "Upload images or use this device's camera to capture images")}
690
+ </p>
691
+ <Button
692
+ className={styles.uploadButton}
693
+ kind={isTablet ? 'ghost' : 'tertiary'}
694
+ onClick={showImageCaptureModal}
695
+ renderIcon={(props) => <Add size={16} {...props} />}
696
+ >
697
+ {t('addImage', 'Add image')}
698
+ </Button>
699
+ <div className={styles.imgThumbnailGrid}>
700
+ {currentImages?.map((image, index) => (
701
+ <div key={index} className={styles.imgThumbnailItem}>
702
+ <div className={styles.imgThumbnailContainer}>
703
+ <img
704
+ className={styles.imgThumbnail}
705
+ src={image.base64Content}
706
+ alt={image.fileDescription ?? image.fileName}
707
+ />
708
+ </div>
709
+ <Button kind="ghost" className={styles.removeButton} onClick={() => handleRemoveImage(index)}>
710
+ <CloseFilled size={16} className={styles.closeIcon} />
711
+ </Button>
712
+ </div>
713
+ ))}
714
+ </div>
715
+ </FormGroup>
716
+ </Column>
717
+ </Row>
718
+ </Stack>
719
+ </div>
720
+ <ButtonSet className={classnames({ [styles.tablet]: isTablet, [styles.desktop]: !isTablet })}>
721
+ <Button className={styles.button} kind="secondary" onClick={() => closeWorkspace()}>
722
+ {t('discard', 'Discard')}
723
+ </Button>
724
+ <Button
725
+ className={styles.button}
726
+ kind="primary"
727
+ disabled={!hasUserUnsavedChanges || isSubmitting}
728
+ type="submit"
729
+ >
730
+ {isSubmitting ? (
731
+ <InlineLoading className={styles.spinner} description={t('saving', 'Saving') + '...'} />
732
+ ) : (
733
+ <span>{t('saveAndClose', 'Save and close')}</span>
734
+ )}
735
+ </Button>
736
+ </ButtonSet>
737
+ </Form>
738
+ </Workspace2>
739
+ );
740
+ };
741
+
742
+ function DiagnosisSearch({
743
+ name,
744
+ control,
745
+ labelText,
746
+ placeholder,
747
+ handleSearch,
748
+ error,
749
+ setIsSearching,
750
+ }: DiagnosisSearchProps) {
751
+ const isTablet = useLayoutType() === 'tablet';
752
+ const inputRef = useRef(null);
753
+
754
+ const searchInputFocus = () => {
755
+ inputRef.current.focus();
756
+ };
757
+
758
+ useEffect(() => {
759
+ if (error) {
760
+ searchInputFocus();
761
+ }
762
+ }, [error]);
763
+
764
+ return (
765
+ <Controller
766
+ name={name}
767
+ control={control}
768
+ render={({ field: { value, onChange, onBlur }, fieldState }) => (
769
+ <>
770
+ <ResponsiveWrapper>
771
+ <Search
772
+ ref={inputRef}
773
+ size={isTablet ? 'lg' : 'md'}
774
+ id={name}
775
+ labelText={labelText}
776
+ className={error && styles.diagnoserrorOutline}
777
+ placeholder={placeholder}
778
+ renderIcon={error && ((props) => <WarningFilled fill="red" {...props} />)}
779
+ onChange={(e) => {
780
+ setIsSearching(true);
781
+ onChange(e);
782
+ handleSearch(name);
783
+ }}
784
+ value={value instanceof Date ? value.toISOString() : value}
785
+ onBlur={onBlur}
786
+ />
787
+ </ResponsiveWrapper>
788
+ {fieldState?.error?.message && <p className={styles.errorMessage}>{fieldState?.error?.message}</p>}
789
+ </>
790
+ )}
791
+ />
792
+ );
793
+ }
794
+
795
+ function DiagnosesDisplay({
796
+ fieldName,
797
+ isDiagnosisNotSelected,
798
+ isLoading,
799
+ isSearching,
800
+ onAddDiagnosis,
801
+ searchResults,
802
+ t,
803
+ value,
804
+ }: DiagnosesDisplayProps) {
805
+ if (!value) {
806
+ return null;
807
+ }
808
+
809
+ if (isSearching || isLoading) {
810
+ return <Loader />;
811
+ }
812
+
813
+ if (!isSearching && searchResults?.length > 0) {
814
+ return (
815
+ <ul className={styles.diagnosisList}>
816
+ {searchResults.filter(isDiagnosisNotSelected).map((diagnosis) => (
817
+ <li
818
+ className={styles.diagnosis}
819
+ key={diagnosis.uuid}
820
+ onClick={() => onAddDiagnosis(diagnosis, fieldName)}
821
+ role="menuitem"
822
+ >
823
+ {diagnosis.display}
824
+ </li>
825
+ ))}
826
+ </ul>
827
+ );
828
+ }
829
+
830
+ if (searchResults?.length === 0) {
831
+ return (
832
+ <ResponsiveWrapper>
833
+ <Tile className={styles.emptyResults}>
834
+ <span>
835
+ {t('noMatchingDiagnoses', 'No diagnoses found matching')} <strong>"{value}"</strong>
836
+ </span>
837
+ </Tile>
838
+ </ResponsiveWrapper>
839
+ );
840
+ }
841
+ }
842
+
843
+ function Loader() {
844
+ return (
845
+ <>
846
+ {Array.from({ length: 5 }).map((_, index) => (
847
+ <SkeletonText key={index} className={styles.skeleton} />
848
+ ))}
849
+ </>
850
+ );
851
+ }
852
+
853
+ export default VisitNotesForm;