@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,24 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Layer, ClickableTile } from '@carbon/react';
4
+ import { ArrowRight } from '@carbon/react/icons';
5
+
6
+ const ReportBuilderAdminCardLink: React.FC = () => {
7
+ const { t } = useTranslation();
8
+ const header = t('manageForms', 'Report Builder');
9
+
10
+ return (
11
+ <Layer>
12
+ <ClickableTile href={`${window.spaBase}/report-builder`} rel="noopener noreferrer">
13
+ <div>
14
+ <div className="heading">{header}</div>
15
+ <div className="content">{t('reportBuilder', 'Report Builder')}</div>
16
+ </div>
17
+ <div className="iconWrapper">
18
+ <ArrowRight size={16} />
19
+ </div>
20
+ </ClickableTile>
21
+ </Layer>
22
+ );
23
+ };
24
+ export default ReportBuilderAdminCardLink;
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { ClickableTile, Stack } from '@carbon/react';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import Header from '../shared/header/header.component';
5
+
6
+ const tileStyle: React.CSSProperties = { minHeight: '10rem' };
7
+
8
+ export default function AdminPage() {
9
+ const navigate = useNavigate();
10
+
11
+ return (
12
+ <Stack gap={5}>
13
+ <Header
14
+ title="Admin"
15
+ subtitle="Manage shared configuration, catalogues, and library references for the report builder."
16
+ />
17
+
18
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
19
+ <ClickableTile style={tileStyle} onClick={() => navigate('/admin/report-library')}>
20
+ <h4>Report Library</h4>
21
+ <p>Catalog completed and legacy reports before full migration into the builder.</p>
22
+ </ClickableTile>
23
+
24
+ <ClickableTile style={tileStyle} onClick={() => navigate('/admin/themes')}>
25
+ <h4>Data Themes</h4>
26
+ <p>Manage reusable source configurations and table mappings.</p>
27
+ </ClickableTile>
28
+ </div>
29
+ </Stack>
30
+ );
31
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import {
3
+ Accordion,
4
+ AccordionItem,
5
+ InlineNotification,
6
+ Search,
7
+ Stack,
8
+ Tag,
9
+ Tile,
10
+ } from '@carbon/react';
11
+ import Header from '../shared/header/header.component';
12
+ import { listAgeCategoriesWithGroups, type AgeCategoryOption } from '../../resources/agegroup/agegroups.api';
13
+
14
+ const AgeCategoriesPage: React.FC = () => {
15
+ const [q, setQ] = React.useState('');
16
+ const [rows, setRows] = React.useState<AgeCategoryOption[]>([]);
17
+ const [loading, setLoading] = React.useState(false);
18
+ const [error, setError] = React.useState<string | null>(null);
19
+
20
+ React.useEffect(() => {
21
+ const ac = new AbortController();
22
+ setLoading(true);
23
+ setError(null);
24
+ listAgeCategoriesWithGroups(ac.signal)
25
+ .then(setRows)
26
+ .catch((e: any) => setError(e?.message ?? 'Failed to load age categories'))
27
+ .finally(() => setLoading(false));
28
+ return () => ac.abort();
29
+ }, []);
30
+
31
+ const filtered = rows.filter((row) => {
32
+ const needle = q.trim().toLowerCase();
33
+ if (!needle) return true;
34
+ return [row.name, row.code, row.description || '', row.label]
35
+ .join(' ')
36
+ .toLowerCase()
37
+ .includes(needle);
38
+ });
39
+
40
+ return (
41
+ <Stack gap={5}>
42
+ <Header
43
+ title="Age Categories"
44
+ subtitle="Review reusable age disaggregation structures used by indicators and sections."
45
+ />
46
+
47
+ <div style={{ padding: '0 1rem 1rem', display: 'grid', gap: '1rem' }}>
48
+ <Search
49
+ size="lg"
50
+ labelText="Search"
51
+ placeholder="Search age categories…"
52
+ value={q}
53
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
54
+ />
55
+
56
+ {error ? <InlineNotification lowContrast kind="error" title="Error" subtitle={error} /> : null}
57
+ {loading ? <div>Loading…</div> : null}
58
+
59
+ {!loading && !error ? (
60
+ <Accordion>
61
+ {filtered.map((row) => (
62
+ <AccordionItem
63
+ key={row.uuid}
64
+ title={
65
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
66
+ <span style={{ fontWeight: 600 }}>{row.name}</span>
67
+ <Tag type="blue">{row.code}</Tag>
68
+ <Tag type="green">{row.ageGroups.length} age groups</Tag>
69
+ </div>
70
+ }
71
+ >
72
+ <Tile>
73
+ <div style={{ display: 'grid', gap: '0.75rem' }}>
74
+ <div><strong>Description:</strong> {row.description || '—'}</div>
75
+ <div><strong>UUID:</strong> {row.uuid}</div>
76
+ <div>
77
+ <strong>Groups:</strong>
78
+ <div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.75rem' }}>
79
+ {row.ageGroups.map((group) => (
80
+ <div
81
+ key={group.id}
82
+ style={{
83
+ display: 'grid',
84
+ gridTemplateColumns: '2fr 1fr 1fr 1fr',
85
+ gap: '0.75rem',
86
+ padding: '0.75rem',
87
+ border: '1px solid var(--cds-border-subtle, #e0e0e0)',
88
+ }}
89
+ >
90
+ <span>{group.label}</span>
91
+ <span>Min days: {group.minAgeDays}</span>
92
+ <span>Max days: {group.maxAgeDays}</span>
93
+ <span>Sort: {group.sortOrder}</span>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </Tile>
100
+ </AccordionItem>
101
+ ))}
102
+ </Accordion>
103
+ ) : null}
104
+ </div>
105
+ </Stack>
106
+ );
107
+ };
108
+
109
+ export default AgeCategoriesPage;
@@ -0,0 +1,146 @@
1
+ import React from 'react';
2
+ import {
3
+ DataTable,
4
+ DataTableHeader,
5
+ DataTableSkeleton,
6
+ InlineNotification,
7
+ Search,
8
+ Stack,
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableContainer,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ Tag,
17
+ } from '@carbon/react';
18
+ import Header from '../shared/header/header.component';
19
+ import { listAgeCategoriesWithGroups } from '../../resources/agegroup/agegroups.api';
20
+
21
+ type AgeGroupRow = {
22
+ id: string;
23
+ categoryName: string;
24
+ categoryCode: string;
25
+ label: string;
26
+ minAgeDays: number;
27
+ maxAgeDays: number;
28
+ sortOrder: number;
29
+ active: boolean;
30
+ };
31
+
32
+ const headers: DataTableHeader[] = [
33
+ { key: 'label', header: 'Label' },
34
+ { key: 'category', header: 'Category' },
35
+ { key: 'range', header: 'Range (days)' },
36
+ { key: 'sortOrder', header: 'Sort Order' },
37
+ { key: 'status', header: 'Status' },
38
+ ];
39
+
40
+ const AgeGroupsPage: React.FC = () => {
41
+ const [q, setQ] = React.useState('');
42
+ const [rows, setRows] = React.useState<AgeGroupRow[]>([]);
43
+ const [loading, setLoading] = React.useState(false);
44
+ const [error, setError] = React.useState<string | null>(null);
45
+
46
+ React.useEffect(() => {
47
+ const ac = new AbortController();
48
+ setLoading(true);
49
+ setError(null);
50
+ listAgeCategoriesWithGroups(ac.signal)
51
+ .then((categories) => {
52
+ const flattened = categories.flatMap((category) =>
53
+ (category.ageGroups ?? []).map((group) => ({
54
+ id: `${category.uuid}-${group.id}`,
55
+ categoryName: category.name,
56
+ categoryCode: category.code,
57
+ label: group.label,
58
+ minAgeDays: group.minAgeDays,
59
+ maxAgeDays: group.maxAgeDays,
60
+ sortOrder: group.sortOrder,
61
+ active: group.active,
62
+ })),
63
+ );
64
+ setRows(flattened);
65
+ })
66
+ .catch((e: any) => setError(e?.message ?? 'Failed to load age groups'))
67
+ .finally(() => setLoading(false));
68
+ return () => ac.abort();
69
+ }, []);
70
+
71
+ const filtered = rows.filter((row) => {
72
+ const needle = q.trim().toLowerCase();
73
+ if (!needle) return true;
74
+ return [row.label, row.categoryName, row.categoryCode].join(' ').toLowerCase().includes(needle);
75
+ });
76
+
77
+ const tableRows = filtered.map((row) => ({
78
+ id: row.id,
79
+ label: row.label,
80
+ category: `${row.categoryName} (${row.categoryCode})`,
81
+ range: `${row.minAgeDays} - ${row.maxAgeDays}`,
82
+ sortOrder: String(row.sortOrder),
83
+ status: row.active ? 'Active' : 'Inactive',
84
+ }));
85
+
86
+ return (
87
+ <Stack gap={5}>
88
+ <Header
89
+ title="Age Groups"
90
+ subtitle="Inspect age group ranges, ordering and category membership across the builder."
91
+ />
92
+
93
+ <div style={{ padding: '0 1rem 1rem', display: 'grid', gap: '1rem' }}>
94
+ <Search
95
+ size="lg"
96
+ labelText="Search"
97
+ placeholder="Search age groups…"
98
+ value={q}
99
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
100
+ />
101
+
102
+ {error ? <InlineNotification lowContrast kind="error" title="Error" subtitle={error} /> : null}
103
+
104
+ {loading ? (
105
+ <DataTableSkeleton rowCount={8} columnCount={5} showHeader={false} showToolbar={false} />
106
+ ) : (
107
+ <DataTable rows={tableRows} headers={headers}>
108
+ {({ rows: dtRows, headers, getHeaderProps, getRowProps }) => (
109
+ <TableContainer>
110
+ <Table useZebraStyles>
111
+ <TableHead>
112
+ <TableRow>
113
+ {headers.map((header) => (
114
+ <TableHeader key={header.key} {...getHeaderProps({ header })}>
115
+ {header.header}
116
+ </TableHeader>
117
+ ))}
118
+ </TableRow>
119
+ </TableHead>
120
+ <TableBody>
121
+ {dtRows.map((dtRow) => {
122
+ const source = filtered.find((x) => x.id === dtRow.id);
123
+ return (
124
+ <TableRow key={dtRow.id} {...getRowProps({ row: dtRow })}>
125
+ <TableCell>{source?.label}</TableCell>
126
+ <TableCell>{source ? `${source.categoryName} (${source.categoryCode})` : '—'}</TableCell>
127
+ <TableCell>{source ? `${source.minAgeDays} - ${source.maxAgeDays}` : '—'}</TableCell>
128
+ <TableCell>{source?.sortOrder}</TableCell>
129
+ <TableCell>
130
+ <Tag type={source?.active ? 'green' : 'gray'}>{source?.active ? 'Active' : 'Inactive'}</Tag>
131
+ </TableCell>
132
+ </TableRow>
133
+ );
134
+ })}
135
+ </TableBody>
136
+ </Table>
137
+ </TableContainer>
138
+ )}
139
+ </DataTable>
140
+ )}
141
+ </div>
142
+ </Stack>
143
+ );
144
+ };
145
+
146
+ export default AgeGroupsPage;
@@ -0,0 +1,344 @@
1
+ import React from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ DataTableHeader,
6
+ DataTableSkeleton,
7
+ InlineNotification,
8
+ Modal,
9
+ Search,
10
+ Stack,
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableContainer,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ Tag,
19
+ TextArea,
20
+ TextInput,
21
+ Toggle,
22
+ } from '@carbon/react';
23
+ import { Add, Edit, TrashCan } from '@carbon/icons-react';
24
+ import Header from '../shared/header/header.component';
25
+ import {
26
+ createETLSource,
27
+ deleteETLSource,
28
+ ETLSourceDto,
29
+ listETLSources,
30
+ SaveETLSourcePayload,
31
+ updateETLSource,
32
+ } from '../../resources/etl-source/etl-source.api';
33
+
34
+ type FormState = {
35
+ name: string;
36
+ code: string;
37
+ description: string;
38
+ tablePatterns: string;
39
+ schemaName: string;
40
+ sourceType: string;
41
+ active: boolean;
42
+ };
43
+
44
+ const headers: DataTableHeader[] = [
45
+ { key: 'name', header: 'Name' },
46
+ { key: 'code', header: 'Code' },
47
+ { key: 'schemaName', header: 'Schema' },
48
+ { key: 'sourceType', header: 'Source Type' },
49
+ { key: 'status', header: 'Status' },
50
+ { key: 'actions', header: 'Actions' },
51
+ ];
52
+
53
+ const emptyForm: FormState = {
54
+ name: '',
55
+ code: '',
56
+ description: '',
57
+ tablePatterns: '',
58
+ schemaName: '',
59
+ sourceType: '',
60
+ active: true,
61
+ };
62
+
63
+ export default function ETLSourcesPage() {
64
+ const [q, setQ] = React.useState('');
65
+ const [rows, setRows] = React.useState<ETLSourceDto[]>([]);
66
+ const [loading, setLoading] = React.useState(false);
67
+ const [error, setError] = React.useState<string | null>(null);
68
+
69
+ const [open, setOpen] = React.useState(false);
70
+ const [mode, setMode] = React.useState<'create' | 'edit'>('create');
71
+ const [editing, setEditing] = React.useState<ETLSourceDto | null>(null);
72
+ const [form, setForm] = React.useState<FormState>(emptyForm);
73
+ const [saving, setSaving] = React.useState(false);
74
+
75
+ const load = React.useCallback(async (signal?: AbortSignal) => {
76
+ setLoading(true);
77
+ setError(null);
78
+ try {
79
+ const data = await listETLSources(q, signal);
80
+ setRows(data);
81
+ } catch (e: any) {
82
+ setError(e?.message ?? 'Failed to load ETL sources');
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ }, [q]);
87
+
88
+ React.useEffect(() => {
89
+ const ac = new AbortController();
90
+ load(ac.signal);
91
+ return () => ac.abort();
92
+ }, [load]);
93
+
94
+ const openCreate = () => {
95
+ setMode('create');
96
+ setEditing(null);
97
+ setForm(emptyForm);
98
+ setOpen(true);
99
+ };
100
+
101
+ const openEdit = (row: ETLSourceDto) => {
102
+ setMode('edit');
103
+ setEditing(row);
104
+ setForm({
105
+ name: row.name ?? '',
106
+ code: row.code ?? '',
107
+ description: row.description ?? '',
108
+ tablePatterns: row.tablePatterns ?? '',
109
+ schemaName: row.schemaName ?? '',
110
+ sourceType: row.sourceType ?? '',
111
+ active: row.active ?? true,
112
+ });
113
+ setOpen(true);
114
+ };
115
+
116
+ const onSave = async () => {
117
+ if (!form.name.trim()) return;
118
+
119
+ setSaving(true);
120
+ setError(null);
121
+
122
+ try {
123
+ const payload: SaveETLSourcePayload = {
124
+ name: form.name.trim(),
125
+ code: form.code.trim() || undefined,
126
+ description: form.description.trim() || undefined,
127
+ tablePatterns: form.tablePatterns.trim() || undefined,
128
+ schemaName: form.schemaName.trim() || undefined,
129
+ sourceType: form.sourceType.trim() || undefined,
130
+ active: form.active,
131
+ };
132
+
133
+ if (mode === 'create') {
134
+ await createETLSource(payload);
135
+ } else if (editing?.uuid) {
136
+ await updateETLSource(editing.uuid, payload);
137
+ }
138
+
139
+ setOpen(false);
140
+ const ac = new AbortController();
141
+ await load(ac.signal);
142
+ } catch (e: any) {
143
+ setError(e?.message ?? 'Failed to save ETL source');
144
+ } finally {
145
+ setSaving(false);
146
+ }
147
+ };
148
+
149
+ const onDelete = async (row: ETLSourceDto) => {
150
+ if (!row?.uuid) return;
151
+ const yes = window.confirm(`Retire ETL source "${row.name}"?`);
152
+ if (!yes) return;
153
+
154
+ try {
155
+ await deleteETLSource(row.uuid);
156
+ const ac = new AbortController();
157
+ await load(ac.signal);
158
+ } catch (e: any) {
159
+ setError(e?.message ?? 'Failed to retire ETL source');
160
+ }
161
+ };
162
+
163
+ const tableRows = rows.map((row) => ({
164
+ id: row.uuid,
165
+ name: row.name,
166
+ code: row.code || '—',
167
+ schemaName: row.schemaName || '—',
168
+ sourceType: row.sourceType || '—',
169
+ status: row.active === false || row.voided ? 'Inactive' : 'Active',
170
+ actions: '',
171
+ }));
172
+
173
+ return (
174
+ <Stack gap={5}>
175
+ <Header
176
+ title="ETL Sources"
177
+ subtitle="Manage ETL source definitions used by the report builder."
178
+ actions={
179
+ <Button data-testid="etl-source-new" size="sm" renderIcon={Add} onClick={openCreate}>
180
+ New ETL Source
181
+ </Button>
182
+ }
183
+ />
184
+
185
+ <div style={{ padding: '0 1rem 1rem', display: 'grid', gap: '1rem' }}>
186
+ <Search
187
+ data-testid="etl-source-search"
188
+ size="lg"
189
+ labelText="Search"
190
+ placeholder="Search ETL sources…"
191
+ value={q}
192
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
193
+ />
194
+
195
+ {error ? <InlineNotification lowContrast kind="error" title="Error" subtitle={error} /> : null}
196
+
197
+ {loading ? (
198
+ <DataTableSkeleton rowCount={5} columnCount={6} showHeader={false} showToolbar={false} />
199
+ ) : (
200
+ <DataTable rows={tableRows} headers={headers}>
201
+ {({ rows: dtRows, headers, getHeaderProps, getRowProps }) => (
202
+ <TableContainer>
203
+ <Table useZebraStyles>
204
+ <TableHead>
205
+ <TableRow>
206
+ {headers.map((header) => (
207
+ <TableHeader key={header.key} {...getHeaderProps({ header })}>
208
+ {header.header}
209
+ </TableHeader>
210
+ ))}
211
+ </TableRow>
212
+ </TableHead>
213
+ <TableBody>
214
+ {dtRows.map((row) => {
215
+ const original = rows.find((r) => r.uuid === row.id);
216
+ return (
217
+ <TableRow key={row.id} {...getRowProps({ row })}>
218
+ <TableCell>{row.cells[0].value}</TableCell>
219
+ <TableCell>{row.cells[1].value}</TableCell>
220
+ <TableCell>{row.cells[2].value}</TableCell>
221
+ <TableCell>{row.cells[3].value}</TableCell>
222
+ <TableCell>
223
+ {(original?.active === false || original?.voided) ? (
224
+ <Tag type="gray">Inactive</Tag>
225
+ ) : (
226
+ <Tag type="green">Active</Tag>
227
+ )}
228
+ </TableCell>
229
+ <TableCell>
230
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
231
+ <Button
232
+ kind="ghost"
233
+ size="sm"
234
+ renderIcon={Edit}
235
+ iconDescription="Edit"
236
+ hasIconOnly
237
+ onClick={() => original && openEdit(original)}
238
+ />
239
+ <Button
240
+ kind="ghost"
241
+ size="sm"
242
+ renderIcon={TrashCan}
243
+ iconDescription="Retire"
244
+ hasIconOnly
245
+ onClick={() => original && onDelete(original)}
246
+ />
247
+ </div>
248
+ </TableCell>
249
+ </TableRow>
250
+ );
251
+ })}
252
+ </TableBody>
253
+ </Table>
254
+ </TableContainer>
255
+ )}
256
+ </DataTable>
257
+ )}
258
+ </div>
259
+
260
+ <Modal
261
+ open={open}
262
+ modalHeading={mode === 'create' ? 'New ETL Source' : 'Edit ETL Source'}
263
+ data-testid="etl-source-modal"
264
+ primaryButtonText={saving ? 'Saving…' : 'Save'}
265
+ secondaryButtonText="Cancel"
266
+ onRequestClose={() => !saving && setOpen(false)}
267
+ onRequestSubmit={onSave}
268
+ primaryButtonDisabled={saving || !form.name.trim()}
269
+ >
270
+ <Stack gap={5}>
271
+ <TextInput
272
+ id="etl-source-name"
273
+ data-testid="etl-source-name"
274
+ labelText="Name"
275
+ value={form.name}
276
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
277
+ setForm((prev) => ({ ...prev, name: e.target.value }))
278
+ }
279
+ />
280
+
281
+ <TextInput
282
+ id="etl-source-code"
283
+ data-testid="etl-source-code"
284
+ labelText="Code"
285
+ value={form.code}
286
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
287
+ setForm((prev) => ({ ...prev, code: e.target.value }))
288
+ }
289
+ />
290
+
291
+ <TextInput
292
+ id="etl-source-schema"
293
+ data-testid="etl-source-schema"
294
+ labelText="Schema Name"
295
+ value={form.schemaName}
296
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
297
+ setForm((prev) => ({ ...prev, schemaName: e.target.value }))
298
+ }
299
+ />
300
+
301
+ <TextInput
302
+ id="etl-source-type"
303
+ data-testid="etl-source-type"
304
+ labelText="Source Type"
305
+ value={form.sourceType}
306
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
307
+ setForm((prev) => ({ ...prev, sourceType: e.target.value }))
308
+ }
309
+ />
310
+
311
+ <TextArea
312
+ id="etl-source-description"
313
+ data-testid="etl-source-description"
314
+ labelText="Description"
315
+ rows={3}
316
+ value={form.description}
317
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
318
+ setForm((prev) => ({ ...prev, description: e.target.value }))
319
+ }
320
+ />
321
+
322
+ <TextArea
323
+ id="etl-source-table-patterns"
324
+ data-testid="etl-source-table-patterns"
325
+ labelText="Table Patterns"
326
+ helperText="Store table matching patterns as text."
327
+ rows={4}
328
+ value={form.tablePatterns}
329
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
330
+ setForm((prev) => ({ ...prev, tablePatterns: e.target.value }))
331
+ }
332
+ />
333
+
334
+ <Toggle
335
+ id="etl-source-active"
336
+ labelText="Active"
337
+ toggled={form.active}
338
+ onToggle={(checked: boolean) => setForm((prev) => ({ ...prev, active: checked }))}
339
+ />
340
+ </Stack>
341
+ </Modal>
342
+ </Stack>
343
+ );
344
+ }