@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.
- package/.eslintignore +5 -0
- package/.eslintrc.js +19 -0
- package/.prettierrc +8 -0
- package/LICENSE +401 -0
- package/README.md +317 -0
- package/dist/184.js +2 -0
- package/dist/184.js.LICENSE.txt +14 -0
- package/dist/184.js.map +1 -0
- package/dist/211.js +1 -0
- package/dist/211.js.map +1 -0
- package/dist/300.js +1 -0
- package/dist/311.js +1 -0
- package/dist/311.js.map +1 -0
- package/dist/44.js +2 -0
- package/dist/44.js.LICENSE.txt +30 -0
- package/dist/44.js.map +1 -0
- package/dist/478.js +2 -0
- package/dist/478.js.LICENSE.txt +9 -0
- package/dist/478.js.map +1 -0
- package/dist/535.js +1 -0
- package/dist/535.js.map +1 -0
- package/dist/540.js +2 -0
- package/dist/540.js.LICENSE.txt +9 -0
- package/dist/540.js.map +1 -0
- package/dist/710.js +1 -0
- package/dist/710.js.map +1 -0
- package/dist/729.js +1 -0
- package/dist/729.js.map +1 -0
- package/dist/872.js +1 -0
- package/dist/872.js.map +1 -0
- package/dist/917.js +1 -0
- package/dist/917.js.map +1 -0
- package/dist/961.js +2 -0
- package/dist/961.js.LICENSE.txt +19 -0
- package/dist/961.js.map +1 -0
- package/dist/972.js +2 -0
- package/dist/972.js.LICENSE.txt +21 -0
- package/dist/972.js.map +1 -0
- package/dist/main.js +1 -0
- package/dist/main.js.map +1 -0
- package/dist/openmrs-esm-report-builder.js +1 -0
- package/dist/openmrs-esm-report-builder.js.buildmanifest.json +482 -0
- package/dist/openmrs-esm-report-builder.js.map +1 -0
- package/dist/routes.json +1 -0
- package/e2e/core/env.ts +5 -0
- package/e2e/core/global-setup.ts +34 -0
- package/e2e/core/test.ts +21 -0
- package/e2e/pages/app-shell.page.ts +34 -0
- package/e2e/pages/etl-sources.page.ts +35 -0
- package/e2e/pages/report-sections.page.ts +20 -0
- package/e2e/pages/section-preview.modal.ts +29 -0
- package/e2e/specs/etl-sources.spec.ts +51 -0
- package/e2e/specs/navigation.spec.ts +13 -0
- package/e2e/specs/section-preview-validation.spec.ts +38 -0
- package/e2e/specs/section-preview.spec.ts +94 -0
- package/e2e/tsconfig.json +11 -0
- package/example.env +5 -0
- package/jest.config.js +28 -0
- package/package.json +102 -0
- package/playwright.config.ts +28 -0
- package/src/admin-card-link.extension.module.scss +55 -0
- package/src/admin-card-link.extension.tsx +24 -0
- package/src/components/admin/admin-page.component.tsx +31 -0
- package/src/components/admin/age-categories-page.component.tsx +109 -0
- package/src/components/admin/age-groups-page.component.tsx +146 -0
- package/src/components/admin/etl-sources-page.component.tsx +344 -0
- package/src/components/admin/report-categories-page.component.tsx +256 -0
- package/src/components/admin/report-library-page.component.tsx +540 -0
- package/src/components/ai-support/ai-assist-button.component.tsx +30 -0
- package/src/components/ai-support/ai-assist-modal.component.tsx +81 -0
- package/src/components/ai-support/ai.service.ts +21 -0
- package/src/components/ai-support/ai.types.ts +12 -0
- package/src/components/app-shell/report-builder-shell.component.tsx +120 -0
- package/src/components/app-shell/report-builder-shell.scss +15 -0
- package/src/components/data-themes/data-theme-form.component.tsx +59 -0
- package/src/components/data-themes/data-theme-modal.component.tsx +349 -0
- package/src/components/data-themes/data-themes-page.component.tsx +132 -0
- package/src/components/data-themes/data-themes-table.component.tsx +91 -0
- package/src/components/data-themes/icon-registry.ts +35 -0
- package/src/components/data-themes/sections/data-theme-basics.section.tsx +63 -0
- package/src/components/data-themes/sections/data-theme-condition-columns.section.tsx +397 -0
- package/src/components/data-themes/sections/data-theme-fields-editor.section.tsx +125 -0
- package/src/components/data-themes/sections/data-theme-metadata.section.tsx +245 -0
- package/src/components/data-themes/sections/data-theme-preview.section.tsx +115 -0
- package/src/components/data-themes/sections/data-theme-source.section.tsx +184 -0
- package/src/components/indicators/README.md +24 -0
- package/src/components/indicators/base-indicator-basic-fields.component.tsx +18 -0
- package/src/components/indicators/base-indicator-basics.section.tsx +54 -0
- package/src/components/indicators/base-indicator-countby.section.tsx +25 -0
- package/src/components/indicators/base-indicator-criteria.section.tsx +83 -0
- package/src/components/indicators/base-indicator-details-form.component.tsx +80 -0
- package/src/components/indicators/base-indicator-theme-unit-fields.component.tsx +39 -0
- package/src/components/indicators/create-base-indicator-modal.component.tsx +501 -0
- package/src/components/indicators/create-composite-base-indicator-modal.component.tsx +297 -0
- package/src/components/indicators/create-final-indicator-modal.component.tsx +260 -0
- package/src/components/indicators/diagnosis-filters-form.component.tsx +101 -0
- package/src/components/indicators/disaggregation-panel.component.tsx +93 -0
- package/src/components/indicators/handler/concept-search-multiselect.component.tsx +153 -0
- package/src/components/indicators/handler/question-answer-concept-search.component.tsx +336 -0
- package/src/components/indicators/indicator-status-tag.component.tsx +9 -0
- package/src/components/indicators/indicators-page.component.tsx +481 -0
- package/src/components/indicators/indicators-page.scss +121 -0
- package/src/components/indicators/indicators-table.component.tsx +166 -0
- package/src/components/indicators/preview-table.component.tsx +31 -0
- package/src/components/indicators/sections/composite-indicator-basics.section.tsx +36 -0
- package/src/components/indicators/sections/composite-indicator-picker.section.tsx +161 -0
- package/src/components/indicators/sections/composite-indicator-sql-preview.section.tsx +31 -0
- package/src/components/indicators/sections/final-indicator-basics.section.tsx +46 -0
- package/src/components/indicators/sections/final-indicator-disaggregation.section.tsx +47 -0
- package/src/components/indicators/sections/final-indicator-picker.section.tsx +92 -0
- package/src/components/indicators/sections/final-indicator-preview.section.tsx +20 -0
- package/src/components/indicators/sections/final-indicator-results-preview.section.tsx +65 -0
- package/src/components/indicators/sections/indicator-basics.section.tsx +40 -0
- package/src/components/indicators/sections/indicator-conditions.section.tsx +194 -0
- package/src/components/indicators/sections/indicator-sql-preview.section.tsx +26 -0
- package/src/components/indicators/sections/indicator-theme.section.tsx +48 -0
- package/src/components/indicators/sql-preview.component.tsx +19 -0
- package/src/components/indicators/types/base-indicator-form.types.ts +17 -0
- package/src/components/indicators/types/base-indicator-theme.constants.ts +8 -0
- package/src/components/indicators/types/composite-indicator.types.ts +21 -0
- package/src/components/indicators/types/condition-ui.types.ts +11 -0
- package/src/components/indicators/types/data-theme-config.types.ts +88 -0
- package/src/components/indicators/types/indicator-types.ts +56 -0
- package/src/components/indicators/types/sql-builders.ts +43 -0
- package/src/components/indicators/utils/composite-indicator-sql.utils.ts +206 -0
- package/src/components/indicators/utils/concept-preload.utils.ts +21 -0
- package/src/components/indicators/utils/final-indicator-sql.utils.ts +124 -0
- package/src/components/indicators/utils/indicator-conditions-hydration.utils.ts +254 -0
- package/src/components/indicators/utils/indicator-sql.utils.ts +185 -0
- package/src/components/landing/landing-action-item.component.tsx +50 -0
- package/src/components/landing/landing-action-list.component.tsx +12 -0
- package/src/components/landing/landing-activity-list.component.tsx +47 -0
- package/src/components/landing/landing-footer-grid.component.tsx +45 -0
- package/src/components/landing/landing-hero.component.tsx +18 -0
- package/src/components/landing/landing-section-card.component.tsx +33 -0
- package/src/components/landing/report-builder-landing-page.component.tsx +103 -0
- package/src/components/landing/report-builder-landing-page.scss +131 -0
- package/src/components/report/dashboard/reports-table.component.tsx +145 -0
- package/src/components/report/definition/panels/properties-panel.component.tsx +38 -0
- package/src/components/report/definition/panels/report-structure-panel.component.tsx +61 -0
- package/src/components/report/definition/panels/selection-panel.component.tsx +58 -0
- package/src/components/report/definition/report-definition-editor.component.tsx +99 -0
- package/src/components/report/definition/report-definition.types.ts +66 -0
- package/src/components/report/design/panels/IndicatorPropertiesPanel.scss +33 -0
- package/src/components/report/design/panels/IndicatorPropertiesPanel.tsx +389 -0
- package/src/components/report/design/panels/JsonPreviewPanel.scss +32 -0
- package/src/components/report/design/panels/JsonPreviewPanel.tsx +104 -0
- package/src/components/report/design/panels/mapping-preview-panel.component.tsx +82 -0
- package/src/components/report/design/panels/mapping-preview-panel.scss +55 -0
- package/src/components/report/design/panels/template-structure-panel.component.tsx +296 -0
- package/src/components/report/design/panels/template-structure-panel.scss +81 -0
- package/src/components/report/design/report-design-editor.component.tsx +894 -0
- package/src/components/report/design/report-design.types.ts +43 -0
- package/src/components/report/design/report-design.utils.ts +86 -0
- package/src/components/report/panels/report-detail.component.tsx +71 -0
- package/src/components/report/panels/report-panel-shell.component.tsx +60 -0
- package/src/components/report/report-dashboard.page.component.tsx +75 -0
- package/src/components/report/report-editor.page.component.tsx +466 -0
- package/src/components/report/report.routes.tsx +30 -0
- package/src/components/report-sections/create-report-section-modal.component.tsx +278 -0
- package/src/components/report-sections/hooks/useAgeCategories.ts +25 -0
- package/src/components/report-sections/hooks/useSectionEditorState.ts +306 -0
- package/src/components/report-sections/panels/Dhis2MappingPanel.tsx +157 -0
- package/src/components/report-sections/panels/DisaggregationPanel.tsx +97 -0
- package/src/components/report-sections/panels/IndicatorPickerPanel.tsx +143 -0
- package/src/components/report-sections/panels/SectionBasicsPanel.tsx +27 -0
- package/src/components/report-sections/report-section-preview-modal.component.tsx +418 -0
- package/src/components/report-sections/report-sections-page.component.tsx +313 -0
- package/src/components/report-sections/section-types.ts +55 -0
- package/src/components/report-sections/section-utils.ts +46 -0
- package/src/components/report-sections/sections-page.scss +79 -0
- package/src/components/run-reports/run-reports-page.component.tsx +23 -0
- package/src/components/shared/header/builder-header-actions.component.tsx +38 -0
- package/src/components/shared/header/header.component.tsx +85 -0
- package/src/components/shared/header/header.scss +150 -0
- package/src/components/shared/header/illustration.component.tsx +34 -0
- package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.scss +33 -0
- package/src/components/shared/indicator-properties-panel/IndicatorPropertiesPanel.tsx +389 -0
- package/src/components/shared/json-preview-panel/JsonPreviewPanel.scss +32 -0
- package/src/components/shared/json-preview-panel/JsonPreviewPanel.tsx +104 -0
- package/src/components/shared/mapping-preview-panel/mapping-preview-panel.component.tsx +82 -0
- package/src/components/shared/mapping-preview-panel/mapping-preview-panel.scss +55 -0
- package/src/components/shared/preview/query-results-preview.component.tsx +204 -0
- package/src/components/shared/template-structure-panel/template-structure-panel.component.tsx +296 -0
- package/src/components/shared/template-structure-panel/template-structure-panel.scss +81 -0
- package/src/config-schema.ts +23 -0
- package/src/globals.d.ts +14 -0
- package/src/hooks/theme/useDataThemes.ts +28 -0
- package/src/hooks/theme/useMambaTableMeta.ts +34 -0
- package/src/hooks/theme/useMambaTables.ts +63 -0
- package/src/index.ts +23 -0
- package/src/resources/agegroup/agegroups.api.ts +58 -0
- package/src/resources/concepts/concept-mapper.ts +21 -0
- package/src/resources/concepts/concept-types.ts +31 -0
- package/src/resources/concepts/concepts.resource.ts +44 -0
- package/src/resources/concepts/useConceptSearch.ts +41 -0
- package/src/resources/etl-source/etl-source.api.ts +65 -0
- package/src/resources/indicator/indicators.api.ts +125 -0
- package/src/resources/openmrs-api.ts +24 -0
- package/src/resources/preview/sql-preview.api.ts +54 -0
- package/src/resources/report/reports.api.ts +117 -0
- package/src/resources/report-category/report-category.api.ts +41 -0
- package/src/resources/report-library/report-library.api.ts +63 -0
- package/src/resources/report-section/report-sections.api.ts +73 -0
- package/src/resources/report-section/section-preview.api.ts +21 -0
- package/src/resources/theme/data-theme.api.ts +88 -0
- package/src/resources/theme/mamba-schema.api.ts +31 -0
- package/src/resources/theme/mamba-table-meta.api.ts +45 -0
- package/src/root.component.tsx +20 -0
- package/src/routes/report-builder.routes.tsx +66 -0
- package/src/routes/report-builder.scss +56 -0
- package/src/routes.json +21 -0
- package/src/sample-template.ts +121 -0
- package/src/template-utils.ts +110 -0
- package/src/types/carbon-react-inert.d.ts +13 -0
- package/src/types/condition-operators.ts +49 -0
- package/src/types/theme/data-theme.types.ts +88 -0
- package/src/utils/html-entities.utils.ts +23 -0
- package/tools/setup-tests.ts +1 -0
- package/translations/en.json +4 -0
- package/tsconfig.json +43 -0
- 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
|
+
}
|