@openmrs/esm-patient-documents-admin-app 4.4.1-pre.635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/README.md +10 -0
  3. package/dist/105.js +1 -0
  4. package/dist/105.js.map +1 -0
  5. package/dist/117.js +1 -0
  6. package/dist/117.js.map +1 -0
  7. package/dist/121.js +1 -0
  8. package/dist/121.js.map +1 -0
  9. package/dist/335.js +1 -0
  10. package/dist/335.js.map +1 -0
  11. package/dist/361.js +6 -0
  12. package/dist/361.js.map +1 -0
  13. package/dist/366.js +1 -0
  14. package/dist/366.js.map +1 -0
  15. package/dist/466.js +1 -0
  16. package/dist/466.js.map +1 -0
  17. package/dist/483.js +32 -0
  18. package/dist/483.js.map +1 -0
  19. package/dist/61.js +1 -0
  20. package/dist/61.js.map +1 -0
  21. package/dist/689.js +1 -0
  22. package/dist/689.js.map +1 -0
  23. package/dist/697.js +1 -0
  24. package/dist/697.js.map +1 -0
  25. package/dist/712.js +1 -0
  26. package/dist/712.js.map +1 -0
  27. package/dist/720.js +1 -0
  28. package/dist/720.js.map +1 -0
  29. package/dist/771.js +1 -0
  30. package/dist/771.js.map +1 -0
  31. package/dist/789.js +1 -0
  32. package/dist/789.js.map +1 -0
  33. package/dist/989.js +1 -0
  34. package/dist/989.js.map +1 -0
  35. package/dist/main.js +6 -0
  36. package/dist/main.js.map +1 -0
  37. package/dist/openmrs-esm-patient-documents-admin-app.js +6 -0
  38. package/dist/openmrs-esm-patient-documents-admin-app.js.buildmanifest.json +597 -0
  39. package/dist/openmrs-esm-patient-documents-admin-app.js.map +1 -0
  40. package/dist/routes.json +1 -0
  41. package/package.json +57 -0
  42. package/rspack.config.js +1 -0
  43. package/src/admin-card-link.component.tsx +34 -0
  44. package/src/config/config.resource.ts +137 -0
  45. package/src/config/visit-summary-config.component.tsx +447 -0
  46. package/src/config/visit-summary-config.scss +106 -0
  47. package/src/config/visit-summary-config.test.tsx +320 -0
  48. package/src/config-schema.ts +3 -0
  49. package/src/declarations.d.ts +2 -0
  50. package/src/index.ts +19 -0
  51. package/src/root.component.tsx +18 -0
  52. package/src/root.scss +5 -0
  53. package/src/routes.json +21 -0
  54. package/src/types.ts +12 -0
  55. package/translations/en.json +40 -0
  56. package/tsconfig.json +4 -0
  57. package/vitest.config.ts +4 -0
@@ -0,0 +1,447 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Button,
4
+ ButtonSkeleton,
5
+ Column,
6
+ Grid,
7
+ IconButton,
8
+ InlineLoading,
9
+ InlineNotification,
10
+ SkeletonText,
11
+ Tile,
12
+ Toggle,
13
+ Tooltip,
14
+ } from '@carbon/react';
15
+ import { ArrowDown, ArrowUp, Information } from '@carbon/react/icons';
16
+ import { useTranslation } from 'react-i18next';
17
+ import { showSnackbar } from '@openmrs/esm-framework';
18
+ import {
19
+ fetchVisitSummaryPreviewPdf,
20
+ getVisitSummaryPreviewErrorType,
21
+ saveSectionSettings,
22
+ sectionPropertyPrefix,
23
+ useVisitSummarySections,
24
+ type SectionSettingWrite,
25
+ type VisitSummaryPreviewErrorType,
26
+ } from './config.resource';
27
+ import type { VisitSummarySection } from '../types';
28
+ import styles from './visit-summary-config.scss';
29
+
30
+ type PreviewState =
31
+ | { status: 'idle' }
32
+ | { status: 'loading' }
33
+ | { status: 'ready'; url: string }
34
+ | { status: 'error'; errorType: VisitSummaryPreviewErrorType };
35
+
36
+ /**
37
+ * Sections rendered as page furniture by the PDF stylesheet (the footer is
38
+ * fo:static-content stamped on every page), so their list position is
39
+ * meaningless and reordering them must not be offered.
40
+ */
41
+ const pinnedBottomSectionKeys = ['footer'];
42
+
43
+ function isPinnedToBottom(section: VisitSummarySection): boolean {
44
+ return pinnedBottomSectionKeys.includes(section.sectionKey);
45
+ }
46
+
47
+ function sortedByOrder(sections: Array<VisitSummarySection>): Array<VisitSummarySection> {
48
+ const sorted = sections.slice().sort((a, b) => a.order - b.order);
49
+ return [...sorted.filter((section) => !isPinnedToBottom(section)), ...sorted.filter(isPinnedToBottom)];
50
+ }
51
+
52
+ const VisitSummaryConfig: React.FC = () => {
53
+ const { t } = useTranslation();
54
+ const { sections, error, isLoading, mutate } = useVisitSummarySections();
55
+
56
+ const [localSections, setLocalSections] = useState<Array<VisitSummarySection>>([]);
57
+ const [isSaving, setIsSaving] = useState(false);
58
+ const [preview, setPreview] = useState<PreviewState>({ status: 'idle' });
59
+ const previewUrlRef = useRef<string | null>(null);
60
+ const previewAbortControllerRef = useRef<AbortController | null>(null);
61
+
62
+ useEffect(() => {
63
+ if (sections) {
64
+ setLocalSections(sortedByOrder(sections));
65
+ }
66
+ }, [sections]);
67
+
68
+ useEffect(() => {
69
+ return () => {
70
+ previewAbortControllerRef.current?.abort();
71
+ if (previewUrlRef.current) {
72
+ window.URL.revokeObjectURL(previewUrlRef.current);
73
+ }
74
+ };
75
+ }, []);
76
+
77
+ const serverSections = useMemo(() => (sections ? sortedByOrder(sections) : []), [sections]);
78
+
79
+ const orderChanged = useMemo(
80
+ () =>
81
+ localSections.map((section) => section.sectionKey).join(',') !==
82
+ serverSections.map((section) => section.sectionKey).join(','),
83
+ [localSections, serverSections],
84
+ );
85
+
86
+ const enabledChanges = useMemo(
87
+ () =>
88
+ localSections.filter((section) => {
89
+ const serverSection = serverSections.find((candidate) => candidate.sectionKey === section.sectionKey);
90
+ return serverSection && serverSection.enabled !== section.enabled;
91
+ }),
92
+ [localSections, serverSections],
93
+ );
94
+
95
+ const isDirty = orderChanged || enabledChanges.length > 0;
96
+
97
+ const handleMove = useCallback((index: number, delta: number) => {
98
+ setLocalSections((current) => {
99
+ const target = index + delta;
100
+ if (target < 0 || target >= current.length) {
101
+ return current;
102
+ }
103
+ if (isPinnedToBottom(current[index]) || isPinnedToBottom(current[target])) {
104
+ return current;
105
+ }
106
+ const next = current.slice();
107
+ [next[index], next[target]] = [next[target], next[index]];
108
+ return next;
109
+ });
110
+ }, []);
111
+
112
+ const handleToggle = useCallback((sectionKey: string, checked: boolean) => {
113
+ setLocalSections((current) =>
114
+ current.map((section) => (section.sectionKey === sectionKey ? { ...section, enabled: checked } : section)),
115
+ );
116
+ }, []);
117
+
118
+ const handleSave = useCallback(async (): Promise<boolean> => {
119
+ const writes: Array<SectionSettingWrite> = [];
120
+
121
+ if (orderChanged) {
122
+ localSections.forEach((section, index) => {
123
+ writes.push({
124
+ sectionKey: section.sectionKey,
125
+ property: `${sectionPropertyPrefix}${section.sectionKey}.order`,
126
+ value: String((index + 1) * 10),
127
+ });
128
+ });
129
+ }
130
+
131
+ for (const section of enabledChanges) {
132
+ writes.push({
133
+ sectionKey: section.sectionKey,
134
+ property: `${sectionPropertyPrefix}${section.sectionKey}.enabled`,
135
+ value: String(section.enabled),
136
+ });
137
+ }
138
+
139
+ if (writes.length === 0) {
140
+ return true;
141
+ }
142
+
143
+ setIsSaving(true);
144
+ try {
145
+ const failed = await saveSectionSettings(writes);
146
+ await mutate();
147
+ if (failed.length > 0) {
148
+ showSnackbar({
149
+ title: t('saveFailedTitle', 'Some settings were not saved'),
150
+ subtitle: t('saveFailedSubtitle', 'Failed to save: {{properties}}. The list has been reloaded.', {
151
+ properties: failed.map((setting) => setting.property).join(', '),
152
+ }),
153
+ kind: 'error',
154
+ isLowContrast: false,
155
+ });
156
+ return false;
157
+ }
158
+ showSnackbar({
159
+ title: t('saveSuccess', 'Visit summary settings saved'),
160
+ kind: 'success',
161
+ isLowContrast: true,
162
+ });
163
+ return true;
164
+ } finally {
165
+ setIsSaving(false);
166
+ }
167
+ }, [enabledChanges, localSections, mutate, orderChanged, t]);
168
+
169
+ const runPreview = useCallback(async () => {
170
+ previewAbortControllerRef.current?.abort();
171
+ const abortController = new AbortController();
172
+ previewAbortControllerRef.current = abortController;
173
+
174
+ setPreview({ status: 'loading' });
175
+ try {
176
+ const blob = await fetchVisitSummaryPreviewPdf(abortController);
177
+ if (abortController.signal.aborted) {
178
+ return;
179
+ }
180
+ if (previewUrlRef.current) {
181
+ window.URL.revokeObjectURL(previewUrlRef.current);
182
+ }
183
+ const url = window.URL.createObjectURL(blob);
184
+ previewUrlRef.current = url;
185
+ setPreview({ status: 'ready', url });
186
+ } catch (previewError) {
187
+ if (abortController.signal.aborted) {
188
+ return;
189
+ }
190
+ setPreview({ status: 'error', errorType: getVisitSummaryPreviewErrorType(previewError) });
191
+ }
192
+ }, []);
193
+
194
+ const handleSaveAndPreview = useCallback(async () => {
195
+ const saved = await handleSave();
196
+ if (saved) {
197
+ await runPreview();
198
+ }
199
+ }, [handleSave, runPreview]);
200
+
201
+ if (isLoading) {
202
+ return (
203
+ <Grid className={styles.grid}>
204
+ <Column sm={4} md={8} lg={8}>
205
+ <SkeletonText heading />
206
+ <SkeletonText paragraph lineCount={8} />
207
+ <ButtonSkeleton />
208
+ </Column>
209
+ </Grid>
210
+ );
211
+ }
212
+
213
+ if (error) {
214
+ const isForbidden = (error as { response?: { status?: number } })?.response?.status === 403;
215
+ return (
216
+ <Grid className={styles.grid}>
217
+ <Column sm={4} md={8} lg={8}>
218
+ <InlineNotification
219
+ kind="error"
220
+ lowContrast
221
+ hideCloseButton
222
+ title={t('sectionsFetchError', "Couldn't load the visit summary sections")}
223
+ subtitle={
224
+ isForbidden
225
+ ? t(
226
+ 'sectionsFetchForbiddenSubtitle',
227
+ 'Your account lacks the Get Global Properties privilege required to view this page.',
228
+ )
229
+ : t('sectionsFetchErrorSubtitle', 'Check that the patientdocuments module is installed and up to date.')
230
+ }
231
+ />
232
+ {!isForbidden && (
233
+ <Button kind="tertiary" onClick={() => mutate()} className={styles.retryButton}>
234
+ {t('retry', 'Retry')}
235
+ </Button>
236
+ )}
237
+ </Column>
238
+ </Grid>
239
+ );
240
+ }
241
+
242
+ if (localSections.length === 0) {
243
+ return (
244
+ <Grid className={styles.grid}>
245
+ <Column sm={4} md={8} lg={8}>
246
+ <Tile>
247
+ <p className={styles.emptyStateTitle}>{t('noSectionsTitle', 'No sections registered')}</p>
248
+ <p className={styles.emptyStateBody}>
249
+ {t(
250
+ 'noSectionsBody',
251
+ 'The server returned no visit summary sections. Sections are registered by the patientdocuments module and by modules that extend it.',
252
+ )}
253
+ </p>
254
+ </Tile>
255
+ </Column>
256
+ </Grid>
257
+ );
258
+ }
259
+
260
+ const previewErrorMessages: Record<VisitSummaryPreviewErrorType, { title: string; subtitle: string }> = {
261
+ notAuthorized: {
262
+ title: t('previewNotAuthorizedTitle', 'Not authorized'),
263
+ subtitle: t(
264
+ 'sectionsFetchForbiddenSubtitle',
265
+ 'Your account lacks the Get Global Properties privilege required to view this page.',
266
+ ),
267
+ },
268
+ endpointMissing: {
269
+ title: t('previewEndpointMissingTitle', 'Preview not available on this server'),
270
+ subtitle: t(
271
+ 'previewEndpointMissing',
272
+ 'The patientdocuments module running on this server is missing or too old to provide the sample preview. Update it and try again.',
273
+ ),
274
+ },
275
+ generationFailed: {
276
+ title: t('previewGenerationFailedTitle', 'PDF generation failed'),
277
+ subtitle: t(
278
+ 'previewGenerationFailed',
279
+ 'The server could not generate the sample preview. Try again — if the problem persists, check the server logs.',
280
+ ),
281
+ },
282
+ network: {
283
+ title: t('previewNetworkErrorTitle', 'Network error'),
284
+ subtitle: t('previewNetworkError', 'The preview could not be retrieved. Check your network connection.'),
285
+ },
286
+ };
287
+
288
+ return (
289
+ <Grid className={styles.grid}>
290
+ <Column sm={4} md={8} lg={8}>
291
+ <p className={styles.instructions}>
292
+ {t(
293
+ 'instructions',
294
+ 'Choose which sections appear in the visit summary PDF and the order they appear in. Changes apply after saving.',
295
+ )}
296
+ </p>
297
+ <ol className={styles.sectionList} aria-label={t('sectionListLabel', 'Visit summary sections')}>
298
+ {localSections.map((section, index) => (
299
+ <li className={styles.sectionRow} key={section.sectionKey}>
300
+ <span className={styles.sectionPosition}>{index + 1}</span>
301
+ <span className={styles.reorderButtons}>
302
+ <IconButton
303
+ kind="ghost"
304
+ size="sm"
305
+ align="right"
306
+ label={t('moveUp', 'Move {{section}} up', { section: section.label })}
307
+ disabled={index === 0 || isSaving || isPinnedToBottom(section)}
308
+ onClick={() => handleMove(index, -1)}
309
+ >
310
+ <ArrowUp />
311
+ </IconButton>
312
+ <IconButton
313
+ kind="ghost"
314
+ size="sm"
315
+ align="right"
316
+ label={t('moveDown', 'Move {{section}} down', { section: section.label })}
317
+ disabled={
318
+ index === localSections.length - 1 ||
319
+ isSaving ||
320
+ isPinnedToBottom(section) ||
321
+ isPinnedToBottom(localSections[index + 1])
322
+ }
323
+ onClick={() => handleMove(index, 1)}
324
+ >
325
+ <ArrowDown />
326
+ </IconButton>
327
+ </span>
328
+ <span className={styles.sectionLabel}>{section.label}</span>
329
+ {section.toggleable ? (
330
+ <Toggle
331
+ id={`section-toggle-${section.sectionKey}`}
332
+ size="sm"
333
+ labelText=""
334
+ aria-label={t('toggleSection', 'Include {{section}}', { section: section.label })}
335
+ labelA={t('toggleOff', 'Off')}
336
+ labelB={t('toggleOn', 'On')}
337
+ toggled={section.enabled}
338
+ disabled={isSaving}
339
+ onToggle={(checked: boolean) => handleToggle(section.sectionKey, checked)}
340
+ />
341
+ ) : (
342
+ <span className={styles.lockedToggle}>
343
+ <Toggle
344
+ id={`section-toggle-${section.sectionKey}`}
345
+ size="sm"
346
+ labelText=""
347
+ aria-label={t('lockedSection', '{{section}} is always included', { section: section.label })}
348
+ labelA={t('toggleOff', 'Off')}
349
+ labelB={t('toggleOn', 'On')}
350
+ toggled
351
+ disabled
352
+ />
353
+ <Tooltip
354
+ align="top"
355
+ label={
356
+ isPinnedToBottom(section)
357
+ ? t(
358
+ 'pinnedSectionExplanation',
359
+ 'This section is always included and always prints at the bottom of every page',
360
+ )
361
+ : t('lockedSectionExplanation', 'This section is always included')
362
+ }
363
+ >
364
+ <button
365
+ type="button"
366
+ className={styles.tooltipTrigger}
367
+ aria-label={
368
+ isPinnedToBottom(section)
369
+ ? t(
370
+ 'pinnedSectionExplanation',
371
+ 'This section is always included and always prints at the bottom of every page',
372
+ )
373
+ : t('lockedSectionExplanation', 'This section is always included')
374
+ }
375
+ >
376
+ <Information />
377
+ </button>
378
+ </Tooltip>
379
+ </span>
380
+ )}
381
+ </li>
382
+ ))}
383
+ </ol>
384
+ <p className={styles.previewHelper}>
385
+ {t(
386
+ 'previewHelper',
387
+ 'The preview is rendered from sample data with the saved settings. No patient record is used.',
388
+ )}
389
+ </p>
390
+ <div className={styles.actions}>
391
+ <Button kind="primary" disabled={!isDirty || isSaving} onClick={handleSave}>
392
+ {isSaving ? <InlineLoading description={t('saving', 'Saving...')} /> : t('saveButton', 'Save')}
393
+ </Button>
394
+ <Button kind="secondary" disabled={isSaving || preview.status === 'loading'} onClick={handleSaveAndPreview}>
395
+ {t('saveAndPreviewButton', 'Save & preview')}
396
+ </Button>
397
+ </div>
398
+ </Column>
399
+ <Column sm={4} md={8} lg={8}>
400
+ {preview.status === 'idle' && (
401
+ <Tile className={styles.previewPlaceholder}>
402
+ {t('previewPlaceholder', 'The PDF preview will appear here after you select Save & preview.')}
403
+ </Tile>
404
+ )}
405
+ {preview.status === 'loading' && (
406
+ <Tile className={styles.previewPlaceholder}>
407
+ <InlineLoading description={t('generatingPreview', 'Generating preview...')} />
408
+ </Tile>
409
+ )}
410
+ {preview.status === 'error' && (
411
+ <>
412
+ <InlineNotification
413
+ kind="error"
414
+ lowContrast
415
+ hideCloseButton
416
+ title={previewErrorMessages[preview.errorType].title}
417
+ subtitle={previewErrorMessages[preview.errorType].subtitle}
418
+ />
419
+ {/* Retrying cannot grant a privilege the account does not have. */}
420
+ {preview.errorType !== 'notAuthorized' && (
421
+ <Button kind="tertiary" onClick={runPreview} className={styles.retryButton}>
422
+ {t('retry', 'Retry')}
423
+ </Button>
424
+ )}
425
+ </>
426
+ )}
427
+ {preview.status === 'ready' && (
428
+ <object
429
+ data={preview.url}
430
+ type="application/pdf"
431
+ className={styles.previewObject}
432
+ aria-label={t('previewPaneLabel', 'Visit summary PDF preview')}
433
+ >
434
+ <p>
435
+ {t('previewUnsupported', "This browser can't display PDFs inline.")}{' '}
436
+ <a href={preview.url} target="_blank" rel="noopener noreferrer">
437
+ {t('previewOpenInNewTab', 'Open the preview in a new tab')}
438
+ </a>
439
+ </p>
440
+ </object>
441
+ )}
442
+ </Column>
443
+ </Grid>
444
+ );
445
+ };
446
+
447
+ export default VisitSummaryConfig;
@@ -0,0 +1,106 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .grid {
6
+ margin-left: 0;
7
+ margin-right: 0;
8
+ padding-left: 0;
9
+ padding-right: 0;
10
+ }
11
+
12
+ .instructions {
13
+ @include type.type-style('body-01');
14
+ margin-bottom: layout.$spacing-05;
15
+ }
16
+
17
+ .sectionList {
18
+ list-style: none;
19
+ margin: 0 0 layout.$spacing-06 0;
20
+ padding: 0;
21
+ border: 1px solid colors.$gray-20;
22
+ }
23
+
24
+ .sectionRow {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: layout.$spacing-03;
28
+ padding: layout.$spacing-02 layout.$spacing-04;
29
+ background-color: colors.$white-0;
30
+
31
+ &:not(:last-child) {
32
+ border-bottom: 1px solid colors.$gray-20;
33
+ }
34
+ }
35
+
36
+ .sectionPosition {
37
+ @include type.type-style('label-01');
38
+ min-width: layout.$spacing-06;
39
+ text-align: right;
40
+ color: colors.$gray-70;
41
+ }
42
+
43
+ .reorderButtons {
44
+ display: flex;
45
+ align-items: center;
46
+ }
47
+
48
+ .sectionLabel {
49
+ @include type.type-style('body-compact-01');
50
+ flex: 1;
51
+ }
52
+
53
+ .lockedToggle {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: layout.$spacing-02;
57
+ }
58
+
59
+ .tooltipTrigger {
60
+ border: none;
61
+ background: none;
62
+ padding: layout.$spacing-02;
63
+ display: flex;
64
+ align-items: center;
65
+ cursor: pointer;
66
+ color: colors.$gray-70;
67
+ }
68
+
69
+ .previewHelper {
70
+ @include type.type-style('body-01');
71
+ color: colors.$gray-70;
72
+ margin-bottom: layout.$spacing-05;
73
+ }
74
+
75
+ .actions {
76
+ display: flex;
77
+ gap: layout.$spacing-03;
78
+ margin-bottom: layout.$spacing-06;
79
+ }
80
+
81
+ .retryButton {
82
+ margin-top: layout.$spacing-04;
83
+ }
84
+
85
+ .previewPlaceholder {
86
+ @include type.type-style('body-01');
87
+ min-height: layout.$spacing-13;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ }
92
+
93
+ .previewObject {
94
+ width: 100%;
95
+ min-height: 40rem;
96
+ border: 1px solid colors.$gray-20;
97
+ }
98
+
99
+ .emptyStateTitle {
100
+ @include type.type-style('heading-compact-01');
101
+ margin-bottom: layout.$spacing-03;
102
+ }
103
+
104
+ .emptyStateBody {
105
+ @include type.type-style('body-01');
106
+ }