Skip to content

Commit b39986d

Browse files
slubwamaclaude
andcommitted
Add comprehensive System Admin and Sync Configuration modules
Implement complete system administration interface with real-time sync monitoring, profile management, task scheduling, and viral load upload functionality. Features include: System Admin Module: - Sync Dashboard: Real-time monitoring with auto-refresh, metrics, alerts, and activity tracking - Sync Profiles: CRUD operations for FHIR sync profiles with detailed modals - Sync Task Types: Manage task types for sync operations with validation - Schedule Task Manager: Configure and automate recurring sync tasks - Sync Logs: View detailed operation logs with filtering and pagination - Viral Load Upload: Modern CSV upload interface with REST API integration, drag-and-drop support, and comprehensive error reporting - SMS Settings: Configure SMS gateway and appointment reminders Sync Configuration Module: - Profile management interface - Task type configuration - Integration with system admin workflows Additional Improvements: - Fixed legacy admin link to point to /openmrs/admin/index.htm - Added sync-related privileges and constants - Updated routing and extension configuration - Implemented proper TypeScript types and error handling - Used Carbon Design System components throughout - SWR-based data fetching with auto-refresh capabilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b20a8e6 commit b39986d

55 files changed

Lines changed: 7170 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,9 @@ export const REGISTRY_REGIONS_URL = 'https://nhfr-staging-api.planetsystems.co/n
3030

3131
// privileges
3232
export const PRIVILEGE_UPDATE_FACILITY_CODE = 'Update Facility Code';
33+
export const PRIVILEGE_VIEW_FHIR_PROFILES = 'View FHIR Profiles';
34+
export const PRIVILEGE_MANAGE_FHIR_PROFILES = 'Manage FHIR Profiles';
35+
export const PRIVILEGE_VIEW_SYNC_TASK_TYPES = 'View Sync Task Types';
36+
export const PRIVILEGE_MANAGE_SYNC_TASK_TYPES = 'Manage Sync Task Types';
37+
export const PRIVILEGE_VIEW_SYNC_LOGS = 'View Sync Logs';
38+
export const PRIVILEGE_MANAGE_SYNC_LOGS = 'Manage Sync Logs';

src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { moduleName } from "./constants";
88

99
import formBuilderAppMenu from "./menu-app-items/form-builder-app-item/form-builder-app-item.component";
1010
import systemInfoAppMenu from "./menu-app-items/system-info-app-item/system-info-app-item.component";
11+
import systemAdminAppMenu from "./menu-app-items/system-admin-item/system-admin-item.component";
1112
import legacyAdminAppMenu from "./menu-app-items/legacy-admin-item/legacy-admin-item.component";
1213
import cohortBuilderAppMenu from "./menu-app-items/cohort-builder-item/cohort-builder-item.component";
1314
import formRenderTestAppMenu from "./menu-app-items/form-render-test-item/form-render-test-item.component";
@@ -42,6 +43,7 @@ const options = {
4243

4344
export const formBuilderAppMenuItem = getSyncLifecycle(formBuilderAppMenu, options);
4445
export const systemInfoAppMenuItem = getSyncLifecycle(systemInfoAppMenu, options);
46+
export const systemAdminAppMenuItem = getSyncLifecycle(systemAdminAppMenu, options);
4547
export const legacyAdminAppMenuItem = getSyncLifecycle(legacyAdminAppMenu, options);
4648
export const cohortBuilderAppMenuItem = getSyncLifecycle(cohortBuilderAppMenu, options);
4749
export const formRenderTestAppMenuItem = getSyncLifecycle(formRenderTestAppMenu, options);
@@ -69,6 +71,30 @@ export const systemInfoPage = getAsyncLifecycle(
6971
},
7072
);
7173

74+
export const systemAdminPage = getAsyncLifecycle(
75+
() => import("./pages/system-admin/system-admin.component"),
76+
{
77+
featureName: "system admin page",
78+
moduleName,
79+
},
80+
);
81+
82+
export const profileDetailModal = getAsyncLifecycle(
83+
() => import("./pages/system-admin/sync-profiles/profile-detail-modal.component"),
84+
{
85+
featureName: "profile detail modal",
86+
moduleName,
87+
},
88+
);
89+
90+
export const taskTypeDetailModal = getAsyncLifecycle(
91+
() => import("./pages/system-admin/sync-task-types/tasktype-detail-modal.component"),
92+
{
93+
featureName: "task type detail modal",
94+
moduleName,
95+
},
96+
);
97+
7298
export const retrieveFacilityCodeModal = getAsyncLifecycle(
7399
() => import("./pages/system-info/facility-modal.component"),
74100
{

src/menu-app-items/legacy-admin-item/legacy-admin-item.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ import { User } from '@carbon/react/icons';
44
import Item from '../item.component';
55

66
const LegacyAdminApp = () => {
7-
return <Item className={styles.customTile} title="Legacy Admin" href="/openmrs/index.htm" icon={User} />;
7+
return <Item className={styles.customTile} title="Legacy Admin" href="/openmrs/admin/index.htm" icon={User} />;
88
};
99
export default LegacyAdminApp;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './sync-configuration-item.component';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
import styles from '../item.scss';
3+
import { Connect } from '@carbon/react/icons';
4+
import Item from '../item.component';
5+
6+
const SyncConfigurationApp = () => {
7+
return <Item className={styles.customTile} title="Sync Configuration" to="sync/configuration" icon={Connect} />;
8+
};
9+
10+
export default SyncConfigurationApp;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './system-admin-item.component';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
import styles from '../item.scss';
3+
import { Settings } from '@carbon/react/icons';
4+
import Item from '../item.component';
5+
6+
const SystemAdminApp = () => {
7+
return <Item className={styles.customTile} title="System Admin" to="system-admin" icon={Settings} />;
8+
};
9+
10+
export default SystemAdminApp;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './sync-configuration.component';
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import {
4+
Modal,
5+
TextInput,
6+
TextArea,
7+
Toggle,
8+
Button,
9+
FormGroup,
10+
Select,
11+
SelectItem,
12+
Tile,
13+
Checkbox,
14+
Layer,
15+
} from '@carbon/react';
16+
import { showNotification, showSnackbar } from '@openmrs/esm-framework';
17+
import { updateSyncProfile, createSyncProfile, testSyncConnection } from './sync-configuration.resources';
18+
import { SyncFhirProfile, SyncProfileFormData } from './sync-configuration.types';
19+
import styles from './profile-detail-modal.scss';
20+
21+
interface ProfileDetailModalProps {
22+
open: boolean;
23+
onClose: () => void;
24+
profile?: SyncFhirProfile;
25+
onSave: () => void;
26+
}
27+
28+
const resourceTypes = [
29+
'Patient',
30+
'Encounter',
31+
'Observation',
32+
'Condition',
33+
'MedicationRequest',
34+
'Location',
35+
'Practitioner',
36+
'Organization',
37+
];
38+
39+
const syncFrequencies = [
40+
{ value: '5m', label: 'Every 5 minutes' },
41+
{ value: '15m', label: 'Every 15 minutes' },
42+
{ value: '30m', label: 'Every 30 minutes' },
43+
{ value: '1h', label: 'Every hour' },
44+
{ value: '6h', label: 'Every 6 hours' },
45+
{ value: '12h', label: 'Every 12 hours' },
46+
{ value: '1d', label: 'Daily' },
47+
{ value: '1w', label: 'Weekly' },
48+
{ value: 'manual', label: 'Manual only' },
49+
];
50+
51+
const ProfileDetailModal: React.FC<ProfileDetailModalProps> = ({
52+
open,
53+
onClose,
54+
profile,
55+
onSave,
56+
}) => {
57+
const { t } = useTranslation();
58+
const [formData, setFormData] = useState<SyncProfileFormData>({
59+
name: '',
60+
description: '',
61+
profileEnabled: true,
62+
serverUrl: '',
63+
username: '',
64+
password: '',
65+
syncFrequency: 'manual',
66+
resourceTypes: [],
67+
});
68+
const [isTesting, setIsTesting] = useState(false);
69+
const [isSaving, setIsSaving] = useState(false);
70+
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
71+
72+
useEffect(() => {
73+
if (profile) {
74+
setFormData({
75+
name: profile.name || '',
76+
description: profile.description || '',
77+
profileEnabled: profile.profileEnabled,
78+
serverUrl: profile.serverUrl || '',
79+
username: profile.username || '',
80+
password: '', // Don't pre-fill password for security
81+
syncFrequency: profile.syncFrequency || 'manual',
82+
resourceTypes: profile.resourceTypes || [],
83+
});
84+
} else {
85+
setFormData({
86+
name: '',
87+
description: '',
88+
profileEnabled: true,
89+
serverUrl: '',
90+
username: '',
91+
password: '',
92+
syncFrequency: 'manual',
93+
resourceTypes: [],
94+
});
95+
}
96+
setTestResult(null);
97+
}, [profile, open]);
98+
99+
const handleChange = (field: keyof SyncProfileFormData, value: any) => {
100+
setFormData(prev => ({ ...prev, [field]: value }));
101+
setTestResult(null);
102+
};
103+
104+
const handleResourceTypeChange = (resourceType: string, checked: boolean) => {
105+
setFormData(prev => ({
106+
...prev,
107+
resourceTypes: checked
108+
? [...(prev.resourceTypes || []), resourceType]
109+
: (prev.resourceTypes || []).filter(rt => rt !== resourceType),
110+
}));
111+
};
112+
113+
const handleTestConnection = async () => {
114+
setIsTesting(true);
115+
setTestResult(null);
116+
117+
try {
118+
if (profile?.uuid) {
119+
await testSyncConnection(profile.uuid);
120+
setTestResult({ success: true, message: t('connectionSuccessful', 'Connection successful!') });
121+
}
122+
} catch (error) {
123+
setTestResult({ success: false, message: t('connectionFailed', 'Connection failed: ') + error.message });
124+
} finally {
125+
setIsTesting(false);
126+
}
127+
};
128+
129+
const handleSubmit = async () => {
130+
setIsSaving(true);
131+
132+
try {
133+
if (profile?.uuid) {
134+
await updateSyncProfile(profile.uuid, formData);
135+
showSnackbar({
136+
isLowContrast: true,
137+
kind: 'success',
138+
title: t('profileUpdated', 'Profile updated'),
139+
subtitle: t('profileUpdatedSuccess', 'Sync profile has been updated successfully'),
140+
autoClose: true,
141+
});
142+
} else {
143+
await createSyncProfile(formData);
144+
showSnackbar({
145+
isLowContrast: true,
146+
kind: 'success',
147+
title: t('profileCreated', 'Profile created'),
148+
subtitle: t('profileCreatedSuccess', 'Sync profile has been created successfully'),
149+
autoClose: true,
150+
});
151+
}
152+
onSave();
153+
onClose();
154+
} catch (error) {
155+
showNotification({
156+
title: t('errorSavingProfile', 'Error saving profile'),
157+
kind: 'error',
158+
critical: true,
159+
description: error.message,
160+
});
161+
} finally {
162+
setIsSaving(false);
163+
}
164+
};
165+
166+
const isFormValid = formData.name && formData.serverUrl;
167+
168+
return (
169+
<Modal
170+
open={open}
171+
onRequestClose={onClose}
172+
modalHeading={profile ? t('editSyncProfile', 'Edit Sync Profile') : t('createSyncProfile', 'Create Sync Profile')}
173+
modalLabel={t('syncConfiguration', 'Sync Configuration')}
174+
primaryButtonText={t('save', 'Save')}
175+
secondaryButtonText={t('cancel', 'Cancel')}
176+
onRequestSubmit={handleSubmit}
177+
primaryButtonDisabled={!isFormValid || isSaving || isTesting}
178+
size="lg"
179+
>
180+
<div className={styles.modalContent}>
181+
<FormGroup legendText={t('profileDetails', 'Profile Details')}>
182+
<TextInput
183+
id="profile-name"
184+
labelText={t('profileName', 'Profile Name')}
185+
placeholder={t('enterProfileName', 'Enter profile name')}
186+
value={formData.name}
187+
onChange={(e) => handleChange('name', e.target.value)}
188+
required
189+
className={styles.formField}
190+
/>
191+
192+
<TextArea
193+
id="profile-description"
194+
labelText={t('description', 'Description')}
195+
placeholder={t('enterDescription', 'Enter description')}
196+
value={formData.description}
197+
onChange={(e) => handleChange('description', e.target.value)}
198+
className={styles.formField}
199+
/>
200+
201+
<Toggle
202+
id="profile-enabled"
203+
labelText={t('profileEnabled', 'Profile Enabled')}
204+
toggled={formData.profileEnabled}
205+
onToggle={(checked) => handleChange('profileEnabled', checked)}
206+
className={styles.formField}
207+
/>
208+
</FormGroup>
209+
210+
<FormGroup legendText={t('connectionSettings', 'Connection Settings')}>
211+
<TextInput
212+
id="server-url"
213+
labelText={t('serverUrl', 'Server URL')}
214+
placeholder="https://fhir.example.com"
215+
value={formData.serverUrl}
216+
onChange={(e) => handleChange('serverUrl', e.target.value)}
217+
required
218+
className={styles.formField}
219+
/>
220+
221+
<TextInput
222+
id="username"
223+
labelText={t('username', 'Username')}
224+
placeholder={t('enterUsername', 'Enter username')}
225+
value={formData.username}
226+
onChange={(e) => handleChange('username', e.target.value)}
227+
className={styles.formField}
228+
/>
229+
230+
<TextInput
231+
id="password"
232+
type="password"
233+
labelText={t('password', 'Password')}
234+
placeholder={t('enterPassword', 'Enter password')}
235+
value={formData.password}
236+
onChange={(e) => handleChange('password', e.target.value)}
237+
className={styles.formField}
238+
/>
239+
240+
{profile?.uuid && (
241+
<Button
242+
kind="secondary"
243+
onClick={handleTestConnection}
244+
disabled={isTesting || !formData.serverUrl}
245+
className={styles.testButton}
246+
>
247+
{isTesting ? t('testing', 'Testing...') : t('testConnection', 'Test Connection')}
248+
</Button>
249+
)}
250+
251+
{testResult && (
252+
<Tile className={styles.testResult}>
253+
<div className={testResult.success ? styles.success : styles.error}>
254+
{testResult.message}
255+
</div>
256+
</Tile>
257+
)}
258+
</FormGroup>
259+
260+
<FormGroup legendText={t('syncSettings', 'Sync Settings')}>
261+
<Select
262+
id="sync-frequency"
263+
labelText={t('syncFrequency', 'Sync Frequency')}
264+
value={formData.syncFrequency}
265+
onChange={(e) => handleChange('syncFrequency', e.target.value)}
266+
className={styles.formField}
267+
>
268+
{syncFrequencies.map(freq => (
269+
<SelectItem key={freq.value} value={freq.value} text={freq.label}>
270+
{freq.label}
271+
</SelectItem>
272+
))}
273+
</Select>
274+
</FormGroup>
275+
276+
<FormGroup legendText={t('resourceTypes', 'Resource Types to Sync')}>
277+
<div className={styles.resourceTypesGrid}>
278+
{resourceTypes.map(resource => (
279+
<Checkbox
280+
key={resource}
281+
id={`resource-${resource}`}
282+
labelText={resource}
283+
checked={formData.resourceTypes?.includes(resource)}
284+
onChange={(event) => handleResourceTypeChange(resource, (event.target as HTMLInputElement).checked)}
285+
className={styles.resourceCheckbox}
286+
/>
287+
))}
288+
</div>
289+
</FormGroup>
290+
</div>
291+
</Modal>
292+
);
293+
};
294+
295+
export default ProfileDetailModal;

0 commit comments

Comments
 (0)