@digihmis/esm-patient-registration-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/131.js +1 -0
- package/dist/131.js.map +1 -0
- package/dist/132.js +1 -0
- package/dist/132.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/247.js +1 -0
- package/dist/247.js.map +1 -0
- package/dist/28.js +1 -0
- package/dist/28.js.map +1 -0
- package/dist/317.js +1 -0
- package/dist/317.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/371.js +1 -0
- package/dist/371.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/45.js +1 -0
- package/dist/45.js.map +1 -0
- package/dist/459.js +12 -0
- package/dist/459.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/640.js +1 -0
- package/dist/640.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/709.js +1 -0
- package/dist/709.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/742.js +1 -0
- package/dist/742.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/806.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/940.js +1 -0
- package/dist/940.js.map +1 -0
- package/dist/957.js +15 -0
- package/dist/957.js.map +1 -0
- package/dist/digihmis-esm-patient-registration-app.js +6 -0
- package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
- package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/active-visit-table/active-visit-table.component.tsx +240 -0
- package/src/active-visit-table/active-visits.resource.ts +338 -0
- package/src/active-visit-table/active-visits.scss +211 -0
- package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
- package/src/checkin-form/base-visit-type.component.tsx +102 -0
- package/src/checkin-form/base-visit-type.scss +75 -0
- package/src/checkin-form/checkin-form.scss +167 -0
- package/src/checkin-form/checkin-form.workspace.tsx +703 -0
- package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
- package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
- package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
- package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
- package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
- package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
- package/src/checkin-form/location-selector.component.tsx +86 -0
- package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
- package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
- package/src/checkin-form/visit-attribute-type.scss +5 -0
- package/src/checkin-form/visit-date-time.component.tsx +201 -0
- package/src/checkin-form/visit-form.resource.ts +308 -0
- package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
- package/src/components/custom-card/compact-patient-banner.scss +34 -0
- package/src/components/custom-card/custom-loader.component.tsx +22 -0
- package/src/components/custom-card/patient-search-context.ts +30 -0
- package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
- package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
- package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
- package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
- package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
- package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
- package/src/config-schema.ts +511 -0
- package/src/constant.ts +6 -0
- package/src/constants/index.ts +1 -0
- package/src/dashboard.meta.ts +8 -0
- package/src/declarations.d.ts +6 -0
- package/src/empty-svg/empty-svg.component.tsx +565 -0
- package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
- package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
- package/src/identifier-workspace/identifier-configure.scss +106 -0
- package/src/identifier-workspace/identifier.resource.ts +90 -0
- package/src/index.ts +33 -0
- package/src/left-panel/left-panel.component.tsx +54 -0
- package/src/left-panel/left-panel.scss +13 -0
- package/src/page-header/page-header.component.tsx +35 -0
- package/src/page-header/page-header.scss +76 -0
- package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
- package/src/patient-tags/deceased-patient-tag.scss +15 -0
- package/src/patient-tags/patient-tags.resource.ts +30 -0
- package/src/patient-tags/visit-tag.extension.tsx +43 -0
- package/src/patient-tags/visit-tag.scss +19 -0
- package/src/registration-form/form-manager.ts +449 -0
- package/src/registration-form/registration-form.component.tsx +1565 -0
- package/src/registration-form/registration-form.resource.ts +264 -0
- package/src/registration-form/registration-form.scss +282 -0
- package/src/routes.json +62 -0
- package/src/search-bar/search-bar.component.tsx +285 -0
- package/src/search-bar/search-bar.resource.ts +274 -0
- package/src/search-bar/search-bar.scss +427 -0
- package/src/search-dashboard/search-dashboard.component.tsx +17 -0
- package/src/search-dashboard/search-dashboard.scss +13 -0
- package/src/setup-tests.ts +1 -0
- package/src/type/index.ts +463 -0
- package/src/utils/fhir-mapper.ts +73 -0
- package/src/visit-summary/visit-summary.component.tsx +32 -0
- package/src/visit-summary/visit-summary.resource.ts +56 -0
- package/src/visit-summary/visit-summary.scss +74 -0
- package/translations/am.json +112 -0
- package/translations/ar.json +120 -0
- package/translations/en.json +112 -0
- package/translations/es.json +114 -0
- package/translations/fr.json +114 -0
- package/translations/he.json +114 -0
- package/translations/km.json +111 -0
- package/translations/pt.json +114 -0
- package/translations/sw.json +112 -0
- package/translations/vi.json +111 -0
- package/translations/zh.json +111 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { UserFollow } from '@carbon/react/icons';
|
|
4
|
+
import { Button, Column, Search, Tile } from '@carbon/react';
|
|
5
|
+
import { navigate } from '@openmrs/esm-framework';
|
|
6
|
+
import VisitSummaryTile from '../visit-summary/visit-summary.component';
|
|
7
|
+
import { EmptySvg } from '../empty-svg/empty-svg.component';
|
|
8
|
+
import { baseSpaPath } from '../constants';
|
|
9
|
+
import styles from './search-bar.scss';
|
|
10
|
+
import { useInfinitePatientSearch, useRecentlyViewedPatients, useRestPatients } from './search-bar.resource';
|
|
11
|
+
import Loader from '../components/custom-card/custom-loader.component';
|
|
12
|
+
import CompactPatientBanner from '../components/custom-card/compact-patient-banner.component';
|
|
13
|
+
import { PatientSearchContextProvider } from '../components/custom-card/patient-search-context';
|
|
14
|
+
import ActiveVisitsTable from '../active-visit-table/active-visit-table.component';
|
|
15
|
+
|
|
16
|
+
const SearchBar: React.FC = () => {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
19
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
|
20
|
+
const [showResults, setShowResults] = useState(false);
|
|
21
|
+
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
22
|
+
const resultsRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
24
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
setDebouncedSearchTerm(searchTerm);
|
|
29
|
+
}, 300);
|
|
30
|
+
|
|
31
|
+
return () => clearTimeout(timer);
|
|
32
|
+
}, [searchTerm]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
36
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
37
|
+
setIsInputFocused(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
42
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const isSearchActive = debouncedSearchTerm.trim().length > 0;
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
data: searchResults,
|
|
49
|
+
isLoading: isSearchLoading,
|
|
50
|
+
fetchError: searchError,
|
|
51
|
+
hasMore,
|
|
52
|
+
isValidating,
|
|
53
|
+
setPage,
|
|
54
|
+
currentPage,
|
|
55
|
+
totalResults,
|
|
56
|
+
} = useInfinitePatientSearch(debouncedSearchTerm, false, isSearchActive, 10);
|
|
57
|
+
|
|
58
|
+
const {
|
|
59
|
+
recentlyViewedPatientUuids,
|
|
60
|
+
updateRecentlyViewedPatients,
|
|
61
|
+
isLoadingPatients: isLoadingRecent,
|
|
62
|
+
} = useRecentlyViewedPatients(true);
|
|
63
|
+
|
|
64
|
+
const { data: recentPatients, isLoading: isLoadingRecentDetails } = useRestPatients(
|
|
65
|
+
recentlyViewedPatientUuids,
|
|
66
|
+
recentlyViewedPatientUuids.length > 0,
|
|
67
|
+
10,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Filter out deceased patients from recently viewed
|
|
71
|
+
const filteredRecentPatients = useMemo(() => {
|
|
72
|
+
if (!recentPatients) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return recentPatients.filter((patient) => !patient?.person?.dead);
|
|
76
|
+
}, [recentPatients]);
|
|
77
|
+
|
|
78
|
+
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
|
+
const value = e.target.value;
|
|
80
|
+
setSearchTerm(value);
|
|
81
|
+
setShowResults(value.trim().length > 0);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const handleSearch = useCallback(() => {
|
|
85
|
+
if (searchTerm.trim()) {
|
|
86
|
+
setShowResults(true);
|
|
87
|
+
setIsInputFocused(true);
|
|
88
|
+
}
|
|
89
|
+
}, [searchTerm]);
|
|
90
|
+
|
|
91
|
+
const handleClearSearch = useCallback(() => {
|
|
92
|
+
setSearchTerm('');
|
|
93
|
+
setDebouncedSearchTerm('');
|
|
94
|
+
setShowResults(false);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleNavigateToRegisterPatient = useCallback(() => {
|
|
98
|
+
navigate({ to: `${baseSpaPath}/patient-registration` });
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const handleLoadMore = useCallback(() => {
|
|
102
|
+
if (hasMore && !isValidating) {
|
|
103
|
+
setPage(currentPage + 1);
|
|
104
|
+
}
|
|
105
|
+
}, [hasMore, isValidating, setPage, currentPage]);
|
|
106
|
+
|
|
107
|
+
const handleInputFocus = useCallback(() => {
|
|
108
|
+
setIsInputFocused(true);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const resultsContainer = resultsRef.current;
|
|
113
|
+
if (!resultsContainer) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const handleScroll = () => {
|
|
118
|
+
const { scrollTop, scrollHeight, clientHeight } = resultsContainer;
|
|
119
|
+
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
|
120
|
+
handleLoadMore();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
resultsContainer.addEventListener('scroll', handleScroll);
|
|
125
|
+
return () => resultsContainer.removeEventListener('scroll', handleScroll);
|
|
126
|
+
}, [handleLoadMore]);
|
|
127
|
+
|
|
128
|
+
const handleKeyPress = useCallback(
|
|
129
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
130
|
+
if (e.key === 'Enter') {
|
|
131
|
+
handleSearch();
|
|
132
|
+
}
|
|
133
|
+
if (e.key === 'Escape') {
|
|
134
|
+
setIsInputFocused(false);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[handleSearch],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Updated to use filteredRecentPatients
|
|
141
|
+
const showEmptyState = !isInputFocused && !searchTerm.trim();
|
|
142
|
+
const showRecentlyViewed =
|
|
143
|
+
isInputFocused && !searchTerm.trim() && filteredRecentPatients && filteredRecentPatients.length > 0;
|
|
144
|
+
const showRecentLoading = isInputFocused && !searchTerm.trim() && (isLoadingRecent || isLoadingRecentDetails);
|
|
145
|
+
const showSearchLoading = isSearchLoading && isSearchActive && isInputFocused;
|
|
146
|
+
const showNoResults =
|
|
147
|
+
isInputFocused &&
|
|
148
|
+
showResults &&
|
|
149
|
+
!isSearchLoading &&
|
|
150
|
+
(!searchResults || searchResults.length === 0) &&
|
|
151
|
+
debouncedSearchTerm.trim();
|
|
152
|
+
const showSearchResults = isInputFocused && showResults && searchResults && searchResults.length > 0;
|
|
153
|
+
|
|
154
|
+
const patientSearchContextValue = {
|
|
155
|
+
patientClickSideEffect: (patientUuid: string) => {
|
|
156
|
+
updateRecentlyViewedPatients(patientUuid);
|
|
157
|
+
setIsInputFocused(false);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<PatientSearchContextProvider value={patientSearchContextValue}>
|
|
163
|
+
<div ref={containerRef}>
|
|
164
|
+
<Tile className={styles.patientSearchTile}>
|
|
165
|
+
<VisitSummaryTile />
|
|
166
|
+
|
|
167
|
+
<div className={styles.searchForm}>
|
|
168
|
+
<div className={styles.searchRow}>
|
|
169
|
+
<Column className={styles.identifierNumberColumn}>
|
|
170
|
+
<div className={styles.searchInputContainer}>
|
|
171
|
+
<Search
|
|
172
|
+
ref={searchInputRef}
|
|
173
|
+
labelText={t('enterIdentifierNumber', 'Enter identifier number')}
|
|
174
|
+
className={styles.formSearch}
|
|
175
|
+
placeholder={t('enterIdentifierNumber', 'Enter identifier number')}
|
|
176
|
+
id="formSearchHealthWorkers"
|
|
177
|
+
size="lg"
|
|
178
|
+
value={searchTerm}
|
|
179
|
+
onChange={handleSearchChange}
|
|
180
|
+
onKeyPress={handleKeyPress}
|
|
181
|
+
onClear={handleClearSearch}
|
|
182
|
+
onFocus={handleInputFocus}
|
|
183
|
+
/>
|
|
184
|
+
<Button
|
|
185
|
+
kind="secondary"
|
|
186
|
+
size="lg"
|
|
187
|
+
className={styles.attachedSearchButton}
|
|
188
|
+
onClick={handleSearch}
|
|
189
|
+
disabled={!searchTerm.trim()}>
|
|
190
|
+
{t('search', 'Search')}
|
|
191
|
+
</Button>
|
|
192
|
+
<div className={styles.registrationButton} />
|
|
193
|
+
<Button kind="primary" size="lg" renderIcon={UserFollow} onClick={handleNavigateToRegisterPatient}>
|
|
194
|
+
{t('registerAPatient', 'Register a patient')}
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</Column>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{showEmptyState && (
|
|
202
|
+
<div className={styles.emptyStateContainer}>
|
|
203
|
+
<EmptySvg />
|
|
204
|
+
<div className={styles.title}>{t('searchForPatient', 'Search for a patient')}</div>
|
|
205
|
+
<div className={styles.subTitle}>
|
|
206
|
+
{t('enterIdOrScanCard', 'Enter an ID number or scan the patient card')}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{(showRecentLoading || showRecentlyViewed || showSearchLoading || showSearchResults || showNoResults) && (
|
|
212
|
+
<div className={styles.dropdownContainer}>
|
|
213
|
+
{showRecentLoading && (
|
|
214
|
+
<div className={styles.loadingContainer}>
|
|
215
|
+
{[...Array(10)].map((_, index) => (
|
|
216
|
+
<Loader key={`skeleton-recent-${index}`} />
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{showRecentlyViewed && (
|
|
222
|
+
<div className={styles.dropdownSection}>
|
|
223
|
+
<div className={styles.dropdownHeader}>
|
|
224
|
+
<span className={styles.dropdownTitle}>{t('recentlyViewed', 'Recently viewed patients')}</span>
|
|
225
|
+
<span className={styles.dropdownCount}>{filteredRecentPatients.length}</span>
|
|
226
|
+
</div>
|
|
227
|
+
<div className={styles.dropdownResults}>
|
|
228
|
+
<CompactPatientBanner patients={filteredRecentPatients} />
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{showSearchLoading && (
|
|
234
|
+
<div className={styles.loadingContainer}>
|
|
235
|
+
{[...Array(3)].map((_, index) => (
|
|
236
|
+
<Loader key={`skeleton-search-${index}`} />
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{showNoResults && (
|
|
242
|
+
<div className={styles.dropdownEmptyState}>
|
|
243
|
+
<EmptySvg />
|
|
244
|
+
<div className={styles.title}>{t('noPatientFound', 'No patient found')}</div>
|
|
245
|
+
<div className={styles.subTitle}>
|
|
246
|
+
{t('tryDifferentSearch', 'Try searching with a different identifier or name')}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{showSearchResults && (
|
|
252
|
+
<div className={styles.dropdownSection}>
|
|
253
|
+
<div className={styles.dropdownHeader}>
|
|
254
|
+
<span className={styles.dropdownTitle}>
|
|
255
|
+
{t('searchResultsCount', '{{count}} result(s) found', { count: totalResults })}
|
|
256
|
+
</span>
|
|
257
|
+
</div>
|
|
258
|
+
<div className={styles.dropdownResults} ref={resultsRef}>
|
|
259
|
+
<CompactPatientBanner patients={searchResults} />
|
|
260
|
+
{isValidating && (
|
|
261
|
+
<div className={styles.loadingMore}>
|
|
262
|
+
<Loader />
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{searchError && isInputFocused && (
|
|
272
|
+
<div className={styles.errorContainer}>
|
|
273
|
+
<div className={styles.errorTitle}>{t('errorSearching', 'Error searching for patients')}</div>
|
|
274
|
+
<div className={styles.errorMessage}>{searchError.message}</div>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
<ActiveVisitsTable />
|
|
279
|
+
</Tile>
|
|
280
|
+
</div>
|
|
281
|
+
</PatientSearchContextProvider>
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export default SearchBar;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import useSWRInfinite from 'swr/infinite';
|
|
4
|
+
import { openmrsFetch, useSession, type FetchResponse, restBaseUrl } from '@openmrs/esm-framework';
|
|
5
|
+
import { PatientSearchResponse, SearchedPatient, User } from '../type';
|
|
6
|
+
|
|
7
|
+
type InfinitePatientSearchResponse = FetchResponse<{
|
|
8
|
+
results: Array<SearchedPatient>;
|
|
9
|
+
links: Array<{ rel: 'prev' | 'next' }>;
|
|
10
|
+
totalCount: number;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
const patientProperties = [
|
|
14
|
+
'patientId',
|
|
15
|
+
'uuid',
|
|
16
|
+
'identifiers',
|
|
17
|
+
'display',
|
|
18
|
+
'patientIdentifier:(uuid,identifier)',
|
|
19
|
+
'person:(gender,age,birthdate,birthdateEstimated,personName,addresses,display,dead,deathDate)',
|
|
20
|
+
'attributes:(value,attributeType:(uuid,display))',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const patientSearchCustomRepresentation = `custom:(${patientProperties.join(',')})`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A custom React hook for implementing infinite scrolling patient search.
|
|
27
|
+
*
|
|
28
|
+
* @param searchQuery - The string to search for in patient records.
|
|
29
|
+
* @param includeDead - Whether to include deceased patients in the search results.
|
|
30
|
+
* @param isSearching - Whether the search should be active. Defaults to true.
|
|
31
|
+
* @param resultsToFetch - The number of results to fetch per page. Defaults to 10.
|
|
32
|
+
* @param customRepresentation - Custom representation string for the patient data. Defaults to patientSearchCustomRepresentation.
|
|
33
|
+
*
|
|
34
|
+
* @returns An object containing:
|
|
35
|
+
* - data: Array of patient search results
|
|
36
|
+
* - isLoading: Boolean indicating if the initial data is loading
|
|
37
|
+
* - fetchError: Any error that occurred during fetching
|
|
38
|
+
* - hasMore: Boolean indicating if there are more results to load
|
|
39
|
+
* - isValidating: Boolean indicating if new data is being loaded
|
|
40
|
+
* - setPage: Function to load the next page of results
|
|
41
|
+
* - currentPage: The current page number
|
|
42
|
+
* - totalResults: The total number of results for the search query
|
|
43
|
+
*/
|
|
44
|
+
export function useInfinitePatientSearch(
|
|
45
|
+
searchQuery: string,
|
|
46
|
+
includeDead: boolean,
|
|
47
|
+
isSearching: boolean = true,
|
|
48
|
+
resultsToFetch: number = 10,
|
|
49
|
+
customRepresentation: string = patientSearchCustomRepresentation,
|
|
50
|
+
): PatientSearchResponse {
|
|
51
|
+
const getUrl = useCallback(
|
|
52
|
+
(
|
|
53
|
+
page: number,
|
|
54
|
+
prevPageData: FetchResponse<{ results: Array<SearchedPatient>; links: Array<{ rel: 'prev' | 'next' }> }>,
|
|
55
|
+
) => {
|
|
56
|
+
// If we have previous page data and it has no 'next' link, we've reached the end
|
|
57
|
+
if (prevPageData && prevPageData?.data && !prevPageData?.data?.links?.some((link) => link.rel === 'next')) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Ensure searchQuery is not empty
|
|
62
|
+
const trimmedQuery = searchQuery?.trim();
|
|
63
|
+
if (!trimmedQuery) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const baseUrl = `${restBaseUrl}/patient`;
|
|
68
|
+
const params = new URLSearchParams({
|
|
69
|
+
q: trimmedQuery,
|
|
70
|
+
v: customRepresentation,
|
|
71
|
+
includeDead: String(includeDead),
|
|
72
|
+
limit: String(resultsToFetch),
|
|
73
|
+
totalCount: 'true',
|
|
74
|
+
startIndex: String(page * resultsToFetch),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return `${baseUrl}?${params.toString()}`;
|
|
78
|
+
},
|
|
79
|
+
[searchQuery, customRepresentation, includeDead, resultsToFetch],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Only fetch if searching is enabled and we have a valid search query
|
|
83
|
+
const shouldFetch = isSearching && searchQuery?.trim().length > 0;
|
|
84
|
+
|
|
85
|
+
const { data, isLoading, isValidating, setSize, error, size } = useSWRInfinite<InfinitePatientSearchResponse, Error>(
|
|
86
|
+
shouldFetch ? getUrl : null,
|
|
87
|
+
openmrsFetch,
|
|
88
|
+
{
|
|
89
|
+
revalidateFirstPage: false,
|
|
90
|
+
revalidateOnFocus: false,
|
|
91
|
+
keepPreviousData: true,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const mappedData = useMemo(() => {
|
|
96
|
+
if (!data || data.length === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return data.flatMap((res) => res?.data?.results ?? []);
|
|
100
|
+
}, [data]);
|
|
101
|
+
|
|
102
|
+
return useMemo(
|
|
103
|
+
() => ({
|
|
104
|
+
data: mappedData,
|
|
105
|
+
isLoading,
|
|
106
|
+
fetchError: error,
|
|
107
|
+
hasMore: data?.at(-1)?.data?.links?.some((link) => link.rel === 'next') ?? false,
|
|
108
|
+
isValidating,
|
|
109
|
+
setPage: setSize,
|
|
110
|
+
currentPage: size,
|
|
111
|
+
totalResults: data?.[0]?.data?.totalCount ?? 0,
|
|
112
|
+
}),
|
|
113
|
+
[mappedData, isLoading, error, data, isValidating, setSize, size],
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A custom React hook for managing and retrieving the list of recently viewed patients.
|
|
119
|
+
*
|
|
120
|
+
* @param showRecentlySearchedPatients - A boolean flag to enable/disable the feature. Defaults to false.
|
|
121
|
+
* @returns An object containing:
|
|
122
|
+
* - error: Any error that occurred during fetching
|
|
123
|
+
* - isLoadingPatients: Boolean indicating if the data is being loaded
|
|
124
|
+
* - recentlyViewedPatientUuids: Array of UUIDs of recently viewed patients
|
|
125
|
+
* - updateRecentlyViewedPatients: Function to update the list with a new patient UUID
|
|
126
|
+
* - mutateUserProperties: Function to trigger a re-fetch of user properties
|
|
127
|
+
*/
|
|
128
|
+
export function useRecentlyViewedPatients(showRecentlySearchedPatients: boolean = false) {
|
|
129
|
+
const { user } = useSession();
|
|
130
|
+
const userUuid = user?.uuid;
|
|
131
|
+
const shouldFetchRecentlyViewedPatients = showRecentlySearchedPatients && userUuid;
|
|
132
|
+
const url = `${restBaseUrl}/user/${userUuid}`;
|
|
133
|
+
|
|
134
|
+
// This request will be loaded from the SWR cache as a preload request happens ahead when the user hovers over the search icon.
|
|
135
|
+
const { data, error, isLoading, mutate } = useSWR<FetchResponse<User>, Error>(
|
|
136
|
+
shouldFetchRecentlyViewedPatients ? url : null,
|
|
137
|
+
openmrsFetch,
|
|
138
|
+
{
|
|
139
|
+
revalidateOnFocus: false,
|
|
140
|
+
revalidateOnReconnect: false,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const userProperties = data?.data?.userProperties;
|
|
145
|
+
|
|
146
|
+
const patientsVisited = useMemo(() => {
|
|
147
|
+
if (!userProperties?.patientsVisited) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return userProperties.patientsVisited.split(',').filter(Boolean);
|
|
151
|
+
}, [userProperties]);
|
|
152
|
+
|
|
153
|
+
const updateRecentlyViewedPatients = useCallback(
|
|
154
|
+
async (patientUuid: string) => {
|
|
155
|
+
if (!patientUuid || !userUuid) {
|
|
156
|
+
console.error('Invalid patientUuid or userUuid');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const uniquePatients = Array.from(new Set([patientUuid, ...patientsVisited]));
|
|
162
|
+
const mostRecentPatients = uniquePatients.slice(0, 10);
|
|
163
|
+
const newUserProperties = {
|
|
164
|
+
...userProperties,
|
|
165
|
+
patientsVisited: mostRecentPatients.join(','),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const response = await openmrsFetch(url, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: {
|
|
171
|
+
'content-type': 'application/json',
|
|
172
|
+
},
|
|
173
|
+
body: {
|
|
174
|
+
userProperties: newUserProperties,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Trigger revalidation of user data
|
|
179
|
+
mutate();
|
|
180
|
+
|
|
181
|
+
return response;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error('Error updating recently viewed patients:', error);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[patientsVisited, url, userProperties, userUuid, mutate],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return useMemo(
|
|
191
|
+
() => ({
|
|
192
|
+
error,
|
|
193
|
+
isLoadingPatients: isLoading,
|
|
194
|
+
recentlyViewedPatientUuids: patientsVisited,
|
|
195
|
+
updateRecentlyViewedPatients,
|
|
196
|
+
mutateUserProperties: mutate,
|
|
197
|
+
}),
|
|
198
|
+
[error, isLoading, mutate, patientsVisited, updateRecentlyViewedPatients],
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A custom React hook for fetching patient data from a REST API based on a list of patient UUIDs.
|
|
204
|
+
*
|
|
205
|
+
* @param patientUuids - An array of patient UUIDs to fetch data for. If null, no data will be fetched.
|
|
206
|
+
* @param isSearching - A boolean flag to determine if the search should be performed. Defaults to true.
|
|
207
|
+
* @param resultsToFetch - The number of results to fetch at a time. Defaults to 10.
|
|
208
|
+
* @param customRepresentation - A string representing the custom representation of patient data to fetch. Defaults to a predefined value 'v'.
|
|
209
|
+
*
|
|
210
|
+
* @returns An object containing:
|
|
211
|
+
* - data: An array of fetched patient data
|
|
212
|
+
* - isLoading: A boolean indicating if the initial data is being loaded
|
|
213
|
+
* - fetchError: Any error that occurred during fetching
|
|
214
|
+
* - hasMore: A boolean indicating if there are more patients to load
|
|
215
|
+
* - isValidating: A boolean indicating if new data is being loaded
|
|
216
|
+
* - setPage: A function to load more data
|
|
217
|
+
* - currentPage: The current page of results
|
|
218
|
+
* - totalResults: The total number of patients to be fetched
|
|
219
|
+
*/
|
|
220
|
+
export function useRestPatients(
|
|
221
|
+
patientUuids: string[] | null,
|
|
222
|
+
isSearching: boolean = true,
|
|
223
|
+
resultsToFetch: number = 10,
|
|
224
|
+
customRepresentation: string = patientSearchCustomRepresentation,
|
|
225
|
+
) {
|
|
226
|
+
const getPatientUrl = useCallback(
|
|
227
|
+
(index: number) => {
|
|
228
|
+
if (!patientUuids || index >= patientUuids.length) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const patientUuid = patientUuids[index];
|
|
233
|
+
if (!patientUuid) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return `${restBaseUrl}/patient/${patientUuid}?v=${customRepresentation}`;
|
|
238
|
+
},
|
|
239
|
+
[patientUuids, customRepresentation],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const shouldFetch = isSearching && patientUuids !== null && patientUuids.length > 0;
|
|
243
|
+
|
|
244
|
+
const { data, isLoading, isValidating, setSize, error, size } = useSWRInfinite<FetchResponse<SearchedPatient>, Error>(
|
|
245
|
+
shouldFetch ? getPatientUrl : null,
|
|
246
|
+
openmrsFetch,
|
|
247
|
+
{
|
|
248
|
+
keepPreviousData: true,
|
|
249
|
+
revalidateFirstPage: false,
|
|
250
|
+
initialSize: patientUuids ? Math.min(resultsToFetch, patientUuids.length) : 0,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const mappedData = useMemo(() => {
|
|
255
|
+
if (!data || data.length === 0) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return data.flatMap((res) => res?.data).filter(Boolean);
|
|
259
|
+
}, [data]);
|
|
260
|
+
|
|
261
|
+
return useMemo(
|
|
262
|
+
() => ({
|
|
263
|
+
data: mappedData,
|
|
264
|
+
isLoading,
|
|
265
|
+
fetchError: error,
|
|
266
|
+
hasMore: patientUuids ? size < patientUuids.length : false,
|
|
267
|
+
isValidating,
|
|
268
|
+
setPage: setSize,
|
|
269
|
+
currentPage: size,
|
|
270
|
+
totalResults: patientUuids?.length ?? 0,
|
|
271
|
+
}),
|
|
272
|
+
[mappedData, isLoading, error, patientUuids, size, isValidating, setSize],
|
|
273
|
+
);
|
|
274
|
+
}
|