@palladium-ethiopia/esm-clinical-workflow-app 5.4.2-pre.20 → 5.4.2-pre.26

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 (84) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/dist/152.js +1 -1
  3. package/dist/152.js.map +1 -1
  4. package/dist/164.js +1 -0
  5. package/dist/164.js.map +1 -0
  6. package/dist/208.js +1 -1
  7. package/dist/208.js.map +1 -1
  8. package/dist/209.js +1 -1
  9. package/dist/209.js.map +1 -1
  10. package/dist/363.js +1 -1
  11. package/dist/363.js.map +1 -1
  12. package/dist/534.js +1 -0
  13. package/dist/534.js.map +1 -0
  14. package/dist/677.js +1 -1
  15. package/dist/677.js.map +1 -1
  16. package/dist/689.js +1 -1
  17. package/dist/689.js.map +1 -1
  18. package/dist/712.js +1 -1
  19. package/dist/712.js.map +1 -1
  20. package/dist/771.js +1 -1
  21. package/dist/771.js.map +1 -1
  22. package/dist/825.js +1 -0
  23. package/dist/825.js.map +1 -0
  24. package/dist/914.js +37 -0
  25. package/dist/914.js.map +1 -0
  26. package/dist/926.js +17 -0
  27. package/dist/926.js.map +1 -0
  28. package/dist/ethiopia-esm-clinical-workflow-app.js +5 -5
  29. package/dist/ethiopia-esm-clinical-workflow-app.js.buildmanifest.json +144 -144
  30. package/dist/ethiopia-esm-clinical-workflow-app.js.map +1 -1
  31. package/dist/main.js +34 -8
  32. package/dist/main.js.map +1 -1
  33. package/dist/routes.json +1 -1
  34. package/package.json +1 -1
  35. package/src/config-schema.ts +98 -0
  36. package/src/index.ts +32 -1
  37. package/src/patient-chart/clinical-views/hooks/useEncountersByVisit.ts +13 -0
  38. package/src/patient-chart/constants.ts +11 -0
  39. package/src/patient-chart/visit/visit-history-table/diagnosis-tags.component.tsx +43 -0
  40. package/src/patient-chart/visit/visit-history-table/diagnosis-tags.module.scss +57 -0
  41. package/src/patient-chart/visit/visit-history-table/visit-actions-cell.component.tsx +20 -0
  42. package/src/patient-chart/visit/visit-history-table/visit-actions-cell.scss +4 -0
  43. package/src/patient-chart/visit/visit-history-table/visit-date-cell.component.tsx +19 -0
  44. package/src/patient-chart/visit/visit-history-table/visit-diagnoses-cell-with-certainty.component.tsx +31 -0
  45. package/src/patient-chart/visit/visit-history-table/visit-diagnoses-cell-with-certainty.module.scss +16 -0
  46. package/src/patient-chart/visit/visit-history-table/visit-history-table.component.tsx +144 -0
  47. package/src/patient-chart/visit/visit-history-table/visit-history-table.scss +25 -0
  48. package/src/patient-chart/visit/visit-history-table/visit-type-cell.component.tsx +15 -0
  49. package/src/patient-chart/visit/visits-widget/encounter-observations/encounter-observations.component.tsx +67 -0
  50. package/src/patient-chart/visit/visits-widget/encounter-observations/index.ts +3 -0
  51. package/src/patient-chart/visit/visits-widget/encounter-observations/styles.scss +22 -0
  52. package/src/patient-chart/visit/visits-widget/past-visits-components/encounters-table/all-encounters-table.component.tsx +44 -0
  53. package/src/patient-chart/visit/visits-widget/past-visits-components/encounters-table/encounters-table.component.tsx +388 -0
  54. package/src/patient-chart/visit/visits-widget/past-visits-components/encounters-table/encounters-table.resource.ts +97 -0
  55. package/src/patient-chart/visit/visits-widget/past-visits-components/encounters-table/encounters-table.scss +113 -0
  56. package/src/patient-chart/visit/visits-widget/past-visits-components/encounters-table/visit-encounters-table.component.tsx +42 -0
  57. package/src/patient-chart/visit/visits-widget/past-visits-components/medications-summary.component.tsx +157 -0
  58. package/src/patient-chart/visit/visits-widget/past-visits-components/notes-summary.component.tsx +34 -0
  59. package/src/patient-chart/visit/visits-widget/past-visits-components/tests-summary.component.tsx +16 -0
  60. package/src/patient-chart/visit/visits-widget/past-visits-components/visit-actions-cell.scss +4 -0
  61. package/src/patient-chart/visit/visits-widget/past-visits-components/visit-summary.component.tsx +176 -0
  62. package/src/patient-chart/visit/visits-widget/past-visits-components/visit-summary.scss +72 -0
  63. package/src/patient-chart/visit/visits-widget/single-visit-details/visit-timeline/visit-timeline.component.tsx +94 -0
  64. package/src/patient-chart/visit/visits-widget/single-visit-details/visit-timeline/visit-timeline.scss +60 -0
  65. package/src/patient-chart/visit/visits-widget/visit-detail-overview.component.tsx +50 -0
  66. package/src/patient-chart/visit/visits-widget/visit-detail-overview.scss +262 -0
  67. package/src/patient-chart/visit/visits-widget/visit.resource.tsx +144 -0
  68. package/src/patient-notes/types/index.ts +194 -0
  69. package/src/patient-notes/visit-note-action-button.extension.tsx +28 -0
  70. package/src/patient-notes/visit-note-config-schema.ts +38 -0
  71. package/src/patient-notes/visit-notes-form-shadow.workspace.tsx +963 -0
  72. package/src/patient-notes/visit-notes-form.scss +453 -0
  73. package/src/patient-notes/visit-notes.resource.ts +113 -0
  74. package/src/routes.json +23 -0
  75. package/translations/am.json +168 -0
  76. package/translations/en.json +168 -0
  77. package/dist/410.js +0 -1
  78. package/dist/410.js.map +0 -1
  79. package/dist/484.js +0 -11
  80. package/dist/484.js.map +0 -1
  81. package/dist/540.js +0 -1
  82. package/dist/540.js.map +0 -1
  83. package/dist/545.js +0 -43
  84. package/dist/545.js.map +0 -1
@@ -0,0 +1,963 @@
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/debounce';
5
+ import { useTranslation } from 'react-i18next';
6
+ import type { TFunction } from 'i18next';
7
+ import { useSWRConfig } from 'swr';
8
+ import { z } from 'zod';
9
+ import { zodResolver } from '@hookform/resolvers/zod';
10
+ import { Controller, useForm, type Control } from 'react-hook-form';
11
+ import {
12
+ Button,
13
+ ButtonSet,
14
+ Column,
15
+ Dropdown,
16
+ Form,
17
+ FormGroup,
18
+ InlineLoading,
19
+ InlineNotification,
20
+ Row,
21
+ Search,
22
+ SkeletonText,
23
+ Stack,
24
+ Tag,
25
+ TextArea,
26
+ Tile,
27
+ } from '@carbon/react';
28
+ import { Add, CloseFilled, WarningFilled, CheckmarkFilled, Help } from '@carbon/react/icons';
29
+ import {
30
+ createAttachment,
31
+ createErrorHandler,
32
+ ExtensionSlot,
33
+ OpenmrsDatePicker,
34
+ ResponsiveWrapper,
35
+ restBaseUrl,
36
+ showModal,
37
+ showSnackbar,
38
+ useConfig,
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 { VisitNoteConfig } 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, certainty: string, 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) => {
88
+ return z.object({
89
+ noteDate: z.date(),
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
+ export interface VisitNotesFormProps {
98
+ encounter?: Encounter;
99
+ formContext: 'creating' | 'editing';
100
+ }
101
+
102
+ const VisitNotesForm: React.FC<PatientWorkspace2DefinitionProps<VisitNotesFormProps, {}>> = ({
103
+ closeWorkspace,
104
+ workspaceProps: { formContext, encounter },
105
+ groupProps: { patientUuid },
106
+ }) => {
107
+ const isEditing: boolean = Boolean(formContext === 'editing' && encounter?.id);
108
+ const searchTimeoutInMs = 500;
109
+ const { t } = useTranslation();
110
+ const isTablet = useLayoutType() === 'tablet';
111
+ const session = useSession();
112
+ const config = useConfig<VisitNoteConfig>();
113
+ const { isPrimaryDiagnosisRequired, visitNoteConfig } = config;
114
+ const memoizedState = useMemo(() => ({ patientUuid }), [patientUuid]);
115
+ const { clinicianEncounterRole, encounterNoteTextConceptUuid, encounterTypeUuid, formConceptUuid } = visitNoteConfig;
116
+
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 [isSubmittingLocal, setIsSubmittingLocal] = useState(false);
129
+
130
+ const visitNoteFormSchema = useMemo(() => createSchema(t), [t]);
131
+
132
+ const customResolver = useCallback(
133
+ async (data, context, options) => {
134
+ const zodResult = await zodResolver(visitNoteFormSchema)(data, context, options);
135
+
136
+ if (isPrimaryDiagnosisRequired && selectedPrimaryDiagnoses.length === 0) {
137
+ return {
138
+ ...zodResult,
139
+ errors: {
140
+ ...zodResult.errors,
141
+ primaryDiagnosisSearch: {
142
+ type: 'custom',
143
+ message: t('primaryDiagnosisRequired', 'Choose at least one primary diagnosis'),
144
+ },
145
+ },
146
+ };
147
+ }
148
+
149
+ return zodResult;
150
+ },
151
+ [visitNoteFormSchema, isPrimaryDiagnosisRequired, selectedPrimaryDiagnoses, t],
152
+ );
153
+
154
+ const {
155
+ clearErrors,
156
+ control,
157
+ formState: { errors, isDirty, isSubmitting },
158
+ handleSubmit,
159
+ setValue,
160
+ watch,
161
+ } = useForm<VisitNotesFormData>({
162
+ mode: 'onSubmit',
163
+ resolver: customResolver,
164
+ defaultValues: {
165
+ primaryDiagnosisSearch: '',
166
+ noteDate: isEditing ? new Date(encounter.datetime) : new Date(),
167
+ clinicalNote: isEditing
168
+ ? String(encounter?.obs?.find((obs) => obs.concept.uuid === encounterNoteTextConceptUuid)?.value || '')
169
+ : '',
170
+ },
171
+ });
172
+
173
+ useEffect(() => {
174
+ if (encounter?.diagnoses?.length) {
175
+ try {
176
+ const transformedDiagnoses = encounter.diagnoses.map((d) => ({
177
+ patient: patientUuid,
178
+ diagnosis: {
179
+ coded: d.diagnosis.coded?.uuid,
180
+ },
181
+ certainty: d.certainty,
182
+ rank: d.rank,
183
+ display: d.display,
184
+ }));
185
+
186
+ const primaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 1);
187
+ const secondaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 2);
188
+
189
+ setSelectedPrimaryDiagnoses(primaryDiagnoses);
190
+ setSelectedSecondaryDiagnoses(secondaryDiagnoses);
191
+ setCombinedDiagnoses([...primaryDiagnoses, ...secondaryDiagnoses]);
192
+ } catch (err) {
193
+ setError(new Error(t('errorTransformingDiagnoses', 'Error transforming diagnoses')));
194
+ createErrorHandler();
195
+ }
196
+ }
197
+ }, [encounter, patientUuid, t]);
198
+
199
+ const currentImages = watch('images');
200
+
201
+ const { mutateVisitNotes } = useVisitNotes(patientUuid);
202
+ const { mutate: globalMutate } = useSWRConfig();
203
+
204
+ const mutateAttachments = useCallback(
205
+ () => globalMutate((key) => typeof key === 'string' && key.startsWith(`${restBaseUrl}/attachment`)),
206
+ [globalMutate],
207
+ );
208
+
209
+ const locationUuid = session?.sessionLocation?.uuid;
210
+ const providerUuid = session?.currentProvider?.uuid;
211
+
212
+ const debouncedSearch = useMemo(
213
+ () =>
214
+ debounce((fieldQuery, fieldName) => {
215
+ clearErrors('primaryDiagnosisSearch');
216
+ if (fieldQuery) {
217
+ if (fieldName === 'primaryDiagnosisSearch') {
218
+ setIsLoadingPrimaryDiagnoses(true);
219
+ } else if (fieldName === 'secondaryDiagnosisSearch') {
220
+ setIsLoadingSecondaryDiagnoses(true);
221
+ }
222
+
223
+ fetchDiagnosisConceptsByName(fieldQuery, config.diagnosisConceptClass)
224
+ .then((matchingConceptDiagnoses: Array<Concept>) => {
225
+ if (fieldName === 'primaryDiagnosisSearch') {
226
+ setSearchPrimaryResults(matchingConceptDiagnoses);
227
+ setIsLoadingPrimaryDiagnoses(false);
228
+ } else if (fieldName === 'secondaryDiagnosisSearch') {
229
+ setSearchSecondaryResults(matchingConceptDiagnoses);
230
+ setIsLoadingSecondaryDiagnoses(false);
231
+ }
232
+ })
233
+ .catch((e) => {
234
+ setError(e);
235
+ createErrorHandler();
236
+ });
237
+ }
238
+ }, searchTimeoutInMs),
239
+ [config.diagnosisConceptClass, clearErrors],
240
+ );
241
+
242
+ const handleSearch = useCallback(
243
+ (fieldName) => {
244
+ const fieldQuery = watch(fieldName);
245
+ if (fieldQuery) {
246
+ debouncedSearch(fieldQuery, fieldName);
247
+ }
248
+ setIsSearching(false);
249
+ },
250
+ [debouncedSearch, watch],
251
+ );
252
+
253
+ const createDiagnosis = useCallback(
254
+ (concept: Concept, certainty: string = 'PROVISIONAL') => ({
255
+ certainty,
256
+ display: concept.display,
257
+ diagnosis: {
258
+ coded: concept.uuid,
259
+ },
260
+ patient: patientUuid,
261
+ rank: 2,
262
+ }),
263
+ [patientUuid],
264
+ );
265
+
266
+ const handleAddDiagnosis = useCallback(
267
+ (conceptDiagnosisToAdd: Concept, certainty: string = 'PROVISIONAL', searchInputField: string) => {
268
+ const newDiagnosis = createDiagnosis(conceptDiagnosisToAdd, certainty);
269
+ if (searchInputField === 'primaryDiagnosisSearch') {
270
+ newDiagnosis.rank = 1;
271
+ setValue('primaryDiagnosisSearch', '');
272
+ setSearchPrimaryResults([]);
273
+ setSelectedPrimaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]);
274
+ clearErrors('primaryDiagnosisSearch');
275
+ } else if (searchInputField === 'secondaryDiagnosisSearch') {
276
+ setValue('secondaryDiagnosisSearch', '');
277
+ setSearchSecondaryResults([]);
278
+ setSelectedSecondaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]);
279
+ }
280
+ setCombinedDiagnoses((combinedDiagnoses) => [...combinedDiagnoses, newDiagnosis]);
281
+ },
282
+ [createDiagnosis, setValue, clearErrors],
283
+ );
284
+
285
+ const handleRemoveDiagnosis = useCallback(
286
+ (diagnosisToRemove: Diagnosis, searchInputField) => {
287
+ if (searchInputField === 'primaryInputSearch') {
288
+ setSelectedPrimaryDiagnoses(
289
+ selectedPrimaryDiagnoses.filter(
290
+ (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded,
291
+ ),
292
+ );
293
+ } else if (searchInputField === 'secondaryInputSearch') {
294
+ setSelectedSecondaryDiagnoses(
295
+ selectedSecondaryDiagnoses.filter(
296
+ (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded,
297
+ ),
298
+ );
299
+ }
300
+ setCombinedDiagnoses(
301
+ combinedDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded),
302
+ );
303
+ },
304
+ [combinedDiagnoses, selectedPrimaryDiagnoses, selectedSecondaryDiagnoses],
305
+ );
306
+
307
+ const isDiagnosisNotSelected = (diagnosis: Concept) => {
308
+ const isPrimaryDiagnosisSelected = selectedPrimaryDiagnoses.some(
309
+ (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded,
310
+ );
311
+ const isSecondaryDiagnosisSelected = selectedSecondaryDiagnoses.some(
312
+ (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded,
313
+ );
314
+
315
+ return !isPrimaryDiagnosisSelected && !isSecondaryDiagnosisSelected;
316
+ };
317
+
318
+ const showImageCaptureModal = useCallback(() => {
319
+ const close = showModal('capture-photo-modal', {
320
+ saveFile: (file: UploadedFile) => {
321
+ if (file.capturedFromWebcam && !file.fileName.includes('.')) {
322
+ file.fileName = `${file.fileName}.png`;
323
+ }
324
+
325
+ setValue('images', currentImages ? [...currentImages, file] : [file]);
326
+ close();
327
+ return Promise.resolve();
328
+ },
329
+ closeModal: () => {
330
+ close();
331
+ },
332
+ allowedExtensions:
333
+ allowedFileExtensions && Array.isArray(allowedFileExtensions)
334
+ ? allowedFileExtensions.filter((ext) => !/pdf/i.test(ext))
335
+ : [],
336
+ collectDescription: true,
337
+ multipleFiles: true,
338
+ });
339
+ }, [allowedFileExtensions, currentImages, setValue]);
340
+
341
+ const handleRemoveImage = (index: number) => {
342
+ const updatedImages = [...currentImages];
343
+ updatedImages.splice(index, 1);
344
+ setValue('images', updatedImages);
345
+
346
+ showSnackbar({
347
+ title: t('imageRemoved', 'Image removed'),
348
+ kind: 'success',
349
+ isLowContrast: true,
350
+ });
351
+ };
352
+
353
+ const onSubmit = useCallback(
354
+ async (data: VisitNotesFormData) => {
355
+ if (isSubmittingLocal) {
356
+ return;
357
+ }
358
+ setIsSubmittingLocal(true);
359
+
360
+ try {
361
+ const { noteDate, clinicalNote, images } = data;
362
+
363
+ if (isPrimaryDiagnosisRequired && !selectedPrimaryDiagnoses.length) {
364
+ return;
365
+ }
366
+
367
+ let finalNoteDate = dayjs(noteDate);
368
+ const now = new Date();
369
+ if (finalNoteDate.diff(now, 'minute') <= 30) {
370
+ finalNoteDate = null;
371
+ }
372
+
373
+ const existingClinicalNoteObs = encounter?.obs?.find(
374
+ (obs) => obs.concept.uuid === encounterNoteTextConceptUuid,
375
+ );
376
+
377
+ const visitNotePayload: VisitNotePayload = {
378
+ encounterDatetime: finalNoteDate?.format(),
379
+ form: formConceptUuid,
380
+ patient: patientUuid,
381
+ location: locationUuid,
382
+ encounterProviders: [
383
+ {
384
+ encounterRole: clinicianEncounterRole,
385
+ provider: providerUuid,
386
+ },
387
+ ],
388
+ encounterType: encounterTypeUuid,
389
+ obs: clinicalNote
390
+ ? [
391
+ {
392
+ concept: { uuid: encounterNoteTextConceptUuid, display: '' },
393
+ value: clinicalNote,
394
+ ...(existingClinicalNoteObs && { uuid: existingClinicalNoteObs.uuid }),
395
+ },
396
+ ]
397
+ : [],
398
+ };
399
+
400
+ const abortController = new AbortController();
401
+
402
+ const savePromise = isEditing
403
+ ? updateVisitNote(abortController, encounter.id, visitNotePayload)
404
+ : saveVisitNote(abortController, visitNotePayload);
405
+
406
+ const response = await savePromise;
407
+ if (response.status !== 201 && response.status !== 200) {
408
+ throw new Error('Unexpected response status');
409
+ }
410
+
411
+ const encounterUuid = isEditing ? encounter.id : response.data.uuid;
412
+
413
+ if (isEditing && encounter?.diagnoses?.length) {
414
+ await Promise.all(
415
+ encounter.diagnoses.map((diagnosis) => deletePatientDiagnosis(abortController, diagnosis.uuid)),
416
+ );
417
+ }
418
+
419
+ await Promise.all(
420
+ combinedDiagnoses.map((diagnosis) => {
421
+ const diagnosesPayload: DiagnosisPayload = {
422
+ encounter: encounterUuid,
423
+ patient: patientUuid,
424
+ condition: null,
425
+ diagnosis: {
426
+ coded: diagnosis.diagnosis.coded,
427
+ },
428
+ certainty: diagnosis.certainty,
429
+ rank: diagnosis.rank,
430
+ };
431
+ return savePatientDiagnosis(abortController, diagnosesPayload);
432
+ }),
433
+ );
434
+
435
+ if (images?.length) {
436
+ await Promise.all(
437
+ images.map((image) => {
438
+ const imageToUpload: UploadedFile = {
439
+ base64Content: image.base64Content,
440
+ file: image.file,
441
+ fileName: image.fileName,
442
+ fileType: image.fileType,
443
+ fileDescription: image.fileDescription || '',
444
+ };
445
+ return createAttachment(patientUuid, imageToUpload);
446
+ }),
447
+ );
448
+ }
449
+
450
+ invalidateVisitAndEncounterData(globalMutate, patientUuid);
451
+ mutateVisitNotes();
452
+ if (images?.length) {
453
+ mutateAttachments();
454
+ }
455
+ closeWorkspace({ discardUnsavedChanges: true });
456
+
457
+ showSnackbar({
458
+ isLowContrast: true,
459
+ subtitle: t('visitNoteNowVisible', 'It is now visible on the Visits page'),
460
+ kind: 'success',
461
+ title: t('visitNoteSaved', 'Visit note saved'),
462
+ });
463
+ } catch (err) {
464
+ createErrorHandler();
465
+ showSnackbar({
466
+ title: t('visitNoteSaveError', 'Error saving visit note'),
467
+ kind: 'error',
468
+ isLowContrast: false,
469
+ subtitle: err?.responseBody?.error?.message ?? err.message,
470
+ });
471
+ } finally {
472
+ setIsSubmittingLocal(false);
473
+ }
474
+ },
475
+ [
476
+ isSubmittingLocal,
477
+ isEditing,
478
+ encounter,
479
+ isPrimaryDiagnosisRequired,
480
+ selectedPrimaryDiagnoses.length,
481
+ combinedDiagnoses,
482
+ clinicianEncounterRole,
483
+ providerUuid,
484
+ locationUuid,
485
+ encounterTypeUuid,
486
+ formConceptUuid,
487
+ encounterNoteTextConceptUuid,
488
+ patientUuid,
489
+ globalMutate,
490
+ mutateVisitNotes,
491
+ mutateAttachments,
492
+ closeWorkspace,
493
+ t,
494
+ ],
495
+ );
496
+
497
+ const onError = (errors) => console.error(errors);
498
+
499
+ return (
500
+ <Workspace2 title={t('visitNoteWorkspaceTitle', 'Visit note')} hasUnsavedChanges={isDirty}>
501
+ <Form className={styles.form} onSubmit={handleSubmit(onSubmit, onError)}>
502
+ <ExtensionSlot name="visit-context-header-slot" state={{ patientUuid }} />
503
+
504
+ {isTablet && (
505
+ <Row className={styles.headerGridRow}>
506
+ <ExtensionSlot name="visit-form-header-slot" className={styles.dataGridRow} state={memoizedState} />
507
+ </Row>
508
+ )}
509
+
510
+ <div className={styles.formContainer}>
511
+ <Stack gap={2}>
512
+ {isTablet ? <h2 className={styles.heading}>{t('addVisitNote', 'Add a visit note')}</h2> : null}
513
+ <Row className={styles.row}>
514
+ <Column sm={1}>
515
+ <span className={styles.columnLabel}>{t('date', 'Date')}</span>
516
+ </Column>
517
+ <Column sm={3}>
518
+ <Controller
519
+ name="noteDate"
520
+ control={control}
521
+ render={({ field, fieldState }) => (
522
+ <ResponsiveWrapper>
523
+ <OpenmrsDatePicker
524
+ {...field}
525
+ data-testid="visitDateTimePicker"
526
+ id="visitDateTimePicker"
527
+ invalid={Boolean(fieldState?.error?.message)}
528
+ invalidText={fieldState?.error?.message}
529
+ isDisabled={isEditing}
530
+ labelText={t('visitDate', 'Visit date')}
531
+ maxDate={new Date()}
532
+ />
533
+ </ResponsiveWrapper>
534
+ )}
535
+ />
536
+ </Column>
537
+ </Row>
538
+ <div className={styles.diagnosesText}>
539
+ {selectedPrimaryDiagnoses && selectedPrimaryDiagnoses.length ? (
540
+ <>
541
+ {selectedPrimaryDiagnoses.map((diagnosis, index) => {
542
+ const displayText =
543
+ diagnosis.display.length > 30 ? `${diagnosis.display.substring(0, 30)}...` : diagnosis.display;
544
+ const certaintyText =
545
+ diagnosis.certainty === 'CONFIRMED' ? t('confirmed', 'Confirmed') : t('presumed', 'Presumed');
546
+
547
+ return (
548
+ <div key={index} className={styles.tagWrapper} title={`${diagnosis.display} (${certaintyText})`}>
549
+ <Tag
550
+ className={styles.tag}
551
+ filter
552
+ onClose={() => handleRemoveDiagnosis(diagnosis, 'primaryInputSearch')}
553
+ type="red">
554
+ <span className={styles.tagContent}>
555
+ {displayText}
556
+ {diagnosis.certainty === 'CONFIRMED' ? (
557
+ <CheckmarkFilled size={14} className={classnames(styles.tagIcon, styles.confirmedIcon)} />
558
+ ) : (
559
+ <Help size={14} className={classnames(styles.tagIcon, styles.presumedIcon)} />
560
+ )}
561
+ </span>
562
+ </Tag>
563
+ </div>
564
+ );
565
+ })}
566
+ </>
567
+ ) : null}
568
+ {selectedSecondaryDiagnoses && selectedSecondaryDiagnoses.length ? (
569
+ <>
570
+ {selectedSecondaryDiagnoses.map((diagnosis, index) => {
571
+ const displayText =
572
+ diagnosis.display.length > 30 ? `${diagnosis.display.substring(0, 30)}...` : diagnosis.display;
573
+ const certaintyText =
574
+ diagnosis.certainty === 'CONFIRMED' ? t('confirmed', 'Confirmed') : t('presumed', 'Presumed');
575
+
576
+ return (
577
+ <div key={index} className={styles.tagWrapper} title={`${diagnosis.display} (${certaintyText})`}>
578
+ <Tag
579
+ className={styles.tag}
580
+ filter
581
+ onClose={() => handleRemoveDiagnosis(diagnosis, 'secondaryInputSearch')}
582
+ type="blue">
583
+ <span className={styles.tagContent}>
584
+ {displayText}
585
+ {diagnosis.certainty === 'CONFIRMED' ? (
586
+ <CheckmarkFilled size={12} className={classnames(styles.tagIcon, styles.confirmedIcon)} />
587
+ ) : (
588
+ <Help size={12} className={classnames(styles.tagIcon, styles.presumedIcon)} />
589
+ )}
590
+ </span>
591
+ </Tag>
592
+ </div>
593
+ );
594
+ })}
595
+ </>
596
+ ) : null}
597
+ {selectedPrimaryDiagnoses &&
598
+ !selectedPrimaryDiagnoses.length &&
599
+ selectedSecondaryDiagnoses &&
600
+ !selectedSecondaryDiagnoses.length && (
601
+ <span>{t('emptyDiagnosisText', 'No diagnosis selected — Enter a diagnosis below')}</span>
602
+ )}
603
+ </div>
604
+ <Row className={styles.row}>
605
+ <Column sm={1}>
606
+ <span className={styles.columnLabel}>{t('primaryDiagnosis', 'Primary diagnosis')}</span>
607
+ </Column>
608
+ <Column sm={3}>
609
+ <FormGroup legendText={t('searchForPrimaryDiagnosis', 'Search for a primary diagnosis')}>
610
+ <DiagnosisSearch
611
+ name="primaryDiagnosisSearch"
612
+ control={control}
613
+ labelText={t('enterPrimaryDiagnoses', 'Enter Primary diagnoses')}
614
+ placeholder={t('primaryDiagnosisInputPlaceholder', 'Choose a primary diagnosis')}
615
+ handleSearch={handleSearch}
616
+ error={errors?.primaryDiagnosisSearch}
617
+ setIsSearching={setIsSearching}
618
+ />
619
+ {error ? (
620
+ <InlineNotification
621
+ className={styles.errorNotification}
622
+ lowContrast
623
+ title={t('error', 'Error')}
624
+ subtitle={t('errorFetchingConcepts', 'There was a problem fetching concepts') + '.'}
625
+ onClose={() => setError(null)}
626
+ />
627
+ ) : null}
628
+ <DiagnosesDisplay
629
+ fieldName={'primaryDiagnosisSearch'}
630
+ isDiagnosisNotSelected={isDiagnosisNotSelected}
631
+ isLoading={isLoadingPrimaryDiagnoses}
632
+ isSearching={isSearching}
633
+ onAddDiagnosis={handleAddDiagnosis}
634
+ searchResults={searchPrimaryResults}
635
+ t={t}
636
+ value={watch('primaryDiagnosisSearch')}
637
+ />
638
+ </FormGroup>
639
+ </Column>
640
+ </Row>
641
+ <Row className={styles.row}>
642
+ <Column sm={1}>
643
+ <span className={styles.columnLabel}>{t('secondaryDiagnosis', 'Secondary diagnosis')}</span>
644
+ </Column>
645
+ <Column sm={3}>
646
+ <FormGroup legendText={t('searchForSecondaryDiagnosis', 'Search for a secondary diagnosis')}>
647
+ <DiagnosisSearch
648
+ name="secondaryDiagnosisSearch"
649
+ control={control}
650
+ labelText={t('enterSecondaryDiagnoses', 'Enter Secondary diagnoses')}
651
+ placeholder={t('secondaryDiagnosisInputPlaceholder', 'Choose a secondary diagnosis')}
652
+ handleSearch={handleSearch}
653
+ setIsSearching={setIsSearching}
654
+ />
655
+ {error ? (
656
+ <InlineNotification
657
+ className={styles.errorNotification}
658
+ lowContrast
659
+ title={t('error', 'Error')}
660
+ subtitle={t('errorFetchingConcepts', 'There was a problem fetching concepts') + '.'}
661
+ onClose={() => setError(null)}
662
+ />
663
+ ) : null}
664
+ <DiagnosesDisplay
665
+ fieldName={'secondaryDiagnosisSearch'}
666
+ isDiagnosisNotSelected={isDiagnosisNotSelected}
667
+ isLoading={isLoadingSecondaryDiagnoses}
668
+ isSearching={isSearching}
669
+ onAddDiagnosis={handleAddDiagnosis}
670
+ searchResults={searchSecondaryResults}
671
+ t={t}
672
+ value={watch('secondaryDiagnosisSearch')}
673
+ />
674
+ </FormGroup>
675
+ </Column>
676
+ </Row>
677
+ <Row className={styles.row}>
678
+ <Column sm={1}>
679
+ <span className={styles.columnLabel}>{t('note', 'Note')}</span>
680
+ </Column>
681
+ <Column sm={3}>
682
+ <Controller
683
+ name="clinicalNote"
684
+ control={control}
685
+ render={({ field: { onChange, onBlur, value } }) => (
686
+ <ResponsiveWrapper>
687
+ <TextArea
688
+ id="additionalNote"
689
+ rows={rows}
690
+ labelText={t('clinicalNoteLabel', 'Write your notes')}
691
+ placeholder={t('clinicalNotePlaceholder', 'Write any notes here')}
692
+ value={value}
693
+ onBlur={onBlur}
694
+ onChange={(event) => {
695
+ onChange(event);
696
+ const textareaLineHeight = 24;
697
+ const newRows = Math.ceil(event.target.scrollHeight / textareaLineHeight);
698
+ setRows(newRows);
699
+ }}
700
+ />
701
+ </ResponsiveWrapper>
702
+ )}
703
+ />
704
+ </Column>
705
+ </Row>
706
+ <Row className={styles.row}>
707
+ <Column sm={1}>
708
+ <span className={styles.columnLabel}>{t('image', 'Image')}</span>
709
+ </Column>
710
+ <Column sm={3}>
711
+ <FormGroup legendText="">
712
+ <p className={styles.imgUploadHelperText}>
713
+ {t('imageUploadHelperText', "Upload images or use this device's camera to capture images")}
714
+ </p>
715
+ <Button
716
+ className={styles.uploadButton}
717
+ kind={isTablet ? 'ghost' : 'tertiary'}
718
+ onClick={showImageCaptureModal}
719
+ renderIcon={(props) => <Add size={16} {...props} />}>
720
+ {t('addImage', 'Add image')}
721
+ </Button>
722
+ <div className={styles.imgThumbnailGrid}>
723
+ {currentImages?.map((image, index) => (
724
+ <div key={index} className={styles.imgThumbnailItem}>
725
+ <div className={styles.imgThumbnailContainer}>
726
+ <img
727
+ className={styles.imgThumbnail}
728
+ src={image.base64Content}
729
+ alt={image.fileDescription ?? image.fileName}
730
+ />
731
+ </div>
732
+ <Button kind="ghost" className={styles.removeButton} onClick={() => handleRemoveImage(index)}>
733
+ <CloseFilled size={16} className={styles.closeIcon} />
734
+ </Button>
735
+ </div>
736
+ ))}
737
+ </div>
738
+ </FormGroup>
739
+ </Column>
740
+ </Row>
741
+ </Stack>
742
+ </div>
743
+ <ButtonSet className={classnames({ [styles.tablet]: isTablet, [styles.desktop]: !isTablet })}>
744
+ <Button className={styles.button} kind="secondary" onClick={() => closeWorkspace()}>
745
+ {t('discard', 'Discard')}
746
+ </Button>
747
+ <Button className={styles.button} kind="primary" type="submit" disabled={isSubmitting || isSubmittingLocal}>
748
+ {isSubmitting || isSubmittingLocal ? (
749
+ <InlineLoading description={t('saving', 'Saving') + '...'} />
750
+ ) : (
751
+ <span>{t('saveAndClose', 'Save and close')}</span>
752
+ )}
753
+ </Button>
754
+ </ButtonSet>
755
+ </Form>
756
+ </Workspace2>
757
+ );
758
+ };
759
+
760
+ function DiagnosisSearch({
761
+ name,
762
+ control,
763
+ labelText,
764
+ placeholder,
765
+ handleSearch,
766
+ error,
767
+ setIsSearching,
768
+ }: DiagnosisSearchProps) {
769
+ const isTablet = useLayoutType() === 'tablet';
770
+ const inputRef = useRef(null);
771
+
772
+ const searchInputFocus = () => {
773
+ inputRef.current.focus();
774
+ };
775
+
776
+ useEffect(() => {
777
+ if (error) {
778
+ searchInputFocus();
779
+ }
780
+ }, [error]);
781
+
782
+ return (
783
+ <Controller
784
+ name={name}
785
+ control={control}
786
+ render={({ field: { value, onChange, onBlur }, fieldState }) => (
787
+ <>
788
+ <ResponsiveWrapper>
789
+ <Search
790
+ ref={inputRef}
791
+ size={isTablet ? 'lg' : 'md'}
792
+ id={name}
793
+ labelText={labelText}
794
+ className={error && styles.diagnoserrorOutline}
795
+ placeholder={placeholder}
796
+ renderIcon={error && ((props) => <WarningFilled fill="red" {...props} />)}
797
+ onChange={(e) => {
798
+ setIsSearching(true);
799
+ onChange(e);
800
+ handleSearch(name);
801
+ }}
802
+ value={value instanceof Date ? value.toISOString() : value}
803
+ onBlur={onBlur}
804
+ />
805
+ </ResponsiveWrapper>
806
+ {fieldState?.error?.message && <p className={styles.errorMessage}>{fieldState?.error?.message}</p>}
807
+ </>
808
+ )}
809
+ />
810
+ );
811
+ }
812
+
813
+ function DiagnosesDisplay({
814
+ fieldName,
815
+ isDiagnosisNotSelected,
816
+ isLoading,
817
+ isSearching,
818
+ onAddDiagnosis,
819
+ searchResults,
820
+ t,
821
+ value,
822
+ }: DiagnosesDisplayProps) {
823
+ const [selectedDiagnosis, setSelectedDiagnosis] = useState<Concept | null>(null);
824
+ const [showDropdown, setShowDropdown] = useState(false);
825
+ const dropdownRef = useRef<HTMLDivElement>(null);
826
+ const isTablet = useLayoutType() === 'tablet';
827
+
828
+ useEffect(() => {
829
+ const handleClickOutside = (event: MouseEvent) => {
830
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
831
+ setShowDropdown(false);
832
+ setSelectedDiagnosis(null);
833
+ }
834
+ };
835
+
836
+ document.addEventListener('mousedown', handleClickOutside);
837
+ return () => document.removeEventListener('mousedown', handleClickOutside);
838
+ }, []);
839
+
840
+ if (!value) {
841
+ return null;
842
+ }
843
+
844
+ if (isSearching || isLoading) {
845
+ return <Loader />;
846
+ }
847
+
848
+ if (!isSearching && searchResults?.length > 0) {
849
+ return (
850
+ <ul className={styles.diagnosisList}>
851
+ {searchResults.map((diagnosis, index) => {
852
+ if (isDiagnosisNotSelected(diagnosis)) {
853
+ return (
854
+ <li key={index} className={styles.diagnosisListItem}>
855
+ <div className={styles.diagnosisRow}>
856
+ <div className={styles.diagnosisInfo}>
857
+ <span className={styles.diagnosisName}>{diagnosis.display}</span>
858
+ </div>
859
+ <Button
860
+ size="sm"
861
+ kind="ghost"
862
+ onClick={(e) => {
863
+ e.stopPropagation();
864
+ setSelectedDiagnosis(diagnosis);
865
+ setShowDropdown(!showDropdown || selectedDiagnosis?.uuid !== diagnosis.uuid);
866
+ }}
867
+ className={styles.addButton}
868
+ renderIcon={Add}
869
+ iconDescription={t('addWithCertainty', 'Add with certainty')}>
870
+ {t('add', 'Add')}
871
+ </Button>
872
+ </div>
873
+
874
+ {selectedDiagnosis?.uuid === diagnosis.uuid && showDropdown && (
875
+ <div ref={dropdownRef} className={styles.certaintyDropdown}>
876
+ <div className={styles.dropdownHeader}>
877
+ <div className={styles.dropdownTitle}>
878
+ <span className={styles.diagnosisPreview}>
879
+ {diagnosis.display.length > 40
880
+ ? `${diagnosis.display.substring(0, 40)}...`
881
+ : diagnosis.display}
882
+ </span>
883
+ </div>
884
+ <p className={styles.dropdownSubtitle}>{t('selectCertainty', 'Select certainty level')}</p>
885
+ </div>
886
+
887
+ <div className={styles.certaintyOptions}>
888
+ <button
889
+ className={classnames(styles.certaintyOption, styles.confirmedOption)}
890
+ onClick={() => {
891
+ onAddDiagnosis(diagnosis, 'CONFIRMED', fieldName);
892
+ setShowDropdown(false);
893
+ setSelectedDiagnosis(null);
894
+ }}>
895
+ <div className={styles.optionContent}>
896
+ <CheckmarkFilled size={16} className={styles.optionIcon} />
897
+ <div className={styles.optionText}>
898
+ <span className={styles.optionTitle}>{t('confirmed', 'Confirmed')}</span>
899
+ </div>
900
+ </div>
901
+ </button>
902
+
903
+ <button
904
+ className={classnames(styles.certaintyOption, styles.presumedOption)}
905
+ onClick={() => {
906
+ onAddDiagnosis(diagnosis, 'PROVISIONAL', fieldName);
907
+ setShowDropdown(false);
908
+ setSelectedDiagnosis(null);
909
+ }}>
910
+ <div className={styles.optionContent}>
911
+ <Help size={16} className={styles.optionIcon} />
912
+ <div className={styles.optionText}>
913
+ <span className={styles.optionTitle}>{t('presumed', 'Presumed')}</span>
914
+ </div>
915
+ </div>
916
+ </button>
917
+ </div>
918
+
919
+ <div className={styles.dropdownFooter}>
920
+ <Button
921
+ size="sm"
922
+ kind="tertiary"
923
+ onClick={() => {
924
+ setShowDropdown(false);
925
+ setSelectedDiagnosis(null);
926
+ }}>
927
+ {t('cancel', 'Cancel')}
928
+ </Button>
929
+ </div>
930
+ </div>
931
+ )}
932
+ </li>
933
+ );
934
+ }
935
+ })}
936
+ </ul>
937
+ );
938
+ }
939
+
940
+ if (searchResults?.length === 0) {
941
+ return (
942
+ <ResponsiveWrapper>
943
+ <Tile className={styles.emptyResults}>
944
+ <span>
945
+ {t('noMatchingDiagnoses', 'No diagnoses found matching')} <strong>"{value}"</strong>
946
+ </span>
947
+ </Tile>
948
+ </ResponsiveWrapper>
949
+ );
950
+ }
951
+ }
952
+
953
+ function Loader() {
954
+ return (
955
+ <>
956
+ {Array.from({ length: 5 }).map((_, index) => (
957
+ <SkeletonText key={index} className={styles.skeleton} />
958
+ ))}
959
+ </>
960
+ );
961
+ }
962
+
963
+ export default VisitNotesForm;