@epcare/esm-report-builder 0.1.1-pre.13

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 (222) hide show
  1. package/.eslintignore +5 -0
  2. package/.eslintrc.js +19 -0
  3. package/.prettierrc +8 -0
  4. package/LICENSE +401 -0
  5. package/README.md +317 -0
  6. package/dist/184.js +2 -0
  7. package/dist/184.js.LICENSE.txt +14 -0
  8. package/dist/184.js.map +1 -0
  9. package/dist/211.js +1 -0
  10. package/dist/211.js.map +1 -0
  11. package/dist/300.js +1 -0
  12. package/dist/311.js +1 -0
  13. package/dist/311.js.map +1 -0
  14. package/dist/44.js +2 -0
  15. package/dist/44.js.LICENSE.txt +30 -0
  16. package/dist/44.js.map +1 -0
  17. package/dist/478.js +2 -0
  18. package/dist/478.js.LICENSE.txt +9 -0
  19. package/dist/478.js.map +1 -0
  20. package/dist/535.js +1 -0
  21. package/dist/535.js.map +1 -0
  22. package/dist/540.js +2 -0
  23. package/dist/540.js.LICENSE.txt +9 -0
  24. package/dist/540.js.map +1 -0
  25. package/dist/710.js +1 -0
  26. package/dist/710.js.map +1 -0
  27. package/dist/729.js +1 -0
  28. package/dist/729.js.map +1 -0
  29. package/dist/872.js +1 -0
  30. package/dist/872.js.map +1 -0
  31. package/dist/917.js +1 -0
  32. package/dist/917.js.map +1 -0
  33. package/dist/961.js +2 -0
  34. package/dist/961.js.LICENSE.txt +19 -0
  35. package/dist/961.js.map +1 -0
  36. package/dist/972.js +2 -0
  37. package/dist/972.js.LICENSE.txt +21 -0
  38. package/dist/972.js.map +1 -0
  39. package/dist/main.js +1 -0
  40. package/dist/main.js.map +1 -0
  41. package/dist/openmrs-esm-report-builder.js +1 -0
  42. package/dist/openmrs-esm-report-builder.js.buildmanifest.json +482 -0
  43. package/dist/openmrs-esm-report-builder.js.map +1 -0
  44. package/dist/routes.json +1 -0
  45. package/e2e/core/env.ts +5 -0
  46. package/e2e/core/global-setup.ts +34 -0
  47. package/e2e/core/test.ts +21 -0
  48. package/e2e/pages/app-shell.page.ts +34 -0
  49. package/e2e/pages/etl-sources.page.ts +35 -0
  50. package/e2e/pages/report-sections.page.ts +20 -0
  51. package/e2e/pages/section-preview.modal.ts +29 -0
  52. package/e2e/specs/etl-sources.spec.ts +51 -0
  53. package/e2e/specs/navigation.spec.ts +13 -0
  54. package/e2e/specs/section-preview-validation.spec.ts +38 -0
  55. package/e2e/specs/section-preview.spec.ts +94 -0
  56. package/e2e/tsconfig.json +11 -0
  57. package/example.env +5 -0
  58. package/jest.config.js +28 -0
  59. package/package.json +102 -0
  60. package/playwright.config.ts +28 -0
  61. package/src/admin-card-link.extension.module.scss +55 -0
  62. package/src/admin-card-link.extension.tsx +24 -0
  63. package/src/components/admin/admin-page.component.tsx +31 -0
  64. package/src/components/admin/age-categories-page.component.tsx +109 -0
  65. package/src/components/admin/age-groups-page.component.tsx +146 -0
  66. package/src/components/admin/etl-sources-page.component.tsx +344 -0
  67. package/src/components/admin/report-categories-page.component.tsx +256 -0
  68. package/src/components/admin/report-library-page.component.tsx +540 -0
  69. package/src/components/ai-support/ai-assist-button.component.tsx +30 -0
  70. package/src/components/ai-support/ai-assist-modal.component.tsx +81 -0
  71. package/src/components/ai-support/ai.service.ts +21 -0
  72. package/src/components/ai-support/ai.types.ts +12 -0
  73. package/src/components/app-shell/report-builder-shell.component.tsx +120 -0
  74. package/src/components/app-shell/report-builder-shell.scss +15 -0
  75. package/src/components/data-themes/data-theme-form.component.tsx +59 -0
  76. package/src/components/data-themes/data-theme-modal.component.tsx +349 -0
  77. package/src/components/data-themes/data-themes-page.component.tsx +132 -0
  78. package/src/components/data-themes/data-themes-table.component.tsx +91 -0
  79. package/src/components/data-themes/icon-registry.ts +35 -0
  80. package/src/components/data-themes/sections/data-theme-basics.section.tsx +63 -0
  81. package/src/components/data-themes/sections/data-theme-condition-columns.section.tsx +397 -0
  82. package/src/components/data-themes/sections/data-theme-fields-editor.section.tsx +125 -0
  83. package/src/components/data-themes/sections/data-theme-metadata.section.tsx +245 -0
  84. package/src/components/data-themes/sections/data-theme-preview.section.tsx +115 -0
  85. package/src/components/data-themes/sections/data-theme-source.section.tsx +184 -0
  86. package/src/components/indicators/README.md +24 -0
  87. package/src/components/indicators/base-indicator-basic-fields.component.tsx +18 -0
  88. package/src/components/indicators/base-indicator-basics.section.tsx +54 -0
  89. package/src/components/indicators/base-indicator-countby.section.tsx +25 -0
  90. package/src/components/indicators/base-indicator-criteria.section.tsx +83 -0
  91. package/src/components/indicators/base-indicator-details-form.component.tsx +80 -0
  92. package/src/components/indicators/base-indicator-theme-unit-fields.component.tsx +39 -0
  93. package/src/components/indicators/create-base-indicator-modal.component.tsx +501 -0
  94. package/src/components/indicators/create-composite-base-indicator-modal.component.tsx +297 -0
  95. package/src/components/indicators/create-final-indicator-modal.component.tsx +260 -0
  96. package/src/components/indicators/diagnosis-filters-form.component.tsx +101 -0
  97. package/src/components/indicators/disaggregation-panel.component.tsx +93 -0
  98. package/src/components/indicators/handler/concept-search-multiselect.component.tsx +153 -0
  99. package/src/components/indicators/handler/question-answer-concept-search.component.tsx +336 -0
  100. package/src/components/indicators/indicator-status-tag.component.tsx +9 -0
  101. package/src/components/indicators/indicators-page.component.tsx +481 -0
  102. package/src/components/indicators/indicators-page.scss +121 -0
  103. package/src/components/indicators/indicators-table.component.tsx +166 -0
  104. package/src/components/indicators/preview-table.component.tsx +31 -0
  105. package/src/components/indicators/sections/composite-indicator-basics.section.tsx +36 -0
  106. package/src/components/indicators/sections/composite-indicator-picker.section.tsx +161 -0
  107. package/src/components/indicators/sections/composite-indicator-sql-preview.section.tsx +31 -0
  108. package/src/components/indicators/sections/final-indicator-basics.section.tsx +46 -0
  109. package/src/components/indicators/sections/final-indicator-disaggregation.section.tsx +47 -0
  110. package/src/components/indicators/sections/final-indicator-picker.section.tsx +92 -0
  111. package/src/components/indicators/sections/final-indicator-preview.section.tsx +20 -0
  112. package/src/components/indicators/sections/final-indicator-results-preview.section.tsx +65 -0
  113. package/src/components/indicators/sections/indicator-basics.section.tsx +40 -0
  114. package/src/components/indicators/sections/indicator-conditions.section.tsx +194 -0
  115. package/src/components/indicators/sections/indicator-sql-preview.section.tsx +26 -0
  116. package/src/components/indicators/sections/indicator-theme.section.tsx +48 -0
  117. package/src/components/indicators/sql-preview.component.tsx +19 -0
  118. package/src/components/indicators/types/base-indicator-form.types.ts +17 -0
  119. package/src/components/indicators/types/base-indicator-theme.constants.ts +8 -0
  120. package/src/components/indicators/types/composite-indicator.types.ts +21 -0
  121. package/src/components/indicators/types/condition-ui.types.ts +11 -0
  122. package/src/components/indicators/types/data-theme-config.types.ts +88 -0
  123. package/src/components/indicators/types/indicator-types.ts +56 -0
  124. package/src/components/indicators/types/sql-builders.ts +43 -0
  125. package/src/components/indicators/utils/composite-indicator-sql.utils.ts +206 -0
  126. package/src/components/indicators/utils/concept-preload.utils.ts +21 -0
  127. package/src/components/indicators/utils/final-indicator-sql.utils.ts +124 -0
  128. package/src/components/indicators/utils/indicator-conditions-hydration.utils.ts +254 -0
  129. package/src/components/indicators/utils/indicator-sql.utils.ts +185 -0
  130. package/src/components/landing/landing-action-item.component.tsx +50 -0
  131. package/src/components/landing/landing-action-list.component.tsx +12 -0
  132. package/src/components/landing/landing-activity-list.component.tsx +47 -0
  133. package/src/components/landing/landing-footer-grid.component.tsx +45 -0
  134. package/src/components/landing/landing-hero.component.tsx +18 -0
  135. package/src/components/landing/landing-section-card.component.tsx +33 -0
  136. package/src/components/landing/report-builder-landing-page.component.tsx +103 -0
  137. package/src/components/landing/report-builder-landing-page.scss +131 -0
  138. package/src/components/report/dashboard/reports-table.component.tsx +145 -0
  139. package/src/components/report/definition/panels/properties-panel.component.tsx +38 -0
  140. package/src/components/report/definition/panels/report-structure-panel.component.tsx +61 -0
  141. package/src/components/report/definition/panels/selection-panel.component.tsx +58 -0
  142. package/src/components/report/definition/report-definition-editor.component.tsx +99 -0
  143. package/src/components/report/definition/report-definition.types.ts +66 -0
  144. package/src/components/report/design/panels/IndicatorPropertiesPanel.scss +33 -0
  145. package/src/components/report/design/panels/IndicatorPropertiesPanel.tsx +389 -0
  146. package/src/components/report/design/panels/JsonPreviewPanel.scss +32 -0
  147. package/src/components/report/design/panels/JsonPreviewPanel.tsx +104 -0
  148. package/src/components/report/design/panels/mapping-preview-panel.component.tsx +82 -0
  149. package/src/components/report/design/panels/mapping-preview-panel.scss +55 -0
  150. package/src/components/report/design/panels/template-structure-panel.component.tsx +296 -0
  151. package/src/components/report/design/panels/template-structure-panel.scss +81 -0
  152. package/src/components/report/design/report-design-editor.component.tsx +894 -0
  153. package/src/components/report/design/report-design.types.ts +43 -0
  154. package/src/components/report/design/report-design.utils.ts +86 -0
  155. package/src/components/report/panels/report-detail.component.tsx +71 -0
  156. package/src/components/report/panels/report-panel-shell.component.tsx +60 -0
  157. package/src/components/report/report-dashboard.page.component.tsx +75 -0
  158. package/src/components/report/report-editor.page.component.tsx +466 -0
  159. package/src/components/report/report.routes.tsx +30 -0
  160. package/src/components/report-sections/create-report-section-modal.component.tsx +278 -0
  161. package/src/components/report-sections/hooks/useAgeCategories.ts +25 -0
  162. package/src/components/report-sections/hooks/useSectionEditorState.ts +306 -0
  163. package/src/components/report-sections/panels/Dhis2MappingPanel.tsx +157 -0
  164. package/src/components/report-sections/panels/DisaggregationPanel.tsx +97 -0
  165. package/src/components/report-sections/panels/IndicatorPickerPanel.tsx +143 -0
  166. package/src/components/report-sections/panels/SectionBasicsPanel.tsx +27 -0
  167. package/src/components/report-sections/report-section-preview-modal.component.tsx +418 -0
  168. package/src/components/report-sections/report-sections-page.component.tsx +313 -0
  169. package/src/components/report-sections/section-types.ts +55 -0
  170. package/src/components/report-sections/section-utils.ts +46 -0
  171. package/src/components/report-sections/sections-page.scss +79 -0
  172. package/src/components/run-reports/run-reports-page.component.tsx +23 -0
  173. package/src/components/shared/header/builder-header-actions.component.tsx +38 -0
  174. package/src/components/shared/header/header.component.tsx +85 -0
  175. package/src/components/shared/header/header.scss +150 -0
  176. package/src/components/shared/header/illustration.component.tsx +34 -0
  177. package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.scss +33 -0
  178. package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.tsx +389 -0
  179. package/src/components/shared/json-preview-panel/JsonPreviewPanel.scss +32 -0
  180. package/src/components/shared/json-preview-panel/JsonPreviewPanel.tsx +104 -0
  181. package/src/components/shared/mapping-preview-panel/mapping-preview-panel.component.tsx +82 -0
  182. package/src/components/shared/mapping-preview-panel/mapping-preview-panel.scss +55 -0
  183. package/src/components/shared/preview/query-results-preview.component.tsx +204 -0
  184. package/src/components/shared/template-structure-panel/template-structure-panel.component.tsx +296 -0
  185. package/src/components/shared/template-structure-panel/template-structure-panel.scss +81 -0
  186. package/src/config-schema.ts +23 -0
  187. package/src/globals.d.ts +14 -0
  188. package/src/hooks/theme/useDataThemes.ts +28 -0
  189. package/src/hooks/theme/useMambaTableMeta.ts +34 -0
  190. package/src/hooks/theme/useMambaTables.ts +63 -0
  191. package/src/index.ts +23 -0
  192. package/src/resources/agegroup/agegroups.api.ts +58 -0
  193. package/src/resources/concepts/concept-mapper.ts +21 -0
  194. package/src/resources/concepts/concept-types.ts +31 -0
  195. package/src/resources/concepts/concepts.resource.ts +44 -0
  196. package/src/resources/concepts/useConceptSearch.ts +41 -0
  197. package/src/resources/etl-source/etl-source.api.ts +65 -0
  198. package/src/resources/indicator/indicators.api.ts +125 -0
  199. package/src/resources/openmrs-api.ts +24 -0
  200. package/src/resources/preview/sql-preview.api.ts +54 -0
  201. package/src/resources/report/reports.api.ts +117 -0
  202. package/src/resources/report-category/report-category.api.ts +41 -0
  203. package/src/resources/report-library/report-library.api.ts +63 -0
  204. package/src/resources/report-section/report-sections.api.ts +73 -0
  205. package/src/resources/report-section/section-preview.api.ts +21 -0
  206. package/src/resources/theme/data-theme.api.ts +88 -0
  207. package/src/resources/theme/mamba-schema.api.ts +31 -0
  208. package/src/resources/theme/mamba-table-meta.api.ts +45 -0
  209. package/src/root.component.tsx +20 -0
  210. package/src/routes/report-builder.routes.tsx +66 -0
  211. package/src/routes/report-builder.scss +56 -0
  212. package/src/routes.json +21 -0
  213. package/src/sample-template.ts +121 -0
  214. package/src/template-utils.ts +110 -0
  215. package/src/types/carbon-react-inert.d.ts +13 -0
  216. package/src/types/condition-operators.ts +49 -0
  217. package/src/types/theme/data-theme.types.ts +88 -0
  218. package/src/utils/html-entities.utils.ts +23 -0
  219. package/tools/setup-tests.ts +1 -0
  220. package/translations/en.json +4 -0
  221. package/tsconfig.json +43 -0
  222. package/webpack.config.js +14 -0
@@ -0,0 +1,145 @@
1
+ import React from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableContainer,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ TableToolbar,
13
+ TableToolbarContent,
14
+ TableToolbarSearch,
15
+ } from '@carbon/react';
16
+ import { Add } from '@carbon/icons-react';
17
+ import { useTranslation } from 'react-i18next';
18
+
19
+ export type ReportSummary = {
20
+ id: string;
21
+ name: string;
22
+ status: string;
23
+ updatedAt: string;
24
+ };
25
+
26
+ type HeaderDef = {
27
+ key: string;
28
+ header: string;
29
+ };
30
+
31
+ type Props = {
32
+ reports: ReportSummary[];
33
+ headers: HeaderDef[];
34
+ onRowClick: (reportId: string) => void;
35
+ searchValue: string;
36
+ onSearchChange: (value: string) => void;
37
+ onCreateReport: () => void;
38
+ };
39
+
40
+ const ReportsTable: React.FC<Props> = ({
41
+ reports,
42
+ headers,
43
+ onRowClick,
44
+ searchValue,
45
+ onSearchChange,
46
+ onCreateReport,
47
+ }) => {
48
+ const { t } = useTranslation();
49
+
50
+ const rows = React.useMemo(
51
+ () =>
52
+ reports.map((r) => ({
53
+ id: r.id,
54
+ name: r.name,
55
+ status: r.status,
56
+ updatedAt: r.updatedAt,
57
+ })),
58
+ [reports],
59
+ );
60
+
61
+ return (
62
+ <DataTable rows={rows} headers={headers} useZebraStyles>
63
+ {({ rows, headers, getHeaderProps, getRowProps, getTableProps }) => (
64
+ <TableContainer
65
+ data-testid="reports-table"
66
+ style={{
67
+ background: 'var(--cds-layer, #ffffff)',
68
+ borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)',
69
+ }}
70
+ >
71
+ <TableToolbar>
72
+ <TableToolbarContent
73
+ style={{
74
+ display: 'flex',
75
+ justifyContent: 'space-between',
76
+ gap: '0.75rem',
77
+ }}
78
+ >
79
+ <TableToolbarSearch
80
+ expanded
81
+ value={searchValue}
82
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)}
83
+ placeholder={t('searchThisList', 'Search this list')}
84
+ />
85
+
86
+ <Button
87
+ kind="primary"
88
+ renderIcon={() => <Add size={16} />}
89
+ size="sm"
90
+ onClick={onCreateReport}
91
+ >
92
+ {t('createReport', 'Create report')}
93
+ </Button>
94
+ </TableToolbarContent>
95
+ </TableToolbar>
96
+
97
+ <Table {...getTableProps()}>
98
+ <TableHead
99
+ style={{
100
+ display: 'table-header-group',
101
+ visibility: 'visible',
102
+ }}
103
+ >
104
+ <TableRow>
105
+ {headers.map((header) => (
106
+ <TableHeader
107
+ {...getHeaderProps({ header })}
108
+ key={header.key}
109
+ style={{
110
+ fontWeight: 600,
111
+ backgroundColor: 'var(--cds-layer-accent, #f4f4f4)',
112
+ color: 'var(--cds-text-primary, #161616)',
113
+ borderBottom: '1px solid var(--cds-border-subtle, #e0e0e0)',
114
+ display: 'table-cell',
115
+ visibility: 'visible',
116
+ }}
117
+ >
118
+ {header.header}
119
+ </TableHeader>
120
+ ))}
121
+ </TableRow>
122
+ </TableHead>
123
+
124
+ <TableBody>
125
+ {rows.map((row) => (
126
+ <TableRow
127
+ {...getRowProps({ row })}
128
+ key={row.id}
129
+ onClick={() => onRowClick(row.id)}
130
+ style={{ cursor: 'pointer' }}
131
+ >
132
+ {row.cells.map((cell) => (
133
+ <TableCell key={cell.id}>{cell.value}</TableCell>
134
+ ))}
135
+ </TableRow>
136
+ ))}
137
+ </TableBody>
138
+ </Table>
139
+ </TableContainer>
140
+ )}
141
+ </DataTable>
142
+ );
143
+ };
144
+
145
+ export default ReportsTable;
@@ -0,0 +1,38 @@
1
+
2
+ import React from 'react';
3
+ import { Button, TextArea, TextInput, Toggle, Tag } from '@carbon/react';
4
+ import { Close } from '@carbon/icons-react';
5
+
6
+ import ReportPanelShell from '../../panels/report-panel-shell.component';
7
+ import type { ReportDefinitionDraft, ReportSectionRef, SectionLibraryItem } from '../report-definition.types';
8
+
9
+ type Props = {
10
+ draft: ReportDefinitionDraft;
11
+ onDraftChange: (patch: Partial<ReportDefinitionDraft>) => void;
12
+ selectedSectionRef: ReportSectionRef | null;
13
+ selectedSection: SectionLibraryItem | null;
14
+ onUpdateSelectedSection: (patch: Partial<ReportSectionRef>) => void;
15
+ };
16
+
17
+ const PropertiesPanel: React.FC<Props> = ({ draft, onDraftChange, selectedSectionRef, selectedSection, onUpdateSelectedSection }) => {
18
+ return (
19
+ <ReportPanelShell title="Properties" right={<button type="button" aria-label="close properties" style={{ border: 0, background: 'transparent', cursor: 'pointer' }} onClick={() => undefined}><Close size={18} /></button>} bottom={<><Button size="sm" kind="secondary">Cancel</Button><Button size="sm" kind="primary">Save Draft</Button></>}>
20
+ <div style={{ fontSize: '1rem', fontWeight: 600 }}>Report Details</div>
21
+ <TextInput id="report-definition-name" labelText="Report Name" value={draft.name} onChange={(e) => onDraftChange({ name: (e.target as HTMLInputElement).value })} />
22
+ <TextInput id="report-definition-code" labelText="Report Code" value={draft.code} onChange={(e) => onDraftChange({ code: (e.target as HTMLInputElement).value })} />
23
+ <TextArea id="report-definition-description" labelText="Description" value={draft.description} onChange={(e) => onDraftChange({ description: (e.target as HTMLTextAreaElement).value })} />
24
+ <div style={{ borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)', marginTop: '0.5rem', paddingTop: '1rem' }}>
25
+ <div style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem' }}>Selected Section</div>
26
+ {!selectedSectionRef || !selectedSection ? <div style={{ opacity: 0.75 }}>Select a section from the report structure to edit section-level properties.</div> : <>
27
+ <div style={{ fontWeight: 600 }}>{selectedSection.name}</div>
28
+ <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.5rem', marginBottom: '0.75rem' }}><Tag size="sm" type="gray">{selectedSection.indicatorCount} indicators</Tag>{selectedSection.disaggregationEnabled ? <Tag size="sm" type="blue">{selectedSection.ageCategoryCode ?? 'Disaggregated'}</Tag> : <Tag size="sm" type="warm-gray">No disaggregation</Tag>}</div>
29
+ <TextInput id="selected-section-title-override" labelText="Title Override (optional)" value={selectedSectionRef.titleOverride ?? ''} onChange={(e) => onUpdateSelectedSection({ titleOverride: (e.target as HTMLInputElement).value })} />
30
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem', marginTop: '0.5rem' }}><div style={{ fontWeight: 600 }}>Enabled in this report</div><Toggle id="selected-section-enabled" labelText="" toggled={selectedSectionRef.enabled} onToggle={(enabled) => onUpdateSelectedSection({ enabled })} /></div>
31
+ {selectedSection.description ? <div style={{ marginTop: '0.75rem', opacity: 0.8 }}>{selectedSection.description}</div> : null}
32
+ </>}
33
+ </div>
34
+ </ReportPanelShell>
35
+ );
36
+ };
37
+
38
+ export default PropertiesPanel;
@@ -0,0 +1,61 @@
1
+
2
+ import React from 'react';
3
+ import { Button, Search, Tag, OverflowMenu, OverflowMenuItem } from '@carbon/react';
4
+ import { Add, Folder, ArrowUp, ArrowDown, TrashCan } from '@carbon/icons-react';
5
+
6
+ import ReportPanelShell from '../../panels/report-panel-shell.component';
7
+ import type { ReportSectionRef, SectionLibraryItem } from '../report-definition.types';
8
+
9
+ type Props = {
10
+ reportSections: ReportSectionRef[];
11
+ sectionLibrary: SectionLibraryItem[];
12
+ selectedSectionUuid: string | null;
13
+ search: string;
14
+ onSearchChange: (v: string) => void;
15
+ onSelectSection: (sectionUuid: string) => void;
16
+ onMoveSection: (sectionUuid: string, dir: -1 | 1) => void;
17
+ onRemoveSection: (sectionUuid: string) => void;
18
+ onFocusAvailableSections: () => void;
19
+ };
20
+
21
+ const ReportStructurePanel: React.FC<Props> = ({ reportSections, sectionLibrary, selectedSectionUuid, search, onSearchChange, onSelectSection, onMoveSection, onRemoveSection, onFocusAvailableSections }) => {
22
+ const filtered = React.useMemo(() => {
23
+ const q = search.trim().toLowerCase();
24
+ const sorted = [...reportSections].sort((a, b) => a.sortOrder - b.sortOrder);
25
+ if (!q) return sorted;
26
+ return sorted.filter((ref) => {
27
+ const section = sectionLibrary.find((s) => s.uuid === ref.sectionUuid);
28
+ return section && (section.name.toLowerCase().includes(q) || (section.description ?? '').toLowerCase().includes(q) || (section.code ?? '').toLowerCase().includes(q));
29
+ });
30
+ }, [reportSections, sectionLibrary, search]);
31
+
32
+ return (
33
+ <ReportPanelShell title="Report Structure" right={<OverflowMenu size="sm" aria-label="report structure actions"><OverflowMenuItem itemText="Browse sections" onClick={onFocusAvailableSections} /></OverflowMenu>} bottom={<><span /><Button size="sm" kind="primary" renderIcon={Add} onClick={onFocusAvailableSections}>Add Section</Button></>}>
34
+ <Search size="lg" labelText="Search selected sections" placeholder="Search selected sections" value={search} onChange={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
35
+ <div style={{ border: '1px solid var(--cds-border-subtle, #e0e0e0)', borderRadius: 10, overflow: 'hidden' }}>
36
+ {filtered.length === 0 ? <div style={{ padding: '0.9rem', opacity: 0.75 }}>No sections selected yet.</div> : filtered.map((ref, idx) => {
37
+ const section = sectionLibrary.find((s) => s.uuid === ref.sectionUuid);
38
+ if (!section) return null;
39
+ return (
40
+ <div key={ref.sectionUuid} role="button" tabIndex={0} onClick={() => onSelectSection(ref.sectionUuid)} onKeyDown={(e) => e.key === 'Enter' && onSelectSection(ref.sectionUuid)} style={{ padding: '0.85rem 0.9rem', borderBottom: '1px solid var(--cds-border-subtle, #e0e0e0)', background: selectedSectionUuid === ref.sectionUuid ? 'rgba(0,0,0,0.03)' : '#fff', cursor: 'pointer' }}>
41
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'flex-start' }}>
42
+ <div style={{ minWidth: 0 }}>
43
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', minWidth: 0 }}><Folder size={16} /><div style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ref.titleOverride?.trim() ? ref.titleOverride : section.name}</div>{!ref.enabled ? <Tag size="sm" type="red">Disabled</Tag> : null}</div>
44
+ <div style={{ display: 'flex', gap: '0.4rem', marginTop: 6, flexWrap: 'wrap' }}><Tag size="sm" type="gray">{section.indicatorCount} indicators</Tag>{section.disaggregationEnabled ? <Tag size="sm" type="blue">{section.ageCategoryCode ?? 'Disaggregated'}</Tag> : <Tag size="sm" type="warm-gray">No disaggregation</Tag>}</div>
45
+ {section.description ? <div style={{ marginTop: 6, opacity: 0.8 }}>{section.description}</div> : null}
46
+ </div>
47
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
48
+ <Button kind="ghost" size="sm" hasIconOnly iconDescription="Move up" renderIcon={ArrowUp} disabled={idx === 0} onClick={(e) => { e.stopPropagation(); onMoveSection(ref.sectionUuid, -1); }} />
49
+ <Button kind="ghost" size="sm" hasIconOnly iconDescription="Move down" renderIcon={ArrowDown} disabled={idx === filtered.length - 1} onClick={(e) => { e.stopPropagation(); onMoveSection(ref.sectionUuid, 1); }} />
50
+ <Button kind="ghost" size="sm" hasIconOnly iconDescription="Remove" renderIcon={TrashCan} onClick={(e) => { e.stopPropagation(); onRemoveSection(ref.sectionUuid); }} />
51
+ </div>
52
+ </div>
53
+ </div>
54
+ );
55
+ })}
56
+ </div>
57
+ </ReportPanelShell>
58
+ );
59
+ };
60
+
61
+ export default ReportStructurePanel;
@@ -0,0 +1,58 @@
1
+
2
+ import React from 'react';
3
+ import { Button, Search, Tag, Checkbox } from '@carbon/react';
4
+ import { Add, Folder } from '@carbon/icons-react';
5
+
6
+ import ReportPanelShell from '../../panels/report-panel-shell.component';
7
+ import type { ReportSectionRef, SectionLibraryItem } from '../report-definition.types';
8
+
9
+ type Props = {
10
+ availableSections: SectionLibraryItem[];
11
+ reportSections: ReportSectionRef[];
12
+ selectedAvailableSectionUuid: string | null;
13
+ search: string;
14
+ onSearchChange: (v: string) => void;
15
+ onSelectAvailableSection: (sectionUuid: string) => void;
16
+ onToggleSection: (sectionUuid: string, checked: boolean) => void;
17
+ };
18
+
19
+ const SelectionPanel: React.FC<Props> = ({ availableSections, reportSections, selectedAvailableSectionUuid, search, onSearchChange, onSelectAvailableSection, onToggleSection }) => {
20
+ const filtered = React.useMemo(() => {
21
+ const q = search.trim().toLowerCase();
22
+ if (!q) return availableSections;
23
+ return availableSections.filter((s) => s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q) || (s.code ?? '').toLowerCase().includes(q));
24
+ }, [availableSections, search]);
25
+
26
+ const isIncluded = (sectionUuid: string) => reportSections.some((x) => x.sectionUuid === sectionUuid);
27
+
28
+ return (
29
+ <ReportPanelShell title="Available Sections" bottom={<><span style={{ opacity: 0.75 }}>Select sections to include in this report.</span><Button size="sm" kind="secondary" renderIcon={Add} disabled>Sections are added directly</Button></>}>
30
+ <Search size="lg" labelText="Search sections" placeholder="Search sections" value={search} onChange={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
31
+ <div style={{ border: '1px solid var(--cds-border-subtle, #e0e0e0)', borderRadius: 10, overflow: 'hidden' }}>
32
+ {filtered.length === 0 ? <div style={{ padding: '0.9rem', opacity: 0.75 }}>No sections found.</div> : filtered.map((section) => {
33
+ const checked = isIncluded(section.uuid);
34
+ return (
35
+ <div key={section.uuid} role="button" tabIndex={0} onClick={() => onSelectAvailableSection(section.uuid)} onKeyDown={(e) => e.key === 'Enter' && onSelectAvailableSection(section.uuid)} style={{ padding: '0.85rem 0.9rem', borderBottom: '1px solid var(--cds-border-subtle, #e0e0e0)', background: selectedAvailableSectionUuid === section.uuid ? 'rgba(0,0,0,0.03)' : '#fff', cursor: 'pointer' }}>
36
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'flex-start' }}>
37
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.65rem', minWidth: 0 }}>
38
+ <Checkbox id={`available-section-${section.uuid}`} checked={checked} labelText="" onChange={(arg1: any, arg2: any) => {
39
+ const nextChecked = typeof arg1 === 'boolean' ? arg1 : typeof arg2?.checked === 'boolean' ? arg2.checked : Boolean(arg1?.target?.checked);
40
+ onToggleSection(section.uuid, nextChecked);
41
+ }} />
42
+ <div style={{ minWidth: 0 }}>
43
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}><Folder size={16} /><div style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{section.name}</div></div>
44
+ {section.description ? <div style={{ marginTop: 6, opacity: 0.8 }}>{section.description}</div> : null}
45
+ <div style={{ display: 'flex', gap: '0.4rem', marginTop: 6, flexWrap: 'wrap' }}><Tag size="sm" type="gray">{section.indicatorCount} indicators</Tag>{section.disaggregationEnabled ? <Tag size="sm" type="blue">{section.ageCategoryCode ?? 'Disaggregated'}</Tag> : <Tag size="sm" type="warm-gray">No disaggregation</Tag>}</div>
46
+ </div>
47
+ </div>
48
+ {section.code ? <Tag size="sm" type="cool-gray">{section.code}</Tag> : null}
49
+ </div>
50
+ </div>
51
+ );
52
+ })}
53
+ </div>
54
+ </ReportPanelShell>
55
+ );
56
+ };
57
+
58
+ export default SelectionPanel;
@@ -0,0 +1,99 @@
1
+
2
+ import React from 'react';
3
+ import { InlineLoading, InlineNotification } from '@carbon/react';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import styles from '../../../routes/report-builder.scss';
7
+
8
+ import ReportStructurePanel from './panels/report-structure-panel.component';
9
+ import SelectionPanel from './panels/selection-panel.component';
10
+ import PropertiesPanel from './panels/properties-panel.component';
11
+
12
+ import { parseSectionLibraryItem, sortReportSections, type ReportDefinitionDraft, type ReportSectionRef, type SectionLibraryItem } from './report-definition.types';
13
+ import { listSections } from '../../../resources/report-section/report-sections.api';
14
+
15
+ type Props = {
16
+ value: ReportDefinitionDraft;
17
+ onChange: (next: ReportDefinitionDraft) => void;
18
+ };
19
+
20
+ const ReportDefinitionEditor: React.FC<Props> = ({ value, onChange }) => {
21
+ const { t } = useTranslation();
22
+ const [sectionLibrary, setSectionLibrary] = React.useState<SectionLibraryItem[]>([]);
23
+ const [sectionsLoading, setSectionsLoading] = React.useState(false);
24
+ const [sectionsError, setSectionsError] = React.useState<string | null>(null);
25
+ const [structureSearch, setStructureSearch] = React.useState('');
26
+ const [selectionSearch, setSelectionSearch] = React.useState('');
27
+ const [selectedReportSectionUuid, setSelectedReportSectionUuid] = React.useState<string | null>(null);
28
+ const [selectedAvailableSectionUuid, setSelectedAvailableSectionUuid] = React.useState<string | null>(null);
29
+
30
+ React.useEffect(() => {
31
+ const ac = new AbortController();
32
+ setSectionsLoading(true);
33
+ setSectionsError(null);
34
+ listSections({ v: 'full', includeRetired: false }, ac.signal)
35
+ .then((rows) => setSectionLibrary(rows.filter((x) => !x.retired).map(parseSectionLibraryItem)))
36
+ .catch((e: any) => setSectionsError(e?.message ?? 'Failed to load sections'))
37
+ .finally(() => setSectionsLoading(false));
38
+ return () => ac.abort();
39
+ }, []);
40
+
41
+ const draft = value;
42
+ const updateDraft = React.useCallback((patch: Partial<ReportDefinitionDraft>) => onChange({ ...draft, ...patch }), [draft, onChange]);
43
+ const selectedSectionRef = React.useMemo(() => draft.sections.find((s) => s.sectionUuid === selectedReportSectionUuid) ?? null, [draft.sections, selectedReportSectionUuid]);
44
+ const selectedSection = React.useMemo(() => sectionLibrary.find((s) => s.uuid === selectedReportSectionUuid) ?? null, [sectionLibrary, selectedReportSectionUuid]);
45
+
46
+ const addSectionToDraft = React.useCallback((sectionUuid: string) => {
47
+ if (draft.sections.some((s) => s.sectionUuid === sectionUuid)) { setSelectedReportSectionUuid(sectionUuid); return; }
48
+ const nextRef: ReportSectionRef = { sectionUuid, sortOrder: draft.sections.length + 1, enabled: true, titleOverride: '' };
49
+ onChange({ ...draft, sections: [...draft.sections, nextRef] });
50
+ setSelectedReportSectionUuid(sectionUuid);
51
+ }, [draft, onChange]);
52
+
53
+ const removeSectionFromDraft = React.useCallback((sectionUuid: string) => {
54
+ const kept = draft.sections.filter((s) => s.sectionUuid !== sectionUuid);
55
+ onChange({ ...draft, sections: kept.map((s, idx) => ({ ...s, sortOrder: idx + 1 })) });
56
+ if (selectedReportSectionUuid === sectionUuid) setSelectedReportSectionUuid(null);
57
+ if (selectedAvailableSectionUuid === sectionUuid) setSelectedAvailableSectionUuid(null);
58
+ }, [draft, onChange, selectedReportSectionUuid, selectedAvailableSectionUuid]);
59
+
60
+ const toggleSectionInDraft = React.useCallback((sectionUuid: string, checked: boolean) => checked ? addSectionToDraft(sectionUuid) : removeSectionFromDraft(sectionUuid), [addSectionToDraft, removeSectionFromDraft]);
61
+ const moveSection = React.useCallback((sectionUuid: string, dir: -1 | 1) => {
62
+ const list = sortReportSections(draft.sections);
63
+ const idx = list.findIndex((x) => x.sectionUuid === sectionUuid);
64
+ if (idx < 0) return;
65
+ const j = idx + dir;
66
+ if (j < 0 || j >= list.length) return;
67
+ const tmp = list[idx]; list[idx] = list[j]; list[j] = tmp;
68
+ onChange({ ...draft, sections: list.map((x, k) => ({ ...x, sortOrder: k + 1 })) });
69
+ }, [draft, onChange]);
70
+
71
+ const updateSelectedSectionRef = React.useCallback((patch: Partial<ReportSectionRef>) => {
72
+ if (!selectedReportSectionUuid) return;
73
+ onChange({ ...draft, sections: draft.sections.map((s) => s.sectionUuid === selectedReportSectionUuid ? { ...s, ...patch } : s) });
74
+ }, [draft, onChange, selectedReportSectionUuid]);
75
+
76
+ const chosenSections = React.useMemo(() => sortReportSections(draft.sections), [draft.sections]);
77
+
78
+ return (
79
+ <div className={styles.designWorkspace}>
80
+ <div style={{ marginBottom: '1rem' }}>
81
+ <h3 className={styles.workspaceTitle}>{t('buildReport', 'Build Report')}: {draft.name || 'Untitled report'}</h3>
82
+ <p className={styles.workspaceHint}>{t('draftHint', 'Draft • Define reports as collections of reusable sections')}</p>
83
+ </div>
84
+ {sectionsLoading ? <InlineLoading description="Loading sections…" /> : null}
85
+ {sectionsError ? <div style={{ marginBottom: '1rem' }}><InlineNotification kind="error" lowContrast title="Sections" subtitle={sectionsError} /></div> : null}
86
+ <div style={{ display: 'grid', gridTemplateColumns: '1.1fr 1.1fr 0.9fr', gap: '1rem' }}>
87
+ <ReportStructurePanel reportSections={chosenSections} sectionLibrary={sectionLibrary} selectedSectionUuid={selectedReportSectionUuid} search={structureSearch} onSearchChange={setStructureSearch} onSelectSection={setSelectedReportSectionUuid} onMoveSection={moveSection} onRemoveSection={removeSectionFromDraft} onFocusAvailableSections={() => { if (sectionLibrary.length > 0) setSelectedAvailableSectionUuid((prev) => prev ?? sectionLibrary[0].uuid); }} />
88
+ <SelectionPanel availableSections={sectionLibrary} reportSections={chosenSections} selectedAvailableSectionUuid={selectedAvailableSectionUuid} search={selectionSearch} onSearchChange={setSelectionSearch} onSelectAvailableSection={setSelectedAvailableSectionUuid} onToggleSection={toggleSectionInDraft} />
89
+ <PropertiesPanel draft={draft} onDraftChange={updateDraft} selectedSectionRef={selectedSectionRef} selectedSection={selectedSection} onUpdateSelectedSection={updateSelectedSectionRef} />
90
+ </div>
91
+ <div style={{ marginTop: '1rem', padding: '0.9rem 1rem', background: '#fff', border: '1px solid var(--cds-border-subtle, #e0e0e0)', borderRadius: 12 }}>
92
+ <div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Current Authoring Draft</div>
93
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.85rem' }}>{JSON.stringify(draft, null, 2)}</pre>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ export default ReportDefinitionEditor;
@@ -0,0 +1,66 @@
1
+
2
+ export type ReportSectionRef = {
3
+ sectionUuid: string;
4
+ sortOrder: number;
5
+ titleOverride?: string;
6
+ enabled: boolean;
7
+ };
8
+
9
+ export type ReportDefinitionDraft = {
10
+ name: string;
11
+ code: string;
12
+ description: string;
13
+ sections: ReportSectionRef[];
14
+ };
15
+
16
+ export type SectionLibraryItem = {
17
+ uuid: string;
18
+ name: string;
19
+ description?: string;
20
+ code?: string;
21
+ indicatorCount: number;
22
+ disaggregationEnabled: boolean;
23
+ ageCategoryCode?: string | null;
24
+ raw: any;
25
+ };
26
+
27
+ export function createEmptyReportDefinitionDraft(): ReportDefinitionDraft {
28
+ return {
29
+ name: '',
30
+ code: '',
31
+ description: '',
32
+ sections: [],
33
+ };
34
+ }
35
+
36
+ export function parseSectionLibraryItem(section: any): SectionLibraryItem {
37
+ let indicatorCount = 0;
38
+ let disaggregationEnabled = false;
39
+ let ageCategoryCode: string | null | undefined = null;
40
+
41
+ try {
42
+ const cfg = section.configJson ? JSON.parse(section.configJson) : null;
43
+ indicatorCount = Array.isArray(cfg?.indicators) ? cfg.indicators.length : 0;
44
+ disaggregationEnabled = Boolean(cfg?.disaggregation && cfg?.disaggregation?.none !== true);
45
+ ageCategoryCode = cfg?.disaggregation?.ageCategoryCode ?? null;
46
+ } catch {
47
+ indicatorCount = 0;
48
+ disaggregationEnabled = false;
49
+ ageCategoryCode = null;
50
+ }
51
+
52
+ return {
53
+ uuid: section.uuid,
54
+ name: section.name,
55
+ description: section.description,
56
+ code: section.code,
57
+ indicatorCount,
58
+ disaggregationEnabled,
59
+ ageCategoryCode,
60
+ raw: section,
61
+ };
62
+ }
63
+
64
+ export function sortReportSections<T extends { sortOrder: number }>(items: T[]): T[] {
65
+ return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
66
+ }
@@ -0,0 +1,33 @@
1
+ .tileHeader {
2
+ margin-bottom: 0.75rem;
3
+ }
4
+
5
+ .tileTitle {
6
+ margin: 0;
7
+ font-weight: 600;
8
+ }
9
+
10
+ .formGrid {
11
+ display: grid;
12
+ grid-template-columns: 1fr;
13
+ gap: 0.75rem;
14
+ margin-top: 0.75rem;
15
+ }
16
+
17
+ .note {
18
+ margin-top: 0.75rem;
19
+ padding: 0.75rem;
20
+ border-left: 4px solid var(--cds-support-info, #0f62fe);
21
+ background: var(--cds-layer-accent-01, #edf5ff);
22
+ border-radius: 0.25rem;
23
+ font-size: 0.875rem;
24
+ }
25
+
26
+ .mappingActions {
27
+ margin-top: 0.75rem;
28
+ }
29
+
30
+ .placeholder {
31
+ padding: 1rem 0;
32
+ opacity: 0.8;
33
+ }