@palladium-ethiopia/esm-admin-app 5.4.2-pre.100
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/.turbo/turbo-build.log +15 -0
- package/README.md +12 -0
- package/dist/117.js +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/41.js +1 -0
- package/dist/41.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/555.js +1 -0
- package/dist/555.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/672.js +15 -0
- package/dist/672.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/710.js +1 -0
- package/dist/710.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/826.js +1 -0
- package/dist/826.js.map +1 -0
- package/dist/914.js +27 -0
- package/dist/914.js.map +1 -0
- package/dist/926.js +17 -0
- package/dist/926.js.map +1 -0
- package/dist/ethiopia-esm-admin-app.js +6 -0
- package/dist/ethiopia-esm-admin-app.js.buildmanifest.json +556 -0
- package/dist/ethiopia-esm-admin-app.js.map +1 -0
- package/dist/main.js +32 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +8 -0
- package/package.json +52 -0
- package/rspack.config.js +1 -0
- package/src/components/confirm-modal/confirmation-operation-modal.component.tsx +43 -0
- package/src/components/confirm-modal/confirmation-operation.test.tsx +69 -0
- package/src/components/dashboard/dashboard.component.tsx +131 -0
- package/src/components/dashboard/dashboard.scss +38 -0
- package/src/components/dashboard/etl-dashboard.component.tsx +11 -0
- package/src/components/empty-state/empty-state-log.components.tsx +20 -0
- package/src/components/empty-state/empty-state-log.scss +28 -0
- package/src/components/empty-state/empty-state-log.test.tsx +24 -0
- package/src/components/facility-setup/card.component.tsx +16 -0
- package/src/components/facility-setup/facility-info.component.tsx +142 -0
- package/src/components/facility-setup/facility-info.scss +87 -0
- package/src/components/facility-setup/facility-setup.component.tsx +21 -0
- package/src/components/facility-setup/facility-setup.resource.tsx +7 -0
- package/src/components/facility-setup/facility-setup.scss +38 -0
- package/src/components/facility-setup/header/header.component.tsx +23 -0
- package/src/components/facility-setup/header/header.scss +19 -0
- package/src/components/header/header-illustration.component.tsx +13 -0
- package/src/components/header/header.component.tsx +28 -0
- package/src/components/header/header.scss +19 -0
- package/src/components/hook/healthWorkerAdapter.ts +213 -0
- package/src/components/hook/useFacilityInfo.tsx +37 -0
- package/src/components/hook/useSystemRoleSetting.tsx +33 -0
- package/src/components/locations/auto-suggest/autosuggest.component.tsx +149 -0
- package/src/components/locations/auto-suggest/autosuggest.scss +61 -0
- package/src/components/locations/auto-suggest/location-autosuggest.component.tsx +94 -0
- package/src/components/locations/auto-suggest/location-autosuggest.scss +48 -0
- package/src/components/locations/common/results-tile.component.tsx +45 -0
- package/src/components/locations/common/results-tile.scss +86 -0
- package/src/components/locations/forms/add-location/add-location.workspace.scss +34 -0
- package/src/components/locations/forms/add-location/add-location.workspace.tsx +200 -0
- package/src/components/locations/forms/search-location/search-location.workspace.scss +79 -0
- package/src/components/locations/forms/search-location/search-location.workspace.tsx +215 -0
- package/src/components/locations/header/header.component.tsx +48 -0
- package/src/components/locations/header/header.scss +58 -0
- package/src/components/locations/helpers/index.ts +16 -0
- package/src/components/locations/home/home-locations.component.tsx +18 -0
- package/src/components/locations/home/home-locations.scss +8 -0
- package/src/components/locations/hooks/UseFacilityLocations.ts +12 -0
- package/src/components/locations/hooks/useLocation.ts +18 -0
- package/src/components/locations/hooks/useLocationTags.ts +15 -0
- package/src/components/locations/tables/locations-table.component.tsx +243 -0
- package/src/components/locations/tables/locations-table.resource.ts +26 -0
- package/src/components/locations/tables/locations-table.scss +115 -0
- package/src/components/locations/types/index.ts +120 -0
- package/src/components/locations/utils/index.ts +5 -0
- package/src/components/logs-table/operation-log-resource.ts +41 -0
- package/src/components/logs-table/operation-log-table.component.tsx +120 -0
- package/src/components/logs-table/operation-log.scss +10 -0
- package/src/components/logs-table/operation-log.test.tsx +47 -0
- package/src/components/modal/hwr-confirmation.modal.scss +21 -0
- package/src/components/modal/hwr-confirmation.modal.tsx +170 -0
- package/src/components/modal/hwr-empty.modal.component.tsx +54 -0
- package/src/components/modal/hwr-sync.modal.scss +30 -0
- package/src/components/modal/hwr-sync.modal.tsx +209 -0
- package/src/components/modal/hwr-sync.resource.ts +23 -0
- package/src/components/provider-banner/provider-banner.component.tsx +106 -0
- package/src/components/provider-banner/provider-banner.module.scss +51 -0
- package/src/components/provider-banner/provider-banner.resource.ts +29 -0
- package/src/components/side-menu/left-panel.scss +42 -0
- package/src/components/side-menu/left-pannel.component.tsx +22 -0
- package/src/components/users/header/header.scss +90 -0
- package/src/components/users/header/user-management-header.component.tsx +42 -0
- package/src/components/users/manage-users/hooks/useProviderAttributeMapping.ts +110 -0
- package/src/components/users/manage-users/hooks/useUserFormSteps.ts +119 -0
- package/src/components/users/manage-users/hooks/useUserFormSubmission.ts +264 -0
- package/src/components/users/manage-users/hooks/useUserManagementForm.ts +122 -0
- package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-list/user-role-scope-list.component.tsx +177 -0
- package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-fields.scss +117 -0
- package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope-fields.component.tsx +290 -0
- package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope.workspace.tsx +316 -0
- package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/userRoleScopeFormSchema.tsx +43 -0
- package/src/components/users/manage-users/manage-user.component.tsx +19 -0
- package/src/components/users/manage-users/manage-user.scss +31 -0
- package/src/components/users/manage-users/provider-autosuggest.component.tsx +117 -0
- package/src/components/users/manage-users/provider-search.resource.ts +34 -0
- package/src/components/users/manage-users/sections/demographic-section.component.tsx +156 -0
- package/src/components/users/manage-users/sections/login-section.component.tsx +88 -0
- package/src/components/users/manage-users/sections/provider-section.component.tsx +270 -0
- package/src/components/users/manage-users/sections/roles-section.component.tsx +88 -0
- package/src/components/users/manage-users/user-details/user-detail.scss +75 -0
- package/src/components/users/manage-users/user-details/user-details.component.tsx +182 -0
- package/src/components/users/manage-users/user-list/user-list.component.tsx +378 -0
- package/src/components/users/manage-users/user-list/user-list.resource.ts +30 -0
- package/src/components/users/manage-users/user-list/user-list.scss +37 -0
- package/src/components/users/manage-users/user-management.constants.ts +20 -0
- package/src/components/users/manage-users/user-management.utils.ts +100 -0
- package/src/components/users/manage-users/user-management.workspace.scss +172 -0
- package/src/components/users/manage-users/user-management.workspace.tsx +334 -0
- package/src/components/users/userManagementFormSchema.tsx +179 -0
- package/src/config-schema.ts +142 -0
- package/src/constants.ts +50 -0
- package/src/declarations.d.ts +2 -0
- package/src/index.ts +55 -0
- package/src/left-pannel-link.component.tsx +40 -0
- package/src/root.component.tsx +39 -0
- package/src/root.scss +12 -0
- package/src/routes.json +100 -0
- package/src/setup-tests.ts +1 -0
- package/src/types/index.ts +385 -0
- package/src/user-management.resources.ts +232 -0
- package/src/utils/utils.ts +20 -0
- package/translations/am.json +159 -0
- package/translations/en.json +159 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Button, ButtonSet, FilterableMultiSelect, Form, FormGroup, InlineLoading, Stack } from '@carbon/react';
|
|
2
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
3
|
+
import {
|
|
4
|
+
type DefaultWorkspaceProps,
|
|
5
|
+
ResponsiveWrapper,
|
|
6
|
+
restBaseUrl,
|
|
7
|
+
showSnackbar,
|
|
8
|
+
useLayoutType,
|
|
9
|
+
} from '@openmrs/esm-framework';
|
|
10
|
+
import classNames from 'classnames';
|
|
11
|
+
import React, { useEffect, useState } from 'react';
|
|
12
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
13
|
+
import { useTranslation } from 'react-i18next';
|
|
14
|
+
import { mutate } from 'swr';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { LocationAutosuggest } from '../../auto-suggest/location-autosuggest.component';
|
|
17
|
+
import ResultsTile from '../../common/results-tile.component';
|
|
18
|
+
import { extractErrorMessagesFromResponse } from '../../helpers';
|
|
19
|
+
import { saveOrUpdateLocation } from '../../hooks/useLocation';
|
|
20
|
+
import { useLocationTags } from '../../hooks/useLocationTags';
|
|
21
|
+
import { LocationResponse } from '../../types';
|
|
22
|
+
import styles from './search-location.workspace.scss';
|
|
23
|
+
|
|
24
|
+
type AddLocationWorkspaceProps = DefaultWorkspaceProps & {
|
|
25
|
+
location?: LocationResponse;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const locationFormSchema = z.object({
|
|
29
|
+
name: z.string().min(1, { message: 'Location name is required' }),
|
|
30
|
+
tags: z
|
|
31
|
+
.object({
|
|
32
|
+
uuid: z.string().uuid(),
|
|
33
|
+
display: z.string(),
|
|
34
|
+
})
|
|
35
|
+
.array(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
type LocationFormType = z.infer<typeof locationFormSchema>;
|
|
39
|
+
|
|
40
|
+
const SearchLocationWorkspace: React.FC<AddLocationWorkspaceProps> = ({
|
|
41
|
+
closeWorkspace,
|
|
42
|
+
closeWorkspaceWithSavedChanges,
|
|
43
|
+
promptBeforeClosing,
|
|
44
|
+
location,
|
|
45
|
+
}) => {
|
|
46
|
+
const { t } = useTranslation();
|
|
47
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
48
|
+
const { locationTagList: Tags } = useLocationTags();
|
|
49
|
+
const [selectedLocation, setSelectedLocation] = useState<LocationResponse | null>(null);
|
|
50
|
+
|
|
51
|
+
const handleMutation = () => {
|
|
52
|
+
const url = `${restBaseUrl}/location`;
|
|
53
|
+
mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
handleSubmit,
|
|
58
|
+
control,
|
|
59
|
+
getValues,
|
|
60
|
+
setValue,
|
|
61
|
+
reset,
|
|
62
|
+
formState: { isSubmitting, isDirty, errors },
|
|
63
|
+
} = useForm<LocationFormType>({
|
|
64
|
+
resolver: zodResolver(locationFormSchema),
|
|
65
|
+
defaultValues: {
|
|
66
|
+
name: location?.name || '',
|
|
67
|
+
tags: location?.tags || [],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const onSubmit = async (data: LocationFormType) => {
|
|
72
|
+
try {
|
|
73
|
+
const locationUuid = selectedLocation?.uuid || location?.uuid;
|
|
74
|
+
|
|
75
|
+
const locationTagsUuid = data.tags.map((tag) => tag.uuid);
|
|
76
|
+
|
|
77
|
+
const locationPayload = {
|
|
78
|
+
tags: locationTagsUuid,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await saveOrUpdateLocation(locationUuid, locationPayload);
|
|
82
|
+
|
|
83
|
+
showSnackbar({
|
|
84
|
+
title: t('success', 'Success'),
|
|
85
|
+
kind: 'success',
|
|
86
|
+
subtitle: t('locationUpdated', 'Location {{locationName}} was updated successfully.', {
|
|
87
|
+
locationName: selectedLocation?.name || location?.name || data.name,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
handleMutation();
|
|
92
|
+
closeWorkspaceWithSavedChanges();
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
console.error('Error saving location:', error);
|
|
95
|
+
const errorMessages = extractErrorMessagesFromResponse(error);
|
|
96
|
+
showSnackbar({
|
|
97
|
+
title: t('error', 'Error'),
|
|
98
|
+
kind: 'error',
|
|
99
|
+
subtitle: errorMessages.join(', ') || t('locationSaveError', 'Error saving location'),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleLocationSelected = (locationUuid: string, locationData: LocationResponse) => {
|
|
105
|
+
setSelectedLocation(locationData);
|
|
106
|
+
|
|
107
|
+
setValue('name', locationData.name || '', { shouldDirty: true });
|
|
108
|
+
|
|
109
|
+
if (locationData?.tags && locationData.tags.length > 0) {
|
|
110
|
+
const formattedTags = locationData.tags.map((tag) => ({
|
|
111
|
+
uuid: tag.uuid,
|
|
112
|
+
display: tag.display || tag.name || '',
|
|
113
|
+
}));
|
|
114
|
+
if (formattedTags.length > 0) {
|
|
115
|
+
setValue('tags', formattedTags as [(typeof formattedTags)[0], ...typeof formattedTags], { shouldDirty: true });
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
setValue('tags', [], { shouldDirty: true });
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleClearSelection = () => {
|
|
123
|
+
setSelectedLocation(null);
|
|
124
|
+
reset();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const renderSelectedLocationTile = () => {
|
|
128
|
+
if (!selectedLocation) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div>
|
|
134
|
+
<ResultsTile location={selectedLocation} onClose={handleClearSelection} />
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
promptBeforeClosing(() => isDirty);
|
|
141
|
+
}, [isDirty, promptBeforeClosing]);
|
|
142
|
+
|
|
143
|
+
const isFormReady = (selectedLocation || location) && isDirty;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
|
147
|
+
<div className={styles.formContainer}>
|
|
148
|
+
<Stack gap={3}>
|
|
149
|
+
<ResponsiveWrapper>
|
|
150
|
+
<FormGroup legendText="">
|
|
151
|
+
{!selectedLocation ? (
|
|
152
|
+
<LocationAutosuggest
|
|
153
|
+
onLocationSelected={handleLocationSelected}
|
|
154
|
+
labelText={t('searchForLocation', 'Search for location')}
|
|
155
|
+
placeholder={t('searchParentLocation', 'Search for location...')}
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
renderSelectedLocationTile()
|
|
159
|
+
)}
|
|
160
|
+
</FormGroup>
|
|
161
|
+
</ResponsiveWrapper>
|
|
162
|
+
|
|
163
|
+
<ResponsiveWrapper>
|
|
164
|
+
<FormGroup legendText="">
|
|
165
|
+
<Controller
|
|
166
|
+
control={control}
|
|
167
|
+
name="tags"
|
|
168
|
+
render={({ field: { onChange, value, ref } }) => (
|
|
169
|
+
<FilterableMultiSelect
|
|
170
|
+
id="locationTags"
|
|
171
|
+
titleText={t('selectTags', 'Select tag(s)')}
|
|
172
|
+
placeholder={t('selectTagPlaceholder', 'Select a tag')}
|
|
173
|
+
items={Tags || []}
|
|
174
|
+
selectedItems={(value || []).map(
|
|
175
|
+
(selected) => Tags?.find((tag) => tag.uuid === selected.uuid) || selected,
|
|
176
|
+
)}
|
|
177
|
+
onChange={({ selectedItems }) => onChange(selectedItems || [])}
|
|
178
|
+
itemToString={(item) => (item && typeof item === 'object' ? item.display : '')}
|
|
179
|
+
selectionFeedback="top-after-reopen"
|
|
180
|
+
invalid={!!errors.tags?.message}
|
|
181
|
+
invalidText={errors.tags?.message}
|
|
182
|
+
disabled={!Tags?.length}
|
|
183
|
+
ref={ref}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
</FormGroup>
|
|
188
|
+
</ResponsiveWrapper>
|
|
189
|
+
</Stack>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<ButtonSet
|
|
193
|
+
className={classNames({
|
|
194
|
+
[styles.tablet]: isTablet,
|
|
195
|
+
[styles.desktop]: !isTablet,
|
|
196
|
+
})}>
|
|
197
|
+
<Button className={styles.buttonContainer} kind="secondary" onClick={() => closeWorkspace()}>
|
|
198
|
+
{t('cancel', 'Cancel')}
|
|
199
|
+
</Button>
|
|
200
|
+
<Button className={styles.buttonContainer} disabled={isSubmitting || !isFormReady} kind="primary" type="submit">
|
|
201
|
+
{isSubmitting ? (
|
|
202
|
+
<span className={styles.inlineLoading}>
|
|
203
|
+
{t('submitting', 'Submitting' + '...')}
|
|
204
|
+
<InlineLoading status="active" iconDescription="Loading" />
|
|
205
|
+
</span>
|
|
206
|
+
) : (
|
|
207
|
+
t('saveAndClose', 'Save & close')
|
|
208
|
+
)}
|
|
209
|
+
</Button>
|
|
210
|
+
</ButtonSet>
|
|
211
|
+
</Form>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export default SearchLocationWorkspace;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Calendar, Location } from '@carbon/react/icons';
|
|
4
|
+
import {
|
|
5
|
+
ConfigurableLink,
|
|
6
|
+
formatDate,
|
|
7
|
+
InPatientPictogram,
|
|
8
|
+
PageHeader,
|
|
9
|
+
PageHeaderContent,
|
|
10
|
+
useSession,
|
|
11
|
+
} from '@openmrs/esm-framework';
|
|
12
|
+
import styles from './header.scss';
|
|
13
|
+
import { EuropeAfrica } from '@carbon/pictograms-react';
|
|
14
|
+
|
|
15
|
+
type HeaderProps = {
|
|
16
|
+
title: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const Header: React.FC<HeaderProps> = ({ title }) => {
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const userSession = useSession();
|
|
22
|
+
const userLocation = userSession?.sessionLocation?.display;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<PageHeader className={styles.header}>
|
|
26
|
+
<PageHeaderContent
|
|
27
|
+
illustration={
|
|
28
|
+
<ConfigurableLink to={`${window.getOpenmrsSpaBase()}bed-management`}>
|
|
29
|
+
<EuropeAfrica className={styles.illustrationPictogram} />
|
|
30
|
+
</ConfigurableLink>
|
|
31
|
+
}
|
|
32
|
+
title={title}
|
|
33
|
+
className={styles.leftJustifiedItems}
|
|
34
|
+
/>
|
|
35
|
+
<div className={styles.rightJustifiedItems}>
|
|
36
|
+
<div className={styles.dateAndLocation}>
|
|
37
|
+
<Location size={16} />
|
|
38
|
+
<span className={styles.value}>{userLocation}</span>
|
|
39
|
+
<span className={styles.middot}>·</span>
|
|
40
|
+
<Calendar size={16} />
|
|
41
|
+
<span className={styles.value}>{formatDate(new Date(), { mode: 'standard' })}</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</PageHeader>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default Header;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/layout';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
|
|
5
|
+
.header {
|
|
6
|
+
@include type.type-style('body-compact-02');
|
|
7
|
+
color: colors.$gray-70;
|
|
8
|
+
height: layout.$spacing-12;
|
|
9
|
+
background-color: colors.$white-0;
|
|
10
|
+
display: flex;
|
|
11
|
+
padding-left: layout.$spacing-03;
|
|
12
|
+
justify-content: space-between;
|
|
13
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.leftJustifiedItems {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: row;
|
|
19
|
+
align-items: center;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
|
|
22
|
+
& > div:nth-child(2) {
|
|
23
|
+
margin: layout.$spacing-05;
|
|
24
|
+
|
|
25
|
+
& > p:last-child {
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
@include type.type-style('heading-04');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.illustrationPictogram {
|
|
33
|
+
width: 4.5rem;
|
|
34
|
+
height: 4.5rem;
|
|
35
|
+
fill: var(--brand-03);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.rightJustifiedItems {
|
|
39
|
+
@include type.type-style('body-compact-02');
|
|
40
|
+
color: colors.$gray-70;
|
|
41
|
+
margin: layout.$spacing-03;
|
|
42
|
+
padding-top: layout.$spacing-04;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.dateAndLocation {
|
|
46
|
+
display: flex;
|
|
47
|
+
justify-content: flex-end;
|
|
48
|
+
align-items: center;
|
|
49
|
+
margin-right: layout.$spacing-05;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.value {
|
|
53
|
+
margin-left: layout.$spacing-02;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.middot {
|
|
57
|
+
margin: 0 layout.$spacing-03;
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts a list of error messages from an error object returned from an API call.
|
|
3
|
+
* Checks if the error object contains a fieldErrors property, and if so, extracts all the error messages from the field errors.
|
|
4
|
+
* If the error object does not contain a fieldErrors property, it returns the error message from the error object.
|
|
5
|
+
* @param errorObject an object containing an error response from an API call.
|
|
6
|
+
* @returns a list of error messages.
|
|
7
|
+
*/
|
|
8
|
+
export function extractErrorMessagesFromResponse(errorObject) {
|
|
9
|
+
const fieldErrors = errorObject?.responseBody?.error?.fieldErrors;
|
|
10
|
+
|
|
11
|
+
if (!fieldErrors) {
|
|
12
|
+
return [errorObject?.responseBody?.error?.message ?? errorObject?.message];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Object.values(fieldErrors).flatMap((errors: Array<Error>) => errors.map((error) => error.message));
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Header from '../header/header.component';
|
|
4
|
+
import LocationsTable from '../tables/locations-table.component';
|
|
5
|
+
import styles from './home-locations.scss';
|
|
6
|
+
const HomeComponent: React.FC = () => {
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
return (
|
|
9
|
+
<div>
|
|
10
|
+
<Header title={t('locations', 'Locations')} />
|
|
11
|
+
<div className={styles.tableContainer}>
|
|
12
|
+
<LocationsTable />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default HomeComponent;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import { type LocationResponse } from '../types';
|
|
3
|
+
|
|
4
|
+
export const searchLocation = async (searchTerm: string) => {
|
|
5
|
+
const customPresentation =
|
|
6
|
+
'custom:(uuid,display,name,description,stateProvince,country,countyDistrict,address5,address6,tags,attributes:(uuid,attributeType:(uuid,display),value))';
|
|
7
|
+
|
|
8
|
+
const url = `${restBaseUrl}/location?v=${customPresentation}&q=${searchTerm}`;
|
|
9
|
+
|
|
10
|
+
const response = await openmrsFetch<{ results: Array<LocationResponse> }>(url);
|
|
11
|
+
return response?.data?.results ?? [];
|
|
12
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { restBaseUrl, openmrsFetch } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
export const saveOrUpdateLocation = async (locationPayload, locationUuid) => {
|
|
4
|
+
if (typeof locationPayload === 'string' && typeof locationUuid === 'object') {
|
|
5
|
+
const temp = locationPayload;
|
|
6
|
+
locationPayload = locationUuid;
|
|
7
|
+
locationUuid = temp;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const url = locationUuid ? `${restBaseUrl}/location/${locationUuid}` : `${restBaseUrl}/location`;
|
|
11
|
+
return await openmrsFetch(url, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
body: locationPayload,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { type LocationTagsResponse } from '../types/index';
|
|
4
|
+
|
|
5
|
+
export const useLocationTags = () => {
|
|
6
|
+
const customPresentation = 'custom:(uuid,display,name,description)';
|
|
7
|
+
const url = `${restBaseUrl}/locationtag?v=${customPresentation}`;
|
|
8
|
+
const { isLoading, error, data } = useSWR<FetchResponse<LocationTagsResponse>>(url, openmrsFetch);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
isLoading,
|
|
12
|
+
error,
|
|
13
|
+
locationTagList: data?.data?.results || [],
|
|
14
|
+
};
|
|
15
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React, { useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
DataTable,
|
|
6
|
+
DataTableSkeleton,
|
|
7
|
+
InlineLoading,
|
|
8
|
+
Pagination,
|
|
9
|
+
Search,
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableContainer,
|
|
14
|
+
TableHead,
|
|
15
|
+
TableHeader,
|
|
16
|
+
TableRow,
|
|
17
|
+
Tile,
|
|
18
|
+
Tag,
|
|
19
|
+
} from '@carbon/react';
|
|
20
|
+
import { Add } from '@carbon/react/icons';
|
|
21
|
+
import { WorkspaceContainer, isDesktop as desktopLayout, launchWorkspace, useLayoutType } from '@openmrs/esm-framework';
|
|
22
|
+
import styles from './locations-table.scss';
|
|
23
|
+
import { CardHeader } from '@openmrs/esm-patient-common-lib';
|
|
24
|
+
import { useFacilitiesTagged } from './locations-table.resource';
|
|
25
|
+
import { useLocationTags } from '../hooks/useLocationTags';
|
|
26
|
+
|
|
27
|
+
const LocationsTable: React.FC = () => {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const layout = useLayoutType();
|
|
30
|
+
const isTablet = layout === 'tablet';
|
|
31
|
+
const isDesktop = desktopLayout(layout);
|
|
32
|
+
const [pageSize, setPageSize] = useState(10);
|
|
33
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
34
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
35
|
+
|
|
36
|
+
const { locationTagList, isLoading: tagsLoading, error: tagsError } = useLocationTags();
|
|
37
|
+
const { facilityList, isLoading: taggedLoading } = useFacilitiesTagged({ results: locationTagList });
|
|
38
|
+
|
|
39
|
+
const handleAddLocationWorkspace = () => {
|
|
40
|
+
launchWorkspace('add-location-workspace', {
|
|
41
|
+
workspaceTitle: t('addLocation', 'Add Location'),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleSearchLocationWorkspace = () => {
|
|
46
|
+
launchWorkspace('search-location-workspace', {
|
|
47
|
+
workspaceTitle: t('tagLocation', 'Tag Location'),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const tableHeaders = [
|
|
52
|
+
{
|
|
53
|
+
key: 'name',
|
|
54
|
+
header: t('locationName', 'Location Name'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'description',
|
|
58
|
+
header: t('description', 'Description'),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'tags',
|
|
62
|
+
header: t('tags', 'Tags'),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const getLocationTags = (resource) => {
|
|
67
|
+
if (!resource?.meta?.tag) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return resource.meta.tag.filter((tag) => tag.system && tag.system.includes('location-tag')).map((tag) => tag.code);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const rows = useMemo(() => {
|
|
75
|
+
const uniqueFacilities = new Map();
|
|
76
|
+
|
|
77
|
+
facilityList.forEach((facility) => {
|
|
78
|
+
const resource = facility.resource;
|
|
79
|
+
const uuid = resource?.id;
|
|
80
|
+
|
|
81
|
+
if (uuid && !uniqueFacilities.has(uuid)) {
|
|
82
|
+
uniqueFacilities.set(uuid, facility);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return Array.from(uniqueFacilities.values()).map((facility) => {
|
|
87
|
+
const resource = facility.resource;
|
|
88
|
+
const tags = getLocationTags(resource);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: resource?.id,
|
|
92
|
+
name: resource?.name || resource?.partOf?.display || '--',
|
|
93
|
+
description: resource?.description || '--',
|
|
94
|
+
tags: tags.length > 0 ? tags.join(', ') : '--',
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}, [facilityList]);
|
|
98
|
+
|
|
99
|
+
const filteredRows = useMemo(() => {
|
|
100
|
+
if (!searchTerm) {
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return rows.filter(
|
|
105
|
+
(row) =>
|
|
106
|
+
row.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
107
|
+
row.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
108
|
+
row.tags.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
109
|
+
);
|
|
110
|
+
}, [rows, searchTerm]);
|
|
111
|
+
|
|
112
|
+
const paginatedRows = useMemo(() => {
|
|
113
|
+
const startIndex = (currentPage - 1) * pageSize;
|
|
114
|
+
const endIndex = startIndex + pageSize;
|
|
115
|
+
return filteredRows.slice(startIndex, endIndex);
|
|
116
|
+
}, [filteredRows, currentPage, pageSize]);
|
|
117
|
+
|
|
118
|
+
if (tagsLoading || (taggedLoading && !facilityList.length)) {
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
<div className={styles.widgetCard}>
|
|
122
|
+
<DataTableSkeleton role="progressbar" compact={isDesktop} zebra />
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<div className={styles.widgetCard}>
|
|
131
|
+
<CardHeader title={t('locations', 'Locations')}>
|
|
132
|
+
<span className={styles.backgroundDataFetchingIndicator}>
|
|
133
|
+
<span>{tagsLoading ? <InlineLoading /> : null}</span>
|
|
134
|
+
</span>
|
|
135
|
+
<div className={styles.headerActions}>
|
|
136
|
+
<>
|
|
137
|
+
<div className={styles.filterContainer}>
|
|
138
|
+
<Search
|
|
139
|
+
onChange={(e) => {
|
|
140
|
+
setSearchTerm(e.target.value);
|
|
141
|
+
setCurrentPage(1);
|
|
142
|
+
}}
|
|
143
|
+
placeholder={t('search', 'Search location')}
|
|
144
|
+
value={searchTerm}
|
|
145
|
+
labelText={t('search', 'Search location')}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<Button
|
|
149
|
+
kind="ghost"
|
|
150
|
+
renderIcon={(props) => <Add size={16} {...props} />}
|
|
151
|
+
onClick={handleAddLocationWorkspace}>
|
|
152
|
+
{t('addLocation', 'Add Location')}
|
|
153
|
+
</Button>
|
|
154
|
+
<Button
|
|
155
|
+
kind="ghost"
|
|
156
|
+
renderIcon={(props) => <Add size={16} {...props} />}
|
|
157
|
+
onClick={handleSearchLocationWorkspace}>
|
|
158
|
+
{t('tagLocation', 'Tag Location')}
|
|
159
|
+
</Button>
|
|
160
|
+
</>
|
|
161
|
+
</div>
|
|
162
|
+
</CardHeader>
|
|
163
|
+
|
|
164
|
+
<DataTable rows={paginatedRows} headers={tableHeaders} isSortable size={isTablet ? 'lg' : 'sm'} useZebraStyles>
|
|
165
|
+
{({ rows, headers, getTableProps }) => {
|
|
166
|
+
return (
|
|
167
|
+
<TableContainer>
|
|
168
|
+
<Table {...getTableProps()}>
|
|
169
|
+
<TableHead>
|
|
170
|
+
<TableRow>
|
|
171
|
+
{headers.map((header) => (
|
|
172
|
+
<TableHeader key={header.key}>{header.header}</TableHeader>
|
|
173
|
+
))}
|
|
174
|
+
</TableRow>
|
|
175
|
+
</TableHead>
|
|
176
|
+
<TableBody>
|
|
177
|
+
{rows.map((row) => (
|
|
178
|
+
<TableRow key={row.id}>
|
|
179
|
+
{row.cells.map((cell) => (
|
|
180
|
+
<TableCell key={cell.id}>
|
|
181
|
+
{cell.id.includes('tags') && cell.value !== 'No tags' ? (
|
|
182
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
|
183
|
+
{cell.value.split(', ').map((tag, index) => (
|
|
184
|
+
<Tag key={index} type="blue" size="sm">
|
|
185
|
+
{tag}
|
|
186
|
+
</Tag>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
cell.value
|
|
191
|
+
)}
|
|
192
|
+
</TableCell>
|
|
193
|
+
))}
|
|
194
|
+
</TableRow>
|
|
195
|
+
))}
|
|
196
|
+
</TableBody>
|
|
197
|
+
</Table>
|
|
198
|
+
{rows.length === 0 ? (
|
|
199
|
+
<div className={styles.tileContainer}>
|
|
200
|
+
<Tile className={styles.tile}>
|
|
201
|
+
<div className={styles.tileContent}>
|
|
202
|
+
<p className={styles.content}>
|
|
203
|
+
{searchTerm
|
|
204
|
+
? t('noSearchResults', `No results found for "${searchTerm}"`)
|
|
205
|
+
: t('noData', 'No data to display')}
|
|
206
|
+
</p>
|
|
207
|
+
<p className={styles.helper}>
|
|
208
|
+
{searchTerm
|
|
209
|
+
? t('tryDifferentSearch', 'Try a different search term')
|
|
210
|
+
: t('checkFilters', 'Check the filters above')}
|
|
211
|
+
</p>
|
|
212
|
+
</div>
|
|
213
|
+
</Tile>
|
|
214
|
+
</div>
|
|
215
|
+
) : null}
|
|
216
|
+
<Pagination
|
|
217
|
+
backwardText="Previous page"
|
|
218
|
+
forwardText="Next page"
|
|
219
|
+
page={currentPage}
|
|
220
|
+
pageSize={pageSize}
|
|
221
|
+
pageSizes={[10, 20, 30, 40, 50]}
|
|
222
|
+
totalItems={filteredRows.length}
|
|
223
|
+
onChange={({ pageSize: newPageSize, page }) => {
|
|
224
|
+
if (newPageSize !== pageSize) {
|
|
225
|
+
setPageSize(newPageSize);
|
|
226
|
+
setCurrentPage(1);
|
|
227
|
+
}
|
|
228
|
+
if (page !== currentPage) {
|
|
229
|
+
setCurrentPage(page);
|
|
230
|
+
}
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
</TableContainer>
|
|
234
|
+
);
|
|
235
|
+
}}
|
|
236
|
+
</DataTable>
|
|
237
|
+
</div>
|
|
238
|
+
<WorkspaceContainer contextKey="locations" />
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export default LocationsTable;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useFhirFetchAll } from '@openmrs/esm-framework';
|
|
2
|
+
import { type FHIRLocation, type LocationTagsResponse } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom hook that fetches all facilities with specified location tags.
|
|
6
|
+
* Uses the framework's useFhirFetchAll hook which handles pagination automatically.
|
|
7
|
+
*
|
|
8
|
+
* @param locationTags The location tags response containing tags to filter by
|
|
9
|
+
* @returns Object containing loading state, error, and the list of facilities
|
|
10
|
+
*/
|
|
11
|
+
export const useFacilitiesTagged = (locationTags: LocationTagsResponse) => {
|
|
12
|
+
const tagNames = locationTags?.results
|
|
13
|
+
?.map((tag) => encodeURIComponent(tag.name || tag.display))
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(',');
|
|
16
|
+
|
|
17
|
+
const url = tagNames ? `ws/fhir2/R4/Location?_summary=data&_tag=${tagNames}` : null;
|
|
18
|
+
|
|
19
|
+
const { data, isLoading, error } = useFhirFetchAll<FHIRLocation>(url);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
isLoading,
|
|
23
|
+
error,
|
|
24
|
+
facilityList: data?.map((resource) => ({ resource })) || [],
|
|
25
|
+
};
|
|
26
|
+
};
|