@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,166 @@
1
+ import React from 'react';
2
+ import {
3
+ DataTable,
4
+ Table,
5
+ TableHead,
6
+ TableRow,
7
+ TableHeader,
8
+ TableBody,
9
+ TableCell,
10
+ TableContainer,
11
+ Tag,
12
+ OverflowMenu,
13
+ OverflowMenuItem,
14
+ } from '@carbon/react';
15
+
16
+ export type IndicatorRow = {
17
+ id: string; // uuid
18
+ code: string;
19
+ name: string;
20
+
21
+ kind: 'BASE' | 'COMPOSITE' | 'FINAL' | string;
22
+
23
+ themeName?: string;
24
+ themeColor?: string; // hex
25
+
26
+ status: 'Draft' | 'Published' | 'Retired' | string;
27
+ };
28
+
29
+ type Props = {
30
+ rows: IndicatorRow[];
31
+ onOpen?: (id: string) => void;
32
+ onEdit?: (id: string, kind: IndicatorRow['kind']) => void;
33
+ onRun?: (id: string) => void;
34
+ onDelete?: (id: string) => void;
35
+ };
36
+
37
+ function statusTag(status: string) {
38
+ const s = (status ?? '').toLowerCase();
39
+ if (s.includes('publish')) return <Tag type="green">Published</Tag>;
40
+ if (s.includes('retire')) return <Tag type="red">Retired</Tag>;
41
+ return <Tag type="gray">Draft</Tag>;
42
+ }
43
+
44
+ function kindTag(kind: string) {
45
+ const k = (kind ?? '').toUpperCase();
46
+ if (k === 'FINAL') return <Tag type="purple">FINAL</Tag>;
47
+ if (k === 'COMPOSITE') return <Tag type="teal">COMPOSITE</Tag>;
48
+ return <Tag type="blue">BASE</Tag>;
49
+ }
50
+
51
+ function themePill(themeName?: string, themeColor?: string) {
52
+ if (!themeName) return <span style={{ opacity: 0.7 }}>—</span>;
53
+
54
+ return (
55
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
56
+ <span
57
+ aria-hidden="true"
58
+ style={{
59
+ width: 10,
60
+ height: 10,
61
+ borderRadius: 999,
62
+ background: themeColor || 'var(--cds-icon-primary)',
63
+ display: 'inline-block',
64
+ border: '1px solid var(--cds-border-subtle)',
65
+ }}
66
+ />
67
+ <span style={{ fontWeight: 500 }}>{themeName}</span>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export default function IndicatorsTable({ rows, onOpen, onEdit, onRun, onDelete }: Props) {
73
+ const headers = [
74
+ { key: 'code', header: 'Code' },
75
+ { key: 'name', header: 'Name' },
76
+ { key: 'kind', header: 'Kind' },
77
+ { key: 'theme', header: 'Theme' },
78
+ { key: 'status', header: 'Status' },
79
+ { key: 'actions', header: '' },
80
+ ];
81
+
82
+ const tableRows = (rows ?? []).map((r) => ({
83
+ id: r.id,
84
+ code: r.code,
85
+ name: r.name,
86
+ kind: r.kind,
87
+ theme: r.themeName ?? '', // matches header key
88
+ themeColor: r.themeColor ?? '', // extra (not a column)
89
+ status: r.status,
90
+ }));
91
+
92
+ return (
93
+ <TableContainer title="" description="">
94
+ <DataTable rows={tableRows} headers={headers} isSortable>
95
+ {({ rows: dtRows, headers: dtHeaders, getHeaderProps, getRowProps }) => (
96
+ <Table size="lg" useZebraStyles>
97
+ <TableHead>
98
+ <TableRow>
99
+ {dtHeaders.map((h) => (
100
+ <TableHeader key={h.key} {...getHeaderProps({ header: h })}>
101
+ {h.header}
102
+ </TableHeader>
103
+ ))}
104
+ </TableRow>
105
+ </TableHead>
106
+
107
+ <TableBody>
108
+ {dtRows.length === 0 ? (
109
+ <TableRow>
110
+ <TableCell colSpan={dtHeaders.length} style={{ padding: '1rem', opacity: 0.75 }}>
111
+ No indicators found.
112
+ </TableCell>
113
+ </TableRow>
114
+ ) : null}
115
+
116
+ {dtRows.map((row) => {
117
+ const code = String(row.cells.find((c) => c.info.header === 'code')?.value ?? '');
118
+ const name = String(row.cells.find((c) => c.info.header === 'name')?.value ?? '');
119
+ const kind = String(row.cells.find((c) => c.info.header === 'kind')?.value ?? '');
120
+ const themeName = String(row.cells.find((c) => c.info.header === 'theme')?.value ?? '');
121
+ const status = String(row.cells.find((c) => c.info.header === 'status')?.value ?? '');
122
+
123
+ const themeColor = String((row as any).themeColor ?? '');
124
+
125
+ return (
126
+ <TableRow
127
+ key={row.id}
128
+ {...getRowProps({ row })}
129
+ onClick={() => onOpen?.(row.id)}
130
+ style={{ cursor: onOpen ? 'pointer' : 'default' }}
131
+ >
132
+ <TableCell>{code || <span style={{ opacity: 0.7 }}>—</span>}</TableCell>
133
+
134
+ <TableCell
135
+ style={{
136
+ fontWeight: 600,
137
+ paddingLeft: '0.75rem',
138
+ borderLeft: themeColor ? `6px solid ${themeColor}` : '6px solid transparent',
139
+ }}
140
+ >
141
+ {name}
142
+ </TableCell>
143
+
144
+ <TableCell>{kindTag(kind)}</TableCell>
145
+
146
+ <TableCell>{themePill(themeName, themeColor)}</TableCell>
147
+
148
+ <TableCell>{statusTag(status)}</TableCell>
149
+
150
+ <TableCell onClick={(e) => e.stopPropagation()} style={{ width: 56 }}>
151
+ <OverflowMenu size="sm" flipped>
152
+ <OverflowMenuItem itemText="Edit" onClick={() => onEdit?.(row.id, kind)} />
153
+ <OverflowMenuItem itemText="Run" onClick={() => onRun?.(row.id)} />
154
+ <OverflowMenuItem itemText="Delete" isDelete onClick={() => onDelete?.(row.id)} />
155
+ </OverflowMenu>
156
+ </TableCell>
157
+ </TableRow>
158
+ );
159
+ })}
160
+ </TableBody>
161
+ </Table>
162
+ )}
163
+ </DataTable>
164
+ </TableContainer>
165
+ );
166
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+
3
+ export type PreviewRow = { category: string; count: number };
4
+
5
+ const PreviewTable: React.FC<{ rows: PreviewRow[] }> = ({ rows }) => {
6
+ return (
7
+ <div style={{ border: '1px solid var(--cds-border-subtle, #e0e0e0)', borderRadius: 8, overflow: 'hidden' }}>
8
+ <div style={{ fontWeight: 600, padding: '0.75rem', borderBottom: '1px solid var(--cds-border-subtle, #e0e0e0)' }}>
9
+ Preview
10
+ </div>
11
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
12
+ <thead>
13
+ <tr style={{ background: 'var(--cds-layer, #f4f4f4)' }}>
14
+ <th style={{ textAlign: 'left', padding: '0.6rem 0.75rem' }}>Category</th>
15
+ <th style={{ textAlign: 'right', padding: '0.6rem 0.75rem' }}>Count</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ {rows.map((r) => (
20
+ <tr key={r.category}>
21
+ <td style={{ padding: '0.6rem 0.75rem', borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)' }}>{r.category}</td>
22
+ <td style={{ padding: '0.6rem 0.75rem', borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)', textAlign: 'right' }}>{r.count}</td>
23
+ </tr>
24
+ ))}
25
+ </tbody>
26
+ </table>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export default PreviewTable;
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { Stack, TextInput, TextArea } from '@carbon/react';
3
+
4
+ type Props = {
5
+ value: { name: string; code: string; description: string };
6
+ onChange: (next: { name: string; code: string; description: string }) => void;
7
+ };
8
+
9
+ export default function CompositeIndicatorBasicsSection({ value, onChange }: Props) {
10
+ return (
11
+ <Stack gap={5}>
12
+ <TextInput
13
+ id="composite-name"
14
+ labelText="Indicator Name"
15
+ value={value.name}
16
+ onChange={(e) => onChange({ ...value, name: (e.target as HTMLInputElement).value })}
17
+ />
18
+
19
+ <TextInput
20
+ id="composite-code"
21
+ labelText="Code"
22
+ helperText="Auto-generated if blank"
23
+ value={value.code}
24
+ onChange={(e) => onChange({ ...value, code: (e.target as HTMLInputElement).value })}
25
+ placeholder="E.g. PREG_AND_MAL"
26
+ />
27
+
28
+ <TextArea
29
+ id="composite-description"
30
+ labelText="Description"
31
+ value={value.description}
32
+ onChange={(e) => onChange({ ...value, description: (e.target as HTMLTextAreaElement).value })}
33
+ />
34
+ </Stack>
35
+ );
36
+ }
@@ -0,0 +1,161 @@
1
+ import React from 'react';
2
+ import { ComboBox, RadioButtonGroup, RadioButton } from '@carbon/react';
3
+ import { Information } from '@carbon/icons-react';
4
+
5
+ import type { BaseIndicatorOption, CompositeOperator } from '../types/composite-indicator.types';
6
+ import { idFieldForUnit } from '../utils/composite-indicator-sql.utils';
7
+
8
+ const labelFor = (x: BaseIndicatorOption) => {
9
+ const unit = x.unit ? ` • ${x.unit}` : '';
10
+ return `${x.name} (${x.code})${unit}`;
11
+ };
12
+
13
+ type Props = {
14
+ baseIndicators: BaseIndicatorOption[];
15
+
16
+ indicatorAId: string;
17
+ indicatorBId: string;
18
+ operator: CompositeOperator;
19
+
20
+ inferredUnit: 'Patients' | 'Encounters';
21
+ samePick: boolean;
22
+
23
+ onChangeA: (id: string) => void;
24
+ onChangeB: (id: string) => void;
25
+ onChangeOperator: (op: CompositeOperator) => void;
26
+ };
27
+
28
+ type ComboItem = {
29
+ id: string;
30
+ label: string;
31
+ unit?: string;
32
+ isPlaceholder?: boolean;
33
+ };
34
+
35
+ export default function CompositeIndicatorPickerSection({
36
+ baseIndicators,
37
+ indicatorAId,
38
+ indicatorBId,
39
+ operator,
40
+ inferredUnit,
41
+ samePick,
42
+ onChangeA,
43
+ onChangeB,
44
+ onChangeOperator,
45
+ }: Props) {
46
+ const items: ComboItem[] = React.useMemo(() => {
47
+ const placeholder: ComboItem = { id: '', label: 'Select an indicator…', isPlaceholder: true };
48
+ return [
49
+ placeholder,
50
+ ...baseIndicators.map((x) => ({
51
+ id: x.id,
52
+ label: labelFor(x),
53
+ unit: x.unit,
54
+ })),
55
+ ];
56
+ }, [baseIndicators]);
57
+
58
+ const selectedA: ComboItem | null = React.useMemo(() => {
59
+ return items.find((x) => x.id === indicatorAId) ?? (indicatorAId ? null : items[0] ?? null);
60
+ }, [items, indicatorAId]);
61
+
62
+ const selectedB: ComboItem | null = React.useMemo(() => {
63
+ return items.find((x) => x.id === indicatorBId) ?? (indicatorBId ? null : items[0] ?? null);
64
+ }, [items, indicatorBId]);
65
+
66
+ return (
67
+ <>
68
+ <div style={{ fontWeight: 600 }}>Select base indicators</div>
69
+
70
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: '0.75rem', alignItems: 'end' }}>
71
+ <ComboBox
72
+ id="indicator-a"
73
+ titleText="Indicator A"
74
+ items={items}
75
+ itemToString={(item) => (item ? item.label : '')}
76
+ selectedItem={selectedA}
77
+ placeholder="Type to search…"
78
+ invalid={Boolean(samePick) && Boolean(indicatorAId) && Boolean(indicatorBId)}
79
+ invalidText="Indicator A and B cannot be the same."
80
+ onChange={({ selectedItem }) => {
81
+ if (!selectedItem || selectedItem.isPlaceholder) {
82
+ onChangeA('');
83
+ return;
84
+ }
85
+ onChangeA(selectedItem.id);
86
+ }}
87
+ />
88
+
89
+ <div style={{ paddingBottom: '0.35rem' }}>
90
+ <div style={{ fontSize: '0.875rem', opacity: 0.75, marginBottom: '0.25rem', textAlign: 'center' }}>Logic</div>
91
+ <div
92
+ style={{
93
+ minWidth: '6rem',
94
+ textAlign: 'center',
95
+ padding: '0.55rem 0.75rem',
96
+ borderRadius: '0.25rem',
97
+ background: 'var(--cds-layer, #ffffff)',
98
+ border: '1px solid var(--cds-border-subtle, #e0e0e0)',
99
+ fontWeight: 600,
100
+ }}
101
+ aria-label="Selected operator"
102
+ >
103
+ {operator === 'A_AND_NOT_B' ? 'A AND NOT B' : operator}
104
+ </div>
105
+ </div>
106
+
107
+ <ComboBox
108
+ id="indicator-b"
109
+ titleText="Indicator B"
110
+ items={items}
111
+ itemToString={(item) => (item ? item.label : '')}
112
+ selectedItem={selectedB}
113
+ placeholder="Type to search…"
114
+ invalid={Boolean(samePick) && Boolean(indicatorAId) && Boolean(indicatorBId)}
115
+ invalidText="Indicator A and B cannot be the same."
116
+ onChange={({ selectedItem }) => {
117
+ if (!selectedItem || selectedItem.isPlaceholder) {
118
+ onChangeB('');
119
+ return;
120
+ }
121
+ onChangeB(selectedItem.id);
122
+ }}
123
+ />
124
+ </div>
125
+
126
+ <div
127
+ style={{
128
+ display: 'grid',
129
+ gridTemplateColumns: 'auto 1fr',
130
+ gap: '0.75rem',
131
+ alignItems: 'center',
132
+ padding: '0.75rem',
133
+ borderRadius: '0.25rem',
134
+ background: 'var(--cds-layer-accent, #f4f4f4)',
135
+ marginTop: '0.75rem',
136
+ }}
137
+ >
138
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 600 }}>
139
+ Operator <Information size={16} />
140
+ </div>
141
+
142
+ <RadioButtonGroup
143
+ legendText=""
144
+ name="composite-operator"
145
+ valueSelected={operator}
146
+ onChange={(val) => onChangeOperator(val as CompositeOperator)}
147
+ orientation="horizontal"
148
+ >
149
+ <RadioButton id="op-and" labelText="AND" value="AND" />
150
+ <RadioButton id="op-or" labelText="OR" value="OR" />
151
+ <RadioButton id="op-a-not-b" labelText="A AND NOT B" value="A_AND_NOT_B" />
152
+ </RadioButtonGroup>
153
+
154
+ <div style={{ gridColumn: '1 / -1', fontSize: '0.875rem', opacity: 0.8 }}>
155
+ Composite preview is built by converting each base indicator into a population set ({idFieldForUnit(inferredUnit)}) and applying
156
+ set logic.
157
+ </div>
158
+ </div>
159
+ </>
160
+ );
161
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { InlineLoading, InlineNotification, TextArea } from '@carbon/react';
3
+
4
+ type Props = {
5
+ loading: boolean;
6
+ errA?: string | null;
7
+ errB?: string | null;
8
+ compositeSql: string;
9
+
10
+ show: boolean;
11
+ };
12
+
13
+ export default function CompositeIndicatorSqlPreviewSection({ loading, errA, errB, compositeSql, show }: Props) {
14
+ return (
15
+ <>
16
+ {loading ? <InlineLoading description="Loading selected indicators…" /> : null}
17
+ {errA ? <InlineNotification kind="error" lowContrast title="Indicator A" subtitle={errA} /> : null}
18
+ {errB ? <InlineNotification kind="error" lowContrast title="Indicator B" subtitle={errB} /> : null}
19
+
20
+ {show && !errA && !errB ? (
21
+ <TextArea
22
+ id="composite-sql-preview"
23
+ labelText="Composite SQL Preview"
24
+ value={compositeSql || '—'}
25
+ readOnly
26
+ rows={14}
27
+ />
28
+ ) : null}
29
+ </>
30
+ );
31
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { TextInput, TextArea } from '@carbon/react';
3
+
4
+ type Value = {
5
+ name: string;
6
+ code: string;
7
+ description: string;
8
+ };
9
+
10
+ type Props = {
11
+ value: Value;
12
+ onChange: (next: Value) => void;
13
+ };
14
+
15
+ export default function FinalIndicatorBasicsSection({ value, onChange }: Props) {
16
+ return (
17
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
18
+ <TextInput
19
+ id="final-name"
20
+ labelText="Final indicator name"
21
+ value={value.name}
22
+ onChange={(e) => onChange({ ...value, name: (e.target as HTMLInputElement).value })}
23
+ placeholder="e.g., OPD Confirmed Malaria by age/gender"
24
+ />
25
+
26
+ <TextInput
27
+ id="final-code"
28
+ labelText="Code (optional)"
29
+ value={value.code}
30
+ onChange={(e) => onChange({ ...value, code: (e.target as HTMLInputElement).value })}
31
+ placeholder="AUTO_GENERATED"
32
+ />
33
+
34
+ <div style={{ gridColumn: '1 / -1' }}>
35
+ <TextArea
36
+ id="final-desc"
37
+ labelText="Description (optional)"
38
+ value={value.description}
39
+ rows={3}
40
+ onChange={(e) => onChange({ ...value, description: (e.target as HTMLTextAreaElement).value })}
41
+ placeholder="Short description of what this final indicator represents."
42
+ />
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { Checkbox } from '@carbon/react';
3
+
4
+ type Props = {
5
+ genders: Array<'F' | 'M'>;
6
+ onChange: (next: Array<'F' | 'M'>) => void;
7
+ };
8
+
9
+ export default function FinalIndicatorDisaggregationSection({ genders, onChange }: Props) {
10
+ const hasF = genders.includes('F');
11
+ const hasM = genders.includes('M');
12
+
13
+ return (
14
+ <div style={{ padding: '0.75rem', borderRadius: '0.25rem', background: 'var(--cds-layer-accent, #f4f4f4)' }}>
15
+ <div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Gender breakdown</div>
16
+
17
+ <div style={{ display: 'flex', gap: '1rem' }}>
18
+ <Checkbox
19
+ id="final-gender-f"
20
+ labelText="Female"
21
+ checked={hasF}
22
+ onChange={(_, { checked }) => {
23
+ const next = new Set(genders);
24
+ if (checked) next.add('F');
25
+ else next.delete('F');
26
+ onChange(Array.from(next) as Array<'F' | 'M'>);
27
+ }}
28
+ />
29
+ <Checkbox
30
+ id="final-gender-m"
31
+ labelText="Male"
32
+ checked={hasM}
33
+ onChange={(_, { checked }) => {
34
+ const next = new Set(genders);
35
+ if (checked) next.add('M');
36
+ else next.delete('M');
37
+ onChange(Array.from(next) as Array<'F' | 'M'>);
38
+ }}
39
+ />
40
+ </div>
41
+
42
+ <div style={{ fontSize: '0.875rem', opacity: 0.8, marginTop: '0.5rem' }}>
43
+ Leave unchecked to get totals without gender filtering (later we can add “Total” row).
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { ComboBox } from '@carbon/react';
3
+
4
+ import type { BaseIndicatorOption } from '../types/composite-indicator.types';
5
+ import type { AgeCategoryOption } from '../../../resources/agegroup/agegroups.api';
6
+
7
+ type IndicatorItem = { id: string; label: string };
8
+
9
+ type Props = {
10
+ baseIndicators: BaseIndicatorOption[];
11
+ ageCategories: AgeCategoryOption[];
12
+
13
+ selectedBaseId: string;
14
+ selectedAgeCategoryCode: string;
15
+
16
+ onChangeBaseId: (id: string) => void;
17
+ onChangeAgeCategoryCode: (code: string) => void;
18
+ };
19
+
20
+ export default function FinalIndicatorPickerSection({
21
+ baseIndicators,
22
+ ageCategories,
23
+ selectedBaseId,
24
+ selectedAgeCategoryCode,
25
+ onChangeBaseId,
26
+ onChangeAgeCategoryCode,
27
+ }: Props) {
28
+ const baseItems: IndicatorItem[] = React.useMemo(() => {
29
+ return [{ id: '', label: 'Select a base indicator…' }].concat(
30
+ baseIndicators.map((x) => ({ id: x.id, label: `${x.name} (${x.code})` })),
31
+ );
32
+ }, [baseIndicators]);
33
+
34
+ const ageItems = React.useMemo(() => {
35
+ return [{ code: '', label: 'Select an age category…', uuid: '', name: '', description: '', ageGroups: [] as any[] }].concat(ageCategories as any);
36
+ }, [ageCategories]);
37
+
38
+ const selectedBase = React.useMemo(
39
+ () => baseItems.find((x) => x.id === selectedBaseId) ?? baseItems[0],
40
+ [baseItems, selectedBaseId],
41
+ );
42
+
43
+ const selectedAge = React.useMemo(
44
+ () => (ageItems as any[]).find((x) => x.code === selectedAgeCategoryCode) ?? ageItems[0],
45
+ [ageItems, selectedAgeCategoryCode],
46
+ );
47
+
48
+ return (
49
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
50
+ <ComboBox
51
+ id="final-base-indicator"
52
+ titleText="Base Indicator"
53
+ items={baseItems}
54
+ itemToString={(it) => (it ? it.label : '')}
55
+ selectedItem={selectedBase}
56
+ placeholder="Search base indicators…"
57
+ onChange={({ selectedItem }) => onChangeBaseId(selectedItem?.id ?? '')}
58
+ />
59
+
60
+ <ComboBox
61
+ id="final-age-category"
62
+ titleText="Age category"
63
+ items={ageItems as any[]}
64
+ itemToString={(it) => (it ? it.label : '')}
65
+ selectedItem={selectedAge as any}
66
+ placeholder="Search age categories…"
67
+ onChange={({ selectedItem }: any) => onChangeAgeCategoryCode(selectedItem?.code ?? '')}
68
+ />
69
+
70
+ {selectedAgeCategoryCode ? (
71
+ <div style={{ gridColumn: '1 / -1', fontSize: '0.875rem', opacity: 0.8 }}>
72
+ <div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>Age groups in this category</div>
73
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
74
+ {(selectedAge?.ageGroups ?? []).map((g: any) => (
75
+ <span
76
+ key={g.id}
77
+ style={{
78
+ padding: '0.25rem 0.5rem',
79
+ border: '1px solid var(--cds-border-subtle)',
80
+ borderRadius: '999px',
81
+ background: 'var(--cds-layer)',
82
+ }}
83
+ >
84
+ {g.label}
85
+ </span>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ ) : null}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { CodeSnippet } from '@carbon/react';
3
+
4
+ type Props = {
5
+ sql: string;
6
+ show: boolean;
7
+ };
8
+
9
+ export default function FinalIndicatorPreviewSection({ sql, show }: Props) {
10
+ if (!show) return null;
11
+
12
+ return (
13
+ <div style={{ display: 'grid', gap: '0.5rem' }}>
14
+ <div style={{ fontWeight: 600 }}>SQL Preview</div>
15
+ <CodeSnippet type="multi" wrapText>
16
+ {sql || '-- SQL will appear here once selections are made.'}
17
+ </CodeSnippet>
18
+ </div>
19
+ );
20
+ }