@tebokaroa/openmrs-esm-patient-notes-app 12.1.0-custom.1

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