import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classnames from 'classnames'; import dayjs from 'dayjs'; import { debounce } from 'lodash-es'; import { useTranslation } from 'react-i18next'; import { useSWRConfig } from 'swr'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { Controller, useForm, type Control } from 'react-hook-form'; import type { TFunction } from 'i18next'; import { Button, ButtonSet, Column, Form, FormGroup, InlineLoading, InlineNotification, Row, Search, SkeletonText, Stack, Tag, TextArea, Tile, } from '@carbon/react'; import { Add, CloseFilled, WarningFilled } from '@carbon/react/icons'; import { createAttachment, createErrorHandler, ExtensionSlot, OpenmrsDatePicker, ResponsiveWrapper, restBaseUrl, showModal, showSnackbar, useConfig, useFeatureFlag, useLayoutType, useSession, Workspace2, type Encounter, type UploadedFile, } from '@openmrs/esm-framework'; import { invalidateVisitAndEncounterData, type PatientWorkspace2DefinitionProps, useAllowedFileExtensions, usePatientChartStore, } from '@openmrs/esm-patient-common-lib'; import type { ConfigObject } from '../config-schema'; import type { Concept, Diagnosis, DiagnosisPayload, VisitNotePayload } from '../types'; import { deletePatientDiagnosis, fetchDiagnosisConceptsByName, savePatientDiagnosis, saveVisitNote, updateVisitNote, useVisitNotes, } from './visit-notes.resource'; import styles from './visit-notes-form.scss'; type VisitNotesFormData = Omit>, 'images'> & { images?: UploadedFile[]; }; interface DiagnosesDisplayProps { fieldName: string; isDiagnosisNotSelected: (diagnosis: Concept) => boolean; isLoading: boolean; isSearching: boolean; onAddDiagnosis: (diagnosis: Concept, searchInputField: string) => void; searchResults: Array; t: TFunction; value: string; } interface DiagnosisSearchProps { control: Control; error?: object; handleSearch: (fieldName) => void; labelText: string; name: 'noteDate' | 'primaryDiagnosisSearch' | 'secondaryDiagnosisSearch' | 'clinicalNote'; placeholder: string; setIsSearching: (isSearching: boolean) => void; } const createSchema = (t: TFunction, isRetrospectiveDataEntryEnabled: boolean) => { return z.object({ noteDate: isRetrospectiveDataEntryEnabled ? z.date() : z.date().optional(), primaryDiagnosisSearch: z.string(), secondaryDiagnosisSearch: z.string().optional(), clinicalNote: z.string().optional(), images: z.array(z.any()).optional(), }); }; const SEARCH_TIMEOUT_MS = 500; export interface VisitNotesFormProps { encounter?: Encounter; formContext: 'creating' | 'editing'; } const VisitNotesForm: React.FC> = ({ closeWorkspace, workspaceProps: { formContext, encounter }, groupProps: { patientUuid, patient }, }) => { const isEditing: boolean = Boolean(formContext === 'editing' && encounter?.id); const { t } = useTranslation(); const isTablet = useLayoutType() === 'tablet'; const session = useSession(); const { isPrimaryDiagnosisRequired, ...config } = useConfig(); const memoizedState = useMemo(() => ({ patientUuid, patient }), [patientUuid, patient]); const { clinicianEncounterRole, encounterNoteTextConceptUuid, encounterTypeUuid, formConceptUuid } = config.visitNoteConfig; const [isLoadingPrimaryDiagnoses, setIsLoadingPrimaryDiagnoses] = useState(false); const [isLoadingSecondaryDiagnoses, setIsLoadingSecondaryDiagnoses] = useState(false); const [isSearching, setIsSearching] = useState(false); const [selectedPrimaryDiagnoses, setSelectedPrimaryDiagnoses] = useState>([]); const [selectedSecondaryDiagnoses, setSelectedSecondaryDiagnoses] = useState>([]); const [searchPrimaryResults, setSearchPrimaryResults] = useState>(null); const [searchSecondaryResults, setSearchSecondaryResults] = useState>(null); const [combinedDiagnoses, setCombinedDiagnoses] = useState>([]); const [rows, setRows] = useState(); const [error, setError] = useState(null); const { allowedFileExtensions } = useAllowedFileExtensions(); const isRetrospectiveDataEntryEnabled = useFeatureFlag('rde'); const { visitContext } = usePatientChartStore(patientUuid); const visitNoteFormSchema = useMemo( () => createSchema(t, isRetrospectiveDataEntryEnabled), [t, isRetrospectiveDataEntryEnabled], ); const customResolver = useCallback( async (data, context, options) => { const zodResult = await zodResolver(visitNoteFormSchema)(data, context, options); if (isPrimaryDiagnosisRequired && selectedPrimaryDiagnoses.length === 0) { return { ...zodResult, errors: { ...zodResult.errors, primaryDiagnosisSearch: { type: 'custom', message: t('primaryDiagnosisRequired', 'Choose at least one primary diagnosis'), }, }, }; } return zodResult; }, [visitNoteFormSchema, isPrimaryDiagnosisRequired, selectedPrimaryDiagnoses, t], ); const { clearErrors, control, formState: { errors, dirtyFields, isSubmitting }, handleSubmit, setValue, watch, } = useForm({ mode: 'onSubmit', resolver: customResolver, defaultValues: { primaryDiagnosisSearch: '', noteDate: isEditing ? new Date(encounter.rawDatetime) : new Date(), clinicalNote: isEditing ? String(encounter?.obs?.find((obs) => obs.concept.uuid === encounterNoteTextConceptUuid)?.value || '') : '', }, }); useEffect(() => { if (encounter?.diagnoses?.length) { try { const transformedDiagnoses = encounter.diagnoses.map((d) => ({ patient: patientUuid, diagnosis: { coded: d.diagnosis.coded?.uuid, }, certainty: d.certainty, rank: d.rank, display: d.display, })); const primaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 1); const secondaryDiagnoses = transformedDiagnoses.filter((d) => d.rank === 2); setSelectedPrimaryDiagnoses(primaryDiagnoses); setSelectedSecondaryDiagnoses(secondaryDiagnoses); setCombinedDiagnoses([...primaryDiagnoses, ...secondaryDiagnoses]); } catch (err) { setError(new Error(t('errorTransformingDiagnoses', 'Error transforming diagnoses'))); createErrorHandler(); } } }, [encounter, patientUuid, t]); const currentImages = watch('images'); const { mutateVisitNotes } = useVisitNotes(patientUuid); const { mutate: globalMutate } = useSWRConfig(); const mutateAttachments = useCallback( () => globalMutate((key) => typeof key === 'string' && key.startsWith(`${restBaseUrl}/attachment`)), [globalMutate], ); const locationUuid = session?.sessionLocation?.uuid; const providerUuid = session?.currentProvider?.uuid; const debouncedSearch = useMemo( () => debounce((fieldQuery, fieldName) => { clearErrors('primaryDiagnosisSearch'); if (fieldQuery) { if (fieldName === 'primaryDiagnosisSearch') { setIsLoadingPrimaryDiagnoses(true); } else if (fieldName === 'secondaryDiagnosisSearch') { setIsLoadingSecondaryDiagnoses(true); } fetchDiagnosisConceptsByName(fieldQuery, config.diagnosisConceptClass) .then((matchingConceptDiagnoses: Array) => { if (fieldName === 'primaryDiagnosisSearch') { setSearchPrimaryResults(matchingConceptDiagnoses); setIsLoadingPrimaryDiagnoses(false); } else if (fieldName === 'secondaryDiagnosisSearch') { setSearchSecondaryResults(matchingConceptDiagnoses); setIsLoadingSecondaryDiagnoses(false); } }) .catch((e) => { setError(e); createErrorHandler(); }); } }, SEARCH_TIMEOUT_MS), [config.diagnosisConceptClass, clearErrors], ); const handleSearch = useCallback( (fieldName) => { const fieldQuery = watch(fieldName); if (fieldQuery) { debouncedSearch(fieldQuery, fieldName); } setIsSearching(false); }, [debouncedSearch, watch], ); const createDiagnosis = useCallback( (concept: Concept) => ({ certainty: 'PROVISIONAL', display: concept.display, diagnosis: { coded: concept.uuid, }, patient: patientUuid, rank: 2, }), [patientUuid], ); const handleAddDiagnosis = useCallback( (conceptDiagnosisToAdd: Concept, searchInputField: string) => { const newDiagnosis = createDiagnosis(conceptDiagnosisToAdd); if (searchInputField === 'primaryDiagnosisSearch') { newDiagnosis.rank = 1; setValue('primaryDiagnosisSearch', ''); setSearchPrimaryResults([]); setSelectedPrimaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); clearErrors('primaryDiagnosisSearch'); } else if (searchInputField === 'secondaryDiagnosisSearch') { setValue('secondaryDiagnosisSearch', ''); setSearchSecondaryResults([]); setSelectedSecondaryDiagnoses((selectedDiagnoses) => [...selectedDiagnoses, newDiagnosis]); } setCombinedDiagnoses((combinedDiagnoses) => [...combinedDiagnoses, newDiagnosis]); }, [createDiagnosis, setValue, clearErrors], ); const handleRemoveDiagnosis = useCallback( (diagnosisToRemove: Diagnosis, searchInputField) => { if (searchInputField === 'primaryInputSearch') { setSelectedPrimaryDiagnoses( selectedPrimaryDiagnoses.filter( (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded, ), ); } else if (searchInputField === 'secondaryInputSearch') { setSelectedSecondaryDiagnoses( selectedSecondaryDiagnoses.filter( (diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded, ), ); } setCombinedDiagnoses( combinedDiagnoses.filter((diagnosis) => diagnosis.diagnosis.coded !== diagnosisToRemove.diagnosis.coded), ); }, [combinedDiagnoses, selectedPrimaryDiagnoses, selectedSecondaryDiagnoses], ); const isDiagnosisNotSelected = (diagnosis: Concept) => { const isPrimaryDiagnosisSelected = selectedPrimaryDiagnoses.some( (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded, ); const isSecondaryDiagnosisSelected = selectedSecondaryDiagnoses.some( (selectedDiagnosis) => diagnosis.uuid === selectedDiagnosis.diagnosis.coded, ); return !isPrimaryDiagnosisSelected && !isSecondaryDiagnosisSelected; }; const showImageCaptureModal = useCallback(() => { const close = showModal('capture-photo-modal', { saveFile: (file: UploadedFile) => { if (file.capturedFromWebcam && !file.fileName.includes('.')) { file.fileName = `${file.fileName}.png`; } setValue('images', currentImages ? [...currentImages, file] : [file]); close(); return Promise.resolve(); }, closeModal: () => { close(); }, allowedExtensions: allowedFileExtensions && Array.isArray(allowedFileExtensions) ? allowedFileExtensions.filter((ext) => !/pdf/i.test(ext)) : [], collectDescription: true, multipleFiles: true, }); }, [allowedFileExtensions, currentImages, setValue]); const handleRemoveImage = (index: number) => { const updatedImages = [...currentImages]; updatedImages.splice(index, 1); setValue('images', updatedImages); showSnackbar({ title: t('imageRemoved', 'Image removed'), kind: 'success', isLowContrast: true, }); }; const onSubmit = useCallback( (data: VisitNotesFormData) => { const { noteDate, clinicalNote, images } = data; if (isPrimaryDiagnosisRequired && !selectedPrimaryDiagnoses.length) { return; } let finalNoteDate = dayjs(noteDate); const now = new Date(); // When RDE is off, the datepicker is hidden and noteDate defaults to new Date(). // This always falls within the 30-minute window, so encounterDatetime is intentionally // omitted from the payload -> letting the server attach the correct timestamp. if (finalNoteDate.diff(now, 'minute') <= 30) { finalNoteDate = null; } const existingClinicalNoteObs = encounter?.obs?.find((obs) => obs.concept.uuid === encounterNoteTextConceptUuid); const visitNotePayload: VisitNotePayload = { encounterDatetime: finalNoteDate?.format(), form: formConceptUuid, patient: patientUuid, location: locationUuid, encounterProviders: [ { encounterRole: clinicianEncounterRole, provider: providerUuid, }, ], encounterType: encounterTypeUuid, obs: clinicalNote ? [ { concept: { uuid: encounterNoteTextConceptUuid, display: '' }, value: clinicalNote, ...(existingClinicalNoteObs && { uuid: existingClinicalNoteObs.uuid }), }, ] : [], // Only attach the visit when creating a note. On edit, omitting `visit` leaves the encounter's // existing visit untouched rather than reassigning it to (or detaching it from) the active visit. ...(!isEditing && visitContext?.uuid && { visit: visitContext.uuid }), }; const abortController = new AbortController(); const savePromise = isEditing ? updateVisitNote(abortController, encounter.id, visitNotePayload) : saveVisitNote(abortController, visitNotePayload); return savePromise .then((response) => { if (response.status === 201 || response.status === 200) { const encounterUuid = encounter?.id || response.data.uuid; // If editing, first delete existing diagnoses if (isEditing && encounter?.diagnoses?.length) { return Promise.all( encounter.diagnoses.map((diagnosis) => deletePatientDiagnosis(abortController, diagnosis.uuid)), ).then(() => encounterUuid); } return encounterUuid; } }) .then((encounterUuid) => { return Promise.all( combinedDiagnoses.map((diagnosis) => { const diagnosesPayload: DiagnosisPayload = { encounter: encounterUuid, patient: patientUuid, condition: null, diagnosis: { coded: diagnosis.diagnosis.coded, }, certainty: diagnosis.certainty, rank: diagnosis.rank, }; return savePatientDiagnosis(abortController, diagnosesPayload); }), ); }) .then(() => { if (images?.length) { return Promise.all( images.map((image) => { const imageToUpload: UploadedFile = { base64Content: image.base64Content, file: image.file, fileName: image.fileName, fileType: image.fileType, fileDescription: image.fileDescription || '', }; return createAttachment(patientUuid, imageToUpload); }), ); } else { return Promise.resolve([]); } }) .then(() => { // Invalidate encounter and notes data since we created a new encounter with notes // Also invalidate visit history table since the visit now has new encounters invalidateVisitAndEncounterData(globalMutate, patientUuid); mutateVisitNotes(); if (images?.length) { mutateAttachments(); } closeWorkspace({ discardUnsavedChanges: true }); showSnackbar({ isLowContrast: true, subtitle: t('visitNoteNowVisible', 'It is now visible on the Visits page'), kind: 'success', title: t('visitNoteSaved', 'Visit note saved'), }); }) .catch((err) => { createErrorHandler(); showSnackbar({ title: t('visitNoteSaveError', 'Error saving visit note'), kind: 'error', isLowContrast: false, subtitle: err?.responseBody?.error?.message ?? err.message, }); }); }, [ visitContext?.uuid, clinicianEncounterRole, closeWorkspace, combinedDiagnoses, encounter?.diagnoses, encounter?.id, encounter?.obs, encounterNoteTextConceptUuid, encounterTypeUuid, formConceptUuid, globalMutate, isEditing, isPrimaryDiagnosisRequired, locationUuid, mutateAttachments, mutateVisitNotes, patientUuid, providerUuid, selectedPrimaryDiagnoses.length, t, ], ); const onError = (errors) => console.error(errors); const hasUserUnsavedChanges = Object.keys(dirtyFields).length > 0; return (
{isTablet && ( )}
{isTablet ?

{t('addVisitNote', 'Add a visit note')}

: null} {isRetrospectiveDataEntryEnabled && ( {t('date', 'Date')} ( )} /> )}
{selectedPrimaryDiagnoses.map((diagnosis) => ( handleRemoveDiagnosis(diagnosis, 'primaryInputSearch')} type="red" > {diagnosis.display} ))} {selectedSecondaryDiagnoses.map((diagnosis) => ( handleRemoveDiagnosis(diagnosis, 'secondaryInputSearch')} type="blue" > {diagnosis.display} ))} {!selectedPrimaryDiagnoses.length && !selectedSecondaryDiagnoses.length && ( {t('emptyDiagnosisText', 'No diagnosis selected — Enter a diagnosis below')} )}
{t('primaryDiagnosis', 'Primary diagnosis')} {error ? ( setError(null)} /> ) : null} {t('secondaryDiagnosis', 'Secondary diagnosis')} {error ? ( setError(null)} /> ) : null} {t('note', 'Note')} (