@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,297 @@
1
+ import React from 'react';
2
+ import { Modal, Stack, InlineNotification } from '@carbon/react';
3
+
4
+ import { getIndicator, type IndicatorDto } from '../../resources/indicator/indicators.api';
5
+
6
+ import type { BaseIndicatorOption, CompositeOperator } from './types/composite-indicator.types';
7
+
8
+ import CompositeIndicatorBasicsSection from './sections/composite-indicator-basics.section';
9
+ import CompositeIndicatorPickerSection from './sections/composite-indicator-picker.section';
10
+ import CompositeIndicatorSqlPreviewSection from './sections/composite-indicator-sql-preview.section';
11
+
12
+ import {
13
+ countSqlToPopulationSql,
14
+ buildCompositeCountSql,
15
+ tryGetPatientIdColumnFromConfig,
16
+ tryGetCountSqlFromIndicator,
17
+ } from './utils/composite-indicator-sql.utils';
18
+
19
+ type Props = {
20
+ open: boolean;
21
+ mode?: 'create' | 'edit';
22
+ initial?: IndicatorDto | null;
23
+ onClose: () => void;
24
+ onCreate: (payload: Partial<IndicatorDto>) => Promise<void>;
25
+ onUpdate?: (uuid: string, payload: Partial<IndicatorDto>) => Promise<void>;
26
+ onSaved: () => void;
27
+ baseIndicators: BaseIndicatorOption[];
28
+ };
29
+
30
+ type CompositeIndicatorAuthoring = {
31
+ version: 1;
32
+ unit: 'Patients' | 'Encounters';
33
+ operator: CompositeOperator;
34
+ indicatorAId: string;
35
+ indicatorBId: string;
36
+ indicatorACode?: string;
37
+ indicatorBCode?: string;
38
+ sqlPreview: string;
39
+ };
40
+
41
+ function safeParseJson<T = any>(input?: string | null): T | null {
42
+ if (!input) return null;
43
+ try {
44
+ return JSON.parse(input) as T;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ const toCode = (name: string) =>
51
+ name
52
+ .toUpperCase()
53
+ .replace(/[^A-Z0-9]+/g, '_')
54
+ .replace(/^_+|_+$/g, '')
55
+ .slice(0, 50);
56
+
57
+ const CreateCompositeBaseIndicatorModal: React.FC<Props> = ({
58
+ open,
59
+ mode = 'create',
60
+ initial,
61
+ onClose,
62
+ onCreate,
63
+ onUpdate,
64
+ onSaved,
65
+ baseIndicators,
66
+ }) => {
67
+ // ✅ IMPORTANT: no defaults for create mode
68
+ const [name, setName] = React.useState('');
69
+ const [code, setCode] = React.useState('');
70
+ const [description, setDescription] = React.useState('');
71
+
72
+ const [indicatorAId, setIndicatorAId] = React.useState('');
73
+ const [indicatorBId, setIndicatorBId] = React.useState('');
74
+ const [operator, setOperator] = React.useState<CompositeOperator>('AND');
75
+
76
+ const [loadingA, setLoadingA] = React.useState(false);
77
+ const [loadingB, setLoadingB] = React.useState(false);
78
+ const [errA, setErrA] = React.useState<string | null>(null);
79
+ const [errB, setErrB] = React.useState<string | null>(null);
80
+
81
+ const [indA, setIndA] = React.useState<IndicatorDto | null>(null);
82
+ const [indB, setIndB] = React.useState<IndicatorDto | null>(null);
83
+
84
+ // reset / initialize on open
85
+ React.useEffect(() => {
86
+ if (!open) return;
87
+
88
+ // EDIT: hydrate from selected composite indicator
89
+ if (mode === 'edit' && initial?.uuid) {
90
+ const cfg = safeParseJson<any>(initial.configJson) ?? {};
91
+
92
+ setName(initial.name ?? '');
93
+ setCode(initial.code ?? '');
94
+ setDescription(initial.description ?? '');
95
+
96
+ // support both indicatorAId/indicatorBId and indicatorAUuid/indicatorBUuid
97
+ setIndicatorAId(String(cfg.indicatorAId ?? cfg.indicatorAUuid ?? ''));
98
+ setIndicatorBId(String(cfg.indicatorBId ?? cfg.indicatorBUuid ?? ''));
99
+ setOperator((String(cfg.operator ?? 'AND').toUpperCase() as CompositeOperator) ?? 'AND');
100
+
101
+ setErrA(null);
102
+ setErrB(null);
103
+ setIndA(null);
104
+ setIndB(null);
105
+ return;
106
+ }
107
+
108
+ // CREATE: start blank so users don't confuse it with edit
109
+ setName('');
110
+ setCode('');
111
+ setDescription('');
112
+ setIndicatorAId('');
113
+ setIndicatorBId('');
114
+ setOperator('AND');
115
+ setErrA(null);
116
+ setErrB(null);
117
+ setIndA(null);
118
+ setIndB(null);
119
+ }, [open, mode, initial?.uuid]);
120
+
121
+ const AOpt = React.useMemo(() => baseIndicators.find((x) => x.id === indicatorAId) ?? null, [baseIndicators, indicatorAId]);
122
+ const BOpt = React.useMemo(() => baseIndicators.find((x) => x.id === indicatorBId) ?? null, [baseIndicators, indicatorBId]);
123
+
124
+ const samePick = Boolean(indicatorAId && indicatorBId && indicatorAId === indicatorBId);
125
+
126
+ const inferredUnit: 'Patients' | 'Encounters' = React.useMemo(() => 'Patients', []);
127
+
128
+ // load full indicators for A/B
129
+ React.useEffect(() => {
130
+ if (!open) return;
131
+ if (!indicatorAId) {
132
+ setIndA(null);
133
+ return;
134
+ }
135
+
136
+ const ac = new AbortController();
137
+ setLoadingA(true);
138
+ setErrA(null);
139
+
140
+ getIndicator(indicatorAId, ac.signal, 'full')
141
+ .then((full) => setIndA(full))
142
+ .catch((e: any) => setErrA(e?.message ?? 'Failed to load indicator A'))
143
+ .finally(() => setLoadingA(false));
144
+
145
+ return () => ac.abort();
146
+ }, [open, indicatorAId]);
147
+
148
+ React.useEffect(() => {
149
+ if (!open) return;
150
+ if (!indicatorBId) {
151
+ setIndB(null);
152
+ return;
153
+ }
154
+
155
+ const ac = new AbortController();
156
+ setLoadingB(true);
157
+ setErrB(null);
158
+
159
+ getIndicator(indicatorBId, ac.signal, 'full')
160
+ .then((full) => setIndB(full))
161
+ .catch((e: any) => setErrB(e?.message ?? 'Failed to load indicator B'))
162
+ .finally(() => setLoadingB(false));
163
+
164
+ return () => ac.abort();
165
+ }, [open, indicatorBId]);
166
+
167
+ const populationSqlA = React.useMemo(() => {
168
+ if (!indA) return '';
169
+ const { sql: countSql } = tryGetCountSqlFromIndicator(indA);
170
+ const pidCol = tryGetPatientIdColumnFromConfig(indA);
171
+ if (!countSql) return '';
172
+ return countSqlToPopulationSql(countSql, pidCol, inferredUnit);
173
+ }, [indA, inferredUnit]);
174
+
175
+ const populationSqlB = React.useMemo(() => {
176
+ if (!indB) return '';
177
+ const { sql: countSql } = tryGetCountSqlFromIndicator(indB);
178
+ const pidCol = tryGetPatientIdColumnFromConfig(indB);
179
+ if (!countSql) return '';
180
+ return countSqlToPopulationSql(countSql, pidCol, inferredUnit);
181
+ }, [indB, inferredUnit]);
182
+
183
+ const compositeSql = React.useMemo(() => {
184
+ if (!populationSqlA || !populationSqlB) return '';
185
+ return buildCompositeCountSql({
186
+ unit: inferredUnit,
187
+ operator,
188
+ populationSqlA,
189
+ populationSqlB,
190
+ });
191
+ }, [populationSqlA, populationSqlB, inferredUnit, operator]);
192
+
193
+ const canSubmit =
194
+ Boolean(name.trim()) &&
195
+ Boolean(indicatorAId) &&
196
+ Boolean(indicatorBId) &&
197
+ !samePick &&
198
+ Boolean(compositeSql.trim());
199
+
200
+ const submit = async () => {
201
+ if (!canSubmit) return;
202
+
203
+ const finalCode = code.trim() ? code.trim().toUpperCase() : toCode(name);
204
+
205
+ const authoring: CompositeIndicatorAuthoring = {
206
+ version: 1,
207
+ unit: inferredUnit,
208
+ operator,
209
+ indicatorAId,
210
+ indicatorBId,
211
+ indicatorACode: AOpt?.code,
212
+ indicatorBCode: BOpt?.code,
213
+ sqlPreview: compositeSql,
214
+ };
215
+
216
+ const payload: Partial<IndicatorDto> = {
217
+ name: name.trim(),
218
+ code: finalCode,
219
+ description: description.trim() || undefined,
220
+ kind: 'COMPOSITE',
221
+ defaultValueType: 'NUMBER',
222
+ themeUuid: null,
223
+ configJson: JSON.stringify(authoring, null, 2),
224
+ sqlTemplate: compositeSql,
225
+ };
226
+
227
+ if (mode === 'edit' && initial?.uuid) {
228
+ if (!onUpdate) throw new Error('onUpdate handler is required for edit mode');
229
+ await onUpdate(initial.uuid, payload);
230
+ } else {
231
+ await onCreate(payload);
232
+ }
233
+
234
+ onSaved();
235
+ };
236
+
237
+ return (
238
+ <Modal
239
+ open={open}
240
+ onRequestClose={onClose}
241
+ modalHeading={mode === 'edit' ? 'Edit Composite Indicator' : 'Create Composite Indicator'}
242
+ primaryButtonText={mode === 'edit' ? 'Update Indicator' : 'Save Indicator'}
243
+ secondaryButtonText="Cancel"
244
+ onRequestSubmit={submit}
245
+ primaryButtonDisabled={!canSubmit}
246
+ size="lg"
247
+ >
248
+ <Stack gap={5}>
249
+ <div style={{ opacity: 0.8 }}>
250
+ Select two base indicators, pick an operator, and we generate a composite SQL preview.
251
+ </div>
252
+
253
+ {samePick ? (
254
+ <InlineNotification
255
+ kind="error"
256
+ lowContrast
257
+ title="Pick two different indicators"
258
+ subtitle="Indicator A and Indicator B cannot be the same."
259
+ />
260
+ ) : null}
261
+
262
+ <CompositeIndicatorBasicsSection
263
+ value={{ name, code, description }}
264
+ onChange={(next) => {
265
+ setName(next.name);
266
+ setCode(next.code);
267
+ setDescription(next.description);
268
+ }}
269
+ />
270
+
271
+ <hr style={{ border: 0, borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)' }} />
272
+
273
+ <CompositeIndicatorPickerSection
274
+ baseIndicators={baseIndicators}
275
+ indicatorAId={indicatorAId}
276
+ indicatorBId={indicatorBId}
277
+ operator={operator}
278
+ inferredUnit={inferredUnit}
279
+ samePick={samePick}
280
+ onChangeA={setIndicatorAId}
281
+ onChangeB={setIndicatorBId}
282
+ onChangeOperator={setOperator}
283
+ />
284
+
285
+ <CompositeIndicatorSqlPreviewSection
286
+ loading={loadingA || loadingB}
287
+ errA={errA}
288
+ errB={errB}
289
+ compositeSql={compositeSql}
290
+ show={Boolean(AOpt && BOpt)}
291
+ />
292
+ </Stack>
293
+ </Modal>
294
+ );
295
+ };
296
+
297
+ export default CreateCompositeBaseIndicatorModal;
@@ -0,0 +1,260 @@
1
+ import React from 'react';
2
+ import { Modal, Stack, InlineLoading, InlineNotification } from '@carbon/react';
3
+
4
+ import type { IndicatorDto } from '../../resources/indicator/indicators.api';
5
+ import { getIndicator } from '../../resources/indicator/indicators.api';
6
+
7
+ import type { BaseIndicatorOption } from './types/composite-indicator.types';
8
+ import { listAgeCategoriesWithGroups, type AgeCategoryOption } from '../../resources/agegroup/agegroups.api';
9
+
10
+ import FinalIndicatorBasicsSection from './sections/final-indicator-basics.section';
11
+ import FinalIndicatorPickerSection from './sections/final-indicator-picker.section';
12
+ import FinalIndicatorDisaggregationSection from './sections/final-indicator-disaggregation.section';
13
+ import FinalIndicatorResultsPreviewSection from './sections/final-indicator-results-preview.section';
14
+
15
+ import { buildFinalIndicatorSql, type FinalIndicatorAuthoringV1 } from './utils/final-indicator-sql.utils';
16
+
17
+ type Props = {
18
+ open: boolean;
19
+ mode?: 'create' | 'edit';
20
+ initial?: IndicatorDto | null;
21
+
22
+ baseIndicators: BaseIndicatorOption[];
23
+
24
+ onClose: () => void;
25
+
26
+ onCreate: (payload: Partial<IndicatorDto>) => Promise<void>;
27
+ onUpdate?: (uuid: string, payload: Partial<IndicatorDto>) => Promise<void>;
28
+ onSaved: () => void;
29
+ };
30
+
31
+ function safeParseJson<T = any>(input?: string | null): T | null {
32
+ if (!input) return null;
33
+ try {
34
+ return JSON.parse(input) as T;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ const toCode = (name: string) =>
41
+ name
42
+ .toUpperCase()
43
+ .replace(/[^A-Z0-9]+/g, '_')
44
+ .replace(/^_+|_+$/g, '')
45
+ .slice(0, 50);
46
+
47
+ function todayIso(): string {
48
+ const d = new Date();
49
+ const yyyy = d.getFullYear();
50
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
51
+ const dd = String(d.getDate()).padStart(2, '0');
52
+ return `${yyyy}-${mm}-${dd}`;
53
+ }
54
+
55
+ export default function CreateFinalIndicatorModal({
56
+ open,
57
+ mode = 'create',
58
+ initial,
59
+ baseIndicators,
60
+ onClose,
61
+ onCreate,
62
+ onUpdate,
63
+ onSaved,
64
+ }: Props) {
65
+ const [ageCategories, setAgeCategories] = React.useState<AgeCategoryOption[]>([]);
66
+ const [loadingCats, setLoadingCats] = React.useState(false);
67
+ const [catsError, setCatsError] = React.useState<string | null>(null);
68
+
69
+ const [basics, setBasics] = React.useState({ name: '', code: '', description: '' });
70
+
71
+ const [baseIndicatorId, setBaseIndicatorId] = React.useState('');
72
+ const [ageCategoryCode, setAgeCategoryCode] = React.useState('');
73
+ const [genders, setGenders] = React.useState<Array<'F' | 'M'>>(['F', 'M']);
74
+
75
+ const [baseFull, setBaseFull] = React.useState<IndicatorDto | null>(null);
76
+ const [loadingBase, setLoadingBase] = React.useState(false);
77
+ const [baseError, setBaseError] = React.useState<string | null>(null);
78
+
79
+ const [sqlPreview, setSqlPreview] = React.useState('');
80
+
81
+ // ✅ Preview parameters (must be inside component)
82
+ const [previewStartDate, setPreviewStartDate] = React.useState('2025-01-01');
83
+ const [previewEndDate, setPreviewEndDate] = React.useState(todayIso);
84
+
85
+ // load categories when modal opens
86
+ React.useEffect(() => {
87
+ if (!open) return;
88
+ const ac = new AbortController();
89
+
90
+ setLoadingCats(true);
91
+ setCatsError(null);
92
+
93
+ listAgeCategoriesWithGroups(ac.signal)
94
+ .then((items) => setAgeCategories(items))
95
+ .catch((e: any) => setCatsError(e?.message ?? 'Failed to load age categories'))
96
+ .finally(() => setLoadingCats(false));
97
+
98
+ return () => ac.abort();
99
+ }, [open]);
100
+
101
+ // init create vs edit
102
+ React.useEffect(() => {
103
+ if (!open) return;
104
+
105
+ if (mode === 'edit' && initial?.uuid) {
106
+ const cfg = safeParseJson<Partial<FinalIndicatorAuthoringV1>>(initial.configJson) ?? {};
107
+
108
+ setBasics({
109
+ name: initial.name ?? '',
110
+ code: initial.code ?? '',
111
+ description: initial.description ?? '',
112
+ });
113
+
114
+ setBaseIndicatorId(String(cfg.baseIndicatorId ?? ''));
115
+ setAgeCategoryCode(String(cfg.ageGroupSetCode ?? cfg.ageCategoryCode ?? '')); // tolerate both keys
116
+ setGenders((cfg.genders as any) ?? ['F', 'M']);
117
+
118
+ setSqlPreview(String(cfg.sqlPreview ?? initial.sqlTemplate ?? ''));
119
+
120
+ // keep preview dates as-is (user controlled)
121
+ return;
122
+ }
123
+
124
+ // create defaults
125
+ setBasics({ name: '', code: '', description: '' });
126
+ setBaseIndicatorId('');
127
+ setAgeCategoryCode('');
128
+ setGenders(['F', 'M']);
129
+ setSqlPreview('');
130
+ setBaseFull(null);
131
+ setBaseError(null);
132
+ // preview dates remain (or you can reset here if desired)
133
+ }, [open, mode, initial?.uuid]);
134
+
135
+ // load full base indicator
136
+ React.useEffect(() => {
137
+ if (!open) return;
138
+ if (!baseIndicatorId) {
139
+ setBaseFull(null);
140
+ return;
141
+ }
142
+
143
+ const ac = new AbortController();
144
+ setLoadingBase(true);
145
+ setBaseError(null);
146
+
147
+ getIndicator(baseIndicatorId, ac.signal, 'full')
148
+ .then((full) => setBaseFull(full))
149
+ .catch((e: any) => setBaseError(e?.message ?? 'Failed to load selected base indicator'))
150
+ .finally(() => setLoadingBase(false));
151
+
152
+ return () => ac.abort();
153
+ }, [open, baseIndicatorId]);
154
+
155
+ // compute sql preview
156
+ React.useEffect(() => {
157
+ if (!open) return;
158
+
159
+ if (!baseFull || !ageCategoryCode) {
160
+ setSqlPreview('');
161
+ return;
162
+ }
163
+
164
+ const sql = buildFinalIndicatorSql({
165
+ baseIndicator: baseFull,
166
+ ageCategoryCode,
167
+ genders,
168
+ });
169
+
170
+ setSqlPreview(sql);
171
+ }, [open, baseFull, ageCategoryCode, genders]);
172
+
173
+ const canSubmit =
174
+ Boolean(basics.name.trim()) &&
175
+ Boolean(baseIndicatorId) &&
176
+ Boolean(ageCategoryCode) &&
177
+ Boolean(sqlPreview.trim());
178
+
179
+ const submit = async () => {
180
+ if (!canSubmit || !baseFull) return;
181
+
182
+ const finalCode = basics.code.trim() ? basics.code.trim().toUpperCase() : toCode(basics.name);
183
+
184
+ // store both keys for forward compatibility (you can later remove ageGroupSetCode)
185
+ const authoring: any = {
186
+ version: 1,
187
+ baseIndicatorId,
188
+ ageCategoryCode,
189
+ ageGroupSetCode: ageCategoryCode,
190
+ genders,
191
+ sqlPreview,
192
+ };
193
+
194
+ const payload: Partial<IndicatorDto> = {
195
+ name: basics.name.trim(),
196
+ code: finalCode,
197
+ description: basics.description.trim() || undefined,
198
+ kind: 'FINAL',
199
+ defaultValueType: 'NUMBER',
200
+ themeUuid: null,
201
+ configJson: JSON.stringify(authoring, null, 2),
202
+ sqlTemplate: sqlPreview,
203
+ };
204
+
205
+ if (mode === 'edit' && initial?.uuid) {
206
+ if (!onUpdate) throw new Error('onUpdate handler is required for edit mode');
207
+ await onUpdate(initial.uuid, payload);
208
+ } else {
209
+ await onCreate(payload);
210
+ }
211
+
212
+ onSaved();
213
+ };
214
+
215
+
216
+ return (
217
+ <Modal
218
+ open={open}
219
+ onRequestClose={onClose}
220
+ modalHeading={mode === 'edit' ? 'Edit Final Indicator' : 'Create Final Indicator'}
221
+ primaryButtonText={mode === 'edit' ? 'Update Indicator' : 'Save Indicator'}
222
+ secondaryButtonText="Cancel"
223
+ onRequestSubmit={submit}
224
+ primaryButtonDisabled={!canSubmit}
225
+ size="lg"
226
+ >
227
+ <Stack gap={6}>
228
+ {loadingCats ? <InlineLoading description="Loading age categories…" /> : null}
229
+ {catsError ? <InlineNotification kind="error" lowContrast title="Age categories" subtitle={catsError} /> : null}
230
+
231
+ <FinalIndicatorBasicsSection value={basics} onChange={setBasics} />
232
+
233
+ <hr style={{ border: 0, borderTop: '1px solid var(--cds-border-subtle, #e0e0e0)' }} />
234
+
235
+ <FinalIndicatorPickerSection
236
+ baseIndicators={baseIndicators}
237
+ ageCategories={ageCategories}
238
+ selectedBaseId={baseIndicatorId}
239
+ selectedAgeCategoryCode={ageCategoryCode}
240
+ onChangeBaseId={setBaseIndicatorId}
241
+ onChangeAgeCategoryCode={setAgeCategoryCode}
242
+ />
243
+
244
+ {loadingBase ? <InlineLoading description="Loading base indicator details…" /> : null}
245
+ {baseError ? <InlineNotification kind="error" lowContrast title="Base indicator" subtitle={baseError} /> : null}
246
+
247
+ <FinalIndicatorDisaggregationSection genders={genders} onChange={setGenders} />
248
+
249
+ <FinalIndicatorResultsPreviewSection
250
+ sql={sqlPreview}
251
+ maxRows={200}
252
+ startDate={previewStartDate}
253
+ endDate={previewEndDate}
254
+ onChangeStartDate={setPreviewStartDate}
255
+ onChangeEndDate={setPreviewEndDate}
256
+ />
257
+ </Stack>
258
+ </Modal>
259
+ );
260
+ }
@@ -0,0 +1,101 @@
1
+ /*
2
+ import React from 'react';
3
+ import { Select, SelectItem, TextInput } from '@carbon/react';
4
+
5
+ import ConceptSearchMultiSelect, { SelectedConcept } from './concept-search-multiselect.component';
6
+ import type { DiagnosisBaseConfig } from './types/indicator-types';
7
+
8
+ type Props = {
9
+ value: DiagnosisBaseConfig;
10
+ onChange: (next: DiagnosisBaseConfig) => void;
11
+ };
12
+
13
+ export default function DiagnosisFiltersForm({ value, onChange }: Props) {
14
+ const onChangeConcepts = (selected: SelectedConcept[]) => {
15
+ // Use a for-loop to avoid any surprises and to keep behavior explicit.
16
+ const conceptIds: number[] = [];
17
+ const conceptUuids: string[] = [];
18
+ const conceptLabels: string[] = [];
19
+ const icd10Codes: string[] = [];
20
+ const icd11Codes: string[] = [];
21
+
22
+ for (const x of selected ?? []) {
23
+ // IDs (warehouse join)
24
+ const idNum = Number((x as any)?.id);
25
+ if (Number.isFinite(idNum) && idNum > 0) {
26
+ conceptIds.push(idNum);
27
+ }
28
+
29
+ // Labels / uuids
30
+ if (x?.uuid) conceptUuids.push(x.uuid);
31
+ if (x?.display) conceptLabels.push(x.display);
32
+
33
+ // Optional metadata
34
+ if (x?.icd10Code) icd10Codes.push(x.icd10Code);
35
+ if (x?.icd11Code) icd11Codes.push(x.icd11Code);
36
+ }
37
+
38
+ onChange({
39
+ ...value,
40
+ // ✅ self-contained selection (survives search reset)
41
+ selectedConcepts: selected ?? [],
42
+
43
+ // ✅ derived fields used by SQL builder + UI
44
+ conceptIds,
45
+ conceptUuids,
46
+ conceptLabels,
47
+ icd10Codes,
48
+ icd11Codes,
49
+ });
50
+ };
51
+
52
+ return (
53
+ <>
54
+ <div style={{ fontWeight: 600 }}>Diagnosis filters</div>
55
+
56
+ <ConceptSearchMultiSelect
57
+ id="diag-concepts"
58
+ labelText="Diagnosis concept(s)"
59
+ helperText="Search OpenMRS concepts and add one or more. ICD codes are shown to help you choose."
60
+ // ✅ Pass the full self-contained objects
61
+ value={value.selectedConcepts ?? []}
62
+ onChange={onChangeConcepts}
63
+ />
64
+
65
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', marginTop: '0.75rem' }}>
66
+ <Select
67
+ id="diag-certainty"
68
+ labelText="Certainty"
69
+ value={value.certainty}
70
+ onChange={(e) =>
71
+ onChange({
72
+ ...value,
73
+ certainty: (e.target as HTMLSelectElement).value as DiagnosisBaseConfig['certainty'],
74
+ })
75
+ }
76
+ >
77
+ <SelectItem value="PROVISIONAL" text="PROVISIONAL" />
78
+ <SelectItem value="CONFIRMED" text="CONFIRMED" />
79
+ </Select>
80
+ </div>
81
+
82
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', marginTop: '0.75rem' }}>
83
+ <TextInput
84
+ id="diag-ranks"
85
+ labelText="Diagnosis ranks (CSV)"
86
+ helperText="Example: 1,2"
87
+ value={value.dxRanksCsv}
88
+ onChange={(e) => onChange({ ...value, dxRanksCsv: (e.target as HTMLInputElement).value })}
89
+ />
90
+
91
+ <TextInput
92
+ id="diag-derived-ids"
93
+ labelText="Derived diagnosis_coded IDs"
94
+ helperText="Uses concept.id (perfect warehouse join)."
95
+ value={value.conceptIds?.length ? value.conceptIds.join(',') : ''}
96
+ readOnly
97
+ />
98
+ </div>
99
+ </>
100
+ );
101
+ }*/