@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,206 @@
1
+ import type { IndicatorDto } from '../../../resources/indicator/indicators.api';
2
+
3
+ export type CountSqlSource =
4
+ | 'indicator.sqlTemplate'
5
+ | 'configJson.sqlPreview'
6
+ | 'configJson.sqlTemplate'
7
+ | 'configJson.base.sqlPreview'
8
+ | 'configJson.base.sqlTemplate'
9
+ | 'configJson.authoring.base.sqlPreview'
10
+ | 'configJson.authoring.base.sqlTemplate'
11
+ | 'configJson.baseIndicator.sqlPreview'
12
+ | 'configJson.baseIndicator.sqlTemplate'
13
+ | 'none'
14
+ | 'none(parse-error)';
15
+
16
+ export function idFieldForUnit(unit: 'Patients' | 'Encounters') {
17
+ return unit === 'Encounters' ? 'encounter_id' : 'patient_id';
18
+ }
19
+
20
+ /**
21
+ * Extract patientIdColumn from stored authoring (if present),
22
+ * otherwise fallback to "patient_id".
23
+ */
24
+ export function tryGetPatientIdColumnFromConfig(ind: IndicatorDto): string {
25
+ try {
26
+ const parsed: any = ind?.configJson ? JSON.parse(ind.configJson) : null;
27
+
28
+ const cfg =
29
+ parsed?.themeConfig ||
30
+ parsed?.base?.themeConfig ||
31
+ parsed?.authoring?.base?.themeConfig ||
32
+ parsed?.baseIndicator?.themeConfig ||
33
+ null;
34
+
35
+ const pid = cfg?.patientIdColumn;
36
+ return pid ? String(pid) : 'patient_id';
37
+ } catch {
38
+ return 'patient_id';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * ✅ Key fix:
44
+ * Many times the backend returns sqlTemplate empty, but configJson contains sqlPreview.
45
+ * We support all known config shapes.
46
+ */
47
+ export function tryGetCountSqlFromIndicator(ind: IndicatorDto): { sql: string; source: CountSqlSource } {
48
+ const direct = (ind?.sqlTemplate ?? '').trim();
49
+ if (direct) return { sql: direct, source: 'indicator.sqlTemplate' };
50
+
51
+ try {
52
+ const parsed: any = ind?.configJson ? JSON.parse(ind.configJson) : null;
53
+
54
+ // flat
55
+ const flatPreview = (parsed?.sqlPreview ?? '').trim();
56
+ if (flatPreview) return { sql: flatPreview, source: 'configJson.sqlPreview' };
57
+
58
+ const flatTemplate = (parsed?.sqlTemplate ?? '').trim();
59
+ if (flatTemplate) return { sql: flatTemplate, source: 'configJson.sqlTemplate' };
60
+
61
+ // base
62
+ const basePreview = (parsed?.base?.sqlPreview ?? '').trim();
63
+ if (basePreview) return { sql: basePreview, source: 'configJson.base.sqlPreview' };
64
+
65
+ const baseTemplate = (parsed?.base?.sqlTemplate ?? '').trim();
66
+ if (baseTemplate) return { sql: baseTemplate, source: 'configJson.base.sqlTemplate' };
67
+
68
+ // authoring.base
69
+ const authPreview = (parsed?.authoring?.base?.sqlPreview ?? '').trim();
70
+ if (authPreview) return { sql: authPreview, source: 'configJson.authoring.base.sqlPreview' };
71
+
72
+ const authTemplate = (parsed?.authoring?.base?.sqlTemplate ?? '').trim();
73
+ if (authTemplate) return { sql: authTemplate, source: 'configJson.authoring.base.sqlTemplate' };
74
+
75
+ // sometimes wrapped differently
76
+ const biPreview = (parsed?.baseIndicator?.sqlPreview ?? '').trim();
77
+ if (biPreview) return { sql: biPreview, source: 'configJson.baseIndicator.sqlPreview' };
78
+
79
+ const biTemplate = (parsed?.baseIndicator?.sqlTemplate ?? '').trim();
80
+ if (biTemplate) return { sql: biTemplate, source: 'configJson.baseIndicator.sqlTemplate' };
81
+
82
+ return { sql: '', source: 'none' };
83
+ } catch {
84
+ return { sql: '', source: 'none(parse-error)' };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert a base indicator COUNT sqlTemplate into a population query.
90
+ *
91
+ * Supports BOTH:
92
+ * A) Base-style COUNT SQL:
93
+ * SELECT COUNT(*) AS total
94
+ * FROM <table> a
95
+ * ...
96
+ *
97
+ * B) Composite-style COUNT SQL:
98
+ * WITH A AS (...), B AS (...)
99
+ * SELECT COUNT(*) AS total
100
+ * FROM ( SELECT A.patient_id ... ) X;
101
+ *
102
+ * For composite-style, we MUST preserve the WITH ... prefix,
103
+ * otherwise the resulting query references A/B without defining them.
104
+ */
105
+ export function countSqlToPopulationSql(sql: string, idColumn: string, unit: 'Patients' | 'Encounters') {
106
+ const idField = idFieldForUnit(unit);
107
+
108
+ const raw = (sql ?? '').trim();
109
+ if (!raw) return '';
110
+
111
+ // normalize trailing semicolons
112
+ const noSemi = raw.replace(/;+\s*$/, '');
113
+
114
+ // ✅ Case 1: Composite COUNT SQL
115
+ // Preserve any WITH CTE prefix (e.g. WITH A AS (...), B AS (...))
116
+ // Replace "SELECT COUNT(*) AS total FROM ( <inner select> ) X" with a population select.
117
+ const countIdx = noSemi.search(/SELECT\s+COUNT\s*\(\s*\*\s*\)\s+AS\s+total/i);
118
+ const innerMatch = noSemi.match(/FROM\s*\(\s*(SELECT[\s\S]*?)\)\s*X\b/i);
119
+
120
+ if (countIdx >= 0 && innerMatch?.[1]) {
121
+ const prefix = noSemi.slice(0, countIdx).trim(); // keeps WITH A,B if present
122
+ const inner = innerMatch[1].trim().replace(/;+\s*$/, '');
123
+
124
+ return `
125
+ ${prefix}
126
+ SELECT DISTINCT pop.${idField} AS ${idField}
127
+ FROM (
128
+ ${inner}
129
+ ) pop
130
+ `.trim();
131
+ }
132
+
133
+ // ✅ Case 2: Base COUNT SQL (assumes alias "a" exists in query)
134
+ // Replace the COUNT select with a DISTINCT id select.
135
+ const replaced = noSemi.replace(
136
+ /SELECT\s+([\s\S]*?)COUNT\s*\(\s*\*\s*\)\s+AS\s+total\s*/i,
137
+ `SELECT DISTINCT a.${idColumn} AS ${idField}\n`,
138
+ );
139
+
140
+ // If it didn’t replace, fallback: try simpler replace
141
+ if (replaced === noSemi) {
142
+ return noSemi.replace(/COUNT\s*\(\s*\*\s*\)\s+AS\s+total/gi, `DISTINCT a.${idColumn} AS ${idField}`);
143
+ }
144
+
145
+ return replaced;
146
+ }
147
+
148
+ /**
149
+ * Build composite COUNT SQL from two population queries.
150
+ * Population queries MUST return a column named patient_id or encounter_id.
151
+ */
152
+ export function buildCompositeCountSql(args: {
153
+ unit: 'Patients' | 'Encounters';
154
+ operator: 'AND' | 'OR' | 'A_AND_NOT_B';
155
+ populationSqlA: string;
156
+ populationSqlB: string;
157
+ }) {
158
+ const idField = idFieldForUnit(args.unit);
159
+
160
+ const A = args.populationSqlA.trim().replace(/;+\s*$/, '');
161
+ const B = args.populationSqlB.trim().replace(/;+\s*$/, '');
162
+
163
+ if (!A || !B) return '';
164
+
165
+ if (args.operator === 'AND') {
166
+ return `
167
+ WITH
168
+ A AS (${A}),
169
+ B AS (${B})
170
+ SELECT COUNT(*) AS total
171
+ FROM (
172
+ SELECT A.${idField}
173
+ FROM A
174
+ INNER JOIN B ON B.${idField} = A.${idField}
175
+ ) X;
176
+ `.trim();
177
+ }
178
+
179
+ if (args.operator === 'OR') {
180
+ return `
181
+ WITH
182
+ A AS (${A}),
183
+ B AS (${B})
184
+ SELECT COUNT(*) AS total
185
+ FROM (
186
+ SELECT ${idField} FROM A
187
+ UNION
188
+ SELECT ${idField} FROM B
189
+ ) X;
190
+ `.trim();
191
+ }
192
+
193
+ // A_AND_NOT_B
194
+ return `
195
+ WITH
196
+ A AS (${A}),
197
+ B AS (${B})
198
+ SELECT COUNT(*) AS total
199
+ FROM (
200
+ SELECT A.${idField}
201
+ FROM A
202
+ LEFT JOIN B ON B.${idField} = A.${idField}
203
+ WHERE B.${idField} IS NULL
204
+ ) X;
205
+ `.trim();
206
+ }
@@ -0,0 +1,21 @@
1
+ import { omrsGet } from '../../../resources/openmrs-api';
2
+ import type { ConceptSummary } from '../../../resources/concepts/concept-types';
3
+ import type { SelectedConcept } from '../handler/concept-search-multiselect.component';
4
+
5
+ export async function searchConcepts(q: string, signal?: AbortSignal): Promise<ConceptSummary[]> {
6
+ const trimmed = q.trim();
7
+ if (!trimmed) return [];
8
+ const data = await omrsGet<any>(`/concept?q=${encodeURIComponent(trimmed)}&v=default`, signal);
9
+ const results = Array.isArray(data?.results) ? data.results : Array.isArray(data) ? data : [];
10
+ return results as ConceptSummary[];
11
+ }
12
+
13
+ export function toSelectedConcept(c: any): SelectedConcept {
14
+ return {
15
+ id: Number(c.id) || 0,
16
+ uuid: c.uuid,
17
+ display: c.display,
18
+ conceptClass: c.conceptClass?.name,
19
+ datatype: c.datatype?.name,
20
+ };
21
+ }
@@ -0,0 +1,124 @@
1
+ import type { IndicatorDto } from '../../../resources/indicator/indicators.api';
2
+
3
+ import {
4
+ countSqlToPopulationSql,
5
+ tryGetPatientIdColumnFromConfig,
6
+ tryGetCountSqlFromIndicator,
7
+ } from './composite-indicator-sql.utils';
8
+
9
+ export type FinalIndicatorAuthoringV1 = {
10
+ version: 1;
11
+ baseIndicatorId: string;
12
+
13
+ // Backward/forward compatibility
14
+ ageGroupSetCode?: string;
15
+ ageCategoryCode?: string;
16
+
17
+ genders: Array<'F' | 'M'>;
18
+ sqlPreview: string;
19
+ };
20
+
21
+ type BuildFinalSqlArgs = {
22
+ baseIndicator: IndicatorDto;
23
+ ageCategoryCode: string;
24
+ genders: Array<'F' | 'M'>;
25
+ };
26
+
27
+ /**
28
+ * Builds a Final Indicator query that ALWAYS returns all age groups (even if value = 0),
29
+ * and for each selected gender.
30
+ *
31
+ * Strategy:
32
+ * - base_pop: patient population set from base indicator
33
+ * - ag: all age groups for the selected category (active only)
34
+ * - genders: the selected genders as rows
35
+ * - cnt: computed counts per (age_group_id, gender)
36
+ * - final: ag CROSS JOIN genders LEFT JOIN cnt, COALESCE(value,0)
37
+ */
38
+ export function buildFinalIndicatorSql({ baseIndicator, ageCategoryCode, genders }: BuildFinalSqlArgs): string {
39
+ const { sql: countSql } = tryGetCountSqlFromIndicator(baseIndicator);
40
+ const pidCol = tryGetPatientIdColumnFromConfig(baseIndicator);
41
+
42
+ const populationSql = countSql ? countSqlToPopulationSql(countSql, pidCol, 'Patients') : '';
43
+
44
+ if (!populationSql.trim()) {
45
+ return `-- Unable to build final indicator SQL: base indicator does not contain usable count SQL.\n-- Ensure the base indicator has a valid sqlTemplate or configJson.sqlPreview.`;
46
+ }
47
+
48
+ // If user unchecks everything, default to both (better UX than returning no rows)
49
+ const selectedGenders = (genders ?? []).length ? genders : (['F', 'M'] as Array<'F' | 'M'>);
50
+
51
+ const gendersCte = buildGenderCte(selectedGenders);
52
+
53
+ // NOTE: adjust these table names if your DB uses different names.
54
+ // If you adopted the new naming, use report_builder_dim_age_category/group.
55
+ const AGE_CATEGORY_TABLE = 'report_builder_dim_age_category';
56
+ const AGE_GROUP_TABLE = 'report_builder_dim_age_group';
57
+
58
+ return `
59
+ WITH base_pop AS (
60
+ ${indent(populationSql.trim(), 2)}
61
+ ),
62
+ ag AS (
63
+ SELECT
64
+ ag.age_group_id,
65
+ ag.label,
66
+ ag.min_age_days,
67
+ ag.max_age_days,
68
+ ag.sort_order
69
+ FROM ${AGE_GROUP_TABLE} ag
70
+ JOIN ${AGE_CATEGORY_TABLE} ac
71
+ ON ac.age_category_id = ag.age_category_id
72
+ WHERE ac.code = '${escapeSqlLiteral(ageCategoryCode)}'
73
+ AND ag.is_active = 1
74
+ ),
75
+ genders AS (
76
+ ${indent(gendersCte, 2)}
77
+ ),
78
+ cnt AS (
79
+ SELECT
80
+ ag.age_group_id AS age_group_id,
81
+ mdp.gender AS gender,
82
+ COUNT(DISTINCT base_pop.patient_id) AS value
83
+ FROM base_pop
84
+ JOIN mamba_fact_patients_latest_patient_demographics mdp
85
+ ON mdp.patient_id = base_pop.patient_id
86
+ JOIN ag
87
+ ON TIMESTAMPDIFF(DAY, mdp.birthdate, ':endDate')
88
+ BETWEEN ag.min_age_days AND ag.max_age_days
89
+ WHERE mdp.birthdate IS NOT NULL
90
+ AND mdp.gender IS NOT NULL
91
+ AND mdp.gender IN (${selectedGenders.map((g) => `'${g}'`).join(',')})
92
+ GROUP BY ag.age_group_id, mdp.gender
93
+ )
94
+ SELECT
95
+ ag.label AS age_group,
96
+ g.gender AS gender,
97
+ COALESCE(cnt.value, 0) AS value
98
+ FROM ag
99
+ CROSS JOIN genders g
100
+ LEFT JOIN cnt
101
+ ON cnt.age_group_id = ag.age_group_id
102
+ AND cnt.gender = g.gender
103
+ ORDER BY ag.sort_order, g.gender;
104
+ `.trim();
105
+ }
106
+
107
+ function buildGenderCte(genders: Array<'F' | 'M'>): string {
108
+ // Produce rows:
109
+ // SELECT 'F' AS gender UNION ALL SELECT 'M' AS gender
110
+ const parts = genders.map((g, i) => (i === 0 ? `SELECT '${g}' AS gender` : `UNION ALL SELECT '${g}' AS gender`));
111
+ return parts.join('\n');
112
+ }
113
+
114
+ function indent(s: string, spaces: number) {
115
+ const pad = ' '.repeat(spaces);
116
+ return s
117
+ .split('\n')
118
+ .map((l) => (l.trim().length ? pad + l : l))
119
+ .join('\n');
120
+ }
121
+
122
+ function escapeSqlLiteral(s: string) {
123
+ return String(s).replace(/'/g, "''");
124
+ }
@@ -0,0 +1,254 @@
1
+ // src/report-builder/components/indicators/utils/indicator-conditions-hydration.utils.ts
2
+
3
+ import type { ThemeCondition } from '../types/data-theme-config.types';
4
+ import type { IndicatorCondition } from '../types/indicator-types';
5
+
6
+ import { getConceptByUuid } from '../../../resources/concepts/concepts.resource';
7
+ import type { ConceptSummary } from '../../../resources/concepts/concept-types';
8
+
9
+ import { toSelectedConcept, type SelectedConcept } from '../handler/concept-search-multiselect.component';
10
+ import type { ConceptUiMap, QaUiMap } from '../types/condition-ui.types';
11
+
12
+ export type HydrationOptions = {
13
+ force?: boolean;
14
+ dedupe?: boolean;
15
+ preserveUnknownKeys?: boolean;
16
+ };
17
+
18
+ export type HydrationResult = {
19
+ conceptUi: ConceptUiMap;
20
+ qaUi: QaUiMap;
21
+ stats: {
22
+ themeConditionsCount: number;
23
+ pickedConditionsCount: number;
24
+ conceptConditionsHydrated: number;
25
+ qaConditionsHydrated: number;
26
+ tokensHydrated: number;
27
+ tokensMissed: number;
28
+ };
29
+ };
30
+
31
+ // Stored value shape in pickedConditions for QA handler
32
+ // (support both legacy single-question and current multi-question formats)
33
+ type QAValue = {
34
+ question?: string | number | null;
35
+ questions?: Array<string | number>;
36
+ answers?: Array<string | number>;
37
+ };
38
+
39
+ function uniq(tokens: string[]) {
40
+ const out: string[] = [];
41
+ const seen = new Set<string>();
42
+ for (const t of tokens) {
43
+ const k = String(t ?? '').trim();
44
+ if (!k) continue;
45
+ if (seen.has(k)) continue;
46
+ seen.add(k);
47
+ out.push(k);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Hydrate ONE token into ConceptSummary (internal).
54
+ * We now persist UUIDs, so we call direct endpoint by UUID.
55
+ */
56
+ async function hydrateConceptSummaryToken(token: string, signal?: AbortSignal): Promise<ConceptSummary | null> {
57
+ const tok = String(token ?? '').trim();
58
+ if (!tok) return null;
59
+
60
+ try {
61
+ const concept = await getConceptByUuid(tok, signal);
62
+ if (!concept?.uuid) return null;
63
+ return concept;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Hydrate ONE token into SelectedConcept (UI shape).
71
+ * ✅ Uses toSelectedConcept so ICD mappings are preserved.
72
+ */
73
+ async function hydrateConceptToken(token: string, signal?: AbortSignal): Promise<SelectedConcept | null> {
74
+ const cs = await hydrateConceptSummaryToken(token, signal);
75
+ return cs ? toSelectedConcept(cs) : null;
76
+ }
77
+
78
+ /**
79
+ * Hydrate MANY tokens into SelectedConcept[].
80
+ * ✅ for-loop; preserves order; returns only valid concepts.
81
+ */
82
+ async function hydrateConceptTokens(tokens: string[], signal?: AbortSignal, dedupe = true): Promise<SelectedConcept[]> {
83
+ const clean = dedupe ? uniq(tokens) : tokens.map((t) => String(t ?? '').trim()).filter(Boolean);
84
+ if (!clean.length) return [];
85
+
86
+ const results: SelectedConcept[] = [];
87
+
88
+ for (const tok of clean) {
89
+ if (signal?.aborted) break;
90
+
91
+ try {
92
+ const concept = await hydrateConceptToken(tok, signal);
93
+ if (concept) results.push(concept);
94
+ } catch {
95
+ // ignore individual token failures
96
+ }
97
+ }
98
+
99
+ return results;
100
+ }
101
+
102
+ /**
103
+ * Hydrates UI state for concept-based conditions (edit mode):
104
+ * - iterate picked first (source of truth)
105
+ * - match to themeByKey for handler
106
+ * - ensure defaults for theme conditions not saved
107
+ */
108
+ export async function hydrateConditionUiState(
109
+ themeConditions: ThemeCondition[] | undefined,
110
+ pickedConditions: IndicatorCondition[] | undefined,
111
+ existingConceptUi: ConceptUiMap | undefined,
112
+ existingQaUi: QaUiMap | undefined,
113
+ signal?: AbortSignal,
114
+ options?: HydrationOptions,
115
+ ): Promise<HydrationResult> {
116
+ const opt: Required<HydrationOptions> = {
117
+ force: options?.force ?? true,
118
+ dedupe: options?.dedupe ?? true,
119
+ preserveUnknownKeys: options?.preserveUnknownKeys ?? true,
120
+ };
121
+
122
+ const themeList = themeConditions ?? [];
123
+ const pickedList = pickedConditions ?? [];
124
+
125
+ const themeByKey = new Map<string, ThemeCondition>();
126
+ for (const tc of themeList) {
127
+ if (tc?.key) themeByKey.set(tc.key, tc);
128
+ }
129
+
130
+ const nextConceptUi: ConceptUiMap = { ...(existingConceptUi ?? {}) };
131
+ const nextQaUi: QaUiMap = { ...(existingQaUi ?? {}) };
132
+
133
+ let conceptConditionsHydrated = 0;
134
+ let qaConditionsHydrated = 0;
135
+ let tokensHydrated = 0;
136
+ let tokensMissed = 0;
137
+
138
+ const seenKeys = new Set<string>();
139
+
140
+ // 1) hydrate from picked conditions (source of truth)
141
+ for (const pc of pickedList) {
142
+ if (!pc?.key) continue;
143
+ seenKeys.add(pc.key);
144
+
145
+ const tc = themeByKey.get(pc.key);
146
+ if (!tc) {
147
+ if (!opt.preserveUnknownKeys) {
148
+ delete nextConceptUi[pc.key];
149
+ delete nextQaUi[pc.key];
150
+ }
151
+ continue;
152
+ }
153
+
154
+ // -----------------------------
155
+ // CONCEPT_SEARCH
156
+ // -----------------------------
157
+ if (tc.handler === 'CONCEPT_SEARCH') {
158
+ const shouldHydrate =
159
+ opt.force || !Array.isArray(nextConceptUi[tc.key]) || nextConceptUi[tc.key].length === 0;
160
+
161
+ if (!shouldHydrate) continue;
162
+
163
+ const raw = pc.value;
164
+
165
+ if (Array.isArray(raw) && raw.length) {
166
+ const tokens = raw.map((x) => String(x)).filter(Boolean);
167
+ const selected = await hydrateConceptTokens(tokens, signal, opt.dedupe);
168
+
169
+ tokensHydrated += selected.length;
170
+ tokensMissed += Math.max(0, tokens.length - selected.length);
171
+
172
+ nextConceptUi[tc.key] = selected;
173
+ } else {
174
+ nextConceptUi[tc.key] = [];
175
+ }
176
+
177
+ conceptConditionsHydrated += 1;
178
+ continue;
179
+ }
180
+
181
+ // -----------------------------
182
+ // QUESTION_ANSWER_CONCEPT_SEARCH
183
+ // -----------------------------
184
+ if (tc.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH') {
185
+ const existing = nextQaUi[tc.key];
186
+
187
+ const shouldHydrate =
188
+ opt.force ||
189
+ !existing ||
190
+ ((existing.questions?.length ?? 0) === 0 && (existing.answers?.length ?? 0) === 0 && !existing.error);
191
+
192
+ if (!shouldHydrate) continue;
193
+
194
+ const raw: any = pc.value;
195
+
196
+ const qa: QAValue | null = raw && typeof raw === 'object' ? (raw as QAValue) : null;
197
+
198
+ // ✅ support both shapes: { question } OR { questions: [] }
199
+ const qTokens: string[] = Array.isArray(qa?.questions)
200
+ ? (qa?.questions ?? []).map((x) => String(x).trim()).filter(Boolean)
201
+ : qa?.question !== null && qa?.question !== undefined
202
+ ? [String(qa.question).trim()].filter(Boolean)
203
+ : [];
204
+
205
+ const aTokens: string[] = Array.isArray(qa?.answers)
206
+ ? (qa?.answers ?? []).map((x) => String(x).trim()).filter(Boolean)
207
+ : [];
208
+
209
+ const questions: SelectedConcept[] = qTokens.length
210
+ ? await hydrateConceptTokens(qTokens, signal, opt.dedupe)
211
+ : [];
212
+
213
+ const answers: SelectedConcept[] = aTokens.length
214
+ ? await hydrateConceptTokens(aTokens, signal, opt.dedupe)
215
+ : [];
216
+
217
+ tokensHydrated += questions.length + answers.length;
218
+ tokensMissed += Math.max(0, qTokens.length - questions.length) + Math.max(0, aTokens.length - answers.length);
219
+
220
+ nextQaUi[tc.key] = { questions, answers };
221
+ qaConditionsHydrated += 1;
222
+ continue;
223
+ }
224
+
225
+ // other handlers ignored
226
+ }
227
+
228
+ // 2) ensure all theme conditions have UI defaults
229
+ for (const tc of themeList) {
230
+ if (!tc?.key) continue;
231
+
232
+ if (!seenKeys.has(tc.key)) {
233
+ if (tc.handler === 'CONCEPT_SEARCH') {
234
+ if (!Array.isArray(nextConceptUi[tc.key])) nextConceptUi[tc.key] = [];
235
+ }
236
+ if (tc.handler === 'QUESTION_ANSWER_CONCEPT_SEARCH') {
237
+ if (!nextQaUi[tc.key]) nextQaUi[tc.key] = { questions: [], answers: [] };
238
+ }
239
+ }
240
+ }
241
+
242
+ return {
243
+ conceptUi: nextConceptUi,
244
+ qaUi: nextQaUi,
245
+ stats: {
246
+ themeConditionsCount: themeList.length,
247
+ pickedConditionsCount: pickedList.length,
248
+ conceptConditionsHydrated,
249
+ qaConditionsHydrated,
250
+ tokensHydrated,
251
+ tokensMissed,
252
+ },
253
+ };
254
+ }